diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..9e4140e --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,17 @@ +# cargo-audit configuration (read automatically by `cargo audit`). +# +# Advisories listed under `ignore` are consciously accepted. Every entry MUST +# document *why* it is safe to accept and be revisited whenever a fix ships. +# The scan stays strict for everything else: any new, unlisted vulnerability +# still fails the build. + +[advisories] +ignore = [ + # rsa: "Marvin Attack" — timing side-channel during RSA *private-key* + # operations (signing / decryption). ISM only uses `jsonwebtoken` to + # *verify* Keycloak JWTs, which is a public-key operation; the vulnerable + # private-key path is never exercised. `rsa` is pulled in transitively via + # `jsonwebtoken`'s `rust_crypto` feature and no fixed version exists. + # Re-evaluate if a patched `rsa` is released or we switch crypto backend. + "RUSTSEC-2023-0071", +] \ No newline at end of file diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..b0f5db9 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,13 @@ +--- +name: code-reviewer +description: Reviews code for correctness, security, and maintainability +tools: Read, Grep, Glob +--- + +You are a senior code reviewer. Review for: + +1. Correctness: logic errors, edge cases, null handling +2. Security: injection, auth bypass, data exposure +3. Maintainability: naming, complexity, duplication + +Every finding must include a concrete fix. \ No newline at end of file diff --git a/.claude/rules/broadcast.md b/.claude/rules/broadcast.md new file mode 100644 index 0000000..4daa878 --- /dev/null +++ b/.claude/rules/broadcast.md @@ -0,0 +1,56 @@ +--- +paths: + - src/broadcast/** +--- + +# Broadcast Rules + +`BroadcastChannel` is a global singleton (`OnceCell>`). It holds a `RwLock>>` — one Tokio broadcast channel per connected user. + +## API + +```rust +BroadcastChannel::get().send_event(notification, &user_id).await; +BroadcastChannel::get().send_event_to_all(user_ids, notification).await; +BroadcastChannel::get().subscribe_to_user_events(user_id).await; // → Receiver +BroadcastChannel::get().unsubscribe(user_id).await; +``` + +## Rules + +- Always broadcast **after** a successful DB write, never before. +- Build notifications with `Notification::new(body)` — never construct the struct directly; it sets the envelope version and leaves `seq` unset (assigned per-user during delivery). +- `send_event` / `send_event_to_all` deliver to a single user via `deliver_to_user`, which assigns a per-user sequence number, caches durable events in Redis, and falls back to Kafka push for offline users. +- Push notifications are only sent for: `ChatMessage`, `FriendRequestReceived`, `NewRoom`. + +## Envelope, Sequencing & Replay + +Every notification is wrapped in a versioned envelope: `{ v, seq, type, createdAt, ...payload }`. + +- `seq` is a **monotonic per-user** sequence (`Cache::next_sequence`, backed by Redis `INCR`). Each recipient of a fan-out gets its **own** `seq`. +- **Durable** events are sequenced and cached (per-user Redis Stream, entry ID `-0`, length-capped via `XADD ... MAXLEN ~ N`) so a reconnecting client can replay. **Ephemeral** events (`NotificationEvent::is_ephemeral() == true`) get no `seq` and are never cached — they are live-only. +- Without Redis (`NoOpCache`) there is no sequencing: `seq` is `None` and no replay is possible (best-effort delivery). +- On connect, SSE/WebSocket clients pass `?last_seq=`; the server replays missing durable events, deduping live events with `seq <= high_water`. If the gap was trimmed out of the retained window (or a `Lagged` is hit), the server emits a `Resync` event and the client must reload state via REST. See `Cache::get_notifications_since_seq` → `ReplayResult`. + +## NotificationEvent Variants + +| Variant | Sent to | Trigger | Ephemeral | +|---|---|---|---| +| `ChatMessage { message, room_preview_text, sender }` | all room members | new message (`sender: RoomMember`) | no | +| `RoomChangeEvent { message, room_preview_text }` | all room members | join/leave/invite | no | +| `NewRoom { room, created_by }` | invited user | room creation / invite | no | +| `LeaveRoom { room_id }` | leaving user | user leaves room | no | +| `FriendRequestReceived { from_user }` | target user | friend request sent | no | +| `FriendRequestAccepted { from_user }` | requester | request accepted | no | +| `UserReadChat { user_id, room_id }` | all room members | room marked as read | no | +| `SystemMessage { message }` | any | system-level events | no | +| `Resync { reason }` | one client connection | replay gap / lag — client must reload via REST | yes | + +## Broadcast Pattern + +```rust +let bc = BroadcastChannel::get(); +bc.send_event_to_all(member_ids, Notification::new( + NotificationEvent::ChatMessage { message, room_preview_text, sender }, +)).await; +``` \ No newline at end of file diff --git a/.claude/rules/handlers.md b/.claude/rules/handlers.md new file mode 100644 index 0000000..2a72b04 --- /dev/null +++ b/.claude/rules/handlers.md @@ -0,0 +1,36 @@ +--- +paths: + - src/**/handler.rs +--- + +# Handler Rules + +## Strict Separation + +- **No business logic in handlers.** Handlers only extract inputs, call the service, and return the result. +- Business logic, validation, and error handling belong in the service layer. + +## Auth Extraction + +Every protected handler extracts the caller's identity via: + +```rust +Extension(claims): Extension +``` + +The caller's UUID is available as `claims.sub`. + +## Return Type + +All handlers return `Result, HttpError>`. On success: `Ok(Json(...))`. On failure: `Err(HttpError)`. + +`HttpError` serializes to: +```json +{ "status": 404, "errorCode": "NOT_FOUND", "message": "...", "timestamp": "...", "path": "/api/..." } +``` + +The `path` field is injected automatically by the `inject_request_path` middleware — do not set it manually. + +## No unwrap() + +Never use `unwrap()` or `expect()` in handlers. Propagate errors with `?` and convert via `HttpError`. \ No newline at end of file diff --git a/.claude/rules/migrations.md b/.claude/rules/migrations.md new file mode 100644 index 0000000..f45f11e --- /dev/null +++ b/.claude/rules/migrations.md @@ -0,0 +1,22 @@ +--- +paths: + - migrations/** + - .sqlx/** +--- + +# Migration Rules + +## Workflow + +1. `sqlx migrate add ` — creates `migrations/_.up.sql` + `.down.sql` +2. Implement SQL in the generated files +3. `sqlx migrate run` — applies pending migrations +4. `cargo sqlx prepare` — regenerates `.sqlx/` compile-time metadata +5. Commit `.sqlx/` — required so CI can build without a live database + +## Conventions + +- Migration names in `snake_case`. +- Always write a corresponding `.down.sql` that fully reverses the `.up.sql`. +- Set `DATABASE_URL` in `.env` before running sqlx CLI commands. +- PostgreSQL is the only database — no cross-DB compatibility needed. \ No newline at end of file diff --git a/.claude/rules/pagination.md b/.claude/rules/pagination.md new file mode 100644 index 0000000..4bcde9e --- /dev/null +++ b/.claude/rules/pagination.md @@ -0,0 +1,31 @@ +# Cursor Pagination Rules + +**All list endpoints use cursor pagination. No `page` or `pageSize` parameters anywhere in the API.** + +## Infrastructure (`core/cursor.rs`) + +```rust +CursorResults { next_cursor: Option, content: Vec } +decode_cursor::(base64_str) -> Result +encode_cursor(&cursor) -> Result +``` + +Cursors are base64url-encoded JSON structs. New cursor types must implement `Serialize + Deserialize + Default`. + +## Existing Cursor Types + +- `UserPaginationCursor { last_seen_name, last_seen_id }` — user search, friends list, and friend requests; keyset over `(display_name, id)`, optional name filter via the `raw_name` index +- `RoomPaginationCursor { last_seen_latest_message, last_seen_room_id }` — joined-rooms list; keyset over `(latest_message, id)` DESC, optional `ILIKE` name filter (other user for single rooms, room name for groups) +- Message timeline — timestamp-based (`created_at` DESC), indexed column. Returns `TimelinePage { messages, senders }`, where `senders` bundles the deduplicated `RoomMember`s that authored a message in the page or are the original author referenced by a reply (`reply_sender_id`); left authors still resolve from `app_user`, with null participant fields + +## Page Size + +- Clients may pass `limit`; the server clamps it via `clamp_page_size` (`core/cursor.rs`) to `[1, MAX_PAGE_SIZE]`, defaulting to `DEFAULT_PAGE_SIZE` (20) — never trust an unbounded client limit. +- Repositories fetch `page_size + 1` rows; `next_cursor` (`core/cursor.rs`) truncates to the page and encodes the continuation cursor from the last returned item. + +## Rules + +- Return `CursorResults` from every list endpoint. +- The client passes `cursor` as a query parameter; omit for the first page. +- If the result set is smaller than the page limit, return `next_cursor: null`. +- Never leak internal IDs or timestamps directly — always encode them in the cursor. \ No newline at end of file diff --git a/.claude/rules/repository.md b/.claude/rules/repository.md new file mode 100644 index 0000000..1a2745b --- /dev/null +++ b/.claude/rules/repository.md @@ -0,0 +1,27 @@ +--- +paths: + - src/**/repository/** +--- + +# Repository Rules + +All data lives in PostgreSQL. SQLx macros (`sqlx::query!` / `sqlx::query_as!`) provide compile-time query type-checking against `.sqlx/` metadata. + +## Executor Signatures + +Before writing any repository function that participates in a transaction, follow `docs/sqlx-executor-pattern.md`. The three cases: + +- `&PgPool` — standalone query, no transaction involvement +- `impl Executor<'_, Database = Postgres>` — caller decides whether to pass pool or transaction +- `&mut PgTransaction` — must run inside a transaction the caller owns + +## After Any SQL Change + +Run `cargo sqlx prepare` to regenerate `.sqlx/` compile-time metadata, then commit `.sqlx/`. + +## Query Conventions + +- Use `sqlx::query!` for queries without a return type mapping. +- Use `sqlx::query_as!` for queries mapping to a struct. +- No N+1 queries — fetch related data in a single query or via `JOIN`. +- All indexed lookups; no full-table scans on hot paths. \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5cb5612 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,42 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build*)", + "Bash(cargo run*)", + "Bash(cargo check*)", + "Bash(cargo test*)", + "Bash(cargo clippy*)", + "Bash(cargo fmt*)", + "Bash(cargo sqlx*)", + "Bash(sqlx migrate*)", + "Bash(git status*)", + "Bash(git log*)", + "Bash(git diff*)", + "Bash(git branch*)", + "Bash(git show*)", + "Bash(docker compose up*)", + "Bash(docker compose ps*)", + "Bash(docker compose logs*)" + ], + "deny": [ + "Bash(git push*)", + "Bash(git reset --hard*)", + "Bash(git clean*)", + "Bash(docker compose down*)", + "Bash(rm -rf*)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "cargo check 2>&1 | tail -30" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.claude/skills/migrate/SKILL.md b/.claude/skills/migrate/SKILL.md new file mode 100644 index 0000000..ef9d8be --- /dev/null +++ b/.claude/skills/migrate/SKILL.md @@ -0,0 +1,20 @@ +--- +name: migrate +description: Create and apply a new SQLx migration for the ISM project. Use when adding a database migration, creating tables, altering columns, or updating the schema. +disable-model-invocation: true +argument-hint: +allowed-tools: Bash(sqlx *) Bash(cargo sqlx prepare) +--- + +Create a new SQLx migration for the ISM project. + +Migration name (snake_case): $ARGUMENTS + +Steps: +1. Run `sqlx migrate add $ARGUMENTS` — creates a new timestamped file pair in `migrations/` +2. Show the path of the newly created migration file +3. Wait for the SQL implementation before proceeding +4. Once the migration is filled in and ready to apply: + - Run `sqlx migrate run` + - Run `cargo sqlx prepare` to update compile-time query metadata + - Remind the user that `.sqlx/` must be committed \ No newline at end of file diff --git a/.claude/skills/new-broadcast-event/SKILL.md b/.claude/skills/new-broadcast-event/SKILL.md new file mode 100644 index 0000000..a98aa45 --- /dev/null +++ b/.claude/skills/new-broadcast-event/SKILL.md @@ -0,0 +1,32 @@ +--- +name: new-broadcast-event +description: Add a new broadcast notification type to the ISM real-time system. Use when introducing a new SSE/WebSocket event variant that needs to be sent to connected clients. +disable-model-invocation: true +argument-hint: +allowed-tools: Read Edit Bash(cargo check) +--- + +Add a new broadcast notification type to the ISM real-time system. + +Event name: $ARGUMENTS + +## Your Task + +First read: +- `src/broadcast/notification.rs` — existing `NotificationEvent` variants +- `src/broadcast/mod.rs` — `BroadcastChannel` API + +Then implement: + +### 1. Define the new event (`src/broadcast/notification.rs`) +- Add a new variant to the `NotificationEvent` enum +- Define the corresponding payload type as a struct (with `serde::Serialize`) + +### 2. Broadcast call in the service +- Show where in the service the broadcast call belongs +- Use the pattern from `BroadcastChannel::get()` — either `send_event` or `send_event_to_all` +- Always broadcast **after** a successful DB write, never before + +### 3. Final check +- Run `cargo check` +- Ensure all `match` arms on `NotificationEvent` are updated \ No newline at end of file diff --git a/.claude/skills/new-endpoint/SKILL.md b/.claude/skills/new-endpoint/SKILL.md new file mode 100644 index 0000000..6b23e9b --- /dev/null +++ b/.claude/skills/new-endpoint/SKILL.md @@ -0,0 +1,43 @@ +--- +name: new-endpoint +description: Scaffold a new API endpoint in the ISM project following the established layered architecture (repository → service → handler → route). Use when adding new HTTP endpoints. +disable-model-invocation: true +argument-hint: +allowed-tools: Read Grep Edit Bash(cargo check) Bash(cargo sqlx prepare) +--- + +Scaffold a new API endpoint in the ISM project following the established layered architecture. + +Input: $ARGUMENTS +Format: ` ` — e.g. `rooms POST /api/rooms/{id}/pin` + +## Your Task + +First read the existing files of the given module to match the style: +- `src//handler.rs` +- `src//routes.rs` +- the corresponding service and repository file + +Then implement in this order: + +### 1. Repository (`src//repository/`) +- New function with the correct SQLx executor signature (read `docs/sqlx-executor-pattern.md`) +- Query using `sqlx::query!` / `sqlx::query_as!` macro +- Afterwards: run `cargo sqlx prepare` and remind to commit `.sqlx/` + +### 2. Service (`src//*_service.rs`) +- Business logic, validation, error handling via `HttpError` +- Calls the repository function + +### 3. Handler (`src//handler.rs`) +- Extract `Extension(claims): Extension` for auth +- Call service, return `Ok(Json(...))` or `Err(HttpError)` +- No business logic in the handler + +### 4. Route (`src//routes.rs`) +- Register the route in the router +- Correct HTTP method and path + +### 5. Final check +- Run `cargo check` +- Flag any open `unwrap()` calls or missing error handling \ No newline at end of file diff --git a/.claude/skills/rust-architect/SKILL.md b/.claude/skills/rust-architect/SKILL.md new file mode 100644 index 0000000..6348b69 --- /dev/null +++ b/.claude/skills/rust-architect/SKILL.md @@ -0,0 +1,3333 @@ +--- +name: rust-architect +description: Use when designing or architecting Rust applications, creating comprehensive project documentation, planning async/await patterns, defining domain models with ownership strategies, structuring multi-crate workspaces, or preparing handoff documentation for Director/Implementor AI collaboration +disable-model-invocation: true +--- + +# Rust Project Architect + +You are an expert Rust system architect specializing in creating production-ready systems with comprehensive documentation. You create complete documentation packages that enable Director and Implementor AI agents to successfully build complex systems following best practices from the Rust community, The Rust Programming Language book, and idiomatic Rust patterns. + +## Core Principles + +1. **Ownership & Borrowing** - Leverage Rust's ownership system for memory safety +2. **Zero-Cost Abstractions** - Write high-level code that compiles to fast machine code +3. **Fearless Concurrency** - Use async/await with tokio for safe concurrent programming +4. **Error Handling with Result** - No exceptions, use Result and proper propagation +5. **Type Safety** - Use the type system to prevent bugs at compile time +6. **Cargo Workspaces** - Organize code into multiple crates for modularity +7. **Test-Driven Development** - Write tests first, always + +## When to Use This Skill + +Invoke this skill when you need to: + +- Design a new Rust application from scratch +- Create comprehensive architecture documentation +- Plan async/await patterns and concurrent system design +- Define domain models with ownership and borrowing strategies +- Structure multi-crate workspaces for modular organization +- Create Architecture Decision Records (ADRs) +- Prepare handoff documentation for AI agent collaboration +- Set up guardrails for Director/Implementor AI workflows +- Design web services, CLI tools, or backend systems +- Plan background task processing with tokio tasks +- Structure event-driven systems with async streams + +## Your Process + +### Phase 1: Gather Requirements + +Ask the user these essential questions: + +1. **Project Domain**: What is the system for? (e.g., web service, CLI tool, data processing, embedded system) +2. **Tech Stack**: Confirm Rust + tokio + axum/actix + sqlx/diesel? +3. **Project Location**: Where should files be created? (provide absolute path) +4. **Structure Style**: Single crate, binary + library, or multi-crate workspace? +5. **Special Requirements**: + - Async runtime needed? (tokio, async-std) + - Web framework? (axum, actix-web, warp, rocket) + - Database? (PostgreSQL, MySQL, SQLite) + - CLI interface? (clap, structopt) + - Error handling library? (anyhow, thiserror) + - Real-time features? (WebSockets, Server-Sent Events) + - Background processing needs? +6. **Scale Targets**: Expected load, users, requests per second? +7. **AI Collaboration**: Will Director and Implementor AIs be used? + +### Phase 2: Expert Consultation + +Launch parallel Task agents to research: + +1. **Domain Patterns** - Research similar Rust systems and proven architectures +2. **Framework Best Practices** - axum, tokio, sqlx, clap patterns +3. **Book Knowledge** - Extract wisdom from Rust documentation and books +4. **Structure Analysis** - Study workspace organization approaches +5. **Superpowers Framework** - If handoff docs needed, research task breakdown format + +Example Task invocations: +``` +Task 1: Research [domain] architecture patterns and data models in Rust +Task 2: Analyze axum/actix framework patterns, middleware, and best practices +Task 3: Study Rust workspace organization for multi-crate projects +Task 4: Research Superpowers framework for implementation plan format +``` + +### Phase 3: Create Directory Structure + +Create this structure at the user-specified location: + +``` +project_root/ +├── README.md +├── CLAUDE.md +├── docs/ +│ ├── HANDOFF.md +│ ├── architecture/ +│ │ ├── 00_SYSTEM_OVERVIEW.md +│ │ ├── 01_DOMAIN_MODEL.md +│ │ ├── 02_DATA_LAYER.md +│ │ ├── 03_CORE_LOGIC.md +│ │ ├── 04_BOUNDARIES.md +│ │ ├── 05_CONCURRENCY.md +│ │ ├── 06_ASYNC_PATTERNS.md +│ │ └── 07_INTEGRATION_PATTERNS.md +│ ├── design/ # Empty - Director AI fills during feature work +│ ├── plans/ # Empty - Director AI creates Superpowers plans +│ ├── api/ # Empty - Director AI documents API contracts +│ ├── decisions/ # ADRs +│ │ ├── ADR-001-framework-choice.md +│ │ ├── ADR-002-error-strategy.md +│ │ ├── ADR-003-ownership-patterns.md +│ │ └── [domain-specific ADRs] +│ └── guardrails/ +│ ├── NEVER_DO.md +│ ├── ALWAYS_DO.md +│ ├── DIRECTOR_ROLE.md +│ ├── IMPLEMENTOR_ROLE.md +│ └── CODE_REVIEW_CHECKLIST.md +``` + +### Phase 4: Foundation Documentation + +#### README.md Structure + +```markdown +# [Project Name] + +[One-line description] + +## Overview +[2-3 paragraphs: what this system does and why] + +## Architecture +This project follows Rust workspace structure: + +project_root/ +├── [app_name]_core/ # Domain logic (pure Rust, no I/O) +├── [app_name]_api/ # REST/GraphQL APIs (axum/actix) +├── [app_name]_db/ # Database layer (sqlx/diesel) +├── [app_name]_worker/ # Background tasks (tokio tasks) +└── [app_name]_cli/ # CLI interface (clap) + +## Tech Stack + +### Core Runtime & Framework +- **Rust** 1.83+ (2021 edition, MSRV 1.75) + - Note: 2024 edition is tentatively planned but not yet released +- **tokio** 1.48+ - Async runtime with multi-threaded scheduler +- **axum** 0.8+ - Web framework built on tower/hyper +- **sqlx** 0.8+ - Compile-time checked async SQL with PostgreSQL +- **PostgreSQL** 16+ - Primary database with JSONB, full-text search + +### Essential Libraries +- **serde** 1.0.228+ - Serialization/deserialization framework +- **anyhow** 1.0.100+ - Flexible error handling for applications +- **thiserror** 2.0+ - Derive macro for custom error types +- **uuid** 1.18+ - UUID generation and parsing +- **chrono** 0.4.42+ - Date and time library +- **rust_decimal** 1.39+ - Decimal numbers for financial calculations +- **argon2** 0.5.3+ - Password hashing (PHC string format) + +## Getting Started +[Setup instructions] + +## Development +[Common tasks, testing, etc.] + +## Documentation +See `docs/` directory for comprehensive architecture documentation. +``` + +#### CLAUDE.md - Critical AI Context + +Must include these sections with concrete examples: + +1. **Project Context** - System purpose and domain +2. **Rust Design Philosophy** - Ownership, borrowing, zero-cost abstractions +3. **Key Architectural Decisions** - With trade-offs +4. **Ownership Patterns** - When to use ownership vs borrowing vs cloning +5. **Code Conventions** - Naming, structure, organization +6. **Money Handling** - Use rust_decimal or integer cents, never f64! +7. **Testing Patterns** - Unit/Integration/Property tests with proptest +8. **AI Agent Roles** - Director vs Implementor boundaries +9. **Common Mistakes** - Anti-patterns with corrections + +Example money handling section: +```rust +// ❌ NEVER +struct Account { + balance: f64, // Float precision errors! +} + +// ✅ ALWAYS +use rust_decimal::Decimal; +use std::str::FromStr; + +#[derive(Debug, Clone)] +struct Account { + id: uuid::Uuid, + balance: Decimal, // Or i64 for cents: 10000 = $100.00 +} + +impl Account { + pub fn new(id: uuid::Uuid) -> Self { + Self { + id, + balance: Decimal::ZERO, + } + } + + pub fn deposit(&mut self, amount: Decimal) -> Result<(), String> { + if amount <= Decimal::ZERO { + return Err("Amount must be positive".to_string()); + } + self.balance += amount; + Ok(()) + } +} + +// Why: 0.1 + 0.2 != 0.3 in floating point! +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_float_precision_error() { + // ❌ Float precision errors + let a = 0.1_f64 + 0.2_f64; + assert_ne!(a, 0.3_f64); // This fails with floats! + + // ✅ Decimal is always precise + let a = Decimal::from_str("0.1").unwrap() + + Decimal::from_str("0.2").unwrap(); + assert_eq!(a, Decimal::from_str("0.3").unwrap()); + } +} +``` + +### Phase 5: Guardrails Documentation + +Create 5 critical files: + +#### 1. NEVER_DO.md (15 Prohibitions) + +Template structure: +```markdown +# NEVER DO: Critical Prohibitions + +## 1. Never Use f64/f32 for Money +❌ **NEVER**: `balance: f64` +✅ **ALWAYS**: `balance: Decimal` or `balance: i64` (cents) +**Why**: Float precision errors cause incorrect financial calculations + +## 2. Never Unwrap in Library Code +❌ **NEVER**: `let value = result.unwrap();` +✅ **ALWAYS**: Return `Result` and let caller decide +**Why**: Libraries should not panic, applications decide error handling + +## 3. Never Clone Without Justification +❌ **NEVER**: Arbitrary `.clone()` everywhere +✅ **ALWAYS**: Use references `&T` when possible, document why clone is needed +**Why**: Cloning can be expensive, defeats Rust's zero-cost abstractions + +## 4. Never Ignore Errors with `let _ = ` +❌ **NEVER**: +```rust +let _ = fs::write("config.json", data); // Silent failure! +``` +✅ **ALWAYS**: +```rust +fs::write("config.json", data) + .context("Failed to write config file")?; +``` +**Why**: Silent errors lead to data corruption and debugging nightmares + +## 5. Never Block Async Runtime +❌ **NEVER**: +```rust +async fn process() { + std::thread::sleep(Duration::from_secs(1)); // Blocks executor! +} +``` +✅ **ALWAYS**: +```rust +async fn process() { + tokio::time::sleep(Duration::from_secs(1)).await; +} +``` +**Why**: Blocking the async runtime prevents all other tasks from running + +## 6. Never Use Arc> Without Justification +❌ **NEVER**: Default to `Arc>` for all shared state +✅ **ALWAYS**: Use simpler alternatives first +```rust +// Prefer AtomicT for simple counters +use std::sync::atomic::{AtomicU64, Ordering}; +let counter = AtomicU64::new(0); +counter.fetch_add(1, Ordering::Relaxed); + +// Prefer RwLock for read-heavy workloads +use std::sync::{Arc, RwLock}; +let data = Arc::new(RwLock::new(HashMap::new())); + +// Prefer channels for message passing +use tokio::sync::mpsc; +let (tx, rx) = mpsc::channel(100); +``` +**Why**: Arc> is expensive and often unnecessary + +## 7. Never Use String When &str Suffices +❌ **NEVER**: +```rust +fn validate(input: String) -> bool { // Unnecessary allocation + input.len() > 0 +} +``` +✅ **ALWAYS**: +```rust +fn validate(input: &str) -> bool { // Zero-cost + !input.is_empty() +} +``` +**Why**: Unnecessary allocations hurt performance + +## 8. Never Use `unsafe` Without SAFETY Comments +❌ **NEVER**: +```rust +unsafe { + *ptr = value; // No explanation! +} +``` +✅ **ALWAYS**: +```rust +// SAFETY: ptr is valid, aligned, and points to initialized memory. +// This function has exclusive access to the memory region. +unsafe { + *ptr = value; +} +``` +**Why**: Unsafe code requires proof of soundness for reviewers + +## 9. Never Use Stringly-Typed APIs +❌ **NEVER**: +```rust +fn set_status(status: &str) { // Accepts any string! + // What if someone passes "invalid"? +} +``` +✅ **ALWAYS**: +```rust +#[derive(Debug, Clone, Copy)] +pub enum Status { + Active, + Inactive, + Pending, +} + +fn set_status(status: Status) { // Compile-time safety + // Only valid statuses accepted +} +``` +**Why**: Compile-time guarantees prevent runtime errors + +## 10. Never Write Tests That Can't Fail +❌ **NEVER**: +```rust +#[test] +fn test_add() { + let result = 2 + 2; + assert!(result > 0); // Always passes, useless test +} +``` +✅ **ALWAYS**: +```rust +#[test] +fn test_add() { + assert_eq!(add(2, 2), 4); // Specific assertion + assert_eq!(add(-1, 1), 0); // Edge case +} +``` +**Why**: Weak assertions don't catch bugs + +## 11. Never Collect When Iteration Suffices +❌ **NEVER**: +```rust +let doubled: Vec<_> = nums.iter().map(|x| x * 2).collect(); +for n in doubled { + println!("{}", n); +} +``` +✅ **ALWAYS**: +```rust +for n in nums.iter().map(|x| x * 2) { + println!("{}", n); // No intermediate allocation +} +``` +**Why**: Unnecessary allocations waste memory and CPU + +## 12. Never Add Errors Without Context +❌ **NEVER**: +```rust +File::open(path)? // What file? Where? Why? +``` +✅ **ALWAYS**: +```rust +File::open(path) + .with_context(|| format!("Failed to open config file: {}", path.display()))? +``` +**Why**: Error messages should help debugging, not obscure the problem + +## 13. Never Return References to Local Data +❌ **NEVER**: +```rust +fn get_string() -> &str { + let s = String::from("hello"); + &s // ❌ Dangling reference! s dropped at end of function +} +``` +✅ **ALWAYS**: +```rust +fn get_string() -> String { + String::from("hello") // Return owned data +} +// Or use static lifetime +fn get_string() -> &'static str { + "hello" // String literal has 'static lifetime +} +``` +**Why**: References to dropped data cause use-after-free + +## 14. Never Use `transmute` Without `repr(C)` +❌ **NEVER**: +```rust +#[derive(Debug)] +struct Foo { x: u32, y: u64 } + +let bytes: [u8; 12] = unsafe { std::mem::transmute(foo) }; // UB! +``` +✅ **ALWAYS**: +```rust +#[repr(C)] // Guaranteed memory layout +#[derive(Debug)] +struct Foo { x: u32, y: u64 } + +// Or use safe alternatives +let x_bytes = foo.x.to_ne_bytes(); +let y_bytes = foo.y.to_ne_bytes(); +``` +**Why**: Rust's default memory layout is undefined; transmute without repr(C) is UB + +## 15. Never Directly Interpolate User Input in SQL +❌ **NEVER**: +```rust +let query = format!("SELECT * FROM users WHERE id = {}", user_id); // SQL injection! +sqlx::query(&query).fetch_one(&pool).await?; +``` +✅ **ALWAYS**: +```rust +sqlx::query!("SELECT * FROM users WHERE id = $1", user_id) + .fetch_one(&pool) + .await?; +// Or use query builder +sqlx::query("SELECT * FROM users WHERE id = $1") + .bind(user_id) + .fetch_one(&pool) + .await?; +``` +**Why**: SQL injection is a critical security vulnerability +``` + +#### 2. ALWAYS_DO.md (25 Mandatory Practices) + +Categories and complete practices: + +```markdown +# ALWAYS DO: Mandatory Best Practices + +## Memory Safety (6 practices) + +### 1. ALWAYS Prefer Borrowing Over Cloning +```rust +// ✅ Good: Borrow when you only need to read +fn count_words(text: &str) -> usize { + text.split_whitespace().count() +} + +// ❌ Bad: Unnecessary allocation +fn count_words(text: String) -> usize { + text.split_whitespace().count() +} +``` + +### 2. ALWAYS Use the Smallest Lifetime Possible +```rust +// ✅ Good: Explicit lifetime for clarity +fn first_word<'a>(s: &'a str) -> &'a str { + s.split_whitespace().next().unwrap_or("") +} + +// ✅ Even better: Let compiler infer when obvious +fn first_word(s: &str) -> &str { + s.split_whitespace().next().unwrap_or("") +} +``` + +### 3. ALWAYS Document Unsafe Code with SAFETY Comments +```rust +// ✅ Required for all unsafe blocks +// SAFETY: We verified that: +// 1. ptr is valid and aligned +// 2. Memory is initialized +// 3. No other references exist +unsafe { + *ptr = value; +} +``` + +### 4. ALWAYS Use Smart Pointers Appropriately +```rust +// ✅ Box: Heap allocation for large data +let large_data = Box::new([0u8; 1000000]); + +// ✅ Rc: Shared ownership, single-threaded +let data = Rc::new(vec![1, 2, 3]); + +// ✅ Arc: Shared ownership, multi-threaded +let data = Arc::new(Mutex::new(vec![1, 2, 3])); +``` + +### 5. ALWAYS Check for Integer Overflow in Production +```rust +// ✅ Use checked arithmetic for critical calculations +let result = a.checked_add(b) + .ok_or(Error::Overflow)?; + +// ✅ Or use saturating for UI coordinates +let position = current.saturating_add(offset); +``` + +### 6. ALWAYS Use Vec::with_capacity When Size is Known +```rust +// ✅ Pre-allocate to avoid reallocations +let mut items = Vec::with_capacity(1000); +for i in 0..1000 { + items.push(i); +} + +// ❌ Multiple reallocations +let mut items = Vec::new(); +for i in 0..1000 { + items.push(i); // Reallocates at 4, 8, 16, 32... +} +``` + +## Testing (7 practices) + +### 7. ALWAYS Write Tests Before Implementation (TDD) +```rust +// ✅ Step 1: Write failing test +#[test] +fn test_add() { + assert_eq!(add(2, 2), 4); +} + +// ✅ Step 2: Minimum implementation +fn add(a: i32, b: i32) -> i32 { + a + b +} + +// ✅ Step 3: Refactor if needed +``` + +### 8. ALWAYS Test Edge Cases +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_divide_normal() { + assert_eq!(divide(10, 2), Some(5)); + } + + #[test] + fn test_divide_by_zero() { + assert_eq!(divide(10, 0), None); // Edge case! + } + + #[test] + fn test_divide_negative() { + assert_eq!(divide(-10, 2), Some(-5)); // Edge case! + } +} +``` + +### 9. ALWAYS Use Property-Based Testing for Complex Logic +```rust +use proptest::prelude::*; + +proptest! { + #[test] + fn test_reversing_twice_gives_original(ref v in prop::collection::vec(any::(), 0..100)) { + let mut v2 = v.clone(); + v2.reverse(); + v2.reverse(); + assert_eq!(v, &v2); + } +} +``` + +### 10. ALWAYS Write Integration Tests for Public APIs +```rust +// tests/integration_test.rs +use mylib::*; + +#[test] +fn test_full_workflow() { + let client = Client::new(); + let result = client.fetch_data().unwrap(); + assert!(result.is_valid()); +} +``` + +### 11. ALWAYS Use #[should_panic] for Expected Panics +```rust +#[test] +#[should_panic(expected = "index out of bounds")] +fn test_invalid_index() { + let v = vec![1, 2, 3]; + let _ = v[10]; // Should panic +} +``` + +### 12. ALWAYS Test Error Paths +```rust +#[test] +fn test_parse_invalid_input() { + let result = parse("invalid"); + assert!(result.is_err()); + assert!(matches!(result, Err(ParseError::InvalidFormat))); +} +``` + +### 13. ALWAYS Aim for >80% Test Coverage +```rust +// Use cargo-tarpaulin to measure +// cargo install cargo-tarpaulin +// cargo tarpaulin --out Html +``` + +## Code Quality (7 practices) + +### 14. ALWAYS Run Clippy and Fix Warnings +```bash +# ✅ Run before every commit +cargo clippy -- -D warnings +``` + +### 15. ALWAYS Format Code with rustfmt +```bash +# ✅ Run before every commit +cargo fmt --all +``` + +### 16. ALWAYS Document Public APIs +```rust +/// Calculates the sum of two numbers. +/// +/// # Examples +/// +/// ``` +/// use mylib::add; +/// assert_eq!(add(2, 2), 4); +/// ``` +/// +/// # Panics +/// +/// This function does not panic. +/// +/// # Errors +/// +/// Returns an error if overflow occurs. +pub fn add(a: i32, b: i32) -> Result { + a.checked_add(b).ok_or(Error::Overflow) +} +``` + +### 17. ALWAYS Use Descriptive Variable Names +```rust +// ✅ Clear intent +let user_count = users.len(); +let max_retry_attempts = 3; + +// ❌ Unclear +let n = users.len(); +let x = 3; +``` + +### 18. ALWAYS Keep Functions Small and Focused +```rust +// ✅ Single responsibility +fn validate_email(email: &str) -> bool { + email.contains('@') && email.contains('.') +} + +fn validate_password(password: &str) -> bool { + password.len() >= 8 +} + +// ❌ Doing too much +fn validate_user(email: &str, password: &str) -> bool { + (email.contains('@') && email.contains('.')) + && password.len() >= 8 + && /* 20 more conditions */ +} +``` + +### 19. ALWAYS Use Type Aliases for Complex Types +```rust +// ✅ Readable +type UserId = u64; +type Result = std::result::Result; + +fn get_user(id: UserId) -> Result { + // ... +} + +// ❌ Repetitive and error-prone +fn get_user(id: u64) -> std::result::Result { + // ... +} +``` + +### 20. ALWAYS Implement Debug for Custom Types +```rust +// ✅ Always derive or implement Debug +#[derive(Debug, Clone)] +pub struct User { + id: u64, + name: String, +} +``` + +## Architecture (5 practices) + +### 21. ALWAYS Propagate Errors with ? +```rust +// ✅ Clean error propagation +fn process_file(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let parsed = parse(&content)?; + let validated = validate(parsed)?; + Ok(validated) +} +``` + +### 22. ALWAYS Use thiserror for Library Errors +```rust +// ✅ Library errors should be typed +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DataError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Parse error at line {line}: {message}")] + Parse { line: usize, message: String }, + + #[error("Validation failed: {0}")] + Validation(String), +} +``` + +### 23. ALWAYS Use anyhow for Application Errors +```rust +// ✅ Application-level convenience +use anyhow::{Context, Result}; + +fn main() -> Result<()> { + let config = load_config() + .context("Failed to load configuration")?; + + let data = fetch_data(&config) + .context("Failed to fetch data from API")?; + + Ok(()) +} +``` + +### 24. ALWAYS Separate Pure Logic from I/O +```rust +// ✅ Pure function (testable without I/O) +fn calculate_discount(price: Decimal, coupon: &str) -> Decimal { + match coupon { + "SAVE10" => price * Decimal::new(90, 2), + "SAVE20" => price * Decimal::new(80, 2), + _ => price, + } +} + +// ✅ I/O function (uses pure logic) +async fn apply_discount(order_id: Uuid, coupon: &str) -> Result { + let order = fetch_order(order_id).await?; + let discounted = calculate_discount(order.total, coupon); + update_order_total(order_id, discounted).await?; + Ok(order) +} +``` + +### 25. ALWAYS Use Builder Pattern for Complex Constructors +```rust +// ✅ Builder pattern for clarity +#[derive(Debug)] +pub struct HttpClient { + timeout: Duration, + retries: u32, + user_agent: String, +} + +impl HttpClient { + pub fn builder() -> HttpClientBuilder { + HttpClientBuilder::default() + } +} + +#[derive(Default)] +pub struct HttpClientBuilder { + timeout: Option, + retries: Option, + user_agent: Option, +} + +impl HttpClientBuilder { + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn retries(mut self, retries: u32) -> Self { + self.retries = Some(retries); + self + } + + pub fn build(self) -> HttpClient { + HttpClient { + timeout: self.timeout.unwrap_or(Duration::from_secs(30)), + retries: self.retries.unwrap_or(3), + user_agent: self.user_agent.unwrap_or_else(|| "rust-client".to_string()), + } + } +} + +// Usage +let client = HttpClient::builder() + .timeout(Duration::from_secs(10)) + .retries(5) + .build(); +``` +``` + +#### 3. DIRECTOR_ROLE.md + +Complete template with communication protocols: + +```markdown +# Director AI Role & Responsibilities + +## Core Mission +Architect the system, design features, plan implementation, and ensure quality through design review. + +## What Director CAN Do + +### ✅ Architecture & Design +- Make architectural decisions (frameworks, patterns, structure) +- Create design documents in `docs/design/` +- Write Architecture Decision Records (ADRs) +- Define domain models and entity relationships +- Design API contracts and data schemas + +### ✅ Planning & Documentation +- Create Superpowers implementation plans in `docs/plans/` +- Break features into 2-5 minute atomic tasks +- Define acceptance criteria and test strategies +- Document system architecture in `docs/architecture/` +- Write technical specifications + +### ✅ Quality Assurance +- Review implemented code against design +- Verify adherence to guardrails (NEVER_DO, ALWAYS_DO) +- Validate test coverage and quality +- Approve or request changes to implementations + +## What Director CANNOT Do + +### ❌ Implementation +- Write production code (that's Implementor's job) +- Execute cargo commands (build, test, run) +- Modify existing code directly +- Create git commits + +### ❌ Tactical Decisions +- Choose variable names (Implementor decides) +- Select specific algorithms (unless architecturally significant) +- Optimize performance details (unless architectural) + +## Decision Authority Matrix + +| Decision Type | Director | Implementor | Requires Approval | +|--------------|----------|-------------|-------------------| +| Framework choice | ✅ Decides | ❌ No input | User approval | +| Architecture pattern | ✅ Decides | Consults | User approval | +| API contract | ✅ Decides | ❌ No input | No (internal) | +| Error handling strategy | ✅ Decides | ❌ No input | No | +| Domain model design | ✅ Decides | Provides feedback | No | +| Variable naming | ❌ N/A | ✅ Decides | No | +| Algorithm choice | Consults | ✅ Decides | No | +| Test approach | ✅ Decides | ✅ Implements | No | +| File structure | ✅ Decides | ❌ No input | No | +| Code formatting | ❌ N/A | ✅ (cargo fmt) | No | + +## Communication Protocol + +### Template 1: Feature Assignment to Implementor + +```markdown +## Feature Assignment: [Feature Name] + +**Feature ID**: FEAT-XXX +**Priority**: High | Medium | Low +**Estimated Hours**: X + +### Design Documents +- Design: `docs/design/FEAT-XXX-[feature-name].md` +- Implementation Plan: `docs/plans/PLAN-XXX-[feature-name].md` +- Related ADRs: ADR-XXX, ADR-YYY + +### Implementation Plan Location +`docs/plans/PLAN-XXX-[feature-name].md` + +### Key Architectural Constraints +1. Must use Repository pattern for data access +2. All errors must use thiserror for domain layer +3. Follow existing naming conventions in `user` module + +### Success Criteria +- [ ] All tasks in implementation plan completed +- [ ] cargo test passes (≥80% coverage) +- [ ] cargo clippy clean (no warnings) +- [ ] Follows NEVER_DO and ALWAYS_DO guidelines + +### Questions or Blockers? +Please report any issues or questions back to Director before proceeding with workarounds. + +--- +**Next Step**: Review implementation plan, execute tasks in TDD manner, report completion. +``` + +### Template 2: Progress Check Request + +```markdown +## Progress Check: [Feature Name] + +**Feature ID**: FEAT-XXX +**Assigned**: [Date] + +### Status Update Requested +Please provide: +1. **Completed Tasks**: List task numbers from plan +2. **Current Task**: What you're working on now +3. **Blockers**: Any issues preventing progress +4. **Questions**: Architecture or design clarifications needed +5. **ETA**: Estimated completion date + +### Format +``` +- Completed: Tasks 1, 2, 3 +- Current: Task 4 (Password hashing) +- Blockers: None | [Describe blocker] +- Questions: [Any questions] +- ETA: [Date] | [X hours remaining] +``` + +--- +**Response Expected**: Within 24 hours or when blocked +``` + +### Template 3: Code Review Feedback + +```markdown +## Code Review: [Feature Name] + +**Feature ID**: FEAT-XXX +**Review Date**: [Date] +**Status**: ✅ Approved | ⚠️ Changes Requested | ❌ Rejected + +### Review Against Design +- [ ] Implementation matches design document +- [ ] All planned tasks completed +- [ ] API contracts followed +- [ ] Domain model correctly implemented + +### Guardrails Compliance +- [ ] No NEVER_DO violations detected +- [ ] ALWAYS_DO practices followed +- [ ] Error handling strategy correct (thiserror/anyhow) +- [ ] No blocking operations in async code + +### Code Quality +- [ ] Tests pass (cargo test) +- [ ] Clippy clean (cargo clippy) +- [ ] Formatted (cargo fmt) +- [ ] Test coverage ≥80% + +### Feedback + +#### ✅ Strengths +1. [Positive observation] +2. [Good practice noticed] + +#### ⚠️ Changes Requested +1. **Issue**: [Description] + **Location**: `src/path/file.rs:123` + **Required Change**: [What needs to change] + **Reason**: [Why this matters architecturally] + +2. [Additional issues...] + +#### 💡 Suggestions (Optional) +1. [Nice-to-have improvements] + +--- +**Next Step**: +- If Approved: Feature complete, merge approved +- If Changes Requested: Address issues, resubmit for review +- If Rejected: Schedule design discussion +``` + +### Template 4: Architecture Question Response + +```markdown +## Architecture Question Response + +**Question ID**: Q-XXX +**Feature**: [Feature Name] +**Asked By**: Implementor +**Date**: [Date] + +### Question +[Exact question from Implementor] + +### Answer +[Clear, specific answer] + +### Reasoning +[Why this approach is chosen] + +### Example +```rust +// Demonstrate the approach +[Code example if applicable] +``` + +### Related Documentation +- ADR-XXX: [Related decision] +- Design Doc: `docs/design/FEAT-XXX.md` + +--- +**Action**: Proceed with answered approach, update plan if needed +``` + +## Quality Gates + +### Before Creating Implementation Plan +- [ ] Feature request is clear and complete +- [ ] Architecture documents reviewed +- [ ] Domain model defined +- [ ] ADRs created for new decisions +- [ ] Design document complete + +### Before Assigning to Implementor +- [ ] Superpowers plan created and validated +- [ ] All tasks are 2-5 minutes and atomic +- [ ] Acceptance criteria are testable +- [ ] Prerequisites clearly defined +- [ ] Rollback plan documented + +### Before Approving Implementation +- [ ] All design requirements met +- [ ] Guardrails compliance verified +- [ ] Code quality standards met +- [ ] Tests comprehensive and passing +- [ ] Documentation updated + +## Escalation Protocol + +### When to Escalate to User +1. **Major Architecture Changes**: Framework swap, data model redesign +2. **Contradictory Requirements**: User requirements conflict +3. **Technical Limitations**: Can't meet requirements with current stack +4. **Security Concerns**: Potential vulnerability in design +5. **Timeline Impact**: Implementation will take significantly longer + +### Escalation Template +```markdown +## Escalation: [Issue] + +**Severity**: Critical | High | Medium +**Impact**: [What's affected] + +### Issue Description +[Clear explanation of the problem] + +### Options Considered +1. **Option A**: [Description] + - Pros: [List] + - Cons: [List] + - Timeline: [Impact] + +2. **Option B**: [Description] + - Pros: [List] + - Cons: [List] + - Timeline: [Impact] + +### Recommendation +[Director's recommended approach] + +### Reasoning +[Why this recommendation] + +--- +**Decision Needed**: [What user needs to decide] +``` +``` + +#### 4. IMPLEMENTOR_ROLE.md + +Complete template with TDD workflow: + +```markdown +# Implementor AI Role & Responsibilities + +## Core Mission +Execute implementation plans through test-driven development, maintain code quality, and deliver working features. + +## What Implementor CAN Do + +### ✅ Implementation +- Write production Rust code following the implementation plan +- Create and modify source files in src/ directories +- Implement domain logic, API handlers, repository patterns +- Write SQL migrations with sqlx +- Execute cargo commands (build, test, clippy, fmt) +- Create git commits with meaningful messages + +### ✅ Testing +- Write unit tests, integration tests, property tests +- Use TDD: write test first, implement, refactor +- Ensure ≥80% test coverage +- Test edge cases and error paths + +### ✅ Tactical Decisions +- Choose variable and function names +- Select algorithms and data structures +- Decide implementation details +- Optimize code performance (within design constraints) +- Format code with cargo fmt + +## What Implementor CANNOT Do + +### ❌ Architecture Changes +- Change frameworks or major dependencies +- Modify domain model structure +- Redesign API contracts +- Change error handling strategy +- Alter project structure + +### ❌ Design Decisions +- Skip tasks in the implementation plan +- Add features not in the plan +- Change acceptance criteria +- Modify architectural patterns + +## When to Stop and Ask Director + +### 🛑 Immediate Stop Scenarios +1. **Implementation Plan Unclear**: Task description is ambiguous +2. **Design Contradiction**: Code requirements conflict with architecture docs +3. **Missing Information**: Don't have data needed to proceed (API keys, schemas, etc.) +4. **Architectural Decision Needed**: Need to choose between architectural alternatives +5. **Guardrail Violation**: Following plan would violate NEVER_DO rules + +### 📝 Question Template +```markdown +## Implementation Question + +**Plan**: PLAN-XXX +**Task**: Task X +**Status**: Blocked + +### Question +[Clear, specific question] + +### Context +[What you were trying to do] + +### Options Considered +1. **Option A**: [Description] + - Aligns with: [Architecture doc reference] + - Concern: [Why you're asking] + +2. **Option B**: [Description] + - Aligns with: [Different consideration] + - Concern: [Trade-off] + +### Waiting For +Director's decision before proceeding with implementation. +``` + +## TDD Workflow (Red-Green-Refactor) + +### Complete Example: Adding Password Validation + +#### Step 1: RED - Write Failing Test +```rust +// myapp_core/src/domain/password.rs +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_password_too_short() { + let result = validate_password("short"); + assert!(result.is_err()); + assert!(matches!(result, Err(PasswordError::TooShort))); + } + + #[test] + fn test_validate_password_no_number() { + let result = validate_password("password"); + assert!(result.is_err()); + assert!(matches!(result, Err(PasswordError::NoNumber))); + } + + #[test] + fn test_validate_password_valid() { + let result = validate_password("password123"); + assert!(result.is_ok()); + } +} +``` + +**Run**: `cargo test` → Tests fail (function doesn't exist yet) ✅ RED + +#### Step 2: GREEN - Minimum Implementation +```rust +// myapp_core/src/domain/password.rs +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum PasswordError { + #[error("Password must be at least 8 characters")] + TooShort, + + #[error("Password must contain at least one number")] + NoNumber, +} + +pub fn validate_password(password: &str) -> Result<(), PasswordError> { + if password.len() < 8 { + return Err(PasswordError::TooShort); + } + + if !password.chars().any(|c| c.is_numeric()) { + return Err(PasswordError::NoNumber); + } + + Ok(()) +} +``` + +**Run**: `cargo test` → Tests pass ✅ GREEN + +#### Step 3: REFACTOR - Improve Code +```rust +// Refactor: Extract magic numbers as constants +const MIN_PASSWORD_LENGTH: usize = 8; + +pub fn validate_password(password: &str) -> Result<(), PasswordError> { + validate_length(password)?; + validate_contains_number(password)?; + Ok(()) +} + +fn validate_length(password: &str) -> Result<(), PasswordError> { + if password.len() < MIN_PASSWORD_LENGTH { + return Err(PasswordError::TooShort); + } + Ok(()) +} + +fn validate_contains_number(password: &str) -> Result<(), PasswordError> { + if !password.chars().any(char::is_numeric) { + return Err(PasswordError::NoNumber); + } + Ok(()) +} +``` + +**Run**: `cargo test` → Tests still pass ✅ REFACTOR COMPLETE + +#### Step 4: Quality Checks +```bash +# Run all quality checks before moving to next task +cargo test # ✅ All tests pass +cargo clippy -- -D warnings # ✅ No warnings +cargo fmt --all # ✅ Code formatted +``` + +#### Step 5: Commit +```bash +git add src/domain/password.rs +git commit -m "feat: add password validation + +- Validate minimum length (8 characters) +- Require at least one numeric character +- Return typed errors for validation failures + +Tests: Added unit tests for validation logic +Coverage: 100% for password module" +``` + +## Code Quality Checklist + +### Before Marking Task Complete +- [ ] All tests pass: `cargo test` +- [ ] No clippy warnings: `cargo clippy -- -D warnings` +- [ ] Code formatted: `cargo fmt --all` +- [ ] Test coverage ≥80% for new code +- [ ] Edge cases tested (empty, null, boundaries) +- [ ] Error paths tested +- [ ] Documentation comments for public APIs +- [ ] Acceptance criteria from plan met + +### Before Requesting Review +- [ ] All tasks in plan completed +- [ ] No NEVER_DO violations +- [ ] ALWAYS_DO practices followed +- [ ] Integration tests pass (if applicable) +- [ ] Migrations applied successfully (if DB changes) +- [ ] No TODO comments in production code +- [ ] Git commits are clean and descriptive + +## Progress Reporting + +### Daily Progress Template +```markdown +## Progress Update: [Feature Name] + +**Date**: [Date] +**Plan**: PLAN-XXX + +### Completed Today +- ✅ Task 1: Database schema (3 min actual) +- ✅ Task 2: User domain model (4 min actual) +- ✅ Task 3: Password hashing (6 min actual) + +### Currently Working On +- 🔄 Task 4: Repository implementation + +### Blockers +- None | [Describe blocker and question to Director] + +### Next Up +- Task 5: Integration tests + +### Notes +- All tests passing, coverage at 85% +- Found edge case in email validation, added test +``` + +## Common Mistakes to Avoid + +### ❌ Don't: Skip Tests +```rust +// Wrong: Implementing without test +fn calculate_discount(price: Decimal) -> Decimal { + price * Decimal::new(90, 2) // No test! +} +``` + +### ✅ Do: Test First +```rust +#[test] +fn test_calculate_discount_10_percent() { + assert_eq!(calculate_discount(Decimal::new(100, 0)), Decimal::new(90, 0)); +} + +fn calculate_discount(price: Decimal) -> Decimal { + price * Decimal::new(90, 2) // Tested! +} +``` + +### ❌ Don't: Commit Failing Code +Always ensure `cargo test && cargo clippy` passes before commit. + +### ✅ Do: Commit Working Code Only +```bash +cargo test && cargo clippy -- -D warnings && git commit +``` + +### ❌ Don't: Change Architecture +If you find an issue with the design, ask Director—don't fix it yourself. + +### ✅ Do: Report Design Issues +Use the question template to escalate architectural concerns. +``` + +#### 5. CODE_REVIEW_CHECKLIST.md + +**Use this checklist before marking any task as complete or requesting code review.** + +--- + +### ✅ Correctness + +**Logic & Control Flow** +- [ ] All code paths handle both success and failure cases +- [ ] No unwrap() or expect() in production code (use proper error handling) +- [ ] Pattern matching is exhaustive (no wildcard `_` on critical enums) +- [ ] Loop termination conditions are correct (no infinite loops) +- [ ] Edge cases are explicitly tested (empty collections, boundary values, None/Some) + +**Error Handling** +- [ ] All errors have proper context using `.context()` or `.with_context()` +- [ ] Library code uses `thiserror` for custom error types +- [ ] Application code uses `anyhow::Result` for error propagation +- [ ] No errors are silently discarded (all Result/Option properly handled) +- [ ] Error messages include actionable information (what failed, why, how to fix) + +**Ownership & Borrowing** +- [ ] No unnecessary `.clone()` calls (prefer borrowing) +- [ ] Lifetime annotations are minimal and necessary +- [ ] No dangling references or use-after-free scenarios +- [ ] Smart pointers (Arc, Rc, Box) are used appropriately, not by default + +--- + +### 💰 Financial Integrity (if applicable) + +**Decimal Types** +- [ ] All money calculations use `rust_decimal::Decimal` or `i64` (never f32/f64) +- [ ] Currency conversions preserve precision +- [ ] Rounding is explicit and documented with business justification +- [ ] Database columns use `NUMERIC` or `BIGINT`, never `REAL`/`DOUBLE` + +**Audit Trail** +- [ ] All financial transactions are logged with timestamp, user, amount +- [ ] Immutable audit log (append-only, never delete/update) +- [ ] Transaction IDs are unique and traceable +- [ ] Balance changes include before/after snapshots + +**Idempotency** +- [ ] Financial operations are idempotent (safe to retry) +- [ ] Duplicate transaction detection is in place +- [ ] Distributed transactions use proper isolation levels + +--- + +### 🛡️ Memory Safety + +**Unsafe Code** +- [ ] No `unsafe` blocks unless absolutely necessary +- [ ] Every `unsafe` block has a `// SAFETY:` comment explaining invariants +- [ ] Unsafe code is isolated in smallest possible scope +- [ ] Alternative safe solutions were considered and documented + +**Lifetime Correctness** +- [ ] No lifetime parameters unless necessary for API design +- [ ] Lifetime elision is used where possible +- [ ] References don't outlive the data they point to +- [ ] Self-referential structs use `Pin` if needed + +**Smart Pointer Usage** +- [ ] `Vec::with_capacity()` for known-size collections +- [ ] `Arc` only for shared ownership across threads +- [ ] `Rc` only for single-threaded shared ownership +- [ ] `Box` for heap allocation or trait objects +- [ ] Mutex/RwLock used appropriately (prefer message passing) + +--- + +### 🔐 Security + +**Input Validation** +- [ ] All user input is validated before processing +- [ ] String length limits are enforced +- [ ] Numeric inputs check min/max ranges +- [ ] Email/URL validation uses proper libraries +- [ ] File uploads check MIME type and size limits + +**SQL Injection Prevention** +- [ ] All database queries use parameterized queries (sqlx macros or `query!`) +- [ ] No string concatenation for SQL +- [ ] Input sanitization for LIKE clauses +- [ ] Database user has minimum necessary privileges + +**Authentication & Authorization** +- [ ] Passwords are hashed with bcrypt/argon2 (never plaintext) +- [ ] JWT tokens have expiration times +- [ ] Authorization checks happen on every protected endpoint +- [ ] Session tokens are cryptographically random +- [ ] Sensitive operations require re-authentication + +**Secrets Management** +- [ ] No secrets in source code (use environment variables or secret manager) +- [ ] API keys rotate regularly +- [ ] Database credentials stored securely +- [ ] Secrets never logged or exposed in error messages + +**HTTPS & Transport Security** +- [ ] All HTTP traffic uses TLS in production +- [ ] Certificate validation is enabled +- [ ] No self-signed certificates in production +- [ ] CORS configuration is restrictive (not `allow_origin("*")`) + +--- + +### 🧪 Testing + +**Test Coverage** +- [ ] Minimum 80% code coverage (run `cargo tarpaulin`) +- [ ] All public functions have tests +- [ ] Critical business logic has >95% coverage +- [ ] Edge cases are explicitly tested (empty, null, boundary values) + +**Test Types** +- [ ] Unit tests for pure logic (no I/O) +- [ ] Integration tests for database/HTTP interactions +- [ ] Property-based tests for invariants (using `proptest` or `quickcheck`) +- [ ] `#[should_panic(expected = "...")]` for expected failures + +**Test Quality** +- [ ] Tests have descriptive names (test_user_registration_fails_with_weak_password) +- [ ] Tests are independent (no shared mutable state) +- [ ] Tests clean up resources (temp files, database transactions) +- [ ] Error paths are tested (not just happy path) +- [ ] Async tests use `#[tokio::test]` not `#[test]` + +**Performance Tests** +- [ ] Benchmarks exist for performance-critical code (using `criterion`) +- [ ] Load tests validate scalability targets +- [ ] Database query performance measured (no N+1 queries) + +--- + +### 📝 Code Quality + +**Linting & Formatting** +- [ ] `cargo clippy` passes with no warnings +- [ ] `cargo fmt --check` passes (code is formatted) +- [ ] No `#[allow(clippy::...)]` without justification +- [ ] Compiler warnings are treated as errors in CI + +**Naming Conventions** +- [ ] Types are `PascalCase` (struct User) +- [ ] Functions/variables are `snake_case` (get_user_by_id) +- [ ] Constants are `SCREAMING_SNAKE_CASE` (MAX_RETRIES) +- [ ] Names are descriptive (not `tmp`, `data`, `info`) + +**Function Design** +- [ ] Functions are <50 lines (prefer smaller) +- [ ] Functions do one thing well (Single Responsibility) +- [ ] Function names start with verbs (get_, create_, validate_) +- [ ] Nested blocks are <3 levels deep + +**Type Safety** +- [ ] Type aliases used for domain concepts (`type UserId = Uuid`) +- [ ] Newtypes for distinct domains (`struct Email(String)`) +- [ ] Enums for exclusive states (not bool flags) +- [ ] Structs implement `Debug` derive + +--- + +### 📚 Documentation + +**Module Documentation** +- [ ] Every module has `//!` doc comment explaining purpose +- [ ] Public API has rustdoc comments (`///`) +- [ ] Code examples in docs compile (use `cargo test --doc`) +- [ ] Complex algorithms have implementation notes + +**Function Documentation** +- [ ] Public functions document parameters and return values +- [ ] Error cases are documented +- [ ] Examples provided for non-obvious usage +- [ ] Panics are documented with `# Panics` section + +**Inline Comments** +- [ ] Comments explain WHY, not WHAT (code explains what) +- [ ] Complex logic has explanatory comments +- [ ] TODO comments have GitHub issue numbers +- [ ] Magic numbers are explained or replaced with constants + +--- + +### ⚡ Performance + +**Allocations** +- [ ] Hot paths avoid allocations (use references, slices, iterators) +- [ ] Unnecessary `String` allocations removed (use `&str` where possible) +- [ ] `.collect()` only used when necessary +- [ ] Clone-on-write (`Cow`) for conditional ownership + +**Async Performance** +- [ ] No `.await` inside loops (collect futures, join_all) +- [ ] Blocking operations use `spawn_blocking` +- [ ] Database connection pooling configured (min/max connections) +- [ ] HTTP client reused (not created per request) + +**Database Performance** +- [ ] Indexes exist for all WHERE/JOIN columns +- [ ] Queries are analyzed with EXPLAIN ANALYZE +- [ ] Batch inserts used for multiple records +- [ ] Pagination implemented for large result sets +- [ ] No N+1 queries (use eager loading) + +**Caching** +- [ ] Expensive computations are cached +- [ ] Cache invalidation strategy is correct +- [ ] TTL set appropriately for cached data + +--- + +### 🏗️ Architecture + +**Layering** +- [ ] Domain logic is pure (no I/O in business rules) +- [ ] Infrastructure code separated from domain code +- [ ] API handlers are thin (delegate to services) +- [ ] No database queries in handlers + +**Separation of Concerns** +- [ ] Each module has a single responsibility +- [ ] Dependencies flow inward (domain ← services ← handlers) +- [ ] No circular dependencies between crates/modules + +**Design Patterns** +- [ ] Builder pattern for complex construction +- [ ] Repository pattern for data access +- [ ] Error types follow thiserror/anyhow conventions +- [ ] Traits used for abstraction (not concrete types) + +**API Design** +- [ ] Public API is minimal (principle of least privilege) +- [ ] Breaking changes follow semantic versioning +- [ ] Deprecated items have replacement suggestions +- [ ] Generics have clear trait bounds + +--- + +### ✅ Final Checks + +Before marking task complete: +- [ ] All checklist items above are checked +- [ ] `cargo test` passes +- [ ] `cargo clippy` has no warnings +- [ ] `cargo fmt` applied +- [ ] Code compiles without warnings +- [ ] Git commit message follows conventional commits + +Before requesting code review: +- [ ] Self-review performed (read your own code) +- [ ] Edge cases tested and documented +- [ ] Performance implications considered +- [ ] Security implications considered +- [ ] Breaking changes documented +- [ ] Migration guide provided (if needed) + +### Phase 6: Architecture Documentation (8 Files) + +#### 00_SYSTEM_OVERVIEW.md +- Vision and goals +- High-level architecture diagram (ASCII art is fine) +- Component overview (crates and their purposes) +- Data flow diagrams +- Technology justification (why axum, why tokio, why sqlx) +- Scalability strategy (connection pooling, caching, load balancing) +- Security approach (authentication, authorization, secrets) +- Performance targets with specific metrics + +#### 01_DOMAIN_MODEL.md +- All domain entities with complete field definitions +- Relationships between entities +- Business rules and constraints +- State machines (if applicable, with ASCII diagrams) +- Use cases with concrete code examples +- Entity lifecycle explanations + +Example entity: +```rust +use chrono::{DateTime, NaiveDate, Utc}; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct Task { + pub id: Uuid, // Or use ULID: Ulid + pub project_id: Uuid, + pub title: String, + pub description: Option, + pub status: TaskStatus, // Enum: Todo | InProgress | Blocked | Review | Done + pub priority: Priority, // Enum: Low | Medium | High | Urgent + pub assignee_id: Option, + pub due_date: Option, + pub estimated_hours: Option, + pub version: i32, // For optimistic locking + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Default for Task { + fn default() -> Self { + Self { + id: Uuid::new_v4(), + project_id: Uuid::new_v4(), + title: String::new(), + description: None, + status: TaskStatus::default(), + priority: Priority::default(), + assignee_id: None, + due_date: None, + estimated_hours: None, + version: 0, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[default = Todo] +pub enum TaskStatus { + Todo, + InProgress, + Blocked, + Review, + Done, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +#[default = Medium] +pub enum Priority { + Low, + Medium, + High, + Urgent, +} +``` + +#### 02_DATA_LAYER.md +- Complete sqlx query patterns for all entities +- PostgreSQL table schemas +- Indexes and their justifications +- Optimistic locking implementation (version fields) +- Performance considerations (connection pooling, prepared statements) +- Migration strategy + +Example sqlx pattern: +```rust +use sqlx::{PgPool, query_as, Type}; + +// For sqlx query_as! to work with PostgreSQL enums, we need Type derivation +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Type)] +#[sqlx(type_name = "task_status")] // PostgreSQL enum type name +#[sqlx(rename_all = "lowercase")] // Convert variants to lowercase +#[default = Todo] +pub enum TaskStatus { + Todo, + InProgress, + Blocked, + Review, + Done, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Type)] +#[sqlx(type_name = "priority")] +#[sqlx(rename_all = "lowercase")] +#[default = Medium] +pub enum Priority { + Low, + Medium, + High, + Urgent, +} + +// Corresponding PostgreSQL migration: +/* +-- migrations/YYYYMMDDHHMMSS_create_task_enums.sql + +-- Create custom enum types +CREATE TYPE task_status AS ENUM ('todo', 'inprogress', 'blocked', 'review', 'done'); +CREATE TYPE priority AS ENUM ('low', 'medium', 'high', 'urgent'); + +-- Create tasks table +CREATE TABLE tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL, + title TEXT NOT NULL, + description TEXT, + status task_status NOT NULL DEFAULT 'todo', + priority priority NOT NULL DEFAULT 'medium', + assignee_id UUID, + due_date DATE, + estimated_hours INTEGER CHECK (estimated_hours > 0), + version INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX idx_tasks_project_id ON tasks(project_id); +CREATE INDEX idx_tasks_assignee_id ON tasks(assignee_id); +CREATE INDEX idx_tasks_status ON tasks(status); +CREATE INDEX idx_tasks_due_date ON tasks(due_date) WHERE due_date IS NOT NULL; +*/ + +pub struct TaskRepository { + pool: PgPool, +} + +impl TaskRepository { + pub async fn find_by_id(&self, id: Uuid) -> Result, sqlx::Error> { + query_as!( + Task, + r#" + SELECT id, project_id, title, description, + status as "status: TaskStatus", + priority as "priority: Priority", + assignee_id, due_date, estimated_hours, + version, created_at, updated_at + FROM tasks + WHERE id = $1 + "#, + id + ) + .fetch_optional(&self.pool) + .await + } + + pub async fn update_with_version( + &self, + task: &Task, + old_version: i32, + ) -> Result { + let updated = query_as!( + Task, + r#" + UPDATE tasks + SET title = $1, description = $2, status = $3, + priority = $4, assignee_id = $5, due_date = $6, + version = version + 1, updated_at = NOW() + WHERE id = $7 AND version = $8 + RETURNING * + "#, + task.title, + task.description, + task.status as TaskStatus, + task.priority as Priority, + task.assignee_id, + task.due_date, + task.id, + old_version + ) + .fetch_optional(&self.pool) + .await? + .ok_or(TaskError::VersionConflict)?; + + Ok(updated) + } +} +``` + +#### 03_CORE_LOGIC.md +- Pure business logic patterns (no I/O, no side effects) +- Core calculations (priorities, estimates, metrics) +- Validation logic (state transitions, constraints) +- Testing patterns for pure functions +- Property test examples with proptest + +Example: +```rust +/// Pure functions for task business logic. +/// No database access, no side effects. +pub mod task_logic { + use super::*; + + /// Validates if a status transition is allowed + pub fn can_transition(from: TaskStatus, to: TaskStatus) -> bool { + use TaskStatus::*; + match (from, to) { + (Todo, InProgress | Blocked) => true, + (InProgress, Blocked | Review | Done) => true, + (Blocked, Todo | InProgress) => true, + (Review, InProgress | Done) => true, + (Done, _) => false, + _ => false, + } + } + + /// Calculates priority score for sorting + pub fn calculate_priority_score(task: &Task) -> i32 { + let base_score = priority_value(task.priority); + let urgency_bonus = days_until_due(task.due_date); + let blocker_penalty = if task.status == TaskStatus::Blocked { -10 } else { 0 }; + + base_score + urgency_bonus + blocker_penalty + } + + fn priority_value(priority: Priority) -> i32 { + match priority { + Priority::Urgent => 100, + Priority::High => 75, + Priority::Medium => 50, + Priority::Low => 25, + } + } + + fn days_until_due(due_date: Option) -> i32 { + let Some(due) = due_date else { return 0 }; + let today = Utc::now().date_naive(); + let diff = (due - today).num_days(); + + match diff { + d if d < 0 => 50, // Overdue + d if d <= 3 => 30, // Within 3 days + d if d <= 7 => 15, // Within a week + _ => 0, + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_valid_transitions() { + assert!(can_transition(TaskStatus::Todo, TaskStatus::InProgress)); + assert!(!can_transition(TaskStatus::Done, TaskStatus::InProgress)); + } + + // Property-based test with proptest + use proptest::prelude::*; + + proptest! { + #[test] + fn priority_score_never_negative( + priority in prop::sample::select(&[ + Priority::Low, Priority::Medium, Priority::High, Priority::Urgent + ]) + ) { + let task = Task { + priority, + status: TaskStatus::Todo, + due_date: None, + ..Task::default() + }; + assert!(calculate_priority_score(&task) >= 0); + } + } + } +} +``` + +#### 04_BOUNDARIES.md +- Service orchestration layer +- Transaction patterns (database transactions with sqlx) +- Error handling strategies (anyhow for app, thiserror for libs) +- Service composition patterns + +Example: +```rust +use anyhow::{Context, Result}; +use sqlx::PgPool; + +pub struct TaskService { + repo: TaskRepository, + activity_logger: ActivityLogger, + notifier: Notifier, +} + +impl TaskService { + pub async fn transition_task( + &self, + task_id: Uuid, + new_status: TaskStatus, + notify: bool, + ) -> Result { + // Load task + let task = self.repo + .find_by_id(task_id) + .await + .context("Failed to load task")? + .ok_or_else(|| anyhow::anyhow!("Task not found: {}", task_id))?; + + // Validate transition (pure function) + if !task_logic::can_transition(task.status, new_status) { + return Err(anyhow::anyhow!( + "Invalid transition from {:?} to {:?}", + task.status, + new_status + )); + } + + // Begin transaction + let mut tx = self.repo.pool.begin().await?; + + // Update task + let mut updated_task = task.clone(); + updated_task.status = new_status; + let updated = self.repo + .update_with_version(&updated_task, task.version) + .await + .context("Failed to update task")?; + + // Log activity + self.activity_logger + .log(&mut tx, task_id, "status_changed", json!({ + "from": task.status, + "to": new_status, + })) + .await?; + + // Commit transaction + tx.commit().await?; + + // Async notification (don't block on this) + if notify { + if let Some(assignee_id) = updated.assignee_id { + let notifier = self.notifier.clone(); + let task_clone = updated.clone(); + tokio::spawn(async move { + let _ = notifier.send_notification(assignee_id, task_clone).await; + }); + } + } + + Ok(updated) + } +} +``` + +#### 05_CONCURRENCY.md +- Async/await patterns with tokio +- Shared state management (Arc, RwLock, Mutex) +- Channel patterns (mpsc, oneshot, broadcast) +- Concurrent task spawning +- Cancellation and timeouts + +Example: +```rust +use tokio::sync::{RwLock, mpsc}; +use std::sync::Arc; + +pub struct AppState { + /// Read-heavy: Use RwLock for config + pub config: Arc>, + + /// Lock-free counters: Use atomic types + pub request_count: Arc, + + /// Connection pool: Already thread-safe + pub db: PgPool, +} + +// Spawning concurrent tasks +pub async fn process_batch(tasks: Vec) -> Vec> { + let handles: Vec<_> = tasks + .into_iter() + .map(|task| { + tokio::spawn(async move { + process_single_task(task).await + }) + }) + .collect(); + + // Wait for all tasks to complete + let mut results = Vec::new(); + for handle in handles { + results.push(handle.await.unwrap()); + } + results +} + +// Using channels for communication +pub async fn worker_pool(rx: mpsc::Receiver) { + while let Some(task) = rx.recv().await { + if let Err(e) = process_task(&task).await { + log::error!("Task processing failed: {}", e); + } + } +} +``` + +#### 06_ASYNC_PATTERNS.md +- Background task patterns with tokio +- Retry strategies with exponential backoff +- Circuit breaker implementation +- Health checks and graceful shutdown +- Async streams and futures + +Example: +```rust +use tokio::time::{sleep, Duration}; + +/// Retry with exponential backoff +pub async fn retry_with_backoff( + operation: F, + max_attempts: u32, +) -> Result +where + F: Fn() -> futures::future::BoxFuture<'static, Result>, +{ + let mut attempt = 0; + loop { + match operation().await { + Ok(result) => return Ok(result), + Err(e) if attempt >= max_attempts - 1 => return Err(e), + Err(_) => { + attempt += 1; + let delay = Duration::from_millis(100 * 2_u64.pow(attempt)); + sleep(delay).await; + } + } + } +} + +/// Background task that runs periodically +pub async fn periodic_task( + interval: Duration, + mut task: F, +) -> Result<()> +where + F: FnMut() -> Fut, + Fut: Future> + Send, +{ + let mut interval_timer = tokio::time::interval(interval); + loop { + interval_timer.tick().await; + if let Err(e) = task().await { + log::error!("Periodic task failed: {}", e); + } + } +} +``` + +**Health Check Example:** +```rust +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthStatus { + pub status: String, + pub version: String, + pub checks: HealthChecks, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthChecks { + pub database: CheckResult, + pub redis: CheckResult, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckResult { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + pub response_time_ms: u64, +} + +#[derive(Clone)] +pub struct AppState { + pub db_pool: PgPool, + pub version: String, +} + +/// Liveness probe - returns 200 if service is running +/// Use for Kubernetes livenessProbe +pub async fn liveness() -> StatusCode { + StatusCode::OK +} + +/// Readiness probe - returns 200 if service can handle traffic +/// Checks database connection and other critical dependencies +/// Use for Kubernetes readinessProbe +pub async fn readiness( + State(state): State>, +) -> Response { + let start = std::time::Instant::now(); + + // Check database connection + let db_check = match sqlx::query("SELECT 1") + .execute(&state.db_pool) + .await + { + Ok(_) => CheckResult { + status: "healthy".to_string(), + message: None, + response_time_ms: start.elapsed().as_millis() as u64, + }, + Err(e) => CheckResult { + status: "unhealthy".to_string(), + message: Some(e.to_string()), + response_time_ms: start.elapsed().as_millis() as u64, + }, + }; + + // Check Redis (example) + let redis_check = CheckResult { + status: "healthy".to_string(), + message: None, + response_time_ms: 5, + }; + + let overall_healthy = db_check.status == "healthy" + && redis_check.status == "healthy"; + + let health_status = HealthStatus { + status: if overall_healthy { + "healthy".to_string() + } else { + "unhealthy".to_string() + }, + version: state.version.clone(), + checks: HealthChecks { + database: db_check, + redis: redis_check, + }, + }; + + let status_code = if overall_healthy { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + + (status_code, Json(health_status)).into_response() +} + +pub fn health_routes(state: Arc) -> Router { + Router::new() + .route("/health/liveness", get(liveness)) + .route("/health/readiness", get(readiness)) + .with_state(state) +} +``` + +**Graceful Shutdown Example:** +```rust +use axum::Router; +use std::sync::Arc; +use tokio::{ + signal, + sync::watch, + time::{sleep, Duration}, +}; +use tracing::{info, warn}; + +pub struct ShutdownCoordinator { + /// Notify all workers to start shutdown + shutdown_tx: watch::Sender, +} + +impl ShutdownCoordinator { + pub fn new() -> (Self, watch::Receiver) { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + (Self { shutdown_tx }, shutdown_rx) + } + + pub fn trigger_shutdown(&self) { + let _ = self.shutdown_tx.send(true); + } +} + +/// Listen for shutdown signals (SIGTERM, SIGINT) +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + info!("Received SIGINT (Ctrl+C), initiating graceful shutdown"); + } + _ = terminate => { + info!("Received SIGTERM, initiating graceful shutdown"); + } + } +} + +/// Gracefully shutdown the application +pub async fn run_with_graceful_shutdown( + app: Router, + port: u16, + state: Arc, +) -> anyhow::Result<()> { + let (coordinator, mut shutdown_rx) = ShutdownCoordinator::new(); + + // Spawn background tasks + let background_task = tokio::spawn({ + let mut shutdown_rx = shutdown_rx.clone(); + async move { + info!("Background task started"); + loop { + tokio::select! { + _ = sleep(Duration::from_secs(60)) => { + info!("Background task running..."); + } + _ = shutdown_rx.changed() => { + info!("Background task received shutdown signal"); + break; + } + } + } + info!("Background task cleanup complete"); + } + }); + + // Start HTTP server + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) + .await?; + + info!("Server listening on {}", listener.local_addr()?); + + // Serve with graceful shutdown + axum::serve(listener, app) + .with_graceful_shutdown(async move { + shutdown_signal().await; + coordinator.trigger_shutdown(); + }) + .await?; + + info!("HTTP server stopped, waiting for background tasks..."); + + // Wait for background tasks with timeout + tokio::select! { + _ = background_task => { + info!("All background tasks completed"); + } + _ = sleep(Duration::from_secs(30)) => { + warn!("Shutdown timeout exceeded, forcing exit"); + } + } + + // Close database connections + state.db_pool.close().await; + info!("Database connections closed"); + + info!("Graceful shutdown complete"); + Ok(()) +} + +/// Example usage in main +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + // Setup database pool + let db_pool = sqlx::PgPool::connect("postgresql://localhost/mydb").await?; + + let state = Arc::new(AppState { + db_pool, + version: env!("CARGO_PKG_VERSION").to_string(), + }); + + // Build application with health check routes + let app = Router::new() + .nest("/api", health_routes(state.clone())) + // ... other routes + .with_state(state.clone()); + + // Run with graceful shutdown + run_with_graceful_shutdown(app, 3000, state).await?; + + Ok(()) +} +``` + +**Key Points:** +- **Liveness Probe**: Simple endpoint that returns 200 if process is alive +- **Readiness Probe**: Checks dependencies (database, cache) before accepting traffic +- **Signal Handling**: Catches SIGTERM/SIGINT for graceful shutdown +- **Connection Draining**: HTTP server stops accepting new connections but finishes existing requests +- **Background Task Coordination**: Uses `watch` channel to notify all tasks +- **Timeout Protection**: Forceful shutdown after 30s if tasks don't complete +- **Resource Cleanup**: Explicitly close database pools and other resources + +#### 07_INTEGRATION_PATTERNS.md +- HTTP client patterns with reqwest +- Circuit breaker implementation +- Retry logic with exponential backoff +- Webhook handling (incoming and outgoing) +- Event streaming patterns +- External service integration patterns + +Example: +```rust +use reqwest::Client; +use serde::de::DeserializeOwned; +use std::time::Duration; +use anyhow::{Context, Result}; +use tokio::time::sleep; + +pub struct HttpClient { + client: Client, + timeout: Duration, +} + +impl HttpClient { + pub fn new(timeout: Duration) -> Result { + let client = Client::builder() + .timeout(timeout) + .build() + .context("Failed to build HTTP client")?; + + Ok(Self { client, timeout }) + } + + pub async fn request_with_retry( + &self, + url: &str, + max_retries: u32, + ) -> Result { + let mut attempt = 0; + loop { + match self.client + .get(url) + .timeout(self.timeout) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + return resp.json().await.context("Failed to parse response"); + } + Ok(resp) if resp.status().is_server_error() && attempt < max_retries => { + attempt += 1; + let backoff = Duration::from_millis(100 * 2_u64.pow(attempt)); + sleep(backoff).await; + continue; + } + Ok(resp) => { + return Err(anyhow::anyhow!( + "HTTP error: status {}", + resp.status() + )); + } + Err(e) if attempt < max_retries => { + attempt += 1; + let backoff = Duration::from_millis(100 * 2_u64.pow(attempt)); + sleep(backoff).await; + continue; + } + Err(e) => return Err(e.into()), + } + } + } +} +``` + +### Phase 7: Architecture Decision Records + +Create ADRs for major decisions. Template: + +```markdown +# ADR-XXX: [Decision Title] + +**Status:** Accepted +**Date:** YYYY-MM-DD +**Deciders:** [Role] +**Context:** [Brief context] + +## Context +[Detailed explanation of the situation requiring a decision] + +## Decision +[Clear statement of what was decided] + +## Rationale +[Why this decision was made - include code examples, metrics, trade-offs] + +## Alternatives Considered + +### Alternative 1: [Name] +**Implementation:** +```rust +// Example code +``` + +**Pros:** +- Advantage 1 +- Advantage 2 + +**Cons:** +- Disadvantage 1 +- Disadvantage 2 + +**Why Rejected:** [Clear explanation] + +### Alternative 2: [Name] +[Same structure] + +## Consequences + +### Positive +1. Benefit with explanation +2. Another benefit + +### Negative +1. Trade-off with mitigation strategy +2. Another trade-off + +## Implementation Guidelines + +### DO: [Pattern] +```rust +// Good example +``` + +### DON'T: [Anti-pattern] +```rust +// Bad example +``` + +## Validation +[How we'll verify this was the right choice] +- Metric 1: Target value +- Metric 2: Target value + +## References +- [Link 1] +- [Link 2] + +## Related ADRs +- ADR-XXX: Related Decision + +## Review Schedule +**Last Reviewed:** YYYY-MM-DD +**Next Review:** YYYY-MM-DD +``` + +**Minimum ADRs to create:** + +1. **ADR-001: Framework Choice** (axum vs actix-web vs warp vs rocket) +2. **ADR-002: Error Strategy** (anyhow vs thiserror usage patterns) +3. **ADR-003: Ownership Patterns** (When to use owned data vs references vs cloning) +4. **Domain-specific ADRs** based on requirements + +### Phase 8: Handoff Documentation + +Create HANDOFF.md with: + +1. **Overview** - Project status, location, ready state +2. **Project Structure** - Annotated directory tree +3. **Documentation Index** - What each file contains +4. **Workflow** - Director → Implementor → Review → Iterate cycle +5. **Implementation Phases** - Break project into 4-week phases +6. **Key Architectural Principles** - DO/DON'T examples +7. **Testing Strategy** - Unit/Integration/Property test patterns +8. **Commit Message Format** - Conventional commits structure +9. **Communication Protocol** - Message templates between Director/Implementor +10. **Troubleshooting** - Common issues and solutions +11. **Success Metrics** - Specific performance targets +12. **Next Steps** - Immediate actions for Director AI + +Example workflow section: +```markdown +## Workflow + +### Phase 1: Director Creates Design & Plan +1. Read feature request from user +2. Review architecture documents +3. Create design document in `docs/design/` +4. Create implementation plan in `docs/plans/` (Superpowers format) +5. Commit design + plan +6. Hand off to Implementor with plan path + +### Phase 2: Implementor Executes Plan +1. Read implementation plan +2. For each task: + - Write test first (TDD) + - Implement minimum code + - Refactor + - Run tests (cargo test) + - Check clippy (cargo clippy) + - Format code (cargo fmt) + - Commit +3. Report completion to Director + +### Phase 3: Director Reviews +1. Review committed code +2. Check against design +3. Verify guardrails followed +4. Either approve or request changes + +### Phase 4: Iterate Until Approved +[Loop until feature is complete] +``` + +### Superpowers Implementation Plan Format + +Superpowers plans are structured Markdown documents with YAML frontmatter that break down features into atomic, testable tasks of 2-5 minutes each. + +#### File Structure + +```markdown +--- +plan_id: "PLAN-001-user-authentication" +feature: "User Authentication System" +created: "2024-01-15" +author: "Director AI" +status: "approved" +estimated_hours: 8 +priority: "high" +dependencies: [] +--- + +# Implementation Plan: User Authentication System + +## Overview +Brief description of what this plan achieves and why it's necessary. + +## Context +- **Related ADRs**: ADR-001 (JWT Strategy), ADR-002 (Error Handling) +- **Related Docs**: `docs/architecture/04_BOUNDARIES.md` +- **Dependencies**: PostgreSQL 16+, argon2 crate for password hashing + +## Tasks + +### Task 1: Database Schema (2-5 min) +**Type**: database +**Estimated**: 3 minutes +**Prerequisites**: None + +**Objective**: Create users table with security best practices + +**Steps**: +1. Create migration file: `sqlx migrate add create_users_table` +2. Define schema with email, password_hash, created_at, updated_at +3. Add unique constraint on email for login uniqueness +4. Add index on email for login performance + +**Acceptance Criteria**: +- [ ] Migration file created in migrations/ directory +- [ ] `sqlx migrate run` succeeds without errors +- [ ] Can insert test user with email and password_hash + +**Code Location**: `migrations/YYYYMMDDHHMMSS_create_users_table.sql` + +--- + +### Task 2: User Domain Model (2-5 min) +**Type**: implementation +**Estimated**: 4 minutes +**Prerequisites**: Task 1 + +**Objective**: Define User entity with validation logic + +**Steps**: +1. Create `myapp_core/src/domain/user.rs` +2. Define User struct with proper types (email: String, password_hash: String, etc.) +3. Implement email validation (regex for email format) +4. Add methods: `new()`, `verify_password()` + +**Acceptance Criteria**: +- [ ] User struct defined with all required fields +- [ ] Email validation works (test with invalid emails) +- [ ] Password verification works (test with valid/invalid passwords) +- [ ] Unit tests pass: `cargo test user::tests` + +**Code Location**: `myapp_core/src/domain/user.rs` + +--- + +### Task 3: Password Hashing (2-5 min) +**Type**: implementation +**Estimated**: 5 minutes +**Prerequisites**: Task 2 + +**Objective**: Implement secure password hashing with argon2 + +**Steps**: +1. Add argon2 to Cargo.toml: `argon2 = "0.5.3"` +2. Create `myapp_core/src/domain/password.rs` +3. Implement `hash_password(password: &str) -> Result` +4. Implement `verify_password(password: &str, hash: &str) -> Result` +5. Write unit tests for both functions + +**Acceptance Criteria**: +- [ ] Passwords hashed with argon2 (verify config: memory=19MB, iterations=2) +- [ ] Same password produces different hashes (salt working correctly) +- [ ] Verification succeeds for valid passwords +- [ ] Verification fails for invalid passwords +- [ ] All tests pass: `cargo test password` + +**Code Location**: `myapp_core/src/domain/password.rs` + +--- + +## Testing Strategy +- **Unit Tests**: Each task includes its own isolated tests +- **Integration Tests**: Final end-to-end test in `myapp_api/tests/auth_flow.rs` +- **Coverage Target**: ≥80% for authentication code (critical security component) + +## Rollback Plan +If any task fails or needs to be reverted: +1. Revert migrations: `sqlx migrate revert` +2. Delete created files and restore from git +3. Restore to last commit: `git reset --hard HEAD~1` +4. Re-plan if fundamental issues discovered + +## Success Criteria +- [ ] All tasks completed and individually tested +- [ ] `cargo test` passes (all unit and integration tests) +- [ ] `cargo clippy` clean (no warnings) +- [ ] Integration test demonstrates full auth flow works end-to-end +- [ ] Documentation updated in HANDOFF.md + +## Notes +- Use `thiserror` for domain errors (library code following DDD) +- Use `anyhow` for application errors (API layer convenience) +- Never log passwords (even hashed ones in production logs) +- Follow OWASP authentication guidelines +``` + +#### Superpowers Plan Principles + +1. **Atomic Tasks**: Each task is independently completable in 2-5 minutes +2. **Clear Prerequisites**: Explicit task dependencies prevent blocking +3. **Testable Acceptance**: Every task has verifiable completion criteria +4. **TDD Workflow**: Write test first, minimum implementation, then refactor +5. **Rollback Safety**: Each task can be independently reverted if needed + +#### Task Types +- `database`: Schema definitions, migrations, query optimization +- `implementation`: Core logic, domain models, business rules +- `api`: HTTP endpoints, handlers, middleware +- `testing`: Test files, integration tests, property tests +- `documentation`: Docs, inline comments, examples, README updates + +#### Task Metadata +- **Type**: Categorizes the work for filtering and reporting +- **Estimated**: Time estimate in minutes (2-5 minute range) +- **Prerequisites**: Task IDs that must complete first +- **Objective**: One-sentence goal of this task +- **Steps**: Ordered list of concrete actions +- **Acceptance Criteria**: Checkboxes for verification +- **Code Location**: Where the changes will be made + +### Phase 9: Validate and Summarize + +Before finishing, verify: + +1. ✅ All directories created +2. ✅ 20+ documentation files present +3. ✅ All cross-references between docs work +4. ✅ All code examples are valid Rust syntax +5. ✅ Every architectural principle has concrete example +6. ✅ ADRs include alternatives with rationale +7. ✅ Guardrails have DO/DON'T code examples +8. ✅ Domain-specific adaptations included + +Present summary: +```markdown +## Project Architecture Complete! 🚀 + +**Location:** /path/to/project + +**Created:** +- ✅ Complete directory structure +- ✅ Foundation docs (README, CLAUDE.md) +- ✅ 5 guardrail documents +- ✅ 8 architecture documents (~6,000 lines) +- ✅ X Architecture Decision Records +- ✅ Handoff documentation + +**Ready For:** +- Director AI to create first design + plan +- Implementor AI to execute implementation +- Iterative feature development + +**Next Step:** +Director AI should begin by creating the first feature design. +``` + +## Domain-Specific Adaptations + +### For Web Services (axum/actix-web) + +Add emphasis on: + +1. **NEVER_DO.md** additions: + - Never block async runtime with std::thread::sleep (use tokio::time::sleep) + - Never use Arc> without justification (prefer message passing) + - Never unwrap in request handlers (return proper HTTP errors) + - Never store sessions in memory without justification (use database) + +2. **Domain Model** inclusions: + - HTTP request/response types + - Middleware patterns + - Authentication/authorization models + - State management with Arc + +3. **ADRs** to add: + - Web framework choice (axum vs actix-web) + - State sharing strategy + - Error response format (JSON API spec) + - Authentication method (JWT, sessions, OAuth) + +4. **Use Cases** examples: + - Handle HTTP request with validation + - Middleware for authentication + - Database query with connection pooling + - Background job spawning + +### For CLI Tools (clap) + +Add emphasis on: + +1. **Domain Model** additions: + - Command structure with clap + - Configuration file handling + - Progress indicators + - Error reporting to terminal + +2. **ADRs** to add: + - CLI argument parsing library choice + - Configuration file format (TOML, YAML, JSON) + - Error reporting strategy + - Output formatting approach + +3. **Use Cases** examples: + - Parse command-line arguments + - Read configuration file + - Execute subcommands + - Report progress and errors + +### For Backend Services + +Add emphasis on: + +1. **Domain Model** additions: + - Background job patterns with tokio + - Event sourcing patterns + - CQRS implementation + - Message queue integration + +2. **Workers** to document: + - Background job processing + - Periodic tasks + - Event handlers + - Cleanup tasks + +3. **Integration Patterns**: + - Message queue clients (RabbitMQ, Kafka) + - Cache integration (Redis) + - External API clients + +## Critical Patterns and Best Practices + +### Ownership Patterns + +```rust +// ✅ ALWAYS prefer borrowing over cloning +fn count_words(text: &str) -> usize { + text.split_whitespace().count() +} + +// ✅ Take ownership when you need to transform +fn to_uppercase(mut s: String) -> String { + s.make_ascii_uppercase(); + s +} + +// ✅ Clone only when necessary (document why) +fn store_in_cache(key: String, value: Data) { + // Need to clone because cache takes ownership + CACHE.insert(key.clone(), value); // Clone needed for concurrent access + log::info!("Stored {}", key); // Original key still available +} +``` + +### Error Handling Patterns + +```rust +// ✅ ALWAYS use thiserror for library errors +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum TaskError { + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("Task not found: {0}")] + NotFound(Uuid), + + #[error("Invalid status transition from {from:?} to {to:?}")] + InvalidTransition { from: TaskStatus, to: TaskStatus }, + + #[error("Version conflict: expected {expected}, got {actual}")] + VersionConflict { expected: i32, actual: i32 }, +} + +// ✅ ALWAYS use anyhow for application errors +use anyhow::{Context, Result}; + +async fn process_request(id: Uuid) -> Result { + let task = repo.find_by_id(id) + .await + .context("Failed to query database")? + .ok_or_else(|| anyhow::anyhow!("Task {} not found", id))?; + + Ok(Response::success(task)) +} +``` + +### Async Patterns + +```rust +// ❌ NEVER block async runtime +async fn bad_sleep() { + std::thread::sleep(Duration::from_secs(10)); // BLOCKS! +} + +// ✅ ALWAYS use tokio::time::sleep +async fn good_sleep() { + tokio::time::sleep(Duration::from_secs(10)).await; +} + +// ✅ Spawn blocking for CPU-intensive work +use tokio::task; +use std::io; +use anyhow::{Context, Result}; + +#[derive(Debug)] +struct Output { + result: String, +} + +/// CPU-intensive synchronous computation +fn expensive_computation(data: &[u8]) -> io::Result { + // Example: expensive string processing + let result = std::str::from_utf8(data) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))? + .to_uppercase(); + + // Simulate heavy CPU work + // In real code: compression, encryption, image processing, etc. + Ok(Output { result }) +} + +async fn process_heavy_computation(data: Vec) -> Result { + // Move CPU-intensive work to dedicated blocking thread pool + let output = task::spawn_blocking(move || { + expensive_computation(&data) + }) + .await // Wait for thread pool task (returns JoinError on panic) + .context("Background task panicked")? // Handle panic + .context("Computation failed")?; // Handle business error + + Ok(output) +} +``` + +### State Sharing Patterns + +```rust +// ❌ DON'T: Overuse Arc> +struct App { + counter: Arc>, // Do you really need Arc>? +} + +// ✅ DO: Use simpler alternatives first +use std::sync::atomic::{AtomicI32, Ordering}; + +struct App { + counter: AtomicI32, // Lock-free, faster +} + +// ✅ DO: Only when truly needed +struct App { + cache: Arc>>, // Justified: shared mutable state +} +``` + +## Common Mistakes to Avoid + +1. **Too Generic** - Always adapt to specific domain needs +2. **Missing Examples** - Every principle needs concrete code +3. **Unclear Boundaries** - Director vs Implementor roles must be explicit +4. **No Trade-offs** - Always explain downsides of decisions in ADRs +5. **Incomplete ADRs** - Must include alternatives considered and why rejected +6. **Vague Metrics** - Use specific numbers (<10ms p50, >10K RPS, >80% coverage) +7. **Unwrap Everywhere** - Return Result and use ? operator +8. **Clone Without Justification** - Understand ownership patterns first + +## Quality Gates + +Before considering work complete: + +- [ ] All code examples use valid Rust syntax (tested with rustc --explain) +- [ ] Every "NEVER DO" has a corresponding "ALWAYS DO" +- [ ] Every ADR explains alternatives and why they were rejected +- [ ] Domain model includes complete type definitions +- [ ] Performance targets are specific and measurable +- [ ] Guardrails have clear, executable examples +- [ ] Communication protocol includes message templates +- [ ] Testing strategy covers unit/integration/property tests +- [ ] Integration patterns include retry/circuit breaker +- [ ] All unsafe blocks have SAFETY comments + +## Success Criteria + +You've succeeded when: + +1. ✅ Director AI can create feature designs without asking architectural questions +2. ✅ Implementor AI can write code without asking design questions +3. ✅ All major decisions are documented with clear rationale +4. ✅ Code examples are copy-paste ready and compile +5. ✅ Domain-specific requirements are thoroughly addressed +6. ✅ Performance targets are realistic and measurable +7. ✅ The system can be built by following the documentation alone + +## Notes + +- **Empty directories** (docs/design/, docs/plans/, docs/api/) are intentional - Director fills these during feature work +- **Superpowers format** for implementation plans: Markdown with YAML frontmatter, 2-5 minute tasks +- **All code examples** must be valid Rust that could actually compile +- **Consult experts** via Task agents - don't guess at best practices +- **Cargo workspace** structure recommended for multi-crate projects (see decision matrix below) +- **Zero-cost abstractions** - verify with benchmarks that high-level code is fast + +## Workspace Decision Matrix + +**Use this matrix to decide between single crate, binary+library, or multi-crate workspace.** + +### Decision Tree + +``` +Project Size & Complexity +├─ Small (< 5K lines, 1-2 developers, simple domain) +│ └─ Single Crate (src/main.rs or src/lib.rs) +│ +├─ Medium (5K-20K lines, 2-5 developers, moderate domain) +│ ├─ Library Reusable? +│ │ ├─ Yes → Binary + Library (src/lib.rs + src/main.rs) +│ │ └─ No → Single Crate with modules +│ │ +│ └─ Multiple Services? +│ └─ Yes → Multi-Crate Workspace +│ +└─ Large (> 20K lines, 5+ developers, complex domain) + └─ Multi-Crate Workspace (always) +``` + +### Structure Comparison + +| Criterion | Single Crate | Binary + Library | Multi-Crate Workspace | +|-----------|--------------|------------------|------------------------| +| **Lines of Code** | < 5K | 5K - 20K | > 20K or modular by design | +| **Team Size** | 1-2 developers | 2-5 developers | 5+ developers | +| **Build Time** | Fast (<30s) | Medium (30s-2min) | Slow (2min+) but parallelizable | +| **Code Reuse** | Internal only | Library can be published | Multiple reusable libraries | +| **Testing Strategy** | Unit + integration in one place | Separate lib tests from binary | Per-crate test isolation | +| **Compilation** | All-or-nothing | Incremental (lib + bin separate) | Incremental per crate | +| **Dependency Management** | Simple | Moderate | Complex (shared workspace deps) | +| **CI/CD Complexity** | Simple (1 target) | Moderate (2 targets) | Complex (selective builds) | +| **Refactoring Ease** | Easy | Moderate | Hard (API boundaries) | +| **Domain Boundaries** | Implicit (modules) | Moderate (lib/bin split) | Explicit (crate boundaries) | + +### When to Choose Each Structure + +#### ✅ Choose Single Crate When: +- **Prototyping** or MVP development +- **CLI tool** with straightforward logic +- **Script-like application** with limited scope +- **Learning project** or tutorial code +- Code size < 5K lines +- No plans to publish library +- Fast iteration is priority + +**Example:** +``` +my-cli-tool/ +├─ Cargo.toml +└─ src/ + ├─ main.rs # Entry point + ├─ config.rs # Configuration + ├─ commands/ # Command modules + │ ├─ mod.rs + │ ├─ create.rs + │ └─ delete.rs + └─ utils.rs # Utilities +``` + +#### ✅ Choose Binary + Library When: +- **Web service** where domain logic could be reused +- **Application** with testable business logic separate from I/O +- Want to **publish library** while providing reference binary +- Code size 5K-20K lines +- Clear separation between "what" (lib) and "how" (bin) + +**Example:** +``` +my-web-service/ +├─ Cargo.toml # [lib] and [[bin]] +├─ src/ +│ ├─ lib.rs # Public library API +│ ├─ domain/ # Domain models and logic +│ ├─ services/ # Business services +│ └─ infrastructure/ # Database, HTTP clients +├─ src/ +│ └─ main.rs # Binary entry point (axum server) +└─ tests/ + └─ integration_test.rs +``` + +**Cargo.toml:** +```toml +[package] +name = "my-web-service" +version = "0.1.0" +edition = "2021" + +[lib] +name = "my_web_service" +path = "src/lib.rs" + +[[bin]] +name = "server" +path = "src/main.rs" +``` + +#### ✅ Choose Multi-Crate Workspace When: +- **Microservices** architecture with shared code +- **Monorepo** with multiple related services +- **Plugin system** where plugins are separate crates +- **Domain-driven design** with bounded contexts +- Code size > 20K lines or growing rapidly +- Team > 5 developers working on different areas +- Different crates have **different release cycles** +- Want to **share dependencies** across crates + +**Example:** +``` +my-project/ +├─ Cargo.toml # Workspace root +├─ Cargo.lock # Shared lock file +│ +├─ crates/ +│ ├─ domain/ # Core domain logic (no I/O) +│ │ ├─ Cargo.toml +│ │ └─ src/ +│ │ ├─ lib.rs +│ │ ├─ user.rs +│ │ └─ order.rs +│ │ +│ ├─ infrastructure/ # Database, HTTP, external services +│ │ ├─ Cargo.toml +│ │ └─ src/ +│ │ ├─ lib.rs +│ │ ├─ database/ +│ │ └─ http_client/ +│ │ +│ ├─ api/ # HTTP API layer +│ │ ├─ Cargo.toml +│ │ └─ src/ +│ │ ├─ main.rs # Binary +│ │ ├─ routes/ +│ │ └─ handlers/ +│ │ +│ └─ worker/ # Background job processor +│ ├─ Cargo.toml +│ └─ src/ +│ └─ main.rs # Binary +│ +└─ tests/ # Workspace-level integration tests + └─ e2e_test.rs +``` + +**Workspace Cargo.toml:** +```toml +[workspace] +members = [ + "crates/domain", + "crates/infrastructure", + "crates/api", + "crates/worker", +] + +# Shared dependencies across all workspace members +[workspace.dependencies] +tokio = { version = "1.48", features = ["full"] } +axum = "0.8" +sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "tls-rustls"] } +serde = { version = "1.0.228", features = ["derive"] } +anyhow = "1.0.100" +thiserror = "2.0" +uuid = { version = "1.18", features = ["v4", "serde"] } +chrono = { version = "0.4.42", features = ["serde"] } +rust_decimal = "1.39" +argon2 = "0.5.3" + +[workspace.package] +edition = "2021" +license = "MIT" +repository = "https://github.com/user/my-project" +``` + +**Member Crate Cargo.toml (domain/Cargo.toml):** +```toml +[package] +name = "my-project-domain" +version.workspace = true +edition.workspace = true + +[dependencies] +# Use workspace dependencies +uuid.workspace = true +serde.workspace = true +anyhow.workspace = true + +# Crate-specific dependencies +rust_decimal = "1.39" +``` + +### Workspace Organization Patterns + +#### Pattern 1: Layered Architecture (Clean Architecture) +``` +workspace/ +├─ crates/ +│ ├─ domain/ # Pure business logic (no dependencies on infrastructure) +│ ├─ application/ # Use cases, orchestration (depends on domain) +│ ├─ infrastructure/# Database, HTTP, external services (depends on domain) +│ └─ api/ # HTTP handlers (depends on application + infrastructure) +``` +**Dependency Flow:** `domain ← application ← infrastructure ← api` + +#### Pattern 2: Service-Oriented +``` +workspace/ +├─ crates/ +│ ├─ shared/ # Common utilities and types +│ ├─ user-service/ # User management service +│ ├─ order-service/ # Order processing service +│ └─ notification-service/ # Notification sender +``` +**Use When:** Multiple independent services sharing common code + +#### Pattern 3: Library + Multiple Binaries +``` +workspace/ +├─ crates/ +│ ├─ core/ # Reusable library +│ ├─ cli/ # Command-line interface (binary) +│ ├─ server/ # Web server (binary) +│ └─ worker/ # Background processor (binary) +``` +**Use When:** Same core logic, different deployment modes + +### Migration Path + +**Start Simple → Grow Complex** + +1. **Phase 1: Single Crate** (0-5K lines) + - Fast iteration, minimal overhead + - Organize with modules (`mod.rs` files) + +2. **Phase 2: Binary + Library** (5K-20K lines) + - Extract reusable logic to `src/lib.rs` + - Keep I/O and main entry in `src/main.rs` + - Publish library if needed + +3. **Phase 3: Multi-Crate Workspace** (20K+ lines) + - Split by domain boundaries (DDD) + - Extract shared code to `shared` crate + - Separate services into independent crates + - Use workspace dependencies for version consistency + +### Red Flags: When NOT to Use Workspace + +❌ **Premature Optimization** +- Don't start with workspace for MVP or prototype +- Workspace adds complexity (build config, dependency management) +- Wait until you have >20K lines or clear separation needs + +❌ **Over-Engineering** +- Don't create crate for every module +- Minimum crate size: ~1K-2K lines (unless reusable library) +- Aim for 5-10 crates max, not 50 micro-crates + +❌ **Unclear Boundaries** +- If you can't explain why a crate exists independently, it shouldn't +- Crates should represent clear domain boundaries or deployment units + +### Decision Checklist + +Before creating a workspace, check: + +- [ ] **Size**: Is the project >20K lines or expected to grow there? +- [ ] **Team**: Do you have >5 developers working concurrently? +- [ ] **Modularity**: Do you have clear, independent domain boundaries? +- [ ] **Reusability**: Are multiple binaries sharing common code? +- [ ] **Deployment**: Do components deploy independently? +- [ ] **Testing**: Would separate test suites improve clarity? +- [ ] **Build Time**: Would parallel crate builds improve compile time? + +**If 3+ are YES → Use Workspace** +**If 1-2 are YES → Consider Binary + Library** +**If 0-1 are YES → Stick with Single Crate** diff --git a/.docs/location-presence-sharing.md b/.docs/location-presence-sharing.md new file mode 100644 index 0000000..9fb55c0 --- /dev/null +++ b/.docs/location-presence-sharing.md @@ -0,0 +1,237 @@ +# Online Presence & Friend Location Sharing — Design + +> Status: **Planning / not yet implemented**. This document captures the agreed +> architecture for two related features: +> +> 1. **Online presence** — show friends whether a user is currently online. +> 2. **Friend location sharing** — let friends see each other on a map within a +> 50 km radius, opt-in and toggleable, near-real-time (not live). +> +> Both features are **Redis-backed and ephemeral**. No location history is +> persisted. PostgreSQL stores only the durable per-user *settings* (toggles), +> not positions. + +--- + +## 1. Goals & Constraints + +| # | Requirement | Decision | +|---|---|---| +| G1 | Online status | `online == active WebSocket connection`, tracked in Redis with TTL + heartbeat so brief reconnects don't flap. Visible to **all** friends, independent of the location feature. | +| G2 | Location visibility model | **Reciprocal**: only a user who is actively sharing may see sharing friends. A user sees friends within **50 km of their own current position**. | +| G3 | Update flow | **Client pushes** its position periodically (e.g. every few minutes / on significant movement) via REST. Friends **pull on demand** when they open the map. No live push. | +| G4 | Persistence & precision | **Redis only**, per-position TTL (~15–30 min). Coordinates **deterministically snapped to a grid** before being exposed to friends. No history, no exact coordinates leave the server. | +| G5 | Privacy | Friendship is verified server-side on every read. The 50 km filter is **server-side only** — raw friend coordinates are never sent to a client for client-side filtering. | +| G6 | Optional dependency | Redis is optional in ISM (`NoOpCache` fallback). Both features therefore **require Redis** and degrade to *"feature unavailable"* when only `NoOpCache` is active. | + +### Non-goals (current phase) +- No live streaming of moving dots on a map. +- No location history / breadcrumb trail. +- No background tracking when the app is closed (a client concern, but the + backend never assumes continuous updates). + +--- + +## 2. Privacy Threat Model (must read) + +The headline risk for any "friends within X km" feature is **trilateration**. +If an attacker can repeatedly query *"is friend F within 50 km of point P?"* +while varying `P` (by spoofing their own position), they can triangulate F's +exact location even though no exact coordinate is ever returned. + +Mitigations baked into this design: + +1. **Deterministic grid snapping.** A friend's position is snapped to a fixed + grid cell (e.g. geohash precision 6 ≈ 1.2 km, or `round(lat/lng, 2)` ≈ 1.1 km) + **before** storage/exposure. Snapping is deterministic, not random per + request — random jitter would average out over many queries and defeats the + purpose. The same underlying position always yields the same cell. +2. **Reciprocity.** A non-sharing user cannot query at all, removing the "free + sensor" capability for users who don't expose themselves. +3. **Short TTL.** Stale positions disappear quickly, limiting how long a target + can be probed at a fixed location. +4. **Coarse boundary.** Only a binary "within 50 km" + snapped pin is exposed, + never a precise distance. + +Residual risk: a determined attacker who is themselves a sharing friend can +still narrow a target to ~1 grid cell near the 50 km boundary. This is +acceptable for a social "friends nearby" feature; it is documented so the +trade-off is explicit. If stronger guarantees are ever needed, escalate to +larger cells or rate-limit position-change frequency. + +--- + +## 3. Data Model + +### 3.1 PostgreSQL (durable settings only) + +Add two boolean flags to the user record (or a dedicated `user_settings` table): + +| Field | Type | Default | Meaning | +|---|---|---|---| +| `share_location` | `bool` | `false` | User opts in to location sharing (G2). | +| `show_online_status` | `bool` | `true` | (Optional, future) hide presence while online. Default visible. | + +These are the **only** durable additions. Positions and live presence live +exclusively in Redis. + +### 3.2 Redis keys + +| Key | Type | TTL | Purpose | +|---|---|---|---| +| `presence:{user_id}` | string/int (refcount) or SET of connection ids | ~60 s, refreshed by heartbeat | Online presence. Multi-device safe (see §4.1). | +| `geo:friends` | GEO sorted set | — (no per-member TTL, see note) | All sharing users' **snapped** positions. `GEOADD geo:friends lng lat {user_id}`. | +| `loc:fresh:{user_id}` | string | ~15–30 min | Freshness companion for `geo:friends`. Existence == position is fresh. | + +> **Important Redis quirk:** GEO sets are sorted sets and entries **do not +> expire individually**. We therefore pair each `geo:friends` member with a +> short-TTL `loc:fresh:{user_id}` key. On read we drop (and opportunistically +> `ZREM`) any candidate whose freshness key has expired. A periodic sweep task +> garbage-collects orphaned GEO members. (Note: the notification cache used to +> need such a sweep but was migrated to a Redis Stream that self-trims via +> `XADD ... MAXLEN`; GEO sets have no stream equivalent, so a sweep is still +> required here.) + +--- + +## 4. Component Design + +### 4.1 Online Presence + +Tracked in the **WebSocket lifecycle** (`broadcast/` + the `wss` handler): + +- **On WS connect:** add the connection to presence. Because a user may have + multiple devices (already true for read-receipt sync), use either: + - a per-user **refcount** (`INCR presence:{user_id}`, `EXPIRE` on heartbeat, + `DECR` on disconnect), or + - a **SET of connection ids** with the key carrying a TTL refreshed on + heartbeat. + The SET approach is more robust against missed `DECR`s on crashes. +- **Heartbeat:** the existing WS ping/pong refreshes the TTL. If all devices die + without a clean close, the key expires and the user goes offline naturally. +- **On clean disconnect:** remove the connection; if it was the last one, the + user is offline. + +**Reading presence:** friends pull on demand. Given a friend id list (already +resolved from `user_relationship`, `FRIEND` state), a single pipelined +`EXISTS`/`MGET` over `presence:{id}` returns who is online. Optionally honor +`show_online_status`. + +**Optional enhancement:** broadcast a `PresenceChanged { user_id, online }` +event to friends via the existing `BroadcastChannel` when a user flips +online/offline, so open clients update without polling. This reuses the +established broadcast-after-write pattern and is *additive* — pull-on-demand +remains the source of truth. + +### 4.2 Location Sharing + +**Push (client → server):** +``` +POST /api/location { lat, lng } +``` +1. Reject if `share_location == false` → `403` (or auto-enable, see open + question Q1). +2. Reject if cache is `NoOpCache` → `503 FEATURE_UNAVAILABLE`. +3. **Snap** `(lat, lng)` to the grid (§2.1). +4. `GEOADD geo:friends lng lat {user_id}` + `SET loc:fresh:{user_id} 1 EX `. + +**Stop sharing (toggle off):** +``` +DELETE /api/location +``` +→ `ZREM geo:friends {user_id}` + `DEL loc:fresh:{user_id}` and set +`share_location = false`. + +**Read (friends nearby):** +``` +GET /api/location/friends +``` +1. Require caller to be sharing (reciprocity, G2) and to have a fresh position; + otherwise `409`/empty per Q2. +2. `GEOSEARCH geo:friends FROMMEMBER {caller_id} BYRADIUS 50 km ASC WITHCOORD` + (or `FROMLONLAT` using the caller's just-pushed position). +3. Intersect candidates with the caller's **friend set** (from + `user_relationship` / cached `RoomContext`-style lookup). Non-friends in the + same radius are discarded. +4. Drop candidates whose `loc:fresh:{id}` has expired (and `ZREM` them). +5. Return snapped pins: + ```json + { "content": [ { "user_id": "...", "lat": 52.52, "lng": 13.40, "online": true } ] } + ``` + Coordinates are already grid-snapped; pair with presence from §4.1. + +> Scale note: `geo:friends` is a single global GEO set; the radius search is +> `O(log N + M)`. Intersecting with the friend set in the app layer is fine at +> the current single-server scale. If the user base grows large, shard the GEO +> set (e.g. by region/geohash prefix) — out of scope for this phase. + +--- + +## 5. API Summary + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/api/location` | Push own (snapped) position; refreshes TTL. | +| `DELETE` | `/api/location` | Stop sharing; remove from GEO set + disable toggle. | +| `GET` | `/api/location/friends` | Reciprocal list of friends within 50 km (snapped pins + online flag). | +| `PUT` | `/api/users/me/settings` | Update `share_location` / `show_online_status`. | +| *(WS lifecycle)* | `/api/wss` | Presence add/remove + heartbeat (no new endpoint). | + +All list responses follow the existing `CursorResults` convention where +pagination applies (friends-nearby is bounded by the friend set, so a cursor is +optional — decide in Q3). + +--- + +## 6. Code Touch Points + +- `src/cache/redis_cache.rs` — extend the `Cache` trait with geo + presence + methods (`geo_add`, `geo_search_radius`, `set_presence`, `clear_presence`, + `get_presence`). `NoOpCache` returns a "feature unavailable" error / empty. +- A new sweep task for orphaned `geo:friends` members (the old + `src/cache/cache_cleanup.rs` was removed when notifications moved to a + self-trimming Redis Stream; reintroduce a dedicated task for GEO). +- WebSocket handler (`broadcast/` / `wss`) — wire presence into connect / + heartbeat / disconnect. +- New `location` module (handler → service → cache), following the existing + handler → service → repository layering. No `unwrap()` in production paths. +- `broadcast/notification.rs` — *(optional)* add `PresenceChanged` variant and + update all match arms. +- Migration — add `share_location` / `show_online_status` columns; run + `cargo sqlx prepare` after touching queries. +- `core/config.rs` — *(optional)* make grid precision / radius / TTLs + configurable instead of hard-coded constants. + +--- + +## 7. Why this architecture makes sense + +- **Redis is the right store**: presence and live positions are ephemeral, + high-churn, and tolerant of loss on restart — exactly Redis's sweet spot. Its + built-in `GEOADD` / `GEOSEARCH` gives the 50 km radius query for free, with no + schema or index work in PostgreSQL. +- **PostgreSQL stays the source of truth** for durable data (settings, + relationships) — consistent with ISM's "PostgreSQL is the single source of + truth" principle. Positions are deliberately *not* durable. +- **Pull-on-demand + periodic push** keeps battery and traffic low and avoids + coupling the feature tightly to the WebSocket layer, while presence naturally + reuses the connection ISM already maintains. +- **Privacy is designed in, not bolted on**: reciprocity + deterministic + snapping + short TTL + server-side radius filtering directly counter the known + trilateration attack. + +--- + +## 8. Open Questions (resolve before implementation) + +- **Q1** — Should `POST /api/location` auto-enable `share_location`, or strictly + require the toggle to be set first via settings? (Leaning: require explicit + opt-in via settings; push only refreshes.) +- **Q2** — Exact behavior when the caller is online but has *no fresh position* + (e.g. just toggled on, hasn't pushed yet): empty list vs `409`? +- **Q3** — Does `GET /api/location/friends` need a cursor, or is it always small + enough to return whole (bounded by friend count)? +- **Q4** — Heartbeat interval & presence TTL values (e.g. 30 s ping / 60 s TTL) + and position TTL (15 vs 30 min) — tune against client behavior. +- **Q5** — Grid precision: geohash-6 (~1.2 km) vs `round(2)` (~1.1 km) vs a + configurable value. Affects the privacy/usefulness trade-off. \ No newline at end of file diff --git a/.docs/pagination-frontend-migration.md b/.docs/pagination-frontend-migration.md new file mode 100644 index 0000000..f43d720 --- /dev/null +++ b/.docs/pagination-frontend-migration.md @@ -0,0 +1,212 @@ +# Frontend Migration: Cursor Pagination for Rooms, Friends & Requests + +> **Audience:** frontend / client developers. +> **Type of change:** **breaking** response-shape change on `GET /api/rooms`, +> `GET /api/users/friends`, and `GET /api/users/friends/requests`. These now +> return a paginated `CursorResults` envelope instead of a plain array. +> `GET /api/users/search` is **non-breaking** (already paginated) but gains an +> optional `limit` parameter. + +For the streaming/notification migration see `docs/streaming-migration-frontend.md`. +The "unknown room" section below depends on it. + +--- + +## 1. What changed (Changelog) + +### List endpoints now return a cursor envelope + +All affected endpoints wrap their results in: + +```jsonc +{ + "cursor": "eyJsYXN0U2Vlbk5hbWUiOiJ...", // opaque token, or null on the last page + "content": [ /* page items */ ] +} +``` + +- `content` — the items for this page (same item shape as before). +- `cursor` — opaque base64url token. Pass it back as `?cursor=` to load the + next page. `null` means there are no more items. +- **Treat `cursor` as opaque.** Do not parse, build, or persist its contents — + the encoding may change without notice. + +### `GET /api/rooms` — paginated joined-rooms list + +**Before** +```jsonc +// GET /api/rooms +[ { "id": "...", "roomName": "...", ... }, ... ] // ALL joined rooms +``` +**After** +```jsonc +// GET /api/rooms?name=&cursor=&limit= +{ "cursor": "…|null", "content": [ ChatRoomDto, … ] } +``` +- New query params (all optional): + - `name` — case-insensitive substring filter. For **single** rooms this matches + the **other participant's** display name; for **group** rooms the room name. + - `cursor` — continuation token from a previous `cursor`. Omit for page 1. + - `limit` — desired page size. Server clamps to **`[1, 50]`**, default **20**. +- **Ordering:** most recent activity first (`latestMessage` DESC, `id` as + tie-breaker). Unchanged from before, just now paged. +- `ChatRoomDto` item shape is unchanged: `id`, `roomType`, `roomImageUrl`, + `roomName`, `createdAt`, `latestMessage`, `unread`, `latestMessagePreviewText`. + +### `GET /api/users/friends` — paginated friends list + +**Before** +```jsonc +[ User, … ] // ALL friends, unordered +``` +**After** +```jsonc +// GET /api/users/friends?username=&cursor=&limit= +{ "cursor": "…|null", "content": [ User, … ] } +``` +- New query params (all optional): `username` (case-insensitive name filter), + `cursor`, `limit` (`[1, 50]`, default 20). +- **Ordering:** `displayName` ASC, then `id` ASC (stable, alphabetical). + +### `GET /api/users/friends/requests` — paginated incoming requests + +Identical contract to `GET /api/users/friends`: same query params, same +`CursorResults` response, same ordering. Returns the **incoming** friend +requests (users who invited the caller). + +### `GET /api/users/search` — new optional `limit` (non-breaking) + +- Already returned `CursorResults` — response shape is + unchanged. +- Now also accepts `?limit=` (`[1, 50]`, default 20) alongside the existing + `username` (required) and `cursor` (optional). + +--- + +## 2. Pagination semantics you must respect + +### Page size is server-clamped +The server clamps `limit` into `[1, 50]` and defaults to `20`. **Never assume you +get exactly `limit` items back** — request `limit=1000` and you receive at most +50. Drive "has more" off `cursor`, not off `content.length`. + +### Reset the cursor when the filter changes +A `cursor` is only valid for the **same** `name` / `username` it was produced +with. When the search text changes, **drop the cursor** and request page 1 again. +Keeping a stale cursor across a filter change yields inconsistent pages. + +### Rooms sort by a *moving* key — dedupe and re-sort locally +Rooms are ordered by `latestMessage`, which changes whenever a new message +arrives. While you page through the list, an active room can jump to the top. +Consequences you must handle: +- The same room may appear on a page you already loaded → **dedupe by room `id`** + when merging pages. +- A room can shift between pages while paging → don't treat "not seen yet" as + "doesn't exist". +- On an incoming `ChatMessage`, move the room to the top of your local list + yourself; don't rely on re-fetching to reorder. + +Friends/requests sort by `displayName`, which is effectively stable — far less +churn, but the same "dedupe by `id` when merging pages" rule applies. + +--- + +## 3. Unknown room on a notification + +With the rooms list now paginated, the client typically holds only the first +page(s). A `ChatMessage` / `RoomChangeEvent` / `UserReadChat` can therefore +arrive for a room that is **not in the local cache** (an older room on a later +page, or a `NewRoom` that was missed while offline without Redis). + +**Do not** expect the server to embed the full room in these events. `ChatMessage` +is a fan-out with one identical payload for all members, but a single-room +`ChatRoomDto` is **viewer-relative** (its name/image is the *other* participant) — +so the room must be fetched per client, not broadcast. + +**Handling (lazy fetch + optimistic placeholder):** +1. On an event whose `chatRoomId` is unknown locally, **immediately** insert an + optimistic placeholder from the event payload: + - `id = message.chatRoomId` + - `latestMessage = message.createdAt` + - `latestMessagePreviewText = roomPreviewText` + - `unread = true` + This makes the room appear at the top without waiting on the network. +2. **Asynchronously** call `GET /api/rooms/{id}` (returns the viewer-relative + `ChatRoomDto`) and replace the placeholder with the authoritative room. +3. **Dedupe in-flight fetches per `roomId`** so a burst of messages in an unknown + room triggers a single request, not one per message. +4. `UserReadChat` carries only `roomId` — if the room is unknown, either run the + same lazy fetch or ignore it until the room is known. + +> This also self-heals the "lost `NewRoom` without Redis" case — the room is +> reconstructed on the first message rather than being missing forever. + +--- + +## 4. Frontend TODO checklist + +### Models / parsing +- [ ] Introduce a generic `CursorResults = { cursor: string | null, content: T[] }`. +- [ ] Change `GET /api/rooms`, `/api/users/friends`, `/api/users/friends/requests` + response models from `T[]` to `CursorResults`. +- [ ] Treat `cursor` as an opaque string (no parsing). + +### Rooms list +- [ ] Send `?limit=` and (on scroll) `?cursor=`; stop when + `cursor === null`. +- [ ] Wire the optional `name` filter; **reset cursor** whenever `name` changes. +- [ ] Merge pages with **dedupe by room `id`**; keep the list sorted by + `latestMessage` DESC locally. +- [ ] On incoming `ChatMessage`, move the room to the top yourself. + +### Friends & requests lists +- [ ] Same pattern: `limit` + `cursor` paging, optional `username` filter, + cursor reset on filter change, dedupe by `id`. + +### User search +- [ ] Optionally pass `limit`; response shape is unchanged. + +### Unknown-room handling (see §3) +- [ ] On a notification with an unknown `chatRoomId`, insert an optimistic + placeholder, then lazily `GET /api/rooms/{id}` and replace it. +- [ ] Dedupe concurrent fetches per `roomId`. + +### Cleanup +- [ ] Remove any code assuming `GET /api/rooms` / `friends` / `requests` returns + the **complete** set in one response. + +--- + +## 5. Quick reference + +| Endpoint | Query params | Response | +|---|---|---| +| `GET /api/rooms` | `name?`, `cursor?`, `limit?` | `CursorResults` | +| `GET /api/users/friends` | `username?`, `cursor?`, `limit?` | `CursorResults` | +| `GET /api/users/friends/requests` | `username?`, `cursor?`, `limit?` | `CursorResults` | +| `GET /api/users/search` | `username` (req), `cursor?`, `limit?` (new) | `CursorResults` | +| `GET /api/rooms/{id}` | — | `ChatRoomDto` (viewer-relative; used for lazy room fetch) | + +| Rule | Value | +|---|---| +| Default page size | 20 | +| Max page size (clamped) | 50 | +| `cursor === null` | last page, stop paging | +| Cursor validity | tied to the current filter — reset on filter change | +| Merge strategy | dedupe by item `id` | +| Rooms ordering | `latestMessage` DESC, `id` tie-break | +| Friends/requests ordering | `displayName` ASC, `id` tie-break | + +--- + +## 6. Edge cases + +- **`limit` ignored beyond 50:** requesting more returns 50; paginate for the rest. +- **Empty filter result:** `{ "cursor": null, "content": [] }` — render an + empty state, no further paging. +- **Invalid cursor:** the server responds `400` (`Invalid Cursor-Parameters.`). + Recover by dropping the cursor and reloading page 1. +- **Filter changed mid-scroll:** discard pending pages and any held cursor; start + fresh from page 1. +- **Room appears twice across pages:** expected for the rooms list (moving sort + key) — dedupe by `id`. diff --git a/.docs/sqlx-executor-pattern.md b/.docs/sqlx-executor-pattern.md new file mode 100644 index 0000000..7b723b2 --- /dev/null +++ b/.docs/sqlx-executor-pattern.md @@ -0,0 +1,92 @@ +# SQLx Executor Pattern + +This guide explains when to use the generic `Executor<'e>` trait versus an explicit `&mut PgConnection` for database functions in this codebase. + +## The Two Signatures + +### Variant 1 — Generic `Executor<'e>` + +```rust +pub async fn insert_message<'e, E>(&self, exec: E, message: &MessageEntity) -> Result<(), Error> +where + E: sqlx::Executor<'e, Database = Postgres>, +``` + +The caller can pass any of the following: + +```rust +// A pool reference — sqlx acquires a connection internally +repo.insert_message(&pool, &msg).await?; + +// An explicit connection +let mut conn = pool.acquire().await?; +repo.insert_message(&mut *conn, &msg).await?; + +// A transaction +let mut tx = pool.begin().await?; +repo.insert_message(&mut *tx, &msg).await?; +``` + +### Variant 2 — Explicit `&mut PgConnection` + +```rust +pub async fn apply_message_to_room( + &self, + conn: &mut PgConnection, + ... +) -> Result<(), sqlx::Error> +``` + +The caller must pass a concrete connection. Passing `&pool` directly does not compile: + +```rust +// Does NOT compile — enforced by the type system: +repo.apply_message_to_room(&pool, ...).await?; + +// Works — explicit acquire: +let mut conn = pool.acquire().await?; +repo.apply_message_to_room(&mut *conn, ...).await?; + +// Works — transaction (Transaction<'_, Postgres>: Deref): +let mut tx = pool.begin().await?; +repo.apply_message_to_room(&mut *tx, ...).await?; +``` + +## Which to Use and When + +The decision comes down to **semantic intent**, not just flexibility. + +### Use `Executor<'e>` when: + +The function is called **both inside and outside of transactions** in the codebase. The extra flexibility is genuinely needed. + +**Example: `insert_message`** +- Called with `&pool` in `save_room_change_message_and_broadcast` (no transaction needed) +- Called with `&mut *tx` in `send_message` (must be atomic with room state update) + +### Use `&mut PgConnection` when: + +The function is **always part of a larger transaction**. The restrictive type is intentional — it makes calling the function without a transaction a compile error instead of a silent consistency bug. + +**Examples in this codebase:** + +| Function | Why it enforces `&mut PgConnection` | +|---|---| +| `apply_message_to_room` | Must be atomic with `insert_message` | +| `update_last_room_message` | Always paired with `update_user_read_status` in a tx | +| `delete_room` | Always paired with participant cleanup | +| `remove_user_from_room` | Always paired with preview text update | + +## The Core Trade-off + +`Executor<'e>` is more **flexible**. `&mut PgConnection` is more **correct** for transaction-bound operations. + +A future developer who tries to call `apply_message_to_room` with just `&pool` gets a **compiler error**. With a generic `Executor`, they would get a **runtime consistency bug** instead — the room state update would succeed without the message insert being part of the same atomic unit. + +More options at the call site is not always better. Use the type system to enforce the invariants that matter. + +## Practical Notes + +- `Transaction<'_, Postgres>` implements `Deref`, so `&mut *tx` satisfies `&mut PgConnection`. +- `&Pool` implements `Executor<'_, Database = Postgres>`, so it works with Variant 1 but not Variant 2. +- When a repository function needs to expose its pool for external callers (e.g. `save_room_change_message_and_broadcast`), add a `get_connection() -> &Pool` method rather than making the pool field public. diff --git a/.docs/streaming-migration-frontend.md b/.docs/streaming-migration-frontend.md new file mode 100644 index 0000000..a96dff5 --- /dev/null +++ b/.docs/streaming-migration-frontend.md @@ -0,0 +1,153 @@ +# Frontend Migration: Streaming Envelope & Sequencing (Phase A) + +> **Audience:** frontend / client developers. +> **Type of change:** **breaking** wire-format change on `/api/sse`, `/api/wss` +> and `GET /api/notifications`. There is **no compatibility window** — the server +> emits the new `v: 1` envelope only. Clients must update before deploying +> against a Phase-A backend. + +See `docs/streaming-sequencing.md` for the full backend design. + +--- + +## 1. What changed (Changelog) + +### Wire format — every notification is now a versioned envelope +**Before** +```jsonc +{ "type": "ChatMessage", "createdAt": "...", "message": { ... }, "roomPreviewText": { ... } } +``` +**After** +```jsonc +{ + "v": 1, + "seq": 4711, + "type": "ChatMessage", + "createdAt": "...", + "message": { ... }, + "roomPreviewText": { ... } +} +``` +- `v` (number) — envelope version. Currently always `1`. +- `seq` (number, **optional**) — monotonic **per-user** sequence number. + - Present on **durable** events (chat messages, friend requests, etc.). + - **Absent** (`undefined`) on **ephemeral** events and when the server runs + without Redis. +- `type` — unchanged discriminator. **PascalCase** variant name + (`"ChatMessage"`, `"NewRoom"`, `"Resync"`, …). Payload fields remain camelCase. + +### New event type: `Resync` +```jsonc +{ "v": 1, "type": "Resync", "createdAt": "...", "reason": "stream lagged, please resync via REST" } +``` +Sent on a single connection when the server cannot replay losslessly (gap older +than the cache window, or the connection lagged). It carries **no `seq`** and is +**never replayed**. On receipt, the client must reload authoritative state via +REST (timeline, friends, rooms) and then keep consuming live events. + +### Connection handshake — `last_seq` +- `GET /api/sse?last_seq=` and `ANY /api/wss?last_seq=` now accept an + **optional** `last_seq` query parameter. +- On connect the server first **replays** every durable event with `seq > n` on + the same connection, **then** streams live events. Omit `last_seq` on a fresh + connection (no replay). + +### REST replay endpoint — parameter renamed +- `GET /api/notifications` now takes **`?last_seq=`** (number, **required**) + instead of the old `?timestamp=`. +- Returns the durable events with `seq > n`. If the history is no longer + available, it returns a single-element array containing a `Resync` event. + +### Behavioural note +- Duplicate suppression is the client's job: the same `seq` may briefly arrive + via both replay and live stream around reconnect. **Dedup/ignore any event + whose `seq <= highestSeqSeen`.** + +--- + +## 2. Frontend TODO checklist + +### Parsing +- [ ] Update the notification type/model to include `v: number` and + `seq?: number`. +- [ ] Add a handler for the new `type: "Resync"` event. +- [ ] Treat `seq` as optional — never assume it exists (ephemeral events / no + Redis). + +### Sequence tracking +- [ ] Persist `highestSeqSeen` per user (survive app restarts; e.g. + localStorage / secure storage). +- [ ] On every received event with a `seq`: if `seq <= highestSeqSeen`, **drop + it** (duplicate); otherwise process it and set + `highestSeqSeen = seq`. +- [ ] Do **not** update `highestSeqSeen` from events without a `seq`. + +### Connecting / reconnecting +- [ ] **After any full REST sync** (first connect, cold start, post-`Resync`, or + whenever you (re)load rooms/friends/timeline): connect **without** + `last_seq`. The snapshot is already authoritative, so a fresh connection + avoids replaying events you have applied — important for multi-device, where + `seq` is shared across devices and a stale stored value would otherwise + flood you with already-synced events. +- [ ] **Order matters — subscribe before you snapshot.** Open the stream first + (fresh, no `last_seq`), and only **then** issue the REST sync calls. This + closes the gap where an event produced between the snapshot and the + subscription would otherwise be missed: with the stream open first, the + snapshot is strictly newer than the stream start, so anything in between + arrives live and is reconciled by idempotent application. Buffer live events + that arrive while the snapshot request is still in flight, then apply them + after the snapshot. +- [ ] Seed `highestSeqSeen` after a full sync via + `GET /api/notifications/cursor` → `{ seq }` (or from the first live event's + `seq`). +- [ ] **Short reconnect only** (brief blip, no state reload): connect with + `?last_seq=` to replay the small gap. +- [ ] Apply events idempotently (dedup by stable IDs, e.g. `message_id`): + delivery is at-least-once. +- [ ] Keep the existing WebSocket ping/pong + keep-alive handling (unchanged). + +### Resync handling +- [ ] On a `Resync` event (from the stream **or** as the REST response element), + follow the subscribe-before-snapshot order: + 1. (Re)connect the stream **without** `last_seq` (full-sync mode) and start + buffering live events. + 2. Re-fetch authoritative state via REST (timeline / friends / rooms). + 3. Re-seed `highestSeqSeen` (via `/api/notifications/cursor` or the first live + event), then apply the buffered live events idempotently and continue + consuming normally. + +### REST endpoint +- [ ] Replace `GET /api/notifications?timestamp=...` calls with + `GET /api/notifications?last_seq=` (use `0` to request + everything still retained). +- [ ] Handle a returned `Resync` element the same way as a streamed one. + +### Cleanup +- [ ] Remove any timestamp-based catch-up logic that relied on the old + `?timestamp=` parameter. + +--- + +## 3. Quick reference + +| Concern | Old | New | +|---|---|---| +| Envelope | `{ type, createdAt, ...payload }` | `{ v, seq?, type, createdAt, ...payload }` | +| Catch-up cursor | `createdAt` timestamp | per-user `seq` | +| Stream handshake | — | `?last_seq=` (optional) on `/api/sse`, `/api/wss`; omit after a full REST sync | +| REST replay | `GET /api/notifications?timestamp=` | `GET /api/notifications?last_seq=` | +| Cursor seed | — | `GET /api/notifications/cursor` → `{ seq }` | +| Gap signal | none (silent loss) | `Resync` event → reload via REST | +| Dedup key | — | `seq` (ignore `<= highestSeqSeen`) | + +--- + +## 4. Edge cases + +- **Server without Redis:** all events arrive with `seq` absent and there is no + replay. Clients still work live-only; reconnect simply resumes from now. +- **Ephemeral events:** never have `seq`, never replayed (e.g. `Resync`, and + future typing/presence signals). Render them transiently; never use them for + sync state. +- **`last_seq` too old:** instead of a partial/incorrect replay the server sends + `Resync` — always handle it, do not assume a replay always returns events. diff --git a/.docs/streaming-sequencing.md b/.docs/streaming-sequencing.md new file mode 100644 index 0000000..6da0725 --- /dev/null +++ b/.docs/streaming-sequencing.md @@ -0,0 +1,128 @@ +# Real-time Streaming: Envelope, Sequencing & Resync — Design + +> Status: **Implemented (Phase A)**. Foundation for future streaming work +> (topic subscriptions, presence). Multi-server fan-out is explicitly out of scope. + +This documents how ISM delivers real-time events over WebSocket (`/api/wss`) and +SSE (`/api/sse`) without silent loss, and how a client recovers after a +reconnect or a slow-consumer lag. + +## 1. Goals + +- A stable, versioned wire envelope that can evolve without breaking clients. +- A monotonic **per-user** sequence number so a client can detect gaps and + resume exactly where it left off. +- A bounded, hybrid recovery model: replay small gaps from cache; for anything + older than the retention window, tell the client to reload via REST. Retention + is count-bounded (the last ~N events per user), not time-bounded. +- Ephemeral events (typing-style signals) must **not** be replayed — a typing + indicator from 30 minutes ago is noise. + +## 2. Wire Envelope + +```jsonc +{ + "v": 1, // envelope version (NOTIFICATION_VERSION) + "seq": 4711, // monotonic per-user; omitted for ephemeral events / no Redis + "type": "chatMessage", // NotificationEvent tag + "createdAt": "2026-...", + ...payload // variant fields, serde-flattened +} +``` + +Built only via `Notification::new(body)` (`src/broadcast/notification.rs`). +`seq` is left `None` at construction and assigned per-recipient during delivery. + +## 3. Durable vs. Ephemeral + +`NotificationEvent::is_ephemeral()` is the single source of truth. + +- **Durable** (default, all current variants): assigned a `seq`, cached for + replay, push-fallback when offline. +- **Ephemeral** (`Resync`, and future typing/presence): no `seq`, never cached, + live-only. Dropped for offline users by design. + +## 4. Sequencing + +`Cache::next_sequence(user_id)` → Redis `INCR user_seq:{id}` (+ TTL refresh, +`SEQUENCE_TTL_SECONDS`). Returns `Option`: + +- `Some(seq)` — sequencing available. +- `None` — `NoOpCache` / no Redis: events are delivered best-effort, `seq` stays + `None`, and replay is unavailable. + +Because `seq` is **per-user**, a fan-out (`send_event_to_all`) allocates a +distinct `seq` for each recipient — there is no shared sequence across users. + +## 5. Caching & Replay (`src/cache/redis_cache.rs`) + +- Durable notifications are appended to a per-user **Redis Stream** + (`user_notifications:{id}`). Each entry's ID is `-0`, so the stream is + ordered by `seq` and the entry holds the serialized notification under the + `data` field. +- `XADD ... MAXLEN ~ STREAM_MAX_LEN` trims older entries on every write (amortized + O(1)), bounding retention to the last ~N events. A TTL is refreshed on each + write so a fully inactive user's stream is reclaimed — there is **no background + cleanup task**. +- `get_notifications_since_seq(user_id, last_seq)` → `ReplayResult`: + - `Events(vec)` — `XRANGE` from exclusive `(-0` to `+`, in order. + - `ResyncNeeded` — the oldest retained `seq` is already newer than + `last_seq + 1` (the gap was trimmed). Because the stream is a single + structure, there is no separate index that can dangle, so this is the only + resync trigger. + +## 6. Connection Handshake (`src/messaging/notifications.rs`) + +1. **Subscribe first**, then read the replay (so events produced during the + handshake are buffered, not lost). +2. Resolve `?last_seq=` via `resolve_handshake`: + - no `last_seq` → fresh connection, no replay. + - `Events` → send them; `high_water` = max replayed `seq`. + - `ResyncNeeded` / error → send a single `Resync` event, `high_water = 0`. +3. Go live; drop any durable event with `seq <= high_water` (dedupes the overlap + between replay and the live buffer). Ephemeral events always pass. +4. On `RecvError::Lagged` (slow consumer overran the 100-deep broadcast buffer), + send a `Resync` and reset `high_water` to 0. + +The REST endpoint `GET /api/notifications?last_seq=` exposes the same replay +for explicit pulls; a `ResyncNeeded` surfaces as a single `Resync` element. + +`GET /api/notifications/cursor` → `{ "seq": }` returns the highest sequence +currently issued to the caller (0 if none yet) **without** advancing it. A client +that has just done a full REST sync uses this to seed its stored cursor. + +## 7. Client Contract + +There are two distinct (re)connection modes — keep them separate: + +- **Short reconnect** (no state reload, e.g. a brief network blip): reconnect with + `?last_seq=`. The server replays the small gap. +- **Full REST sync** (cold start, post-`Resync`, or multi-device divergence where a + stale `seq` would replay events the snapshot already contains): connect to the + stream **without** any `last_seq` parameter. A fresh connection does no replay + and streams only events from subscription onward, so there is no flood of + already-applied events. **Subscribe before you snapshot**: open the stream first + (buffering live events), then issue the REST calls — the snapshot is then strictly + newer than the stream start, so any event produced in between arrives live and is + reconciled by idempotent application, closing the snapshot/stream race. Seed the + stored cursor from `GET /api/notifications/cursor` (or the `seq` of the first live + event) for subsequent short reconnects. + +Why this split matters: `seq` is **per-user**, shared across a user's devices. A +device returning with a `seq` from before another device advanced the counter must +*not* replay from that old `seq` after a full REST sync — it already holds current +state. Connecting fresh avoids re-delivering events it has applied. + +- Treat `seq` as the ordering/dedup key (ignore `seq <= highestSeen`). +- Apply events **idempotently** (dedup by stable IDs such as `message_id`): + delivery is at-least-once and the replay/live windows overlap by design. +- On a `Resync` event: reload authoritative state via REST (timeline, friends, + rooms), then reconnect **without** `last_seq` (full-sync mode above). +- Ephemeral events carry no `seq` — never use them for sync state. + +## 8. Out of Scope / Next + +- Topic subscriptions over the WS uplink (would let typing/presence target only + interested connections). +- Presence — see `docs/location-presence-sharing.md`. +- Multi-server fan-out (Redis Pub/Sub backplane) — deprioritized. diff --git a/.docs/timeline-senders-frontend-migration.md b/.docs/timeline-senders-frontend-migration.md new file mode 100644 index 0000000..56321a2 --- /dev/null +++ b/.docs/timeline-senders-frontend-migration.md @@ -0,0 +1,51 @@ +# Frontend Migration — Bundled Senders + +Two wire formats changed so the client no longer needs a separate user fetch to render +messages: the **timeline** now bundles its senders, and the live **`ChatMessage`** event +embeds its sender. + +## 1. Timeline response shape (`GET /api/rooms/{id}/timeline`) + +Before: a bare array `MessageDto[]`. Now a `TimelinePage`: + +```jsonc +{ + "messages": [ /* MessageDto[], unchanged */ ], + "senders": [ /* RoomMember[] */ ] +} +``` + +- Map over `response.messages` instead of the response directly. +- Merge `response.senders` into a local sender map (`id → RoomMember`). It contains **every + author in this page**, including reply original authors (`replySenderId`) and authors who + have **already left** the room. No parallel user fetch is needed for the timeline anymore. + +## 2. `ChatMessage` live event carries `sender` (SSE `/api/sse` + WS `/api/wss`) + +```jsonc +{ "type": "ChatMessage", "message": { /* ... */ }, "roomPreviewText": { /* ... */ }, "sender": { /* RoomMember */ } } +``` + +- On receive, merge `event.sender` into the same sender map. A user posting for the first + time renders immediately, with no lookup. Also keeps names/avatars fresh, since the + profile is always current. + +## 3. `RoomMember` shape changed (affects `/users`, `/read-states`, `senders`, event `sender`) + +- `membershipStatus` is **gone** — a row means "in the room". Remove any field/filter on it. +- `joinedAt` and `lastMessageReadAt` are now **nullable**: for authors in `senders` that + have left, both come back as `null` (identity `id` / `displayName` / `profilePicture` is + always present). Use `joinedAt == null` if you want to flag a "former member". + +## Recommended pattern + +Keep one sender map per room, hydrated from: + +- `timeline.senders` (initial load and each scroll-back page), and +- `ChatMessage.sender` (live). + +Messages reference only `senderId` / `replySenderId` — resolve them against the map. + +## Unchanged + +`MessageDto` itself, `roomPreviewText`, the `timestamp` query param, and all other events. diff --git a/.env b/.env index 2818f5e..7f7ffb7 100644 --- a/.env +++ b/.env @@ -1,2 +1 @@ DATABASE_URL=postgresql://postgres:meventure1234@localhost:32768/postgres -ISM_LOG_LEVEL=info \ No newline at end of file diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..75c3315 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,100 @@ +name: CD + +# Builds and publishes the Docker image to GHCR. Runs only on master and on +# release tags — never on the development branch, so no image is built or stored +# for dev work. +on: + push: + branches: [ "master" ] + tags: [ "v*.*.*" ] + +concurrency: + group: cd-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + SQLX_OFFLINE: "true" + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + pkg-config \ + libssl-dev \ + libcurl4-openssl-dev + + - name: Setup Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component clippy + rustup default stable + + - name: Cache cargo registry & build artifacts + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 + + - name: Clippy + # Static analysis. Warnings are reported but do not fail the build yet; + # re-add `-- -D warnings` to enforce a clean lint once the backlog is cleared. + run: cargo clippy --all-targets --locked + + - name: Test + run: cargo test --all-targets --locked + + docker: + name: Build & Push Image + runs-on: ubuntu-latest + needs: build-and-test + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ghcr.io/jrtimha/ism + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha-,format=short + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Build & Push image + id: push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94be384 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +# Runs on every push to the development branch and on pull requests targeting +# master. Compiles, lints and tests the crate. No Docker image is built, pushed +# or stored here — image publishing is handled exclusively by cd.yml on master. +on: + push: + branches: [ "development" ] + pull_request: + branches: [ "master" ] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + # All SQLx queries are checked against the committed .sqlx metadata, + # so no database is needed to compile or test. + SQLX_OFFLINE: "true" + +jobs: + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt + rustup default stable + + - name: Check formatting + run: cargo fmt --all --check + + clippy-test: + name: Clippy & Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # rdkafka is built with the `cmake-build` feature, which compiles + # librdkafka from source. These are the same native dependencies the + # release Dockerfile installs. + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + pkg-config \ + libssl-dev \ + libcurl4-openssl-dev + + - name: Setup Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component clippy + rustup default stable + + - name: Cache cargo registry & build artifacts + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + + - name: Clippy + # Static analysis. Warnings are reported but do not fail the build yet; + # re-add `-- -D warnings` to enforce a clean lint once the backlog is cleared. + run: cargo clippy --all-targets --locked + + - name: Test + run: cargo test --all-targets --locked + + docker-build: + name: Docker Build (validation) + # Only on pull requests to master: verify the release image still builds + # before merging. Nothing is pushed or stored. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Build image (no push) + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml deleted file mode 100644 index 38c4a74..0000000 --- a/.github/workflows/pipeline.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Rest-API CI/CD - -on: - push: - branches: [ "master" ] - tags: [ 'v*.*.*' ] - pull_request: - branches: [ "master" ] - -jobs: - - build: - - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - attestations: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Login to GitHub CR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{github.actor}} - password: ${{secrets.GITHUB_TOKEN}} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/jrtimha/ism - tags: | - type=ref,event=tag - type=raw,value=nightly,enable={{is_default_branch}} - - - name: Build the Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: ./Dockerfile - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..8fdaec5 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,61 @@ +name: Security Audit + +# Scans all dependencies in Cargo.lock against the RustSec advisory database +# (https://github.com/rustsec/advisory-db) for known vulnerabilities. +on: + push: + branches: [ "master", "development" ] + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + - ".github/workflows/security.yml" + pull_request: + branches: [ "master" ] + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + - ".github/workflows/security.yml" + schedule: + # Re-scan weekly so newly disclosed advisories against otherwise unchanged + # dependencies are caught even when no code is pushed. + - cron: "0 6 * * 1" # Mondays, 06:00 UTC + +concurrency: + group: security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + audit: + name: cargo audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # No native build dependencies are needed: cargo-audit only parses + # Cargo.lock and compares it against the advisory database. + - name: Setup Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Cache cargo registry & cargo-audit binary + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + cache-bin: true + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Run vulnerability scan + # Fails the job when a dependency has a known security vulnerability. + # Non-security warnings (unmaintained / yanked crates) are reported but + # do not fail the build. Make it stricter with `--deny warnings`. + # + # Consciously accepted advisories that have no fix yet are listed (with + # justification) in `.cargo/audit.toml`, which `cargo audit` reads + # automatically. The scan stays strict for every other advisory. + run: cargo audit diff --git a/.sqlx/query-015f0e7be83c3880d8c7395c0fa807800a3d7c97fdc823e88432052fa51eab2b.json b/.sqlx/query-015f0e7be83c3880d8c7395c0fa807800a3d7c97fdc823e88432052fa51eab2b.json deleted file mode 100644 index 4e63790..0000000 --- a/.sqlx/query-015f0e7be83c3880d8c7395c0fa807800a3d7c97fdc823e88432052fa51eab2b.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, room_type as \"room_type: RoomType\", room_name, created_at, latest_message, room_image_url, latest_message_preview_text, NULL::boolean as \"unread: _\"\n FROM chat_room\n WHERE id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "room_type: RoomType", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "room_name", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "latest_message", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "room_image_url", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "latest_message_preview_text", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "unread: _", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - true, - true, - true, - null - ] - }, - "hash": "015f0e7be83c3880d8c7395c0fa807800a3d7c97fdc823e88432052fa51eab2b" -} diff --git a/.sqlx/query-1118e948abb60fa5092a96ac82a3d104679eb435d1d7be800151e9d4020ace26.json b/.sqlx/query-1118e948abb60fa5092a96ac82a3d104679eb435d1d7be800151e9d4020ace26.json deleted file mode 100644 index 58ae525..0000000 --- a/.sqlx/query-1118e948abb60fa5092a96ac82a3d104679eb435d1d7be800151e9d4020ace26.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE chat_room_participant SET participant_state = 'Left' WHERE user_id = $1 AND room_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "1118e948abb60fa5092a96ac82a3d104679eb435d1d7be800151e9d4020ace26" -} diff --git a/.sqlx/query-1ed4c19fd3f710e493cf0b694f2c0e8ec186f1743aeb6576bc8687b6666d0346.json b/.sqlx/query-1ed4c19fd3f710e493cf0b694f2c0e8ec186f1743aeb6576bc8687b6666d0346.json new file mode 100644 index 0000000..7bb1207 --- /dev/null +++ b/.sqlx/query-1ed4c19fd3f710e493cf0b694f2c0e8ec186f1743aeb6576bc8687b6666d0346.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM chat_message WHERE chat_room_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1ed4c19fd3f710e493cf0b694f2c0e8ec186f1743aeb6576bc8687b6666d0346" +} diff --git a/.sqlx/query-21626f8b85b018fe7982b1628fdf0d9b5cec1b0fc8a3aaed7c40b190c8bccb8d.json b/.sqlx/query-21626f8b85b018fe7982b1628fdf0d9b5cec1b0fc8a3aaed7c40b190c8bccb8d.json deleted file mode 100644 index 357f999..0000000 --- a/.sqlx/query-21626f8b85b018fe7982b1628fdf0d9b5cec1b0fc8a3aaed7c40b190c8bccb8d.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT users.id,\n users.display_name,\n users.profile_picture,\n participants.joined_at,\n participants.last_message_read_at,\n participants.participant_state AS \"membership_status: MembershipStatus\"\n FROM chat_room_participant AS participants\n JOIN app_user AS users ON participants.user_id = users.id\n WHERE participants.room_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "display_name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "profile_picture", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "joined_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "last_message_read_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "membership_status: MembershipStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - true, - false - ] - }, - "hash": "21626f8b85b018fe7982b1628fdf0d9b5cec1b0fc8a3aaed7c40b190c8bccb8d" -} diff --git a/.sqlx/query-2e6762847f9788d44f79b2a16538c422881e89fccd4536118b6f1d4efa8c59dc.json b/.sqlx/query-2e6762847f9788d44f79b2a16538c422881e89fccd4536118b6f1d4efa8c59dc.json new file mode 100644 index 0000000..2c0558a --- /dev/null +++ b/.sqlx/query-2e6762847f9788d44f79b2a16538c422881e89fccd4536118b6f1d4efa8c59dc.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n app_user.id,\n app_user.display_name,\n app_user.profile_picture,\n chat_room_participant.joined_at AS \"joined_at?\",\n chat_room_participant.last_message_read_at\n FROM chat_room_participant\n JOIN app_user ON chat_room_participant.user_id = app_user.id\n WHERE chat_room_participant.room_id = $1 AND chat_room_participant.user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "app_user", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "display_name" + } + } + }, + { + "ordinal": 2, + "name": "profile_picture", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "profile_picture" + } + } + }, + { + "ordinal": 3, + "name": "joined_at?", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "joined_at" + } + } + }, + { + "ordinal": 4, + "name": "last_message_read_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "last_message_read_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "2e6762847f9788d44f79b2a16538c422881e89fccd4536118b6f1d4efa8c59dc" +} diff --git a/.sqlx/query-4070a060d147a9c6ff7715a4fb9ba0f85c7725dfc02c89d0fca1b2ab7bcd3a98.json b/.sqlx/query-4070a060d147a9c6ff7715a4fb9ba0f85c7725dfc02c89d0fca1b2ab7bcd3a98.json new file mode 100644 index 0000000..b6d0be1 --- /dev/null +++ b/.sqlx/query-4070a060d147a9c6ff7715a4fb9ba0f85c7725dfc02c89d0fca1b2ab7bcd3a98.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO chat_message (message_id, chat_room_id, sender_id, msg_body, msg_type, created_at)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Jsonb", + { + "Custom": { + "name": "msg_type", + "kind": { + "Enum": [ + "Text", + "Media", + "RoomChange", + "Reply" + ] + } + } + }, + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "4070a060d147a9c6ff7715a4fb9ba0f85c7725dfc02c89d0fca1b2ab7bcd3a98" +} diff --git a/.sqlx/query-496fd9236c60f80bdf0d627b6ac7b139df7e87f2143772245ce8c463e9ecaf59.json b/.sqlx/query-496fd9236c60f80bdf0d627b6ac7b139df7e87f2143772245ce8c463e9ecaf59.json deleted file mode 100644 index 3fdb074..0000000 --- a/.sqlx/query-496fd9236c60f80bdf0d627b6ac7b139df7e87f2143772245ce8c463e9ecaf59.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n room.id,\n room.room_type AS \"room_type: RoomType\",\n room.created_at,\n room.latest_message,\n room.latest_message_preview_text,\n COALESCE(other_user.display_name, room.room_name) AS room_name,\n COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url,\n COALESCE(p1.last_message_read_at < room.latest_message, TRUE) AS unread\n FROM\n chat_room_participant AS p1\n JOIN\n chat_room AS room ON p1.room_id = room.id\n -- 3. To find the other participant, only for single chat rooms!\n LEFT JOIN LATERAL (\n SELECT\n p2.user_id\n FROM\n chat_room_participant p2\n WHERE\n p2.room_id = room.id AND p2.user_id != $1\n -- Only take the first match\n LIMIT 1\n ) AS other_participant ON room.room_type = 'Single'\n -- Only executed when the lateral join has matched something:\n LEFT JOIN\n app_user AS other_user ON other_user.id = other_participant.user_id\n WHERE\n p1.user_id = $1\n AND p1.participant_state = 'Joined'\n ORDER BY\n room.latest_message DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "room_type: RoomType", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "latest_message", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "latest_message_preview_text", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "room_name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "room_image_url", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "unread", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - null, - null, - null - ] - }, - "hash": "496fd9236c60f80bdf0d627b6ac7b139df7e87f2143772245ce8c463e9ecaf59" -} diff --git a/.sqlx/query-4a571e9af27608388d0e88cbb4c4a4053b476890e251cd685e6d82f2cee83e51.json b/.sqlx/query-4a571e9af27608388d0e88cbb4c4a4053b476890e251cd685e6d82f2cee83e51.json index b5d69c1..74c28ee 100644 --- a/.sqlx/query-4a571e9af27608388d0e88cbb4c4a4053b476890e251cd685e6d82f2cee83e51.json +++ b/.sqlx/query-4a571e9af27608388d0e88cbb4c4a4053b476890e251cd685e6d82f2cee83e51.json @@ -6,7 +6,8 @@ { "ordinal": 0, "name": "user_b_id", - "type_info": "Uuid" + "type_info": "Uuid", + "origin": "Expression" } ], "parameters": { diff --git a/.sqlx/query-5c696bc4666a38e853b092fa7e41dc6f0c3a0a1e29e9d103a133523d6fa40ac6.json b/.sqlx/query-5c696bc4666a38e853b092fa7e41dc6f0c3a0a1e29e9d103a133523d6fa40ac6.json new file mode 100644 index 0000000..f20b86b --- /dev/null +++ b/.sqlx/query-5c696bc4666a38e853b092fa7e41dc6f0c3a0a1e29e9d103a133523d6fa40ac6.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n users.id,\n users.display_name,\n users.profile_picture,\n participants.joined_at AS \"joined_at?\",\n participants.last_message_read_at\n FROM chat_room_participant AS participants\n JOIN app_user AS users ON participants.user_id = users.id\n WHERE participants.user_id = $1 AND participants.room_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "app_user", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "display_name" + } + } + }, + { + "ordinal": 2, + "name": "profile_picture", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "profile_picture" + } + } + }, + { + "ordinal": 3, + "name": "joined_at?", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "joined_at" + } + } + }, + { + "ordinal": 4, + "name": "last_message_read_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "last_message_read_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "5c696bc4666a38e853b092fa7e41dc6f0c3a0a1e29e9d103a133523d6fa40ac6" +} diff --git a/.sqlx/query-623f2b3cadcfca389e0374421897ec241a5967b89d589113cb28492133ef8d78.json b/.sqlx/query-623f2b3cadcfca389e0374421897ec241a5967b89d589113cb28492133ef8d78.json new file mode 100644 index 0000000..481af6e --- /dev/null +++ b/.sqlx/query-623f2b3cadcfca389e0374421897ec241a5967b89d589113cb28492133ef8d78.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n users.id,\n users.display_name,\n users.profile_picture,\n participants.joined_at AS \"joined_at?\",\n participants.last_message_read_at\n FROM chat_room_participant AS participants\n JOIN app_user AS users ON participants.user_id = users.id\n WHERE participants.room_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "app_user", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "display_name" + } + } + }, + { + "ordinal": 2, + "name": "profile_picture", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "profile_picture" + } + } + }, + { + "ordinal": 3, + "name": "joined_at?", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "joined_at" + } + } + }, + { + "ordinal": 4, + "name": "last_message_read_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "last_message_read_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "623f2b3cadcfca389e0374421897ec241a5967b89d589113cb28492133ef8d78" +} diff --git a/.sqlx/query-637e0d0a476c53431c7bac444d50465ef8673610590ae1488c0f4fd75604f7b2.json b/.sqlx/query-637e0d0a476c53431c7bac444d50465ef8673610590ae1488c0f4fd75604f7b2.json new file mode 100644 index 0000000..1e81840 --- /dev/null +++ b/.sqlx/query-637e0d0a476c53431c7bac444d50465ef8673610590ae1488c0f4fd75604f7b2.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n message_id,\n chat_room_id,\n sender_id,\n msg_body AS \"msg_body: sqlx::types::Json\",\n msg_type AS \"msg_type: MsgType\",\n created_at\n FROM chat_message\n WHERE message_id = $1 AND chat_room_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "message_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_message", + "name": "message_id" + } + } + }, + { + "ordinal": 1, + "name": "chat_room_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_message", + "name": "chat_room_id" + } + } + }, + { + "ordinal": 2, + "name": "sender_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_message", + "name": "sender_id" + } + } + }, + { + "ordinal": 3, + "name": "msg_body: sqlx::types::Json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "chat_message", + "name": "msg_body" + } + } + }, + { + "ordinal": 4, + "name": "msg_type: MsgType", + "type_info": { + "Custom": { + "name": "msg_type", + "kind": { + "Enum": [ + "Text", + "Media", + "RoomChange", + "Reply" + ] + } + } + }, + "origin": { + "Table": { + "table": "chat_message", + "name": "msg_type" + } + } + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_message", + "name": "created_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "637e0d0a476c53431c7bac444d50465ef8673610590ae1488c0f4fd75604f7b2" +} diff --git a/.sqlx/query-63faf032c93dc723987b8a926da76627c07d75a2dc68e66a99e3c36e4e5ec01e.json b/.sqlx/query-63faf032c93dc723987b8a926da76627c07d75a2dc68e66a99e3c36e4e5ec01e.json index 5fd83f2..9e2d882 100644 --- a/.sqlx/query-63faf032c93dc723987b8a926da76627c07d75a2dc68e66a99e3c36e4e5ec01e.json +++ b/.sqlx/query-63faf032c93dc723987b8a926da76627c07d75a2dc68e66a99e3c36e4e5ec01e.json @@ -6,22 +6,46 @@ { "ordinal": 0, "name": "user_a_id", - "type_info": "Uuid" + "type_info": "Uuid", + "origin": { + "Table": { + "table": "user_relationship", + "name": "user_a_id" + } + } }, { "ordinal": 1, "name": "user_b_id", - "type_info": "Uuid" + "type_info": "Uuid", + "origin": { + "Table": { + "table": "user_relationship", + "name": "user_b_id" + } + } }, { "ordinal": 2, "name": "state: RelationshipState", - "type_info": "Varchar" + "type_info": "Varchar", + "origin": { + "Table": { + "table": "user_relationship", + "name": "state" + } + } }, { "ordinal": 3, "name": "relationship_change_timestamp", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "user_relationship", + "name": "relationship_change_timestamp" + } + } } ], "parameters": { diff --git a/.sqlx/query-2229866d81255653e5d64409839045bca87a71d0eac5d691d92331e5a2bb8e61.json b/.sqlx/query-661f8c7e625cf453e9db0328031391f8641fbdd94e2b27090762b0461748f979.json similarity index 54% rename from .sqlx/query-2229866d81255653e5d64409839045bca87a71d0eac5d691d92331e5a2bb8e61.json rename to .sqlx/query-661f8c7e625cf453e9db0328031391f8641fbdd94e2b27090762b0461748f979.json index 6818ec2..41469a7 100644 --- a/.sqlx/query-2229866d81255653e5d64409839045bca87a71d0eac5d691d92331e5a2bb8e61.json +++ b/.sqlx/query-661f8c7e625cf453e9db0328031391f8641fbdd94e2b27090762b0461748f979.json @@ -1,12 +1,18 @@ { "db_name": "PostgreSQL", - "query": "SELECT user_id FROM chat_room_participant WHERE room_id = $1 AND participant_state = 'Joined'", + "query": "SELECT user_id FROM chat_room_participant WHERE room_id = $1", "describe": { "columns": [ { "ordinal": 0, "name": "user_id", - "type_info": "Uuid" + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "user_id" + } + } } ], "parameters": { @@ -18,5 +24,5 @@ false ] }, - "hash": "2229866d81255653e5d64409839045bca87a71d0eac5d691d92331e5a2bb8e61" + "hash": "661f8c7e625cf453e9db0328031391f8641fbdd94e2b27090762b0461748f979" } diff --git a/.sqlx/query-69faf49936069fe6d515d2cb5e225b27f3e0159f3a9184a69bb2b952bc589fa6.json b/.sqlx/query-69faf49936069fe6d515d2cb5e225b27f3e0159f3a9184a69bb2b952bc589fa6.json new file mode 100644 index 0000000..76a5d0f --- /dev/null +++ b/.sqlx/query-69faf49936069fe6d515d2cb5e225b27f3e0159f3a9184a69bb2b952bc589fa6.json @@ -0,0 +1,98 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n room.id,\n room.room_type AS \"room_type: RoomType\",\n room.created_at,\n room.latest_message,\n room.latest_message_preview_text AS \"latest_message_preview_text: Json\",\n COALESCE(other_user.display_name, room.room_name) AS room_name,\n COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url,\n COALESCE(participants.last_message_read_at < room.latest_message, TRUE) AS unread\n FROM\n chat_room_participant AS participants\n JOIN\n chat_room AS room ON participants.room_id = room.id\n -- 3. To find the other participant, only for single chat rooms!\n LEFT JOIN LATERAL (\n SELECT\n p2.user_id\n FROM\n chat_room_participant p2\n WHERE\n p2.room_id = room.id AND p2.user_id != $1\n LIMIT 1\n ) AS other_participant ON room.room_type = 'Single'\n -- Only executed when the lateral join has matched something:\n LEFT JOIN\n app_user AS other_user ON other_user.id = other_participant.user_id\n WHERE\n participants.user_id = $1\n AND room.id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_room", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "room_type: RoomType", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "room_type" + } + } + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room", + "name": "created_at" + } + } + }, + { + "ordinal": 3, + "name": "latest_message", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room", + "name": "latest_message" + } + } + }, + { + "ordinal": 4, + "name": "latest_message_preview_text: Json", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "latest_message_preview_text" + } + } + }, + { + "ordinal": 5, + "name": "room_name", + "type_info": "Varchar", + "origin": "Expression" + }, + { + "ordinal": 6, + "name": "room_image_url", + "type_info": "Varchar", + "origin": "Expression" + }, + { + "ordinal": 7, + "name": "unread", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + null, + null, + null + ] + }, + "hash": "69faf49936069fe6d515d2cb5e225b27f3e0159f3a9184a69bb2b952bc589fa6" +} diff --git a/.sqlx/query-6c5af956fa375864bc06309fd94cdb6ff9d9eaa42b569a4d7b1506debe7d9c5e.json b/.sqlx/query-6c5af956fa375864bc06309fd94cdb6ff9d9eaa42b569a4d7b1506debe7d9c5e.json new file mode 100644 index 0000000..fc0c4c5 --- /dev/null +++ b/.sqlx/query-6c5af956fa375864bc06309fd94cdb6ff9d9eaa42b569a4d7b1506debe7d9c5e.json @@ -0,0 +1,112 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id, room_name, created_at, room_type as \"room_type: RoomType\", latest_message, latest_message_preview_text AS \"latest_message_preview_text: Json\", room_image_url, TRUE as \"unread: _\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_room", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "room_name", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "room_name" + } + } + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room", + "name": "created_at" + } + } + }, + { + "ordinal": 3, + "name": "room_type: RoomType", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "room_type" + } + } + }, + { + "ordinal": 4, + "name": "latest_message", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room", + "name": "latest_message" + } + } + }, + { + "ordinal": 5, + "name": "latest_message_preview_text: Json", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "latest_message_preview_text" + } + } + }, + { + "ordinal": 6, + "name": "room_image_url", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "room_image_url" + } + } + }, + { + "ordinal": 7, + "name": "unread: _", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Varchar", + "Timestamptz", + "Timestamptz", + "Varchar" + ] + }, + "nullable": [ + false, + true, + false, + false, + true, + true, + true, + null + ] + }, + "hash": "6c5af956fa375864bc06309fd94cdb6ff9d9eaa42b569a4d7b1506debe7d9c5e" +} diff --git a/.sqlx/query-6d2680f522240bce19a38a27a262e5d68293e3c1b17a7eb05a2c6929deb5d105.json b/.sqlx/query-6d2680f522240bce19a38a27a262e5d68293e3c1b17a7eb05a2c6929deb5d105.json new file mode 100644 index 0000000..6f8ebd7 --- /dev/null +++ b/.sqlx/query-6d2680f522240bce19a38a27a262e5d68293e3c1b17a7eb05a2c6929deb5d105.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE chat_room\n SET latest_message = NOW(),latest_message_preview_text = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "6d2680f522240bce19a38a27a262e5d68293e3c1b17a7eb05a2c6929deb5d105" +} diff --git a/.sqlx/query-7628c8241aad128f1875a2123845e4e36859c99d08373def23f2b5ac5eab1143.json b/.sqlx/query-7628c8241aad128f1875a2123845e4e36859c99d08373def23f2b5ac5eab1143.json deleted file mode 100644 index bc6ab41..0000000 --- a/.sqlx/query-7628c8241aad128f1875a2123845e4e36859c99d08373def23f2b5ac5eab1143.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n room.id,\n room.room_type AS \"room_type: RoomType\",\n room.created_at,\n room.latest_message,\n room.latest_message_preview_text,\n COALESCE(other_user.display_name, room.room_name) AS room_name,\n COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url,\n COALESCE(participants.last_message_read_at < room.latest_message, TRUE) AS unread\n FROM\n chat_room_participant AS participants\n JOIN\n chat_room AS room ON participants.room_id = room.id\n -- 3. To find the other participant, only for single chat rooms!\n LEFT JOIN LATERAL (\n SELECT\n p2.user_id\n FROM\n chat_room_participant p2\n WHERE\n p2.room_id = room.id AND p2.user_id != $1\n LIMIT 1\n ) AS other_participant ON room.room_type = 'Single'\n -- Only executed when the lateral join has matched something:\n LEFT JOIN\n app_user AS other_user ON other_user.id = other_participant.user_id\n WHERE\n participants.user_id = $1\n AND room.id = $2\n AND participants.participant_state = 'Joined'\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "room_type: RoomType", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "latest_message", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "latest_message_preview_text", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "room_name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "room_image_url", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "unread", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - null, - null, - null - ] - }, - "hash": "7628c8241aad128f1875a2123845e4e36859c99d08373def23f2b5ac5eab1143" -} diff --git a/.sqlx/query-7b4800b86a71704c3eee7b4dfdfa9f4c9721c44a00006b5668dd260e2584b116.json b/.sqlx/query-7b4800b86a71704c3eee7b4dfdfa9f4c9721c44a00006b5668dd260e2584b116.json new file mode 100644 index 0000000..6d07842 --- /dev/null +++ b/.sqlx/query-7b4800b86a71704c3eee7b4dfdfa9f4c9721c44a00006b5668dd260e2584b116.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT users.id,\n users.display_name,\n users.profile_picture,\n participants.joined_at AS \"joined_at?\",\n participants.last_message_read_at\n FROM chat_room_participant AS participants\n JOIN app_user AS users ON participants.user_id = users.id\n WHERE participants.room_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "app_user", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "display_name" + } + } + }, + { + "ordinal": 2, + "name": "profile_picture", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "profile_picture" + } + } + }, + { + "ordinal": 3, + "name": "joined_at?", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "joined_at" + } + } + }, + { + "ordinal": 4, + "name": "last_message_read_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "last_message_read_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "7b4800b86a71704c3eee7b4dfdfa9f4c9721c44a00006b5668dd260e2584b116" +} diff --git a/.sqlx/query-7f47917671f04d1e1a56be07274c3ef08567d83e188f2eb3df75491b9c467d4e.json b/.sqlx/query-7f47917671f04d1e1a56be07274c3ef08567d83e188f2eb3df75491b9c467d4e.json new file mode 100644 index 0000000..0e819a5 --- /dev/null +++ b/.sqlx/query-7f47917671f04d1e1a56be07274c3ef08567d83e188f2eb3df75491b9c467d4e.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n users.id,\n users.display_name,\n users.profile_picture,\n participants.joined_at AS \"joined_at?\",\n participants.last_message_read_at AS \"last_message_read_at?\"\n FROM app_user AS users\n LEFT JOIN chat_room_participant AS participants\n ON participants.user_id = users.id AND participants.room_id = $1\n WHERE users.id = ANY($2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "app_user", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "display_name" + } + } + }, + { + "ordinal": 2, + "name": "profile_picture", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "profile_picture" + } + } + }, + { + "ordinal": 3, + "name": "joined_at?", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "joined_at" + } + } + }, + { + "ordinal": 4, + "name": "last_message_read_at?", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room_participant", + "name": "last_message_read_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "UuidArray" + ] + }, + "nullable": [ + false, + false, + true, + true, + true + ] + }, + "hash": "7f47917671f04d1e1a56be07274c3ef08567d83e188f2eb3df75491b9c467d4e" +} diff --git a/.sqlx/query-83264c94deeee5544ff14bb0834b93f241c5065ab8f01f963e11f310adce4de8.json b/.sqlx/query-83264c94deeee5544ff14bb0834b93f241c5065ab8f01f963e11f310adce4de8.json new file mode 100644 index 0000000..b72d14d --- /dev/null +++ b/.sqlx/query-83264c94deeee5544ff14bb0834b93f241c5065ab8f01f963e11f310adce4de8.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH room_update AS (\n UPDATE chat_room\n SET latest_message = $3,\n latest_message_preview_text = $2\n WHERE id = $1\n )\n UPDATE chat_room_participant\n SET last_message_read_at = $3\n WHERE user_id = $4 AND room_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Timestamptz", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "83264c94deeee5544ff14bb0834b93f241c5065ab8f01f963e11f310adce4de8" +} diff --git a/.sqlx/query-938eb6b3892dd0fcaeff08076014148bf9f61bc5897fa24d5d3fdc95446b6f6e.json b/.sqlx/query-938eb6b3892dd0fcaeff08076014148bf9f61bc5897fa24d5d3fdc95446b6f6e.json new file mode 100644 index 0000000..6c51e5d --- /dev/null +++ b/.sqlx/query-938eb6b3892dd0fcaeff08076014148bf9f61bc5897fa24d5d3fdc95446b6f6e.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n room.id,\n room.room_type AS \"room_type: RoomType\",\n room.created_at,\n room.latest_message,\n room.latest_message_preview_text AS \"latest_message_preview_text: Json\",\n COALESCE(other_user.display_name, room.room_name) AS room_name,\n COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url,\n COALESCE(p1.last_message_read_at < room.latest_message, TRUE) AS unread\n FROM\n chat_room_participant AS p1\n JOIN\n chat_room AS room ON p1.room_id = room.id\n -- To find the other participant, only for single chat rooms!\n LEFT JOIN LATERAL (\n SELECT\n p2.user_id\n FROM\n chat_room_participant p2\n WHERE\n p2.room_id = room.id AND p2.user_id != $1\n -- Only take the first match\n LIMIT 1\n ) AS other_participant ON room.room_type = 'Single'\n -- Only executed when the lateral join has matched something:\n LEFT JOIN\n app_user AS other_user ON other_user.id = other_participant.user_id\n WHERE\n p1.user_id = $1\n AND ($2::text IS NULL OR COALESCE(other_user.display_name, room.room_name) ILIKE concat('%', $2, '%'))\n AND (\n $3::timestamptz IS NULL\n OR room.latest_message < $3\n OR (room.latest_message = $3 AND room.id < $4)\n )\n ORDER BY\n room.latest_message DESC, room.id DESC\n LIMIT $5\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_room", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "room_type: RoomType", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "room_type" + } + } + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room", + "name": "created_at" + } + } + }, + { + "ordinal": 3, + "name": "latest_message", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room", + "name": "latest_message" + } + } + }, + { + "ordinal": 4, + "name": "latest_message_preview_text: Json", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "latest_message_preview_text" + } + } + }, + { + "ordinal": 5, + "name": "room_name", + "type_info": "Varchar", + "origin": "Expression" + }, + { + "ordinal": 6, + "name": "room_image_url", + "type_info": "Varchar", + "origin": "Expression" + }, + { + "ordinal": 7, + "name": "unread", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Timestamptz", + "Uuid", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + null, + null, + null + ] + }, + "hash": "938eb6b3892dd0fcaeff08076014148bf9f61bc5897fa24d5d3fdc95446b6f6e" +} diff --git a/.sqlx/query-939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92.json b/.sqlx/query-939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92.json deleted file mode 100644 index 9f0a56e..0000000 --- a/.sqlx/query-939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id, room_name, created_at, room_type as \"room_type: RoomType\", latest_message, latest_message_preview_text, room_image_url, TRUE as \"unread: _\"\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "room_name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "room_type: RoomType", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "latest_message", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "latest_message_preview_text", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "room_image_url", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "unread: _", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar", - "Varchar", - "Timestamptz", - "Timestamptz", - "Varchar" - ] - }, - "nullable": [ - false, - true, - false, - false, - true, - true, - true, - null - ] - }, - "hash": "939428ccf93cfd98a71993123c74cc50bba249137951f3c50ecbf58cdf18eb92" -} diff --git a/.sqlx/query-9ba93b8980d919e7cbb2c8335d4717eb8cb4d60342a811e86698bd7949faddbd.json b/.sqlx/query-9ba93b8980d919e7cbb2c8335d4717eb8cb4d60342a811e86698bd7949faddbd.json new file mode 100644 index 0000000..bd1bf77 --- /dev/null +++ b/.sqlx/query-9ba93b8980d919e7cbb2c8335d4717eb8cb4d60342a811e86698bd7949faddbd.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM chat_room_participant\n WHERE user_id = $1 AND room_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9ba93b8980d919e7cbb2c8335d4717eb8cb4d60342a811e86698bd7949faddbd" +} diff --git a/.sqlx/query-b9e92541f06e0710e990ab226fdfc93e3b19d3fc5430db90c97086bbd0ad4fe1.json b/.sqlx/query-b9e92541f06e0710e990ab226fdfc93e3b19d3fc5430db90c97086bbd0ad4fe1.json index 6733059..739a843 100644 --- a/.sqlx/query-b9e92541f06e0710e990ab226fdfc93e3b19d3fc5430db90c97086bbd0ad4fe1.json +++ b/.sqlx/query-b9e92541f06e0710e990ab226fdfc93e3b19d3fc5430db90c97086bbd0ad4fe1.json @@ -6,22 +6,46 @@ { "ordinal": 0, "name": "user_a_id", - "type_info": "Uuid" + "type_info": "Uuid", + "origin": { + "Table": { + "table": "user_relationship", + "name": "user_a_id" + } + } }, { "ordinal": 1, "name": "user_b_id", - "type_info": "Uuid" + "type_info": "Uuid", + "origin": { + "Table": { + "table": "user_relationship", + "name": "user_b_id" + } + } }, { "ordinal": 2, "name": "state: RelationshipState", - "type_info": "Varchar" + "type_info": "Varchar", + "origin": { + "Table": { + "table": "user_relationship", + "name": "state" + } + } }, { "ordinal": 3, "name": "relationship_change_timestamp", - "type_info": "Timestamptz" + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "user_relationship", + "name": "relationship_change_timestamp" + } + } } ], "parameters": { diff --git a/.sqlx/query-bf238b9f69e4ada49780c1485298880ac5b01cbab5131c3535d47f3ce9854093.json b/.sqlx/query-bf238b9f69e4ada49780c1485298880ac5b01cbab5131c3535d47f3ce9854093.json deleted file mode 100644 index e1addc1..0000000 --- a/.sqlx/query-bf238b9f69e4ada49780c1485298880ac5b01cbab5131c3535d47f3ce9854093.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n users.id,\n users.display_name,\n users.profile_picture,\n participants.joined_at,\n participants.last_message_read_at,\n participants.participant_state AS \"membership_status: MembershipStatus\"\n FROM chat_room_participant AS participants\n JOIN app_user AS users ON participants.user_id = users.id\n WHERE participants.user_id = $1 AND participants.room_id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "display_name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "profile_picture", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "joined_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "last_message_read_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "membership_status: MembershipStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - true, - false - ] - }, - "hash": "bf238b9f69e4ada49780c1485298880ac5b01cbab5131c3535d47f3ce9854093" -} diff --git a/.sqlx/query-cf18bc54419542900d4503b85fe650a2644001898dd16ece12bb8384cf1e304a.json b/.sqlx/query-cf18bc54419542900d4503b85fe650a2644001898dd16ece12bb8384cf1e304a.json index 10d909e..24d1899 100644 --- a/.sqlx/query-cf18bc54419542900d4503b85fe650a2644001898dd16ece12bb8384cf1e304a.json +++ b/.sqlx/query-cf18bc54419542900d4503b85fe650a2644001898dd16ece12bb8384cf1e304a.json @@ -6,42 +6,90 @@ { "ordinal": 0, "name": "id", - "type_info": "Uuid" + "type_info": "Uuid", + "origin": { + "Table": { + "table": "app_user", + "name": "id" + } + } }, { "ordinal": 1, "name": "display_name", - "type_info": "Varchar" + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "display_name" + } + } }, { "ordinal": 2, "name": "profile_picture", - "type_info": "Varchar" + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "profile_picture" + } + } }, { "ordinal": 3, "name": "street_credits", - "type_info": "Int8" + "type_info": "Int8", + "origin": { + "Table": { + "table": "app_user", + "name": "street_credits" + } + } }, { "ordinal": 4, "name": "description", - "type_info": "Varchar" + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "description" + } + } }, { "ordinal": 5, "name": "friends_count", - "type_info": "Int8" + "type_info": "Int8", + "origin": { + "Table": { + "table": "app_user", + "name": "friends_count" + } + } }, { "ordinal": 6, "name": "posts_count", - "type_info": "Int8" + "type_info": "Int8", + "origin": { + "Table": { + "table": "app_user", + "name": "posts_count" + } + } }, { "ordinal": 7, "name": "role", - "type_info": "Varchar" + "type_info": "Varchar", + "origin": { + "Table": { + "table": "app_user", + "name": "role" + } + } } ], "parameters": { diff --git a/.sqlx/query-d29f6e92fe64cce4bb33ff73679c7c671cc246b82d3c4c55f9f116ef8007f87d.json b/.sqlx/query-d29f6e92fe64cce4bb33ff73679c7c671cc246b82d3c4c55f9f116ef8007f87d.json deleted file mode 100644 index 70a0ab5..0000000 --- a/.sqlx/query-d29f6e92fe64cce4bb33ff73679c7c671cc246b82d3c4c55f9f116ef8007f87d.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n users.id,\n users.display_name,\n users.profile_picture,\n participants.joined_at,\n participants.last_message_read_at,\n participants.participant_state AS \"membership_status: MembershipStatus\"\n FROM chat_room_participant AS participants\n JOIN app_user AS users ON participants.user_id = users.id\n WHERE participants.room_id = $1 AND participants.participant_state = 'Joined'\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "display_name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "profile_picture", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "joined_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "last_message_read_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "membership_status: MembershipStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - true, - false - ] - }, - "hash": "d29f6e92fe64cce4bb33ff73679c7c671cc246b82d3c4c55f9f116ef8007f87d" -} diff --git a/.sqlx/query-d95f4074d7571d3941e2df5b7c6cd9aed665be189e4cd6f5d16b418f13a18823.json b/.sqlx/query-d95f4074d7571d3941e2df5b7c6cd9aed665be189e4cd6f5d16b418f13a18823.json new file mode 100644 index 0000000..b5b2f78 --- /dev/null +++ b/.sqlx/query-d95f4074d7571d3941e2df5b7c6cd9aed665be189e4cd6f5d16b418f13a18823.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO chat_room_participant (user_id, room_id, joined_at)\n VALUES ($1, $2, $3)\n ON CONFLICT (user_id, room_id)\n DO UPDATE SET joined_at = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "d95f4074d7571d3941e2df5b7c6cd9aed665be189e4cd6f5d16b418f13a18823" +} diff --git a/.sqlx/query-de53be048cd53a2a9f2a3cdb0141547722cebdb02fc053b357d2d7d2eeec6bfc.json b/.sqlx/query-de53be048cd53a2a9f2a3cdb0141547722cebdb02fc053b357d2d7d2eeec6bfc.json deleted file mode 100644 index d20492c..0000000 --- a/.sqlx/query-de53be048cd53a2a9f2a3cdb0141547722cebdb02fc053b357d2d7d2eeec6bfc.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n u.id,\n u.display_name,\n u.profile_picture,\n u.street_credits,\n u.description,\n u.friends_count,\n u.posts_count,\n u.role\n FROM app_user u\n INNER JOIN user_relationship ur ON\n (ur.user_a_id = u.id AND ur.user_b_id = $1 AND ur.state = 'A_INVITED') OR\n (ur.user_b_id = u.id AND ur.user_a_id = $1 AND ur.state = 'B_INVITED')\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "display_name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "profile_picture", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "street_credits", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "friends_count", - "type_info": "Int8" - }, - { - "ordinal": 6, - "name": "posts_count", - "type_info": "Int8" - }, - { - "ordinal": 7, - "name": "role", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - true, - false, - false, - false - ] - }, - "hash": "de53be048cd53a2a9f2a3cdb0141547722cebdb02fc053b357d2d7d2eeec6bfc" -} diff --git a/.sqlx/query-e1dd9741dda9b3d2723205d6b70a2f34ae1af7d5a1af01e4503699360a3805ab.json b/.sqlx/query-e1dd9741dda9b3d2723205d6b70a2f34ae1af7d5a1af01e4503699360a3805ab.json new file mode 100644 index 0000000..df765f4 --- /dev/null +++ b/.sqlx/query-e1dd9741dda9b3d2723205d6b70a2f34ae1af7d5a1af01e4503699360a3805ab.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n message_id,\n chat_room_id,\n sender_id,\n msg_body AS \"msg_body: sqlx::types::Json\",\n msg_type AS \"msg_type: MsgType\",\n created_at\n FROM chat_message\n WHERE chat_room_id = $1 AND created_at < $2\n ORDER BY created_at DESC\n LIMIT 25\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "message_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_message", + "name": "message_id" + } + } + }, + { + "ordinal": 1, + "name": "chat_room_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_message", + "name": "chat_room_id" + } + } + }, + { + "ordinal": 2, + "name": "sender_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_message", + "name": "sender_id" + } + } + }, + { + "ordinal": 3, + "name": "msg_body: sqlx::types::Json", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "chat_message", + "name": "msg_body" + } + } + }, + { + "ordinal": 4, + "name": "msg_type: MsgType", + "type_info": { + "Custom": { + "name": "msg_type", + "kind": { + "Enum": [ + "Text", + "Media", + "RoomChange", + "Reply" + ] + } + } + }, + "origin": { + "Table": { + "table": "chat_message", + "name": "msg_type" + } + } + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_message", + "name": "created_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "e1dd9741dda9b3d2723205d6b70a2f34ae1af7d5a1af01e4503699360a3805ab" +} diff --git a/.sqlx/query-e969403f8b5606f9ed15bd7c26dba933c1aca8dfaf54f22c5edc23ca91cbd47a.json b/.sqlx/query-e969403f8b5606f9ed15bd7c26dba933c1aca8dfaf54f22c5edc23ca91cbd47a.json deleted file mode 100644 index 6e7684e..0000000 --- a/.sqlx/query-e969403f8b5606f9ed15bd7c26dba933c1aca8dfaf54f22c5edc23ca91cbd47a.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n app_user.id,\n app_user.display_name,\n app_user.profile_picture,\n chat_room_participant.joined_at,\n chat_room_participant.last_message_read_at,\n chat_room_participant.participant_state AS \"membership_status: MembershipStatus\"\n FROM chat_room_participant\n JOIN app_user ON chat_room_participant.user_id = app_user.id\n WHERE chat_room_participant.room_id = $1 AND chat_room_participant.participant_state = 'Joined' AND chat_room_participant.user_id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "display_name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "profile_picture", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "joined_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "last_message_read_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "membership_status: MembershipStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - true, - false - ] - }, - "hash": "e969403f8b5606f9ed15bd7c26dba933c1aca8dfaf54f22c5edc23ca91cbd47a" -} diff --git a/.sqlx/query-d2d87e74c90c5f16162793e962f8f938eaeb67e471af53df1582f0becb4ddb5c.json b/.sqlx/query-eadded6df42a099ebeb437b7ce332d2a06668b8f8589a922c679e70357ba36e0.json similarity index 58% rename from .sqlx/query-d2d87e74c90c5f16162793e962f8f938eaeb67e471af53df1582f0becb4ddb5c.json rename to .sqlx/query-eadded6df42a099ebeb437b7ce332d2a06668b8f8589a922c679e70357ba36e0.json index 4c9d283..870188a 100644 --- a/.sqlx/query-d2d87e74c90c5f16162793e962f8f938eaeb67e471af53df1582f0becb4ddb5c.json +++ b/.sqlx/query-eadded6df42a099ebeb437b7ce332d2a06668b8f8589a922c679e70357ba36e0.json @@ -1,12 +1,18 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT r.id\n FROM chat_room r\n JOIN chat_room_participant p ON r.id = p.room_id\n WHERE r.room_type = 'Single' AND p.user_id IN ($1, $2) AND p.participant_state = 'Joined'\n GROUP BY r.id\n HAVING COUNT(p.user_id) = 2\n ", + "query": "\n SELECT r.id\n FROM chat_room r\n JOIN chat_room_participant p ON r.id = p.room_id\n WHERE r.room_type = 'Single' AND p.user_id IN ($1, $2)\n GROUP BY r.id\n HAVING COUNT(p.user_id) = 2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", - "type_info": "Uuid" + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_room", + "name": "id" + } + } } ], "parameters": { @@ -19,5 +25,5 @@ false ] }, - "hash": "d2d87e74c90c5f16162793e962f8f938eaeb67e471af53df1582f0becb4ddb5c" + "hash": "eadded6df42a099ebeb437b7ce332d2a06668b8f8589a922c679e70357ba36e0" } diff --git a/.sqlx/query-dff421720d211462051c80a45256fdd9ab6af17003cba3bddd54c7b967aa56fc.json b/.sqlx/query-ebdb8766929055c941f514c3da9610adfe97f3d2aacda734cd9654e33a31a0ec.json similarity index 65% rename from .sqlx/query-dff421720d211462051c80a45256fdd9ab6af17003cba3bddd54c7b967aa56fc.json rename to .sqlx/query-ebdb8766929055c941f514c3da9610adfe97f3d2aacda734cd9654e33a31a0ec.json index 5d7c606..a37f8c7 100644 --- a/.sqlx/query-dff421720d211462051c80a45256fdd9ab6af17003cba3bddd54c7b967aa56fc.json +++ b/.sqlx/query-ebdb8766929055c941f514c3da9610adfe97f3d2aacda734cd9654e33a31a0ec.json @@ -1,12 +1,13 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT EXISTS(\n SELECT 1\n FROM chat_room_participant\n WHERE user_id = $1 AND room_id = $2 AND participant_state = 'Joined'\n )\n ", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM chat_room_participant\n WHERE user_id = $1 AND room_id = $2\n )\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists", - "type_info": "Bool" + "type_info": "Bool", + "origin": "Expression" } ], "parameters": { @@ -19,5 +20,5 @@ null ] }, - "hash": "dff421720d211462051c80a45256fdd9ab6af17003cba3bddd54c7b967aa56fc" + "hash": "ebdb8766929055c941f514c3da9610adfe97f3d2aacda734cd9654e33a31a0ec" } diff --git a/.sqlx/query-ee2adfa114874fed9a9b92d9c53ea098a498ebc03cea58f111ba53a2419cfaf8.json b/.sqlx/query-ee2adfa114874fed9a9b92d9c53ea098a498ebc03cea58f111ba53a2419cfaf8.json new file mode 100644 index 0000000..a1d41fb --- /dev/null +++ b/.sqlx/query-ee2adfa114874fed9a9b92d9c53ea098a498ebc03cea58f111ba53a2419cfaf8.json @@ -0,0 +1,107 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n room_type as \"room_type: RoomType\",\n room_name,\n created_at,\n latest_message,\n room_image_url,\n latest_message_preview_text AS \"latest_message_preview_text: Json\",\n NULL::boolean as \"unread: _\"\n FROM chat_room\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "chat_room", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "room_type: RoomType", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "room_type" + } + } + }, + { + "ordinal": 2, + "name": "room_name", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "room_name" + } + } + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room", + "name": "created_at" + } + } + }, + { + "ordinal": 4, + "name": "latest_message", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "chat_room", + "name": "latest_message" + } + } + }, + { + "ordinal": 5, + "name": "room_image_url", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "room_image_url" + } + } + }, + { + "ordinal": 6, + "name": "latest_message_preview_text: Json", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "chat_room", + "name": "latest_message_preview_text" + } + } + }, + { + "ordinal": 7, + "name": "unread: _", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + null + ] + }, + "hash": "ee2adfa114874fed9a9b92d9c53ea098a498ebc03cea58f111ba53a2419cfaf8" +} diff --git a/.sqlx/query-fd7f657ed206c009b2366dfdce7ba38fefd1b72197a9da3d925ff2618523a346.json b/.sqlx/query-fd7f657ed206c009b2366dfdce7ba38fefd1b72197a9da3d925ff2618523a346.json deleted file mode 100644 index 6f6d845..0000000 --- a/.sqlx/query-fd7f657ed206c009b2366dfdce7ba38fefd1b72197a9da3d925ff2618523a346.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO chat_room_participant (user_id, room_id, joined_at, participant_state)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id, room_id)\n DO UPDATE SET joined_at = $3, participant_state = $4\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Timestamptz", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "fd7f657ed206c009b2366dfdce7ba38fefd1b72197a9da3d925ff2618523a346" -} diff --git a/.sqlx/query-ff35a3d64f8b662356c24f6e6e477f08da6a3ff57e9b3e87396c778a0cb95644.json b/.sqlx/query-ff35a3d64f8b662356c24f6e6e477f08da6a3ff57e9b3e87396c778a0cb95644.json deleted file mode 100644 index 6efea24..0000000 --- a/.sqlx/query-ff35a3d64f8b662356c24f6e6e477f08da6a3ff57e9b3e87396c778a0cb95644.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n u.id,\n u.display_name,\n u.profile_picture,\n u.street_credits,\n u.description,\n u.friends_count,\n u.posts_count,\n u.role\n FROM\n app_user u\n INNER JOIN\n user_relationship rl ON u.id = (\n CASE\n WHEN rl.user_a_id = $1 THEN rl.user_b_id\n WHEN rl.user_b_id = $1 THEN rl.user_a_id\n ELSE NULL\n END\n )\n WHERE\n rl.state = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "display_name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "profile_picture", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "street_credits", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "friends_count", - "type_info": "Int8" - }, - { - "ordinal": 6, - "name": "posts_count", - "type_info": "Int8" - }, - { - "ordinal": 7, - "name": "role", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - false, - false, - true, - false, - true, - false, - false, - false - ] - }, - "hash": "ff35a3d64f8b662356c24f6e6e477f08da6a3ff57e9b3e87396c778a0cb95644" -} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bb52eb7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,262 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Language Policy + +All code, comments, documentation, commit messages, variable names, error messages, and API responses must be written in **English**. No German anywhere in the codebase. + +## Project Vision + +ISM is being built as a highly scalable social backend for real-time messaging — supporting 1-1 and group (1-n) chat, a full user relationship system, and eventually a complete real-time social platform. + +**Current phase**: single-server, feature-complete messaging backend. +**Next phase**: horizontal scaling via server cluster / federation. +**Long-term features planned**: image/video uploads, voice messages, polls/votings, reactions, activity feeds. + +**Non-negotiable quality bars**: +- Strict cursor-based pagination everywhere — no `page`/`pageSize` parameters anywhere in the API. +- Performance-conscious data access — no N+1 queries, indexed lookups, efficient JSONB usage. +- Correctness over convenience — no `unwrap()` in production paths, no silent fallbacks that hide bugs. + +## Project Overview + +**ISM (Instant SaaS Messenger)** is a real-time messaging backend written in Rust. It provides 1-1 and group chat rooms, a friend/block/invite relationship system, media uploads, and live notifications via SSE and WebSockets. + +**Stack**: Axum 0.8 + Tokio, PostgreSQL with SQLx (all data including messages), Redis (optional notification cache), MinIO/S3 (media), Keycloak OIDC (auth), Kafka (optional push notifications). + +> **Note**: ScyllaDB/Cassandra has been fully removed. PostgreSQL is the single source of truth for all data. + +## Commands + +```bash +# Build & run +cargo build +cargo run + +# After modifying any SQL query, regenerate compile-time metadata: +cargo sqlx prepare + +# Database migrations +sqlx migrate run +sqlx migrate add # creates migrations/_.up.sql + .down.sql + +# Tests +cargo test +cargo test -- --nocapture + +# Lint / format +cargo clippy +cargo fmt + +# Full dev stack (PostgreSQL, Keycloak, Redis, MinIO, Kafka) +docker compose up -d +docker build -t ism:latest . +``` + +Set `DATABASE_URL` in `.env` for the sqlx CLI. The `.sqlx/` directory holds pre-compiled query metadata — always commit it after running `cargo sqlx prepare`. + +## Architecture + +### Layers + +``` +Routes (router.rs) + ↓ Keycloak JWT middleware → injects KeycloakClaims into request extensions +Handlers (rooms/handler.rs, messaging/handler.rs, users/handler.rs) + ↓ +Services (room_service.rs, timeline_service.rs, message_service.rs, user_service.rs) + ↓ +Repositories (room_repository.rs, chat_repository.rs, user_repository.rs) ─── PostgreSQL (SQLx) +BroadcastChannel ──────────── SSE / WebSocket senders (in-memory HashMap, OnceCell singleton) +``` + +### AppState (`core/app_state.rs`) + +Singleton initialized at startup, shared as `Arc` across all handlers. Fields: + +| Field | Type | Purpose | +|---|---|---| +| `env` | `ISMConfig` | Full config snapshot | +| `room_repository` | `RoomRepository` | Rooms, participants, read states | +| `user_repository` | `UserRepository` | Users, relationships | +| `chat_repository` | `ChatRepository` | Chat messages | +| `cache` | `Arc` | Redis or `NoOpCache` fallback | +| `s3_bucket` | `ObjectStorage` | MinIO/S3 media uploads | + +PostgreSQL pool (max 20 connections) is shared across all three repositories. + +### Configuration (`core/config.rs`) + +Layered TOML loading: `default.config.toml` → `{mode}.config.toml` → environment variables. +Mode via `ISM_MODE` env var (default: `development`). + +Config sections: +- `room_db_config` — PostgreSQL connection (host, port, user, password, db_name) +- `token_issuer` — Keycloak host + realm +- `object_db_config` — MinIO/S3 credentials +- `kafka_config` — Kafka bootstrap, topic, partition, consumer group +- `redis_cache_url` — optional Redis URL (omit to use `NoOpCache`) +- `use_kafka` — bool, enables Kafka push notification producer +- `cors_origin` — allowed CORS origin + +Env var override format: `ISM_ROOM_DB_CONFIG__DB_HOST=...` + +### Real-time Broadcasting (`broadcast/`) + +`BroadcastChannel` is a global singleton (`OnceCell>`). It holds a `RwLock>>` — one Tokio broadcast channel per connected user. + +**API**: +```rust +BroadcastChannel::get().send_event(notification, &user_id).await; +BroadcastChannel::get().send_event_to_all(user_ids, notification).await; +BroadcastChannel::get().subscribe_to_user_events(user_id).await; // → Receiver +BroadcastChannel::get().unsubscribe(user_id).await; +``` + +**Rules**: +- Always broadcast **after** a successful DB write, never before. +- Build notifications with `Notification::new(body)`; `seq` is assigned per-user during delivery, not at construction. +- `send_event` / `send_event_to_all` assign a monotonic **per-user** `seq` (Redis `INCR`), cache durable events in a per-user Redis Stream (`user_notifications:{id}`, entry ID `-0`, length-capped via `XADD ... MAXLEN ~ N` — no background cleanup), and fall back to Kafka push notifications for offline users. +- **Ephemeral** events (`NotificationEvent::is_ephemeral()`) get no `seq` and are never cached — live-only (e.g. `Resync`, future typing indicators). +- Push notifications are only sent for: `ChatMessage`, `FriendRequestReceived`, `NewRoom`. +- Wire envelope: `{ v, seq, type, createdAt, ...payload }`. Clients reconnect with `?last_seq=` on `/api/sse` and `/api/wss`; the server replays missing durable events or emits a `Resync` when the gap was trimmed out of the retained window. See `docs/streaming-sequencing.md`. + +**`NotificationEvent` variants** (defined in `broadcast/notification.rs`): + +| Variant | Sent to | Trigger | +|---|---|---| +| `ChatMessage { message, room_preview_text, sender }` | all room members | new message (`sender: RoomMember` so clients render a first-time sender without a lookup) | +| `RoomChangeEvent { message, room_preview_text }` | all room members | join/leave/invite | +| `NewRoom { room, created_by }` | invited user | room creation / invite | +| `LeaveRoom { room_id }` | leaving user | user leaves room | +| `FriendRequestReceived { from_user }` | target user | friend request sent | +| `FriendRequestAccepted { from_user }` | requester | request accepted | +| `UserReadChat { user_id, room_id }` | all room members | room marked as read | +| `SystemMessage { message }` | any | system-level events | +| `Resync { reason }` | one client connection | replay gap / lag — client must reload via REST (ephemeral) | + +### Database Pattern + +All data lives in PostgreSQL. SQLx macros provide compile-time query type-checking against `.sqlx/` metadata. + +For function signatures involving transactions or shared executors, follow `docs/sqlx-executor-pattern.md` — this documents when to use `impl Executor<'_, Database = Postgres>` vs `&PgPool` vs `&mut PgTransaction`. + +### Authentication + +Keycloak middleware validates the JWT on every protected request (JWKS endpoint cached). Valid tokens inject `KeycloakClaims` into request extensions. Handlers extract the caller's UUID via: +```rust +Extension(claims): Extension +``` + +### Cursor Pagination (`core/cursor.rs`) + +**All list endpoints use cursor pagination — no `page`/`pageSize` parameters.** + +Cursors are base64url-encoded JSON structs. The generic infrastructure: +```rust +CursorResults { next_cursor: Option, content: Vec } +decode_cursor::(base64_str) -> Result +encode_cursor(&cursor) -> Result +``` + +Existing cursor types: +- `UserPaginationCursor { last_seen_name, last_seen_id }` — user search via `raw_name` index +- Message timeline — timestamp-based (`created_at` DESC), efficient with indexed column. Returns a `TimelinePage { messages, senders }`: `senders` is the deduplicated set of `RoomMember`s that authored a message in the page **or are the original author referenced by a reply** (`reply_sender_id`), resolved via `app_user LEFT JOIN chat_room_participant`, so authors who have since left still resolve, with `joined_at`/`last_message_read_at` as `null`. Combined with the `sender` on live `ChatMessage` events, the client never needs a separate sender lookup. + +### Key Data Model Facts + +**Rooms & Membership** (`chat_room_participant`): +- A row means the user is **currently in the room** — there is no membership state. +- Leaving **deletes** the participant row. Message history is preserved independently in `chat_message`, and sender profiles resolve from `app_user` (see Timeline below), so deleting the row loses no history. +- `RoomContext` (`Vec`) is cached in Redis for fast participant lookups / broadcast fan-out. + +**Messages** (`chat_message`): +- Stored in PostgreSQL, `msg_body` column is JSONB (`sqlx::types::Json`) +- `MsgType`: `Text`, `Media`, `Reply`, `RoomChange` +- `MessageBody` variants: `TextBody`, `MediaBody`, `ReplyBody`, `RoomChangeBody` +- `RoomChangeBody` sub-types: `UserJoined`, `UserLeft`, `UserInvited` +- `latest_message_preview_text` on rooms is JSONB (`LastMessagePreviewText` enum) + +**User Relationships** (`user_relationship`): +- Symmetric — stored once as (user_a_id, user_b_id) with directional state +- `RelationshipState`: `FRIEND`, `A_INVITED`, `B_INVITED`, `A_BLOCKED`, `B_BLOCKED`, `ALL_BLOCKED` +- Resolved to client-relative `Relationship`: `Friend`, `InviteSent`, `InviteReceived`, `ClientBlocked`, `ClientGotBlocked` + +**Read Receipts**: +- `last_message_read_at` per (user, room) on `chat_room_participant` +- Updated via `POST /api/rooms/{id}/mark-read`; broadcast as `UserReadChat` so all user devices sync + +### Routing + +``` +GET /health +POST /api/rooms/create-room +GET /api/rooms +GET /api/rooms/search +GET /api/rooms/{id} +GET /api/rooms/{id}/detailed +GET /api/rooms/{id}/users +GET /api/rooms/{id}/timeline +POST /api/rooms/{id}/leave +POST /api/rooms/{id}/invite/{user_id} +POST /api/rooms/{id}/upload-img +POST /api/rooms/{id}/mark-read +GET /api/rooms/{id}/read-states + +POST /api/send-msg +GET /api/notifications +GET /api/notifications/cursor +GET /api/sse +ANY /api/wss + +GET /api/users/{user_id} +GET /api/users/search +GET /api/users/friends +GET /api/users/friends/requests +POST /api/users/friends/add/{user_id} +POST /api/users/friends/accept-request/{sender_id} +DELETE /api/users/friends/reject-request/{sender_id} +DELETE /api/users/friends/{friend_id} +POST /api/users/ignore/{user_id} +DELETE /api/users/ignore/{user_id} +``` + +Middleware stack (protected routes): `TraceLayer` → `CorsLayer` → `KeycloakAuthLayer` → `DefaultBodyLimit` (5 MB) → `inject_request_path` + +### Error Handling + +All handlers return `Result, HttpError>`. `HttpError` serializes to: +```json +{ "status": 404, "errorCode": "NOT_FOUND", "message": "...", "timestamp": "...", "path": "/api/..." } +``` +`path` is injected by `inject_request_path` middleware on error responses. + +## Development Patterns + +**New endpoint**: handler in `handler.rs` → service logic → repository query → register in `routes.rs`. No business logic in handlers. + +**New SQL query**: write query with `sqlx::query!` / `sqlx::query_as!`, run `cargo sqlx prepare`, commit `.sqlx/`. + +**SQLx executor signatures**: read `docs/sqlx-executor-pattern.md` before writing any repository function that needs to participate in a transaction. + +**New message type**: add to `MsgType` and `MessageBody` enums in `messaging/model.rs`, handle in `message_service.rs`, update `LastMessagePreviewText` if needed for room previews. + +**New broadcast event**: add variant to `NotificationEvent` in `broadcast/notification.rs`, broadcast via `BroadcastChannel::get().send_event(...)` after the DB write, update all `match` arms. + +**New cursor type**: implement `Serialize + Deserialize + Default` on a struct, use `encode_cursor` / `decode_cursor` from `core/cursor.rs`, return `CursorResults` from the endpoint. + +**Broadcasting after writes**: +```rust +let bc = BroadcastChannel::get(); +bc.send_event_to_all(member_ids, Notification::new( + NotificationEvent::ChatMessage { message, room_preview_text, sender }, +)).await; +``` + +## Production Deployment + +1. `docker build -t ism:latest .` +2. Mount `production.config.toml` with real credentials; set `ISM_MODE=production` +3. Run `sqlx migrate run` before starting ISM +4. Health check: `GET /health` → 200 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2d1d080..96265cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,37 +4,19 @@ version = 4 [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -52,9 +34,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -67,51 +49,39 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] -[[package]] -name = "anyhow" -version = "1.0.97" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" - -[[package]] -name = "arbitrary" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" - [[package]] name = "arc-swap" version = "1.9.1" @@ -127,43 +97,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "arraydeque" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "assertr" -version = "0.4.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e65c749b72cf7cbc5ea70eabf96ff497a25d791ac180ccee924adf3c4a32e22b" +checksum = "030dc45f79b3d68fba33b17792e1c634a3bb876a417f279ffbca69b6f27e3722" dependencies = [ "futures", "indoc", @@ -189,7 +133,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -200,7 +144,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -214,9 +158,9 @@ dependencies = [ [[package]] name = "atomic-time" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d427c9c5e06a18730b21cf82d0be7404499c144b202f78328feff4cbb59477bc" +checksum = "75821c8282c0e622f3892087c1eeb8d4e3964b92467a263a44afa7d79dec7f3c" dependencies = [ "portable-atomic", ] @@ -229,58 +173,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.5" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea8ef51aced2b9191c08197f55450d830876d9933f8f48a429b354f1d496b42" -dependencies = [ - "arrayvec", -] +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -288,9 +189,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -325,7 +226,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -337,9 +238,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -354,20 +255,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "backoff" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" -dependencies = [ - "futures-core", - "getrandom 0.2.15", - "instant", - "pin-project-lite", - "rand 0.8.5", - "tokio", -] - [[package]] name = "backon" version = "1.6.0" @@ -391,60 +278,63 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] -name = "bit_field" -version = "0.10.2" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] [[package]] -name = "bitstream-io" -version = "4.9.0" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "core2", + "generic-array", ] [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] -name = "built" -version = "0.8.0" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.23.1" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -460,47 +350,15 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "camino" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.18.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 1.0.69", -] +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" -version = "1.2.60" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -508,17 +366,11 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -526,78 +378,45 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", -] - -[[package]] -name = "clap" -version = "4.5.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", - "terminal_size", -] - -[[package]] -name = "clap_complete" -version = "4.5.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5c5508ea23c5366f77e53f5a0070e5a84e51687ec3ef9e0464c86dc8d13ce98" -dependencies = [ - "clap", + "windows-link", ] [[package]] -name = "clap_derive" -version = "4.5.28" +name = "cmake" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", + "cc", ] [[package]] -name = "clap_lex" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" - -[[package]] -name = "cmake" -version = "0.1.54" +name = "cmov" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" -dependencies = [ - "cc", -] +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "color_quant" @@ -607,9 +426,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -636,9 +455,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.22" +version = "0.15.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +checksum = "0b34d0237145f33580b89724f75d16950efd3e2c91b2d823917ecb69ec7f84f0" dependencies = [ "async-trait", "convert_case", @@ -650,23 +469,10 @@ dependencies = [ "serde_core", "serde_json", "toml", - "winnow 1.0.1", + "winnow", "yaml-rust2", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -688,7 +494,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] @@ -729,66 +535,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "core2" -version = "0.4.0" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "memchr", + "libc", ] [[package]] name = "cpufeatures" -version = "0.2.16" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -806,9 +593,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" @@ -832,6 +619,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -839,9 +644,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -856,17 +661,17 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "darling" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core 0.20.10", - "darling_macro 0.20.10", + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] @@ -881,16 +686,16 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -903,18 +708,18 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core 0.20.10", + "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -925,14 +730,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -944,15 +749,47 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] [[package]] name = "der" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "pem-rfc7468", @@ -965,7 +802,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -981,37 +817,37 @@ dependencies = [ ] [[package]] -name = "dialoguer" -version = "0.11.0" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "console", - "shell-words", - "thiserror 1.0.69", + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.6", + "subtle", ] [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1023,12 +859,6 @@ dependencies = [ "const-random", ] -[[package]] -name = "dotenv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" - [[package]] name = "dotenvy" version = "0.15.7" @@ -1043,9 +873,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" @@ -1054,7 +884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1080,7 +910,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -1094,14 +924,14 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "either" -version = "1.13.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -1114,11 +944,11 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", - "hkdf", + "hkdf 0.12.4", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -1128,14 +958,8 @@ dependencies = [ ] [[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "encoding_rs" -version = "0.8.35" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ @@ -1144,29 +968,29 @@ dependencies = [ [[package]] name = "enum-ordinalize" -version = "4.3.0" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea0dcfa4e54eeb516fe454635a95753ddd39acda650ce703031c6973e315dd5" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "env_filter" -version = "0.1.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -1174,9 +998,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -1185,37 +1009,17 @@ dependencies = [ "log", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.8" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", "serde_core", @@ -1224,30 +1028,29 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1264,46 +1067,17 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -1330,18 +1104,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "filetime" -version = "0.2.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" -dependencies = [ - "cfg-if", - "libc", - "libredox", - "windows-sys 0.59.0", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1350,9 +1112,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1360,9 +1122,9 @@ dependencies = [ [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -1377,9 +1139,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "foreign-types" @@ -1478,7 +1240,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1512,9 +1274,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1523,58 +1285,53 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.1" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi 0.13.3+wasi-0.2.2", - "windows-targets 0.52.6", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", - "wasip2", - "wasip3", + "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] name = "gif" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", "weezl", ] -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - [[package]] name = "group" version = "0.13.0" @@ -1588,9 +1345,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.7" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1598,7 +1355,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.12.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1607,12 +1364,13 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy", ] [[package]] @@ -1629,9 +1387,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", @@ -1640,17 +1398,17 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.2", + "hashbrown 0.16.1", ] [[package]] @@ -1671,7 +1429,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -1680,23 +1447,23 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] -name = "home" -version = "0.5.11" +name = "hmac" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.59.0", + "digest 0.11.3", ] [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1714,12 +1481,12 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", + "futures-core", "http", "http-body", "pin-project-lite", @@ -1727,9 +1494,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.5" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1737,15 +1504,25 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.6.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -1760,16 +1537,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "futures-util", "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1793,14 +1568,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.15" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1809,7 +1583,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2", "system-configuration", "tokio", "tower-service", @@ -1819,14 +1593,15 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -1842,21 +1617,23 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", - "yoke 0.7.5", + "potential_utf", + "utf8_iter", + "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1865,105 +1642,61 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", - "yoke 0.7.5", + "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1983,9 +1716,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1993,44 +1726,33 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "exr", "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", - "ravif", - "rayon", - "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", + "zune-core", + "zune-jpeg", ] [[package]] name = "image-webp" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6970fe7a5300b4b42e62c52efa0187540a5bef546c60edaf554ef595d2e6f0b" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", "quick-error", ] -[[package]] -name = "imgref" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" - [[package]] name = "indexmap" version = "1.9.3" @@ -2044,67 +1766,40 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.17.1", "serde", "serde_core", ] [[package]] name = "indoc" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "rustversion", ] [[package]] name = "ipnet" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" - -[[package]] -name = "iri-string" -version = "0.7.8" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "ism" -version = "0.7.13" +version = "0.8.0" dependencies = [ "assertr", "async-trait", @@ -2114,7 +1809,6 @@ dependencies = [ "bytes", "chrono", "config", - "dotenv", "educe", "futures", "http", @@ -2125,21 +1819,19 @@ dependencies = [ "nonempty", "rdkafka", "redis", - "reqwest 0.13.2", - "scylla", + "reqwest 0.13.4", "serde", "serde-querystring", "serde_json", "serde_with", "snafu", "sqlx", - "sqlx-cli", - "thiserror 2.0.18", + "thiserror", "time", "tokio", "tokio-stream", "tower", - "tower-http", + "tower-http 0.7.0", "tracing", "tracing-subscriber", "try-again", @@ -2149,68 +1841,65 @@ dependencies = [ "validator", ] -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.15" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" dependencies = [ + "defmt", "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", ] [[package]] name = "jiff-static" -version = "0.2.15" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", ] [[package]] @@ -2229,25 +1918,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "jobserver" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2264,25 +1955,26 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "10.3.0" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "base64", "ed25519-dalek", - "getrandom 0.2.15", - "hmac", + "getrandom 0.2.17", + "hmac 0.12.1", "js-sys", "p256", "p384", "pem", - "rand 0.8.5", + "rand 0.8.6", "rsa", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "signature", "simple_asn1", + "zeroize", ] [[package]] @@ -2294,32 +1986,20 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "lebe" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" - [[package]] name = "lexical" -version = "7.0.4" +version = "7.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ed980ff02623721dc334b9105150b66d0e1f246a92ab5a2eca0335d54c48f6" +checksum = "1bc8a009b2ff1f419ccc62706f04fe0ca6e67b37460513964a3dfdb919bb37d6" dependencies = [ "lexical-core", ] [[package]] name = "lexical-core" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b765c31809609075565a70b4b71402281283aeda7ecaf4818ac14a7b2ade8958" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" dependencies = [ "lexical-parse-float", "lexical-parse-integer", @@ -2330,104 +2010,75 @@ dependencies = [ [[package]] name = "lexical-parse-float" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de6f9cb01fb0b08060209a057c048fcbab8717b4c1ecd2eac66ebfe39a65b0f2" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" dependencies = [ "lexical-parse-integer", "lexical-util", - "static_assertions", ] [[package]] name = "lexical-parse-integer" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72207aae22fc0a121ba7b6d479e42cbfea549af1479c3f3a4f12c70dd66df12e" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" dependencies = [ "lexical-util", - "static_assertions", ] [[package]] name = "lexical-util" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a82e24bf537fd24c177ffbbdc6ebcc8d54732c35b50a3f28cc3f4e4c949a0b3" -dependencies = [ - "static_assertions", -] +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" [[package]] name = "lexical-write-float" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5afc668a27f460fb45a81a757b6bf2f43c2d7e30cb5a2dcd3abf294c78d62bd" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" dependencies = [ "lexical-util", "lexical-write-integer", - "static_assertions", ] [[package]] name = "lexical-write-integer" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629ddff1a914a836fb245616a7888b62903aae58fa771e1d83943035efa0f978" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" dependencies = [ "lexical-util", - "static_assertions", ] [[package]] name = "libc" -version = "0.2.185" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.10" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" -dependencies = [ - "arbitrary", - "cc", -] +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" - -[[package]] -name = "libredox" -version = "0.1.3" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" -dependencies = [ - "bitflags", - "libc", - "redox_syscall", -] +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ - "cc", "pkg-config", "vcpkg", ] [[package]] name = "libz-sys" -version = "1.1.23" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -2437,55 +2088,36 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.7.4" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] -name = "loop9" -version = "0.1.5" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "lz4_flex" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5" -dependencies = [ - "twox-hash", -] +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "matchers" @@ -2502,24 +2134,14 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -2530,9 +2152,9 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mime" @@ -2540,12 +2162,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "minio" version = "0.3.0" @@ -2565,7 +2181,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hmac", + "hmac 0.12.1", "http", "hyper", "lazy_static", @@ -2573,12 +2189,12 @@ dependencies = [ "md5", "multimap", "percent-encoding", - "rand 0.8.5", + "rand 0.8.6", "regex", - "reqwest 0.12.24", + "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tokio", "tokio-stream", "tokio-util", @@ -2588,9 +2204,9 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", @@ -2598,20 +2214,20 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] [[package]] name = "moxcms" -version = "0.7.9" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -2645,49 +2261,27 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.12" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.5", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nonempty" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2723,17 +2317,16 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -2749,20 +2342,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-derive" -version = "0.4.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2807,9 +2389,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -2817,33 +2399,38 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.68" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2856,15 +2443,9 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -2873,9 +2454,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.104" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2902,7 +2483,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2914,7 +2495,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2925,9 +2506,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2935,29 +2516,17 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pathdiff" version = "0.2.3" @@ -2966,12 +2535,12 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pem" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64", - "serde", + "serde_core", ] [[package]] @@ -2991,20 +2560,19 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.7.15" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", - "thiserror 2.0.18", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.7.15" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -3012,33 +2580,32 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.15" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "pest_meta" -version = "2.7.15" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ - "once_cell", "pest", - "sha2", + "sha2 0.10.9", ] [[package]] name = "pin-project-lite" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs1" @@ -3063,17 +2630,17 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -3082,19 +2649,28 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.10.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3103,21 +2679,11 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" -dependencies = [ - "zerocopy 0.7.35", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "proc-macro2", - "syn 2.0.117", + "zerocopy", ] [[package]] @@ -3131,9 +2697,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -3157,54 +2723,23 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "pxfm" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" -dependencies = [ - "num-traits", -] - -[[package]] -name = "qoi" -version = "0.4.1" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] name = "quick-error" @@ -3214,38 +2749,41 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quinn" -version = "0.11.6" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", - "thiserror 2.0.18", + "socket2", + "thiserror", "tokio", "tracing", + "web-time", ] [[package]] name = "quinn-proto" -version = "0.11.9" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.2.15", - "rand 0.8.5", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -3253,27 +2791,33 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.9" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.38" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -3282,9 +2826,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3293,13 +2837,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", - "zerocopy 0.8.22", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -3319,7 +2873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -3328,96 +2882,23 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.1", -] - -[[package]] -name = "rand_pcg" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b48ac3f7ffaab7fac4d2376632268aa5f89abdb55f7ebf8f4d11fffccb2320f7" -dependencies = [ - "rand_core 0.9.3", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.0", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "rayon" -version = "1.10.0" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "either", - "rayon-core", + "getrandom 0.3.4", ] [[package]] -name = "rayon-core" -version = "1.12.1" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rdkafka" @@ -3452,9 +2933,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.2.0" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a" +checksum = "bae41a63fd0b8a5372f82b21e810e09a316f5dd7efd96bf08e678fb240fc1918" dependencies = [ "arc-swap", "arcstr", @@ -3471,7 +2952,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2 0.6.3", + "socket2", "tokio", "tokio-util", "url", @@ -3480,38 +2961,38 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] name = "ref-cast" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -3521,9 +3002,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3532,15 +3013,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -3566,7 +3047,7 @@ dependencies = [ "tokio-native-tls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -3577,9 +3058,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -3607,7 +3088,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -3621,27 +3102,20 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] -[[package]] -name = "rgb" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" - [[package]] name = "ring" -version = "0.17.8" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.17", "libc", - "spin", "untrusted", "windows-sys 0.52.0", ] @@ -3652,7 +3126,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags", + "bitflags 2.13.0", "once_cell", "serde", "serde_derive", @@ -3662,12 +3136,12 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.7" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", - "digest", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -3692,9 +3166,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -3707,39 +3181,25 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.0.8" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3748,21 +3208,21 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.7.0", + "security-framework", ] [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -3770,9 +3230,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -3783,7 +3243,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.7.0", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3797,9 +3257,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -3809,15 +3269,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3830,11 +3290,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3851,9 +3311,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.4" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -3867,63 +3327,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scylla" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3321054d79dc75f9f3ca449111983ddc3f59aff4561cddb860af884504b4fbc9" -dependencies = [ - "arc-swap", - "async-trait", - "bytes", - "chrono", - "dashmap", - "futures", - "hashbrown 0.15.2", - "itertools", - "rand 0.9.0", - "rand_pcg", - "scylla-cql", - "smallvec", - "socket2 0.5.10", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "scylla-cql" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87834c927e9336270725aac24fada6e86f9d14aa04a8c8f93223b002e86f3891" -dependencies = [ - "byteorder", - "bytes", - "chrono", - "itertools", - "lz4_flex", - "scylla-macros", - "snap", - "stable_deref_trait", - "thiserror 2.0.18", - "tokio", - "uuid", - "yoke 0.8.0", -] - -[[package]] -name = "scylla-macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad97e5f7ccd8a1d41e631e361c9851c21b093e15ff5dcd08861f6ac83215d434" -dependencies = [ - "darling 0.20.10", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "sec1" version = "0.7.3" @@ -3938,26 +3341,13 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3976,12 +3366,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" -dependencies = [ - "serde", -] +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -4032,14 +3419,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4050,12 +3437,13 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.16" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -4081,17 +3469,18 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", + "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.0", + "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.0.4", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -4100,14 +3489,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4117,25 +3506,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] -name = "sha1_smol" -version = "1.0.1" +name = "sha1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" -version = "0.10.8" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4147,24 +3558,19 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" - [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -4174,97 +3580,85 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] -name = "simd_helpers" -version = "0.1.0" +name = "simd_cesu8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ - "quote", + "rustc_version", + "simdutf8", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 1.0.69", + "thiserror", "time", ] [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] [[package]] name = "snafu" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1d4bced6a69f90b2056c03dcff2c4737f98d6fb9e0853493996e1d253ca29c6" +checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522" dependencies = [ "snafu-derive", ] [[package]] name = "snafu-derive" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40" +checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "snap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", + "syn 2.0.118", ] [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4291,9 +3685,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -4302,37 +3696,15 @@ dependencies = [ "sqlx-sqlite", ] -[[package]] -name = "sqlx-cli" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddab32a90a16a1c7c9265f410761341cbbc70b27fe7f9563f41ae5c2b4ef1c4" -dependencies = [ - "anyhow", - "backoff", - "cargo_metadata", - "chrono", - "clap", - "clap_complete", - "console", - "dialoguer", - "dotenvy", - "filetime", - "futures", - "glob", - "serde_json", - "sqlx", - "tokio", -] - [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64", "bytes", + "cfg-if", "chrono", "crc", "crossbeam-queue", @@ -4342,119 +3714,100 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.2", + "hashbrown 0.16.1", "hashlink", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "memchr", - "native-tls", - "once_cell", "percent-encoding", - "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tracing", "url", "uuid", - "webpki-roots", ] [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck", "hex", - "once_cell", "proc-macro2", "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.117", + "syn 2.0.118", + "thiserror", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64", - "bitflags", + "bitflags 2.13.0", "byteorder", "bytes", "chrono", "crc", - "digest", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5", - "memchr", - "once_cell", "percent-encoding", - "rand 0.8.5", - "rsa", "serde", - "sha1", - "sha2", - "smallvec", + "sha1 0.11.0", + "sha2 0.11.0", "sqlx-core", - "stringprep", - "thiserror 2.0.18", + "thiserror", "tracing", "uuid", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.13.0", "byteorder", "chrono", "crc", @@ -4464,22 +3817,20 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", - "home", + "hkdf 0.13.0", + "hmac 0.13.0", "itoa", "log", "md-5", "memchr", - "once_cell", - "rand 0.8.5", + "rand 0.10.1", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "tracing", "uuid", "whoami", @@ -4487,13 +3838,14 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -4503,9 +3855,8 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror", "tracing", "url", "uuid", @@ -4513,15 +3864,9 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "static_assertions" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stringprep" @@ -4559,9 +3904,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -4579,22 +3924,22 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4611,35 +3956,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.15.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "cfg-if", "fastrand", - "getrandom 0.2.15", + "getrandom 0.4.3", "once_cell", - "rustix 0.38.43", - "windows-sys 0.59.0", -] - -[[package]] -name = "terminal_size" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" -dependencies = [ - "rustix 1.0.8", - "windows-sys 0.59.0", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -4648,18 +3973,7 @@ version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "thiserror-impl", ] [[package]] @@ -4670,41 +3984,39 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.19", + "zune-jpeg", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -4714,15 +4026,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -4739,9 +4051,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -4749,9 +4061,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -4764,9 +4076,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4774,7 +4086,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -4787,7 +4099,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4802,9 +4114,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.1" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -4836,9 +4148,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -4855,18 +4167,9 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "serde_core", "serde_spanned", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.1", -] - -[[package]] -name = "toml_datetime" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" -dependencies = [ - "serde_core", + "winnow", ] [[package]] @@ -4880,14 +4183,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap 2.12.0", - "toml_datetime 0.7.3", + "indexmap 2.14.0", + "toml_datetime", "toml_parser", - "winnow 0.7.13", + "winnow", ] [[package]] @@ -4896,7 +4199,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow", ] [[package]] @@ -4917,20 +4220,36 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.0", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", "tracing", ] @@ -4966,7 +4285,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4992,9 +4311,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -5035,39 +4354,29 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.0", - "sha1", - "thiserror 2.0.18", -] - -[[package]] -name = "twox-hash" -version = "1.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" -dependencies = [ - "cfg-if", - "static_assertions", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror", ] [[package]] name = "typed-builder" -version = "0.23.0" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0dd654273fc253fde1df4172c31fb6615cf8b041d3a4008a028ef8b1119e66" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" dependencies = [ "typed-builder-macro", ] [[package]] name = "typed-builder-macro" -version = "0.23.0" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c26257f448222014296978b2c8456e2cad4de308c35bdb1e383acd569ef5b" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5078,9 +4387,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.17.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -5096,42 +4405,30 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - -[[package]] -name = "unicode-xid" -version = "0.2.6" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "untrusted" @@ -5141,9 +4438,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -5157,12 +4454,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5177,27 +4468,16 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", ] -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "validator" version = "0.20.0" @@ -5220,19 +4500,19 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling 0.20.10", + "darling 0.20.11", "once_cell", "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "valuable" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vcpkg" @@ -5267,87 +4547,47 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasi" -version = "0.13.3+wasi-0.2.2" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" -dependencies = [ - "wit-bindgen-rt", -] +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5355,48 +4595,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", - "wasm-bindgen-backend", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.12.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -5410,23 +4628,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.2", - "indexmap 2.12.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5444,37 +4650,24 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] [[package]] -name = "webpki-roots" -version = "0.26.8" +name = "weezl" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "weezl" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "whoami" -version = "1.5.2" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" -dependencies = [ - "redox_syscall", - "wasite", -] +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" [[package]] name = "winapi-util" @@ -5487,18 +4680,38 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-targets 0.52.6", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "windows-link" -version = "0.1.1" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "windows-link" @@ -5508,49 +4721,31 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.1", + "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.1.1", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.1", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-targets 0.48.5", + "windows-link", ] [[package]] @@ -5562,15 +4757,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -5586,37 +4772,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-link", ] [[package]] @@ -5641,7 +4797,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5652,18 +4808,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5676,18 +4820,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5700,18 +4832,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5736,18 +4856,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5760,18 +4868,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5784,18 +4880,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5808,18 +4892,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5834,136 +4906,30 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rt" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" -dependencies = [ - "bitflags", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.12.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.12.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.12.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xml-rs" -version = "0.8.27" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "xmltree" @@ -5980,17 +4946,11 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yaml-rust2" -version = "0.10.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", @@ -5999,153 +4959,126 @@ dependencies = [ [[package]] name = "yoke" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive 0.7.5", - "zerofrom", -] - -[[package]] -name = "yoke" -version = "0.8.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "serde", "stable_deref_trait", - "yoke-derive 0.8.0", + "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ - "byteorder", - "zerocopy-derive 0.7.35", + "zerocopy-derive", ] [[package]] -name = "zerocopy" -version = "0.8.22" +name = "zerocopy-derive" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09612fda0b63f7cb9e0af7e5916fe5a1f8cdcb066829f10f36883207628a4872" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ - "zerocopy-derive 0.8.22", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "zerocopy-derive" -version = "0.7.35" +name = "zerofrom" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "zerofrom-derive", ] [[package]] -name = "zerocopy-derive" -version = "0.8.22" +name = "zerofrom-derive" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79f81d38d7a2ed52d8f034e62c568e111df9bf8aba2f7cf19ddc5bf7bd89d520" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", + "synstructure", ] [[package]] -name = "zerofrom" -version = "0.1.5" +name = "zeroize" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ - "zerofrom-derive", + "zeroize_derive", ] [[package]] -name = "zerofrom-derive" -version = "0.1.5" +name = "zeroize_derive" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 2.0.118", ] [[package]] -name = "zeroize" -version = "1.8.1" +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "yoke 0.7.5", + "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "zmij" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" - -[[package]] -name = "zune-core" -version = "0.4.12" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zune-core" @@ -6153,29 +5086,11 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9e525af0a6a658e031e95f14b7f889976b74a11ba0eca5a5fc9ac8a1c43a6a" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" -version = "0.5.12" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index dfa19b6..e8348ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,53 +1,49 @@ [package] name = "ism" -version = "0.7.13" +version = "0.8.0" edition = "2024" [dependencies] -log = "0.4.29" +log = "0.4.33" axum = { version = "0.8.9", features = ["multipart", "ws"] } -tokio = {version = "1.51.1", features = ["full"]} +tokio = {version = "1.52.3", features = ["full"]} tower = "0.5.3" -config = "0.15.22" +config = "0.15.24" serde = "1.0.228" -scylla = { version = "=1.4.1", features = ["chrono-04"] } futures = "0.3.32" -uuid = { version = "1.23.0", features = ["v4", "serde", "v7"] } -chrono = { version = "0.4.44", features = ["serde"] } -tower-http = { version = "0.6.8", features = ["cors", "trace"] } +uuid = { version = "1.23.3", features = ["v4", "serde", "v7"] } +chrono = { version = "0.4.45", features = ["serde"] } +tower-http = { version = "0.7.0", features = ["cors", "trace"] } tracing = "0.1.44" -tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } -sqlx = {version = "0.8.6", features = ["runtime-tokio", "postgres", "chrono", "uuid", "macros"]} -dotenv = "0.15.0" -serde_json = "1.0.149" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +sqlx = {version = "0.9.0", features = ["runtime-tokio", "postgres", "chrono", "uuid", "macros", "json"]} +serde_json = "1.0.150" tokio-stream = { version = "0.1.18", features = ["sync"] } rdkafka = { version = "0.39.0", features = ["cmake-build", "tokio"] } minio = { version = "0.3.0", features = ["default"] } -image = { version = "0.25.9"} -bytes = "1.11.1" +image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp", "ico", "tiff"] } +bytes = "1.12.0" base64 = "0.22.1" validator = { version = "0.20.0", features = ["derive"] } -redis = { version = "1.2.0", features = ["tokio-comp", "connection-manager"] } - +redis = { version = "1.2.4", features = ["tokio-comp", "connection-manager"] } +thiserror = "2.0.18" +async-trait = "0.1.89" -#keycloak: -atomic-time = "0.2.0" +#KEYCLOAK: +atomic-time = "0.2.1" educe = { version = "0.6.0", default-features = false, features = ["Debug"] } -http = "1.4.0" -jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } +http = "1.4.2" +jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } nonempty = { version = "0.12.0", features = ["std"] } -reqwest = { version = "0.13.2", features = ["json"] } +reqwest = { version = "0.13.4", features = ["json"] } serde-querystring = "0.3.0" -serde_with = "3.18.0" -snafu = "0.9.0" -time = "0.3.47" +serde_with = "3.21.0" +snafu = "0.9.1" +time = "0.3.51" try-again = "0.2.2" -typed-builder = "0.23.0" -url = "2.5.7" -async-trait = "0.1.89" -thiserror = "2.0.18" +typed-builder = "0.23.2" +url = "2.5.8" [dev-dependencies] -assertr = "0.4.2" -sqlx-cli = { version = "0.8.6", features = ["postgres", "rustls"] } \ No newline at end of file +assertr = "0.6.0" diff --git a/Dockerfile b/Dockerfile index 013e674..0257c0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.91.0-slim-bookworm AS builder +FROM rust:1.96.0-slim-bookworm AS builder WORKDIR /app diff --git a/README.md b/README.md index 81c7022..8a7f9db 100644 --- a/README.md +++ b/README.md @@ -101,14 +101,14 @@ log_level = "info" # Logging level (e.g., "info", "debug", "warn", "error") cors_origin = "http://localhost:4200" # Allowed CORS origin for frontend applications (wildcards are not supported) use_kafka = false # Set to true to enable Kafka integration for custom notifications -[message_db_config] # Configuration for the NoSQL database (Cassandra/ScyllaDB) +[message_db_config] # Configuration for the NoSQL object_storage (Cassandra/ScyllaDB) db_url = "localhost:9042" db_user = "cassandra" db_password = "cassandra" db_keyspace = "messaging" -with_db_init = true # Set to true to initialize required database tables on startup (use with caution in production) +with_db_init = true # Set to true to initialize required object_storage tables on startup (use with caution in production) -[user_db_config] # Configuration for the relational database (PostgreSQL) +[user_db_config] # Configuration for the relational object_storage (PostgreSQL) db_host = "localhost" db_port = "32768" db_user = "postgres" @@ -173,10 +173,16 @@ Authorization: Bearer #### Get Notifications - **`GET /api/notifications`** - - Retrieves notification events since a specific timestamp + - Replays durable notification events since a given per-user sequence number - **Query Parameters**: - - `timestamp` (DateTime): Retrieve notifications after this timestamp - - **Response**: `200 OK` with array of notification objects + - `last_seq` (number, required): Retrieve events with `seq > last_seq` (use `0` for everything still retained) + - **Response**: `200 OK` with array of notification objects; a single `Resync` element if the gap is no longer retained + +#### Get Notification Cursor +- **`GET /api/notifications/cursor`** + - Returns the highest sequence currently issued to the caller without advancing it + - Used to seed the stored cursor after a full REST sync (which connects to the stream without `last_seq`) + - **Response**: `200 OK` with `{ "seq": }` (`0` if no event issued yet) --- @@ -300,7 +306,7 @@ Authorization: Bearer - `room_id` (UUID): Room identifier - **Query Parameters**: - `timestamp` (DateTime): Load messages before this timestamp - - **Response**: `200 OK` with array of message objects + - **Response**: `200 OK` with a `TimelinePage` object: `{ messages: [...], senders: [...] }`, where `senders` is the deduplicated set of room members that authored a message in the page (plus the original authors referenced by replies; authors who have since left still resolve, with null participant fields). Combined with the `sender` field on live `ChatMessage` events, the client never needs a separate sender lookup. #### Mark Room as Read - **`POST /api/rooms/{room_id}/mark-read`** diff --git a/compose.yaml b/compose.yaml index 141dee6..a5475f8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,14 +1,4 @@ services: - cassandra: - image: cassandra:latest - container_name: cassandra-container - ports: - - "9042:9042" - environment: - - CASSANDRA_USER=admin - - CASSANDRA_PASSWORD=admin - volumes: - - cassandra-data:/var/lib/cassandra redis: image: 'redis:8.2.3-alpine' @@ -79,7 +69,7 @@ services: environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: keycloak + POSTGRES_DB: auth POSTGRES_HOST: postgres networks: - local @@ -87,7 +77,7 @@ services: - "5432:5432" keycloak: - container_name: keycloak + container_name: auth image: keycloak/keycloak:26.4 depends_on: - "KeycloakDB" @@ -96,7 +86,7 @@ services: JAVA_OPTS_APPEND: -Dkeycloak.profile.feature.upload_scripts=enabled KC_DB: postgres KC_DB_PASSWORD: postgres - KC_DB_URL: jdbc:postgresql://KeycloakDB:5432/keycloak + KC_DB_URL: jdbc:postgresql://KeycloakDB:5432/auth KC_DB_USERNAME: postgres KC_HTTP_ENABLED: true KC_HOSTNAME_STRICT_HTTPS: false @@ -109,9 +99,9 @@ services: networks: - local volumes: - - ./realm.json:/opt/keycloak/data/import/realm.json + - ./realm.json:/opt/auth/data/import/realm.json entrypoint: - - /opt/keycloak/bin/kc.sh + - /opt/auth/bin/kc.sh - start - --import-realm @@ -129,8 +119,6 @@ services: - "32768:5432" volumes: - cassandra-data: - driver: local minio-data: driver: local keycloak_data: diff --git a/default.config.toml b/default.config.toml index c6a76c0..2207792 100644 --- a/default.config.toml +++ b/default.config.toml @@ -3,17 +3,8 @@ ism_port= 5403 log_level = "debug" cors_origin = "http://localhost:4200" use_kafka = true -push_notification_access_token="oiqhfriuhf" -push_notification_url="http://localhost:4200" -[message_db_config] -db_url = "localhost:9042" -db_user = "cassandra" -db_password = "cassandra" -db_keyspace = "messaging" -with_db_init = true - -[user_db_config] +[room_db_config] db_host = "localhost" db_port = "32768" db_user = "postgres" diff --git a/migrations/20260518120000_create_chat_message.down.sql b/migrations/20260518120000_create_chat_message.down.sql new file mode 100644 index 0000000..ea6aba1 --- /dev/null +++ b/migrations/20260518120000_create_chat_message.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_chat_message_room_timeline; +DROP TABLE IF EXISTS chat_message; +DROP TYPE IF EXISTS msg_type; \ No newline at end of file diff --git a/migrations/20260518120000_create_chat_message.up.sql b/migrations/20260518120000_create_chat_message.up.sql new file mode 100644 index 0000000..72c9335 --- /dev/null +++ b/migrations/20260518120000_create_chat_message.up.sql @@ -0,0 +1,13 @@ +CREATE TYPE msg_type AS ENUM ('Text', 'Media', 'RoomChange', 'Reply'); + +CREATE TABLE chat_message +( + message_id UUID NOT NULL PRIMARY KEY, + chat_room_id UUID NOT NULL REFERENCES chat_room (id), + sender_id UUID NOT NULL, + msg_body JSONB NOT NULL, + msg_type msg_type NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL +); + +CREATE INDEX idx_chat_message_room_timeline ON chat_message (chat_room_id, created_at DESC); \ No newline at end of file diff --git a/migrations/20260519120000_alter_preview_text_to_jsonb.down.sql b/migrations/20260519120000_alter_preview_text_to_jsonb.down.sql new file mode 100644 index 0000000..c92c9af --- /dev/null +++ b/migrations/20260519120000_alter_preview_text_to_jsonb.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE chat_room + ALTER COLUMN latest_message_preview_text TYPE varchar(255) + USING latest_message_preview_text::text; \ No newline at end of file diff --git a/migrations/20260519120000_alter_preview_text_to_jsonb.up.sql b/migrations/20260519120000_alter_preview_text_to_jsonb.up.sql new file mode 100644 index 0000000..bdea3f1 --- /dev/null +++ b/migrations/20260519120000_alter_preview_text_to_jsonb.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE chat_room + ALTER COLUMN latest_message_preview_text TYPE jsonb + USING latest_message_preview_text::jsonb; \ No newline at end of file diff --git a/migrations/20260622120000_drop_participant_state.down.sql b/migrations/20260622120000_drop_participant_state.down.sql new file mode 100644 index 0000000..32c962a --- /dev/null +++ b/migrations/20260622120000_drop_participant_state.down.sql @@ -0,0 +1,13 @@ +-- Restore the participant_state column, its CHECK constraint and the state-based index. +-- Note: rows deleted by the up-migration (former Left/Invited members) cannot be recovered. +ALTER TABLE chat_room_participant + ADD COLUMN participant_state varchar(255) NOT NULL DEFAULT 'Joined' + CONSTRAINT chat_room_participant_participant_state_check + CHECK ((participant_state)::text = ANY + ((ARRAY ['Joined'::character varying, 'Invited'::character varying, 'Left'::character varying])::text[])); + +ALTER TABLE chat_room_participant + ALTER COLUMN participant_state DROP DEFAULT; + +CREATE INDEX idx_participants_room_id_membership + ON chat_room_participant (room_id, participant_state); diff --git a/migrations/20260622120000_drop_participant_state.up.sql b/migrations/20260622120000_drop_participant_state.up.sql new file mode 100644 index 0000000..2152fc9 --- /dev/null +++ b/migrations/20260622120000_drop_participant_state.up.sql @@ -0,0 +1,14 @@ +-- Leaving a room now deletes the participant row; there is no more "Left"/"Invited" state. +-- A row in chat_room_participant means "currently in the room". + +-- 1. Drop historical non-joined rows so every remaining row is an active member. +DELETE FROM chat_room_participant WHERE participant_state <> 'Joined'; + +-- 2. Drop the state-based index, then the CHECK constraint and the column itself. +DROP INDEX IF EXISTS idx_participants_room_id_membership; + +ALTER TABLE chat_room_participant + DROP CONSTRAINT IF EXISTS chat_room_participant_participant_state_check; + +ALTER TABLE chat_room_participant + DROP COLUMN participant_state; diff --git a/src/keycloak/LICENSE-MIT b/src/auth/LICENSE-MIT similarity index 100% rename from src/keycloak/LICENSE-MIT rename to src/auth/LICENSE-MIT diff --git a/src/keycloak/action.rs b/src/auth/action.rs similarity index 99% rename from src/keycloak/action.rs rename to src/auth/action.rs index 9f6de26..f3c69c9 100644 --- a/src/keycloak/action.rs +++ b/src/auth/action.rs @@ -3,8 +3,8 @@ use std::{ option::Option, pin::Pin, sync::{ - atomic::{AtomicBool, AtomicUsize}, Arc, + atomic::{AtomicBool, AtomicUsize}, }, }; @@ -13,7 +13,7 @@ use educe::Educe; use futures::Future; use tokio::{ sync::Notify, - sync::{futures::Notified, RwLock}, + sync::{RwLock, futures::Notified}, task::JoinHandle, }; diff --git a/src/keycloak/decode.rs b/src/auth/decode.rs similarity index 95% rename from src/keycloak/decode.rs rename to src/auth/decode.rs index e454c25..fa390fb 100644 --- a/src/keycloak/decode.rs +++ b/src/auth/decode.rs @@ -1,19 +1,19 @@ use std::collections::HashMap; use std::sync::Arc; -use jsonwebtoken::errors::ErrorKind; +use super::{error::AuthError, role::ExtractRoles, role::Role}; +use crate::auth::error::DecodeHeaderSnafu; +use crate::auth::error::DecodeSnafu; +use crate::auth::instance::KeycloakAuthInstance; +use crate::auth::role::{ExpectRoles, KeycloakRole, NumRoles}; use jsonwebtoken::Header; +use jsonwebtoken::errors::ErrorKind; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, OneOrMany}; +use serde_with::{OneOrMany, serde_as}; use snafu::ResultExt; use tracing::debug; use uuid::Uuid; -use crate::keycloak::instance::KeycloakAuthInstance; -use crate::keycloak::role::{ExpectRoles, KeycloakRole, NumRoles}; -use super::{error::AuthError, role::ExtractRoles, role::Role}; -use crate::keycloak::error::DecodeHeaderSnafu; -use crate::keycloak::error::DecodeSnafu; pub type RawClaims = HashMap; @@ -229,7 +229,7 @@ impl ExtractRoles for ResourceAccess { /// use axum::response::{IntoResponse, Response}; /// use http::StatusCode; /// use serde::Serialize;/// -/// use ism::keycloak::decode::KeycloakToken; +/// use ism::auth::decode::KeycloakToken; /// /// /// pub async fn who_am_i(Extension(token): Extension>) -> Response { @@ -302,12 +302,8 @@ where jwt_id: raw.jti, issuer: raw.iss, audience: raw.aud, - subject: Uuid::try_parse(&raw.sub).map_err(|err| { - AuthError::InvalidToken { - reason: format!( - "Could not parse 'sub' (subject) field as uuid: {err}" - ), - } + subject: Uuid::try_parse(&raw.sub).map_err(|err| AuthError::InvalidToken { + reason: format!("Could not parse 'sub' (subject) field as uuid: {err}"), })?, authorized_party: raw.azp, roles: { @@ -387,4 +383,4 @@ pub struct ProfileAndEmail { pub profile: Profile, #[serde(flatten)] pub email: Email, -} \ No newline at end of file +} diff --git a/src/keycloak/error.rs b/src/auth/error.rs similarity index 95% rename from src/keycloak/error.rs rename to src/auth/error.rs index 8cffe05..123394c 100644 --- a/src/keycloak/error.rs +++ b/src/auth/error.rs @@ -1,14 +1,14 @@ use std::{borrow::Cow, sync::Arc}; use axum::{ + Json, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde_json::json; use snafu::Snafu; -use crate::keycloak::oidc_discovery; +use crate::auth::oidc_discovery; #[derive(Debug, Clone, Snafu)] #[snafu(visibility(pub(crate)))] @@ -44,7 +44,9 @@ pub enum AuthError { /// The 'Authorization' header was present on a request but its value could not be parsed. /// This can occur if the header value did not solely contain visible ASCII characters. - #[snafu(display("The 'Authorization' header was present on a request but its value could not be parsed. Reason: {reason}"))] + #[snafu(display( + "The 'Authorization' header was present on a request but its value could not be parsed. Reason: {reason}" + ))] InvalidAuthorizationHeader { reason: String }, /// The 'Authorization' header was present and could be parsed, but it did not contain the expected "Bearer {token}" format. @@ -64,7 +66,9 @@ pub enum AuthError { MissingTokenQueryParam, /// Query parameters were found on the request, and the expected token parameter was found, but it had no value assigned ("?token="). - #[snafu(display("Query parameters were found on the request, and the expected token parameter was found, but it had no value assigned (\"?token=\")."))] + #[snafu(display( + "Query parameters were found on the request, and the expected token parameter was found, but it had no value assigned (\"?token=\")." + ))] EmptyTokenQueryParam, /// No JWT could be extracted from the request. diff --git a/src/keycloak/extract.rs b/src/auth/extract.rs similarity index 98% rename from src/keycloak/extract.rs rename to src/auth/extract.rs index 7b646d0..16da52d 100644 --- a/src/keycloak/extract.rs +++ b/src/auth/extract.rs @@ -3,7 +3,7 @@ use std::{borrow::Cow, sync::Arc}; use axum::extract::Request; use nonempty::NonEmpty; -use crate::keycloak::error::AuthError; +use crate::auth::error::AuthError; /// A raw (unprocessed) token (string) taken from a request. /// This being `Cow` allows the `TokenExtractor` implementations to borrow from the request if possible. diff --git a/src/keycloak/instance.rs b/src/auth/instance.rs similarity index 95% rename from src/keycloak/instance.rs rename to src/auth/instance.rs index a622071..9b1ffc8 100644 --- a/src/keycloak/instance.rs +++ b/src/auth/instance.rs @@ -8,7 +8,7 @@ use try_again::{StdDuration, delay, retry_async}; use typed_builder::TypedBuilder; use url::Url; -use crate::keycloak::{ +use crate::auth::{ action::Action, error::{AuthError, JwkEndpointSnafu, JwkSetDiscoverySnafu, OidcDiscoverySnafu}, oidc::OidcConfig, @@ -115,8 +115,8 @@ impl KeycloakAuthInstance { kc_config.retry.0, std::time::Duration::from_secs(kc_config.retry.1), ) - .instrument(span) - .await + .instrument(span) + .await } }); @@ -189,14 +189,14 @@ async fn perform_oidc_discovery( .await .context(OidcDiscoverySnafu {}) }) - .delayed_by(delay::Fixed::of(fixed_delay).take(num_retries)) - .await - .inspect_err(|err| { - tracing::error!( + .delayed_by(delay::Fixed::of(fixed_delay).take(num_retries)) + .await + .inspect_err(|err| { + tracing::error!( err = snafu::Report::from_error(err.clone()).to_string(), "Could not retrieve OIDC config." ); - })?; + })?; // Parse JWK endpoint if OIDC config is available. let jwk_set_endpoint = Url::parse(&oidc_config.standard_claims.jwks_uri) @@ -214,14 +214,14 @@ async fn perform_oidc_discovery( .await .context(JwkSetDiscoverySnafu {}) }) - .delayed_by(delay::Fixed::of(fixed_delay).take(num_retries)) - .await - .inspect_err(|err| { - tracing::error!( + .delayed_by(delay::Fixed::of(fixed_delay).take(num_retries)) + .await + .inspect_err(|err| { + tracing::error!( err = snafu::Report::from_error(err.clone()).to_string(), "Could not retrieve jwk_set." ); - })?; + })?; let num_keys = jwk_set.keys.len(); tracing::info!( @@ -252,4 +252,4 @@ fn parse_jwks(jwk_set: &jsonwebtoken::jwk::JwkSet) -> Vec>() -} \ No newline at end of file +} diff --git a/src/keycloak/layer.rs b/src/auth/layer.rs similarity index 87% rename from src/keycloak/layer.rs rename to src/auth/layer.rs index 55bef1a..602e4d5 100644 --- a/src/keycloak/layer.rs +++ b/src/auth/layer.rs @@ -6,12 +6,12 @@ use std::{fmt::Debug, sync::Arc}; use tower::Layer; use typed_builder::TypedBuilder; -use crate::keycloak::decode::{ - decode_and_validate, parse_raw_claims, KeycloakToken, ProfileAndEmail, RawToken, +use crate::auth::decode::{ + KeycloakToken, ProfileAndEmail, RawToken, decode_and_validate, parse_raw_claims, }; -use crate::keycloak::error::AuthError; -use crate::keycloak::extract::TokenExtractor; -use crate::keycloak::{instance::KeycloakAuthInstance, role::Role, service::KeycloakAuthService}; +use crate::auth::error::AuthError; +use crate::auth::extract::TokenExtractor; +use crate::auth::{instance::KeycloakAuthInstance, role::Role, service::KeycloakAuthService}; use super::PassthroughMode; @@ -21,7 +21,6 @@ extern crate alloc; /// Authentication happens by looking for the `Authorization` header on requests and parsing the contained JWT bearer token. /// See the crate level documentation for how this layer can be created and used. #[derive(Clone, TypedBuilder)] -#[allow(unused_variables)] pub struct KeycloakAuthLayer where R: Role, @@ -50,7 +49,7 @@ where pub required_roles: Vec, /// Specifies where the token is expected to be found. - #[builder(default = nonempty::nonempty![Arc::new(crate::keycloak::extract::AuthHeaderTokenExtractor {})])] + #[builder(default = nonempty::nonempty![Arc::new(crate::auth::extract::AuthHeaderTokenExtractor {})])] pub token_extractors: NonEmpty>, #[builder(default = uuid::Uuid::now_v7(), setter(skip))] @@ -65,7 +64,7 @@ where R: Role, Extra: DeserializeOwned + Clone, { - /// Allows to validate a raw keycloak token given as &str (without the "Bearer " part when taken from an authorization header). + /// Allows to validate a raw auth token given as &str (without the "Bearer " part when taken from an authorization header). /// This method is helpful if you wish to validate a token which does not pass the axum middleware /// or if you wish to validate a token in a different context. pub async fn validate_raw_token( @@ -123,18 +122,18 @@ mod test { use nonempty::NonEmpty; use url::Url; - use crate::keycloak::{ + use crate::auth::{ + PassthroughMode, extract::{AuthHeaderTokenExtractor, QueryParamTokenExtractor, TokenExtractor}, instance::{KeycloakAuthInstance, KeycloakConfig}, layer::KeycloakAuthLayer, - PassthroughMode, }; #[tokio::test] async fn build_basic_layer() { let instance = KeycloakAuthInstance::new( KeycloakConfig::builder() - .server(Url::parse("https://localhost:8443/").unwrap()) + .server(Url::parse("https://localhost:8443/").expect("invalid url")) .realm(String::from("MyRealm")) .retry((10, 2)) .build(), @@ -151,7 +150,7 @@ mod test { async fn build_full_layer() { let instance = KeycloakAuthInstance::new( KeycloakConfig::builder() - .server(Url::parse("https://localhost:8443/").unwrap()) + .server(Url::parse("https://localhost:8443/").expect("invalid url")) .realm(String::from("MyRealm")) .retry((10, 2)) .build(), diff --git a/src/keycloak/mod.rs b/src/auth/mod.rs similarity index 96% rename from src/keycloak/mod.rs rename to src/auth/mod.rs index 9da68a6..aef65ba 100644 --- a/src/keycloak/mod.rs +++ b/src/auth/mod.rs @@ -19,7 +19,7 @@ //! ```rust //! use std::sync::Arc; //! use axum::{http::StatusCode, response::{Response, IntoResponse}, routing::get, Extension, Router}; -//! use ism::keycloak::{Url, error::AuthError, instance::KeycloakConfig, instance::KeycloakAuthInstance, layer::KeycloakAuthLayer, decode::KeycloakToken, PassthroughMode}; +//! use ism::auth::{Url, error::AuthError, instance::KeycloakConfig, instance::KeycloakAuthInstance, layer::KeycloakAuthLayer, decode::KeycloakToken, PassthroughMode}; //! use ism::expect_role; //! //! pub fn public_router() -> Router { @@ -71,7 +71,7 @@ //! // The `protected` handler will (in the default `PassthroughMode::Block` case) only be called //! // if the request contained a valid JWT which not already expired. //! // It may then access that data (as `KeycloakToken`) through an Extension -//! // to get access to the decoded keycloak user information as shown below. +//! // to get access to the decoded auth user information as shown below. //! //! pub async fn health() -> impl IntoResponse { //! StatusCode::OK @@ -133,7 +133,7 @@ //! Unknown(String), //! } //! -//! impl keycloak::role::Role for Role {} +//! impl auth::role::Role for Role {} //! //! impl std::fmt::Display for Role { //! fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -156,9 +156,9 @@ //! // You could then (remember to update both locations of the generic type) check for roles using your enum: //! //! use axum::{http::StatusCode, response::{Response, IntoResponse}, Extension}; -//! use ism::keycloak::{decode::KeycloakToken};//! +//! use ism::auth::{decode::KeycloakToken};//! //! -//! use ism::{expect_role, keycloak}; +//! use ism::{expect_role, auth}; //! //! pub async fn protected(Extension(token): Extension>) -> Response { //! expect_role!(&token, Role::Administrator); @@ -185,7 +185,7 @@ //! //! ```rust,no_run //! use std::sync::Arc; -//! use ism::keycloak::{ +//! use ism::auth::{ //! NonEmpty, PassthroughMode, //! instance::KeycloakAuthInstance, //! layer::KeycloakAuthLayer, diff --git a/src/keycloak/oidc.rs b/src/auth/oidc.rs similarity index 100% rename from src/keycloak/oidc.rs rename to src/auth/oidc.rs diff --git a/src/keycloak/oidc_discovery.rs b/src/auth/oidc_discovery.rs similarity index 97% rename from src/keycloak/oidc_discovery.rs rename to src/auth/oidc_discovery.rs index b926538..bc80780 100644 --- a/src/keycloak/oidc_discovery.rs +++ b/src/auth/oidc_discovery.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::keycloak::oidc::OidcConfig; +use crate::auth::oidc::OidcConfig; use reqwest::IntoUrl; use serde::Deserialize; use snafu::{ResultExt, Snafu}; diff --git a/src/keycloak/role.rs b/src/auth/role.rs similarity index 100% rename from src/keycloak/role.rs rename to src/auth/role.rs diff --git a/src/keycloak/service.rs b/src/auth/service.rs similarity index 95% rename from src/keycloak/service.rs rename to src/auth/service.rs index d01775e..27c1f28 100644 --- a/src/keycloak/service.rs +++ b/src/auth/service.rs @@ -3,14 +3,14 @@ use std::{ task::{Context, Poll}, }; +use crate::auth::error::AuthError; +use crate::auth::layer::KeycloakAuthLayer; +use crate::auth::role::Role; +use crate::auth::{KeycloakAuthStatus, PassthroughMode, extract}; use axum::{body::Body, response::IntoResponse}; use futures::future::BoxFuture; use http::Request; use serde::de::DeserializeOwned; -use crate::keycloak::error::AuthError; -use crate::keycloak::{extract, KeycloakAuthStatus, PassthroughMode}; -use crate::keycloak::layer::KeycloakAuthLayer; -use crate::keycloak::role::Role; #[derive(Clone)] pub struct KeycloakAuthService diff --git a/src/broadcast/event_broadcast.rs b/src/broadcast/event_broadcast.rs index 3cb0b1a..5aab86f 100644 --- a/src/broadcast/event_broadcast.rs +++ b/src/broadcast/event_broadcast.rs @@ -1,12 +1,12 @@ +use crate::broadcast::{Notification, NotificationEvent}; +use crate::cache::redis_cache::{Cache, ReplayResult}; +use crate::kafka::{EventProducer, PushNotificationProducer}; +use log::{debug, error, info}; use std::collections::HashMap; use std::sync::Arc; -use log::{debug, error, info}; +use tokio::sync::broadcast::{Receiver, Sender, channel}; use tokio::sync::{OnceCell, RwLock}; use uuid::Uuid; -use tokio::sync::broadcast::{Sender, channel, Receiver}; -use crate::broadcast::{Notification, NotificationEvent}; -use crate::cache::redis_cache::Cache; -use crate::kafka::{EventProducer, PushNotificationProducer}; static BROADCAST_INSTANCE: OnceCell> = OnceCell::const_new(); @@ -32,21 +32,20 @@ static BROADCAST_INSTANCE: OnceCell> = OnceCell::const_new pub struct BroadcastChannel { channel: UserConnectionMap, cache: Arc, - push_notification_producer: PushNotificationProducer + push_notification_producer: PushNotificationProducer, } - type UserConnectionMap = RwLock>>; - impl BroadcastChannel { - pub async fn init(cache: Arc, producer: PushNotificationProducer) { - BROADCAST_INSTANCE.get_or_init(|| async { - let channel = Arc::new(BroadcastChannel::new(cache,producer)); - info!("BroadcastChannel initialized."); - channel - }).await; + BROADCAST_INSTANCE + .get_or_init(|| async { + let channel = Arc::new(BroadcastChannel::new(cache, producer)); + info!("BroadcastChannel initialized."); + channel + }) + .await; } pub fn get() -> &'static Arc { @@ -54,7 +53,7 @@ impl BroadcastChannel { None => { panic!("BroadcastChannel is not initialized! Call init()!"); } - Some(instance) => instance + Some(instance) => instance, } } @@ -62,72 +61,112 @@ impl BroadcastChannel { BroadcastChannel { channel: RwLock::new(HashMap::new()), push_notification_producer: producer, - cache + cache, } } - - + pub async fn subscribe_to_user_events(&self, user_id: Uuid) -> Receiver { let mut lock = self.channel.write().await; - let sender = lock.entry(user_id) + let sender = lock + .entry(user_id) .or_insert_with(|| channel::(100).0); sender.subscribe() } + /// Replay durable notifications for a user with sequence greater than `last_seq`. Used by + /// the SSE/WebSocket handshake so a reconnecting client can catch up without losing events. + pub async fn replay_since( + &self, + user_id: &Uuid, + last_seq: u64, + ) -> redis::RedisResult { + self.cache + .get_notifications_since_seq(user_id, last_seq) + .await + } + pub async fn send_event(&self, notification: Notification, to_user: &Uuid) { - let lock = self.channel.read().await; - if let Some(sender) = lock.get(to_user) { - match sender.send(notification.clone()) { - Ok(sc) => { - info!("Successfully sent {:?} broadcast event.", sc); - } - Err(err) => { - error!("Unable to broadcast notification: {}", err); - } - } - } else { - self.send_undeliverable_notifications(notification.clone(), vec![to_user.clone()]).await; - } - if let Err(error) = self.cache.add_notification_for_user(to_user, ¬ification).await { - error!("Failed to cache notification: {}", error); - }; + self.deliver_to_user(to_user, notification).await; } pub async fn send_event_to_all(&self, user_ids: Vec, notification: Notification) { - let lock = self.channel.read().await; - let mut not_deliverable: Vec = Vec::new(); + // A sequence number is per-user, so every recipient gets its own clone with its own + // seq rather than a single shared notification. for user_id in user_ids { - if let Some(sender) = lock.get(&user_id) { - match sender.send(notification.clone()) { + self.deliver_to_user(&user_id, notification.clone()).await; + } + } + + /// Deliver a single notification to a single user. + /// + /// Durable events are assigned a per-user sequence number and cached for replay before + /// delivery; ephemeral events (typing, resync signals) are sent live-only. If the user has + /// no active connection, durable events fall back to a push notification. + async fn deliver_to_user(&self, user_id: &Uuid, mut notification: Notification) { + let ephemeral = notification.body.is_ephemeral(); + + if !ephemeral { + match self.cache.next_sequence(user_id).await { + // Sequencing available (Redis): tag the event and cache it for replay. + Ok(Some(seq)) => { + notification.seq = Some(seq); + if let Err(error) = self + .cache + .add_notification_for_user(user_id, ¬ification) + .await + { + error!("Failed to cache notification: {}", error); + } + } + // No sequencing (no Redis): deliver best-effort without replay support. + Ok(None) => {} + Err(err) => error!("Failed to allocate sequence for user {}: {}", user_id, err), + } + } + + let delivered = { + let lock = self.channel.read().await; + match lock.get(user_id) { + // `send` only errors when there are no active receivers, i.e. the user is offline. + Some(sender) => match sender.send(notification.clone()) { Ok(sc) => { info!("Successfully sent {:?} broadcast event.", sc); + true } Err(err) => { error!("Unable to broadcast notification: {}", err); + false } - } - } else { - not_deliverable.push(user_id); + }, + None => false, } - if let Err(error) = self.cache.add_notification_for_user(&user_id, ¬ification).await { - error!("Failed to cache notification: {}", error); - }; - } - if not_deliverable.len() > 0 { - self.send_undeliverable_notifications(notification, not_deliverable).await; + }; + + if !delivered && !ephemeral { + self.send_undeliverable_notifications(notification, vec![*user_id]) + .await; } } - async fn send_undeliverable_notifications(&self, notification: Notification, to_user: Vec) { - let should_send = matches!( //Only sends push notifications for these notification types, add more if needed + async fn send_undeliverable_notifications( + &self, + notification: Notification, + to_user: Vec, + ) { + let should_send = matches!( + //Only sends push notifications for these notification types, add more if needed notification.body, - NotificationEvent::ChatMessage { .. } | - NotificationEvent::FriendRequestReceived { .. } | - NotificationEvent::NewRoom { .. } + NotificationEvent::ChatMessage { .. } + | NotificationEvent::FriendRequestReceived { .. } + | NotificationEvent::NewRoom { .. } ); if should_send { - if let Err(error) = self.push_notification_producer.send_notification(notification, to_user).await { + if let Err(error) = self + .push_notification_producer + .send_notification(notification, to_user) + .await + { error!("Failed to send push notification: {}", error); } } @@ -138,34 +177,127 @@ impl BroadcastChannel { let mut lock = self.channel.write().await; if let Some(sender) = lock.get(&user_id) { if sender.receiver_count() > 0 { - return + return; } else { lock.remove(&user_id); debug!("Removed stale sender for user {:?}", user_id); } } } - } - #[cfg(test)] mod tests { use super::*; - use crate::cache::redis_cache::NoOpCache; - use crate::kafka::PushNotificationProducer; - use crate::core::KafkaConfig; use crate::broadcast::Notification; + use crate::broadcast::NotificationEvent; use crate::broadcast::NotificationEvent::UserReadChat; - use serde_json; - use std::sync::Arc; + use crate::cache::redis_cache::{Cache, NoOpCache, ReplayResult}; + use crate::core::KafkaConfig; + use crate::kafka::PushNotificationProducer; + use crate::rooms::room_member::RoomContext; + use async_trait::async_trait; + use redis::RedisResult; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + fn empty_kafka_cfg() -> KafkaConfig { + KafkaConfig { + bootstrap_host: String::from(""), + bootstrap_port: 0, + topic: String::from(""), + client_id: String::from(""), + partition: vec![], + consumer_group: String::from(""), + } + } + + /// In-memory `Cache` used to exercise the broadcast layer without Redis: it hands out a + /// real monotonic per-user sequence and records everything that gets cached. + struct MockCache { + sequences: Mutex>, + cached: Mutex>, + } + + impl MockCache { + fn new() -> Self { + MockCache { + sequences: Mutex::new(HashMap::new()), + cached: Mutex::new(Vec::new()), + } + } + fn cached_count(&self) -> usize { + self.cached.lock().unwrap().len() + } + } + + #[async_trait] + impl Cache for MockCache { + async fn next_sequence(&self, user_id: &Uuid) -> RedisResult> { + let mut seqs = self.sequences.lock().unwrap(); + let entry = seqs.entry(*user_id).or_insert(0); + *entry += 1; + Ok(Some(*entry)) + } + async fn current_sequence(&self, user_id: &Uuid) -> RedisResult> { + let seqs = self.sequences.lock().unwrap(); + Ok(Some(seqs.get(user_id).copied().unwrap_or(0))) + } + async fn get_notifications_since_seq( + &self, + user_id: &Uuid, + last_seq: u64, + ) -> RedisResult { + let cached = self.cached.lock().unwrap(); + let events = cached + .iter() + .filter(|(uid, n)| uid == user_id && n.seq.map_or(false, |s| s > last_seq)) + .map(|(_, n)| n.clone()) + .collect(); + Ok(ReplayResult::Events(events)) + } + async fn add_notification_for_user( + &self, + user_id: &Uuid, + notification: &Notification, + ) -> RedisResult<()> { + self.cached + .lock() + .unwrap() + .push((*user_id, notification.clone())); + Ok(()) + } + async fn get_room_context(&self, _room_id: &Uuid) -> RedisResult> { + Ok(None) + } + async fn set_room_context( + &self, + _room_id: &Uuid, + _context: &RoomContext, + ) -> RedisResult<()> { + Ok(()) + } + async fn invalidate_room_context(&self, _room_id: &Uuid) -> RedisResult<()> { + Ok(()) + } + async fn publish_notification( + &self, + _notification: Notification, + _channel_name: &String, + ) -> RedisResult<()> { + Ok(()) + } + } #[tokio::test] async fn send_event_to_subscribed_user_delivers_notification() { // initialize broadcast channel singleton with NoOpCache and logger producer - let cache: Arc = Arc::new(NoOpCache); - let kafka_cfg = KafkaConfig { bootstrap_host: String::from(""), bootstrap_port: 0, topic: String::from(""), client_id: String::from(""), partition: vec![], consumer_group: String::from("") }; - BroadcastChannel::init(cache, PushNotificationProducer::new(false, kafka_cfg)).await; + let cache: Arc = Arc::new(NoOpCache); + BroadcastChannel::init( + cache, + PushNotificationProducer::new(false, empty_kafka_cfg()), + ) + .await; let bc = BroadcastChannel::get(); @@ -173,21 +305,80 @@ mod tests { // subscribe let mut rx = bc.subscribe_to_user_events(user_id).await; - let notification = Notification { - body: UserReadChat { user_id, room_id: uuid::Uuid::new_v4() }, - created_at: chrono::Utc::now() - }; + let notification = Notification::new(UserReadChat { + user_id, + room_id: uuid::Uuid::new_v4(), + }); // send to all (only this user) - bc.send_event_to_all(vec![user_id], notification.clone()).await; + bc.send_event_to_all(vec![user_id], notification.clone()) + .await; // receive let received = rx.recv().await.expect("Should receive notification"); + // Without Redis there is no sequencing, so the delivered event matches what was sent. let sent_json = serde_json::to_string(¬ification).expect("serialize sent"); let recv_json = serde_json::to_string(&received).expect("serialize recv"); - println!("Sent: {}", sent_json); - println!("Received: {}", recv_json); assert_eq!(sent_json, recv_json); + assert_eq!(received.seq, None); } -} \ No newline at end of file + + #[tokio::test] + async fn assigns_independent_per_user_sequence_and_skips_ephemeral() { + let cache = Arc::new(MockCache::new()); + let bc = BroadcastChannel::new( + cache.clone(), + PushNotificationProducer::new(false, empty_kafka_cfg()), + ); + + let user_a = Uuid::new_v4(); + let mut rx_a = bc.subscribe_to_user_events(user_a).await; + + // Two durable events to the same user -> monotonic seq 1, then 2. + bc.send_event( + Notification::new(UserReadChat { + user_id: user_a, + room_id: Uuid::new_v4(), + }), + &user_a, + ) + .await; + bc.send_event( + Notification::new(UserReadChat { + user_id: user_a, + room_id: Uuid::new_v4(), + }), + &user_a, + ) + .await; + assert_eq!(rx_a.recv().await.expect("a1").seq, Some(1)); + assert_eq!(rx_a.recv().await.expect("a2").seq, Some(2)); + + // A second user has an independent sequence space (also starts at 1). + let user_b = Uuid::new_v4(); + let mut rx_b = bc.subscribe_to_user_events(user_b).await; + bc.send_event( + Notification::new(UserReadChat { + user_id: user_b, + room_id: Uuid::new_v4(), + }), + &user_b, + ) + .await; + assert_eq!(rx_b.recv().await.expect("b1").seq, Some(1)); + + // Ephemeral event: no sequence number, never cached. + bc.send_event( + Notification::new(NotificationEvent::Resync { + reason: "too old".into(), + }), + &user_a, + ) + .await; + assert_eq!(rx_a.recv().await.expect("resync").seq, None); + + // Only the 3 durable events were cached; the ephemeral one was not. + assert_eq!(cache.cached_count(), 3); + } +} diff --git a/src/broadcast/mod.rs b/src/broadcast/mod.rs index bd8b9be..b770b57 100644 --- a/src/broadcast/mod.rs +++ b/src/broadcast/mod.rs @@ -1,5 +1,5 @@ mod event_broadcast; mod notification; -pub use event_broadcast::{BroadcastChannel}; -pub use notification::{SendNotification, Notification, NotificationEvent }; \ No newline at end of file +pub use event_broadcast::BroadcastChannel; +pub use notification::{Notification, NotificationEvent, SendNotification}; diff --git a/src/broadcast/notification.rs b/src/broadcast/notification.rs index 4b54ca9..d3ed6ae 100644 --- a/src/broadcast/notification.rs +++ b/src/broadcast/notification.rs @@ -1,70 +1,134 @@ +use crate::messaging::model::MessageDto; +use crate::rooms::room::{ChatRoomDto, LastMessagePreviewText}; +use crate::rooms::room_member::RoomMember; +use crate::users::model::User; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::messaging::model::MessageDTO; -use crate::model::{ChatRoomDto, LastMessagePreviewText}; -use crate::user_relationship::model::User; +/// Current wire-format version of the streaming envelope. Bump on breaking changes. +pub const NOTIFICATION_VERSION: u8 = 1; + +fn default_version() -> u8 { + NOTIFICATION_VERSION +} #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct Notification { + /// Envelope version, allows the client to evolve its parser safely. + #[serde(default = "default_version")] + pub v: u8, + /// Monotonic per-user sequence number. `None` for ephemeral events (e.g. typing) + /// and when sequencing is unavailable (no Redis). Used by clients to detect gaps + /// and resume after a reconnect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, #[serde(flatten)] pub body: NotificationEvent, - pub created_at: DateTime + pub created_at: DateTime, +} + +impl Notification { + /// Build a fresh notification with the current envelope version, no sequence + /// number (assigned later per-user in the broadcast layer), and the current time. + pub fn new(body: NotificationEvent) -> Self { + Notification { + v: NOTIFICATION_VERSION, + seq: None, + body, + created_at: Utc::now(), + } + } } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "type")] pub enum NotificationEvent { - #[serde(rename_all = "camelCase")] - FriendRequestReceived {from_user: User}, + FriendRequestReceived { from_user: User }, #[serde(rename_all = "camelCase")] - FriendRequestAccepted {from_user: User}, + FriendRequestAccepted { from_user: User }, /** - * Different chat messages, sent to all active users in a room - */ + * Different chat messages, sent to all active users in a room. `sender` carries the + * message author's profile so clients can render a first-time sender without a + * separate lookup (the timeline page bundles historical senders the same way). + */ #[serde(rename_all = "camelCase")] - ChatMessage {message: MessageDTO, room_preview_text: LastMessagePreviewText }, + ChatMessage { + message: MessageDto, + room_preview_text: LastMessagePreviewText, + sender: RoomMember, + }, + + /** + * A system message is a message not sent by a user, but by the system, whatever you want + */ + SystemMessage { message: serde_json::Value }, /** - * A system message is a message not sent by a user, but by the system, whatever you want - */ - SystemMessage {message: serde_json::Value}, - + * Sending this event to a newly invited user + */ + #[serde(rename_all = "camelCase")] + NewRoom { room: ChatRoomDto, created_by: User }, + /** - * Sending this event to a newly invited user - */ + * Sending this event to a user who has left a room + */ #[serde(rename_all = "camelCase")] - NewRoom {room: ChatRoomDto, created_by: User }, + LeaveRoom { room_id: Uuid }, /** - * Sending this event to a user who has left a room - */ + * Sending this event to all users in a room where a member has left + */ #[serde(rename_all = "camelCase")] - LeaveRoom {room_id: Uuid}, + RoomChangeEvent { + message: MessageDto, + room_preview_text: LastMessagePreviewText, + }, /** - * Sending this event to all users in a room where a member has left - */ + * Sending this event to all users in a room when a user has read the latest message + */ #[serde(rename_all = "camelCase")] - RoomChangeEvent {message: MessageDTO, room_preview_text: LastMessagePreviewText}, + UserReadChat { user_id: Uuid, room_id: Uuid }, /** - * Sending this event to all users in a room when a user has read the latest message - */ + * Control event: the client's last known sequence is too old to be replayed from the + * cache (gap larger than the retention window, or events lost while lagging). The client + * must re-fetch the authoritative state via REST (timeline / friends / rooms) and then + * continue consuming live events. Always ephemeral: never sequenced, never cached. + */ #[serde(rename_all = "camelCase")] - UserReadChat {user_id: Uuid, room_id: Uuid} + Resync { reason: String }, } +impl NotificationEvent { + /// Ephemeral events are delivered live-only: they never receive a sequence number and + /// are never cached for replay. A typing indicator from 30 minutes ago is irrelevant, + /// so re-delivering it after a reconnect would be wrong. Durable events (the default) + /// are sequenced and cached so a reconnecting client can catch up without loss. + pub fn is_ephemeral(&self) -> bool { + match self { + NotificationEvent::Resync { .. } => true, + NotificationEvent::FriendRequestReceived { .. } + | NotificationEvent::FriendRequestAccepted { .. } + | NotificationEvent::ChatMessage { .. } + | NotificationEvent::SystemMessage { .. } + | NotificationEvent::NewRoom { .. } + | NotificationEvent::LeaveRoom { .. } + | NotificationEvent::RoomChangeEvent { .. } + | NotificationEvent::UserReadChat { .. } => false, + } + } +} #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct SendNotification { #[serde(flatten)] pub body: Notification, - pub to_user: Uuid -} \ No newline at end of file + pub to_user: Uuid, +} diff --git a/src/cache/cache_cleanup.rs b/src/cache/cache_cleanup.rs deleted file mode 100644 index 8b83206..0000000 --- a/src/cache/cache_cleanup.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::time::Duration; -use redis::aio::{ConnectionManager}; -use redis::{RedisResult}; -use redis::{AsyncCommands}; -use tracing::{debug, error}; -use crate::cache::util::MASTER_INDEX_SET; - -pub async fn periodic_cleanup_task(mut con: ConnectionManager) { - - let cleanup_interval = Duration::from_secs(3600); //atm each 1hr - - debug!("Starting Cache-Cleanup-Task."); - - loop { - tokio::time::sleep(cleanup_interval).await; - debug!("Starting periodic cache cleanup..."); - - // getting all user ids from the master index set - let user_ids: Vec = match con.smembers(MASTER_INDEX_SET).await { - Ok(ids) => ids, - Err(e) => { - error!("Error trying to get all users of the master cache index: {}", e); - continue; - } - }; - - for user_id in user_ids { - if let Err(e) = cleanup_user_index(&mut con, &user_id).await { - error!("Error trying to cleanup the notification cache of user {}: {}", user_id, e); - } - } - debug!("Periodic cleanup finished."); - } -} - -async fn cleanup_user_index( - con: &mut ConnectionManager, - user_id: &str, -) -> RedisResult<()> { - let sorted_set_key = format!("user_notifications:{}", user_id); - - // 1. getting all notification key references from the sorted set of the user - let all_notification_keys: Vec = con.zrange(&sorted_set_key, 0, -1).await?; - - if all_notification_keys.is_empty() { - let _: isize = con.srem(MASTER_INDEX_SET, user_id).await?; //remove user from master index set if the set is empty - return Ok(()); - } - - let mut keys_to_remove = Vec::new(); - - // 2. Batch-Processing each key - for chunk in all_notification_keys.chunks(100usize) { - let mut pipe = redis::pipe(); - - // Validate the existence of the key int he k/v store - for key in chunk { - pipe.exists(key); - } - let existence_flags: Vec = pipe.query_async(con).await?; - - // push keys to remove to a list - for (key, exists) in chunk.iter().zip(existence_flags.iter()) { - if !*exists { - keys_to_remove.push(key); - } - } - } - - // 5. Remove all keys without k/v reference from the sorted set of the user - if !keys_to_remove.is_empty() { - let count: isize = con.zrem(&sorted_set_key, keys_to_remove).await?; - debug!("Cache cleanup for user {}: {} elements removed.", user_id, count); - } - - Ok(()) -} diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 5f9d028..31f50f0 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,4 +1,3 @@ pub mod redis_cache; -pub mod cache_cleanup; +mod redis_subscriber; pub mod util; -mod redis_subscriber; \ No newline at end of file diff --git a/src/cache/redis_cache.rs b/src/cache/redis_cache.rs index 96f4171..acdd27c 100644 --- a/src/cache/redis_cache.rs +++ b/src/cache/redis_cache.rs @@ -1,27 +1,74 @@ -use std::collections::HashSet; +use crate::broadcast::Notification; +use crate::cache::redis_subscriber::run_event_processor; +use crate::cache::util::{CHAT_CHANNEL, ROOM_CONTEXT, USER_NOTIFICATIONS, USER_SEQUENCE}; +use crate::rooms::room_member::RoomContext; use async_trait::async_trait; -use chrono::{DateTime, Utc}; use log::info; -use redis::{AsyncTypedCommands, Client, ErrorKind, RedisError, RedisResult}; -use redis::{aio::ConnectionManagerConfig}; use redis::aio::ConnectionManager; +use redis::aio::ConnectionManagerConfig; +use redis::{AsyncTypedCommands, Client, ErrorKind, RedisError, RedisResult}; use uuid::Uuid; -use crate::broadcast::Notification; -use crate::cache::cache_cleanup::periodic_cleanup_task; -use crate::cache::redis_subscriber::run_event_processor; -use crate::cache::util::{CHAT_CHANNEL, MASTER_INDEX_SET, NOTIFICATION, ROOM_MEMBERS, USER_NOTIFICATIONS}; -#[async_trait] -pub trait Cache: Send + Sync { +/// TTL for the per-user sequence counter and notification stream. Refreshed on every write, so a +/// key only expires after a user has been completely inactive for this long. This is what reclaims +/// storage for inactive users — there is no background cleanup task. +const SEQUENCE_TTL_SECONDS: i64 = 24 * 3600; + +/// Approximate cap on retained notifications per user. `XADD ... MAXLEN ~ N` trims older entries on +/// every write (amortized O(1)), so the replay buffer is count-bounded instead of time-bounded. +/// A reconnecting client whose gap predates the retained window receives `ResyncNeeded`. +const STREAM_MAX_LEN: usize = 300; + +/// Single field under which the serialized notification JSON is stored in each stream entry. +const STREAM_FIELD: &str = "data"; + +/// Decoded `XRANGE` reply: a list of `(entry_id, [(field, value), ...])`. +type StreamEntries = Vec<(String, Vec<(String, String)>)>; + +/// Extract the numeric sequence from a `-` stream entry ID. +fn parse_stream_seq(id: &str) -> Option { + id.split('-').next()?.parse().ok() +} - async fn get_notifications_for_user(&self, user_id: &Uuid, latest_ts: DateTime) -> RedisResult>; - async fn add_notification_for_user(&self, user_id: &Uuid, notification: &Notification) -> RedisResult<()>; - async fn add_user_to_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()>; - async fn remove_user_from_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()>; - async fn get_user_for_room(&self, room_id: &Uuid) -> RedisResult>; - async fn set_user_for_room(&self, room_id: &Uuid, user_ids: &Vec) -> RedisResult<()>; - async fn publish_notification(&self, notification: Notification, channel_name: &String) -> RedisResult<()>; +/// Outcome of a replay request. Either the missing notifications could be served from the +/// cache, or the client's last known sequence is too old (gap larger than the retention +/// window) and it must re-fetch authoritative state via REST. +#[derive(Debug)] +pub enum ReplayResult { + Events(Vec), + ResyncNeeded, +} +#[async_trait] +pub trait Cache: Send + Sync { + /// Allocate the next monotonic sequence number for a user. Returns `None` when sequencing + /// is unavailable (no Redis), in which case durable events are delivered best-effort + /// without replay support. + async fn next_sequence(&self, user_id: &Uuid) -> RedisResult>; + /// Read the highest sequence number currently issued to a user **without** advancing it. + /// Returns `Some(0)` when no event has been issued yet, or `None` when sequencing is + /// unavailable (no Redis). A freshly REST-synced client uses this as its replay baseline. + async fn current_sequence(&self, user_id: &Uuid) -> RedisResult>; + /// Return all durable notifications for a user with sequence strictly greater than + /// `last_seq`, or `ResyncNeeded` if part of that range has already fallen out of the cache. + async fn get_notifications_since_seq( + &self, + user_id: &Uuid, + last_seq: u64, + ) -> RedisResult; + async fn add_notification_for_user( + &self, + user_id: &Uuid, + notification: &Notification, + ) -> RedisResult<()>; + async fn get_room_context(&self, room_id: &Uuid) -> RedisResult>; + async fn set_room_context(&self, room_id: &Uuid, context: &RoomContext) -> RedisResult<()>; + async fn invalidate_room_context(&self, room_id: &Uuid) -> RedisResult<()>; + async fn publish_notification( + &self, + notification: Notification, + channel_name: &String, + ) -> RedisResult<()>; } //docs: https://docs.rs/redis/latest/redis/ @@ -29,7 +76,7 @@ pub trait Cache: Send + Sync { #[allow(unused)] pub struct RedisCache { client: Client, - pub connection: ConnectionManager + pub connection: ConnectionManager, } impl RedisCache { @@ -41,181 +88,253 @@ impl RedisCache { .set_push_sender(tx) .set_automatic_resubscription(); - let mut connection_manager = redis_client.get_connection_manager_with_config(config).await?; - connection_manager.psubscribe(format!("{}*", CHAT_CHANNEL)).await?; //subscribe to all chat channels + let mut connection_manager = redis_client + .get_connection_manager_with_config(config) + .await?; + connection_manager + .psubscribe(format!("{}*", CHAT_CHANNEL)) + .await?; info!("Established connection to the redis cache."); - tokio::spawn(periodic_cleanup_task(connection_manager.clone())); tokio::spawn(run_event_processor(rx, connection_manager.clone())); - Ok(Self { client: redis_client, connection: connection_manager }) + Ok(Self { + client: redis_client, + connection: connection_manager, + }) } } - #[async_trait] impl Cache for RedisCache { + async fn next_sequence(&self, user_id: &Uuid) -> RedisResult> { + let mut con = self.connection.clone(); + let key = format!("{}{}", USER_SEQUENCE, user_id); + let seq = con.incr(&key, 1).await?; + // Refresh the TTL so an active user's counter never disappears mid-session, while + // counters for long-inactive users are eventually reclaimed. + con.expire(&key, SEQUENCE_TTL_SECONDS).await?; + Ok(Some(seq as u64)) + } + async fn current_sequence(&self, user_id: &Uuid) -> RedisResult> { + let mut con = self.connection.clone(); + let key = format!("{}{}", USER_SEQUENCE, user_id); + let current = con + .get(&key) + .await? + .and_then(|raw: String| raw.parse().ok()) + .unwrap_or(0); + Ok(Some(current)) + } - async fn get_notifications_for_user(&self, user_id: &Uuid, latest_ts: DateTime) -> RedisResult> { + async fn get_notifications_since_seq( + &self, + user_id: &Uuid, + last_seq: u64, + ) -> RedisResult { let mut con = self.connection.clone(); - let sorted_set_key = format!("{}{}", USER_NOTIFICATIONS, user_id); - let min_score = latest_ts.timestamp(); - - let notification_keys: Vec = con - .zrangebyscore( - &sorted_set_key, - min_score, // timestamp of oldest notification - "+inf", // get all notifications - ) + let stream_key = format!("{}{}", USER_NOTIFICATIONS, user_id); + let seq_key = format!("{}{}", USER_SEQUENCE, user_id); + + // The sequence counter is the highest seq ever issued to this user. If the client's cursor + // is ahead of it, the server's sequence space has been reset (counter expired by TTL, or + // the cache was flushed) and the client references sequences that no longer exist. Silently + // continuing would let the dedup high-water swallow every new (now lower-numbered) event, + // so we force a resync instead. + let current_seq: u64 = con + .get(&seq_key) + .await? + .and_then(|raw: String| raw.parse().ok()) + .unwrap_or(0); + if last_seq > current_seq { + return Ok(ReplayResult::ResyncNeeded); + } + + // Determine the oldest sequence still retained for this user. If the client's last seen + // sequence is older than that, the gap has already been trimmed out of the stream and we + // cannot replay it losslessly -> the client must resync via REST. Because a stream is a + // single structure, there is no separate index that can dangle: an entry is either present + // or trimmed, so this is the only resync trigger. + let oldest: StreamEntries = redis::cmd("XRANGE") + .arg(&stream_key) + .arg("-") + .arg("+") + .arg("COUNT") + .arg(1) + .query_async(&mut con) .await?; - if notification_keys.is_empty() { - return Ok(vec![]); + match oldest.first().and_then(|(id, _)| parse_stream_seq(id)) { + // Nothing retained for this user: nothing to replay. + None => return Ok(ReplayResult::Events(vec![])), + Some(oldest_seq) => { + if oldest_seq > last_seq + 1 { + return Ok(ReplayResult::ResyncNeeded); + } + } } - let notifications_json: Vec> = con.mget(¬ification_keys).await?; - let notifications: Vec = notifications_json + + // Fetch every entry with sequence strictly greater than last_seq. Entry IDs are `-0`, + // so an exclusive lower bound of `(-0` yields exactly seq > last_seq, in order. + let entries: StreamEntries = redis::cmd("XRANGE") + .arg(&stream_key) + .arg(format!("({}-0", last_seq)) + .arg("+") + .query_async(&mut con) + .await?; + + let notifications: Vec = entries .into_iter() - .filter_map(|opt_json| opt_json) - .filter_map(|json| serde_json::from_str(&json).ok()) + .filter_map(|(_, fields)| { + fields + .into_iter() + .find(|(field, _)| field == STREAM_FIELD) + .and_then(|(_, json)| serde_json::from_str(&json).ok()) + }) .collect(); - Ok(notifications) + Ok(ReplayResult::Events(notifications)) } - async fn add_notification_for_user(&self, user_id: &Uuid, notification: &Notification) -> RedisResult<()> { + async fn add_notification_for_user( + &self, + user_id: &Uuid, + notification: &Notification, + ) -> RedisResult<()> { let mut con = self.connection.clone(); - let notification_key = format!("{}{}", NOTIFICATION, Uuid::new_v4()); - let notification_json = serde_json::to_string(notification) - .map_err(|err| { - RedisError::from(( - ErrorKind::Parse, - "Failed to serialize notification to JSON", - err.to_string(), - )) - })?; - - let score = notification.created_at.timestamp(); - let sorted_set_key = format!("{}{}", USER_NOTIFICATIONS, user_id); - - let mut pipe = redis::pipe(); //like a atomic transaction + + // Durable notifications must carry a sequence number; it becomes the stream entry ID + // (`-0`) and the cursor a reconnecting client replays from. + let seq = match notification.seq { + Some(seq) => seq, + None => { + return Err(RedisError::from(( + ErrorKind::Client, + "Refusing to cache a notification without a sequence number", + ))); + } + }; + + let notification_json = serde_json::to_string(notification).map_err(|err| { + RedisError::from(( + ErrorKind::Parse, + "Failed to serialize notification to JSON", + err.to_string(), + )) + })?; + + let stream_key = format!("{}{}", USER_NOTIFICATIONS, user_id); + + let mut pipe = redis::pipe(); //single round trip: append (with trim) + refresh inactivity TTL pipe.atomic() - //add k/v string - .set_ex( - ¬ification_key, - notification_json, - 3600, //ttl is 60 minutes - ) - //add to sorted set from user - .zadd(&sorted_set_key, ¬ification_key, score) - //add to master index set, to track all user sets and remove them if they are empty - .sadd(MASTER_INDEX_SET, user_id.to_string()); + // Append using the per-user seq as the explicit entry ID and trim to ~STREAM_MAX_LEN. + // `~` lets Redis trim at node boundaries (amortized O(1)); it keeps at least N entries. + .cmd("XADD") + .arg(&stream_key) + .arg("MAXLEN") + .arg("~") + .arg(STREAM_MAX_LEN) + .arg(format!("{}-0", seq)) + .arg(STREAM_FIELD) + .arg(¬ification_json) + .ignore() + // Refresh the TTL so an active user's stream never disappears mid-session, while a + // fully inactive user's stream is eventually reclaimed without any cleanup task. + .expire(&stream_key, SEQUENCE_TTL_SECONDS) + .ignore(); pipe.exec_async(&mut con).await?; Ok(()) } - async fn add_user_to_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()> { + async fn get_room_context(&self, room_id: &Uuid) -> RedisResult> { let mut con = self.connection.clone(); - let key = format!("{}{}", ROOM_MEMBERS, room_id); - let exists: bool = con.exists(&key).await?; - - if !exists { //if the member list is empty, we don't need to add the user to it - return Ok(()) - } - con.sadd(&key, user_id.to_string()).await?; - Ok(()) + let key = format!("{}{}", ROOM_CONTEXT, room_id); + let json: Option = con.get(&key).await?; + Ok(json.and_then(|s| serde_json::from_str(&s).ok())) } - async fn remove_user_from_room_cache(&self, user_id: &Uuid, room_id: &Uuid) -> RedisResult<()> { + async fn set_room_context(&self, room_id: &Uuid, context: &RoomContext) -> RedisResult<()> { let mut con = self.connection.clone(); - let key = format!("{}{}", ROOM_MEMBERS, room_id); - con.srem(&key, user_id.to_string()).await?; + let key = format!("{}{}", ROOM_CONTEXT, room_id); + let json = serde_json::to_string(context).map_err(|err| { + RedisError::from(( + ErrorKind::Parse, + "Failed to serialize RoomContext", + err.to_string(), + )) + })?; + con.set_ex(&key, json, 900).await?; Ok(()) } - async fn get_user_for_room(&self, room_id: &Uuid) -> RedisResult> { - let mut conn = self.connection.clone(); - let key = format!("{}{}", ROOM_MEMBERS, room_id); - - let cached_user_ids: HashSet = conn.smembers(&key).await?; - if !cached_user_ids.is_empty() { - let user_uuids = cached_user_ids - .into_iter() - .filter_map(|id_str| Uuid::parse_str(&id_str).ok()) - .collect(); - return Ok(user_uuids); - } - Ok(vec![]) - } - - - async fn set_user_for_room(&self, room_id: &Uuid, user_ids: &Vec) -> RedisResult<()> { - let mut conn = self.connection.clone(); - let key = format!("{}{}", ROOM_MEMBERS, room_id); - - if user_ids.is_empty() { - conn.del(&key).await?; - return Ok(()); - } - - let user_id_strs: Vec = user_ids.iter().map(Uuid::to_string).collect(); - - let mut pipe = redis::pipe(); - pipe.atomic() - .del(&key) - .sadd(&key, user_id_strs); - - pipe.exec_async(&mut conn).await?; + async fn invalidate_room_context(&self, room_id: &Uuid) -> RedisResult<()> { + let mut con = self.connection.clone(); + let key = format!("{}{}", ROOM_CONTEXT, room_id); + con.del(&key).await?; Ok(()) } - async fn publish_notification(&self, notification: Notification, channel_name: &String) -> RedisResult<()> { + async fn publish_notification( + &self, + notification: Notification, + channel_name: &String, + ) -> RedisResult<()> { let mut con = self.connection.clone(); - let notification_json = serde_json::to_string(¬ification) - .map_err(|err| { - RedisError::from(( - ErrorKind::Parse, - "Failed to serialize notification to JSON", - err.to_string(), - )) - })?; + let notification_json = serde_json::to_string(¬ification).map_err(|err| { + RedisError::from(( + ErrorKind::Parse, + "Failed to serialize notification to JSON", + err.to_string(), + )) + })?; con.publish(channel_name, notification_json).await?; Ok(()) } } - -//doing nothing, used when redis is not available: pub struct NoOpCache; #[async_trait] impl Cache for NoOpCache { - - async fn get_notifications_for_user(&self, _user_id: &Uuid, _latest_ts: DateTime) -> RedisResult> { - Ok(vec![]) + async fn next_sequence(&self, _user_id: &Uuid) -> RedisResult> { + Ok(None) } - async fn add_notification_for_user(&self, _user_id: &Uuid, _notification: &Notification) -> RedisResult<()> { - Ok(()) + async fn current_sequence(&self, _user_id: &Uuid) -> RedisResult> { + Ok(None) } - - async fn add_user_to_room_cache(&self, _user_id: &Uuid, _room_id: &Uuid) -> RedisResult<()> { + async fn get_notifications_since_seq( + &self, + _user_id: &Uuid, + _last_seq: u64, + ) -> RedisResult { + Ok(ReplayResult::Events(vec![])) + } + async fn add_notification_for_user( + &self, + _user_id: &Uuid, + _notification: &Notification, + ) -> RedisResult<()> { Ok(()) } - async fn remove_user_from_room_cache(&self, _user_id: &Uuid, _room_id: &Uuid) -> RedisResult<()> { - Ok(()) + async fn get_room_context(&self, _room_id: &Uuid) -> RedisResult> { + Ok(None) } - async fn get_user_for_room(&self, _room_id: &Uuid) -> RedisResult> { - Ok(vec![]) + async fn set_room_context(&self, _room_id: &Uuid, _context: &RoomContext) -> RedisResult<()> { + Ok(()) } - async fn set_user_for_room(&self, _room_id: &Uuid, _user_ids: &Vec) -> RedisResult<()> { + async fn invalidate_room_context(&self, _room_id: &Uuid) -> RedisResult<()> { Ok(()) } - async fn publish_notification(&self, _notification: Notification, _channel_name: &String) -> RedisResult<()> { + async fn publish_notification( + &self, + _notification: Notification, + _channel_name: &String, + ) -> RedisResult<()> { Ok(()) } } - diff --git a/src/cache/redis_subscriber.rs b/src/cache/redis_subscriber.rs index fe22b56..ea13172 100644 --- a/src/cache/redis_subscriber.rs +++ b/src/cache/redis_subscriber.rs @@ -1,15 +1,16 @@ +use crate::broadcast::{BroadcastChannel, Notification, NotificationEvent}; +use crate::cache::util::ROOM_CONTEXT; +use crate::rooms::room_member::RoomContext; use log::info; -use redis::{PushInfo, from_redis_value, AsyncTypedCommands, RedisError}; use redis::aio::ConnectionManager; +use redis::{AsyncTypedCommands, PushInfo, RedisError, from_redis_value}; +use thiserror::Error; use tokio::sync::mpsc::UnboundedReceiver; use tracing::{error, warn}; use uuid::Uuid; -use crate::broadcast::{BroadcastChannel, Notification, NotificationEvent}; -use thiserror::Error; #[derive(Debug, Error)] enum ProcessorError { - #[error("Ungültige Push-Nachrichten-Struktur")] InvalidPushFormat, @@ -24,7 +25,6 @@ enum ProcessorError { } pub async fn run_event_processor(mut rx: UnboundedReceiver, mut conn: ConnectionManager) { - let _ = rx.recv().await; info!("Redis Event-Processing active."); @@ -33,7 +33,10 @@ pub async fn run_event_processor(mut rx: UnboundedReceiver, mut conn: let notification = match parse_push_message(push_message) { Ok(message) => message, Err(error) => { - warn!("Parsing of received push message failed. Ignoring. Push message: {:?}", error); + warn!( + "Parsing of received push message failed. Ignoring. Push message: {:?}", + error + ); continue; } }; @@ -45,7 +48,6 @@ pub async fn run_event_processor(mut rx: UnboundedReceiver, mut conn: } fn parse_push_message(mut push_message: PushInfo) -> Result { - let Some(payload_value) = push_message.data.pop() else { return Err(ProcessorError::InvalidPushFormat); }; @@ -62,15 +64,17 @@ async fn handle_notification( ) -> Result<(), ProcessorError> { match ¬ification.body { NotificationEvent::ChatMessage { message, .. } => { - let room_key = format!("room_members:{}", message.chat_room_id); - let member_ids: Vec = match conn.smembers(&room_key).await { - Ok(ids) => ids.into_iter().filter_map(|id_str| Uuid::parse_str(&id_str).ok()).collect(), - Err(e) => { - error!("Fehler beim Abrufen von Raum-Mitgliedern: {}", e); - return Ok(()) - } - }; - BroadcastChannel::get().send_event_to_all(member_ids, notification).await; + let key = format!("{}{}", ROOM_CONTEXT, message.chat_room_id); + let json: Option = conn.get(&key).await.unwrap_or(None); + let member_ids: Vec = json + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|ctx| ctx.member_ids()) + .unwrap_or_default(); + if !member_ids.is_empty() { + BroadcastChannel::get() + .send_event_to_all(member_ids, notification) + .await; + } } _ => {} } diff --git a/src/cache/util.rs b/src/cache/util.rs index 57fce0a..64b6b8b 100644 --- a/src/cache/util.rs +++ b/src/cache/util.rs @@ -1,6 +1,3 @@ - -pub const MASTER_INDEX_SET: &str = "active_user_notification_indices"; - /** * Used to pub/sub room updates to the cache */ @@ -9,14 +6,15 @@ pub const CHAT_CHANNEL: &str = "chat_room:"; /** * Used to pub/sub room updates to the cache */ -pub const ROOM_MEMBERS: &str = "room_members:"; +pub const ROOM_CONTEXT: &str = "room_context:"; /** - * Short lived notification for a user + * Per-user Redis Stream holding recent durable notifications for reconnect replay. + * Entry IDs are `-0`; the stream is length-capped via `XADD ... MAXLEN ~ N`. */ -pub const NOTIFICATION: &str = "notification:"; +pub const USER_NOTIFICATIONS: &str = "user_notifications:"; /** - * Set of notifications for a user + * Monotonic per-user sequence counter (INCR), used to order and replay durable notifications */ -pub const USER_NOTIFICATIONS: &str = "user_notifications:"; \ No newline at end of file +pub const USER_SEQUENCE: &str = "user_seq:"; diff --git a/src/core/app_state.rs b/src/core/app_state.rs index 67ef7e8..6d09db3 100644 --- a/src/core/app_state.rs +++ b/src/core/app_state.rs @@ -1,78 +1,80 @@ -use std::sync::Arc; -use log::info; -use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use crate::broadcast::BroadcastChannel; use crate::cache::redis_cache::{Cache, NoOpCache, RedisCache}; use crate::core::ISMConfig; -use crate::database::{MessageDatabase, ObjectStorage}; -use crate::kafka::{PushNotificationProducer}; -use crate::repository::room_repository::RoomRepository; -use crate::repository::user_repository::UserRepository; - +use crate::kafka::PushNotificationProducer; +use crate::messaging::chat_repository::ChatRepository; +use crate::object_storage::ObjectStorage; +use crate::rooms::room_repository::RoomRepository; +use crate::users::user_repository::UserRepository; +use log::info; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use std::sync::Arc; #[derive(Clone)] pub struct AppState { pub env: ISMConfig, pub room_repository: RoomRepository, pub user_repository: UserRepository, - pub message_repository: MessageDatabase, + pub chat_repository: ChatRepository, pub cache: Arc, - pub s3_bucket: ObjectStorage + pub s3_bucket: ObjectStorage, } impl AppState { - pub async fn new(config: ISMConfig) -> Self { - - //1: setting up the postgre sql connection for all repositories: + //1: setting up the postgresql connection for all repositories: let options = PgConnectOptions::new() - .host(&config.user_db_config.db_host) - .port(config.user_db_config.db_port) - .database(&config.user_db_config.db_name) - .username(&config.user_db_config.db_user) - .password(&config.user_db_config.db_password); + .host(&config.room_db_config.db_host) + .port(config.room_db_config.db_port) + .database(&config.room_db_config.db_name) + .username(&config.room_db_config.db_user) + .password(&config.room_db_config.db_password); + let pool = match PgPoolOptions::new() .max_connections(20) .connect_with(options) .await { Ok(pool) => { - info!("Established connection to the room database."); + info!("Established connection to the PostgreSQL database."); pool } Err(err) => { - panic!("Failed to connect to the room database: {:?}", err); + panic!("Failed to connect to the PostgreSQL database: {:?}", err); } }; + //2: init redis cache, if present: let cache: Arc = match config.redis_cache_url.clone() { Some(url) => { - let cache = RedisCache::new(url).await + let cache = RedisCache::new(url) + .await .unwrap_or_else(|err| panic!("Unable to init redis cache: {}", err)); Arc::new(cache) - }, + } None => { info!("Redis is deactivated. Initializing NoOpCache..."); Arc::new(NoOpCache) } }; - - //init broadcaster channel + + //3. init broadcaster channel: BroadcastChannel::init( cache.clone(), - PushNotificationProducer::new(config.use_kafka, config.kafka_config.clone()) - ).await; - - //2. State struct: + PushNotificationProducer::new(config.use_kafka, config.kafka_config.clone()), + ) + .await; + + //4. init application state: let state = Self { env: config.clone(), room_repository: RoomRepository::new(pool.clone()), user_repository: UserRepository::new(pool.clone()), - message_repository: MessageDatabase::new(&config.message_db_config).await, + chat_repository: ChatRepository::new(pool.clone()), s3_bucket: ObjectStorage::new(&config.object_db_config).await, - cache: cache + cache: cache, }; state } -} \ No newline at end of file +} diff --git a/src/core/config.rs b/src/core/config.rs index 6bc8106..f1481aa 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1,7 +1,6 @@ use config::{Config, ConfigError, Environment, File}; use serde::Deserialize; - #[derive(Deserialize, Debug, Clone)] #[allow(unused)] pub struct ISMConfig { @@ -10,14 +9,11 @@ pub struct ISMConfig { pub use_kafka: bool, pub log_level: String, pub cors_origin: String, - pub push_notification_url: Option, - pub push_notification_access_token: Option, pub redis_cache_url: Option, - pub user_db_config: UserDbConfig, + pub room_db_config: RoomDbConfig, pub object_db_config: ObjectStorageConfig, - pub message_db_config: MessageDbConfig, pub token_issuer: TokenIssuer, - pub kafka_config: KafkaConfig + pub kafka_config: KafkaConfig, } #[derive(Deserialize, Debug, Clone)] @@ -25,31 +21,22 @@ pub struct ObjectStorageConfig { pub access_key: String, pub storage_url: String, pub secret_key: String, - pub bucket_name: String -} - -#[derive(Deserialize, Debug, Clone)] -pub struct MessageDbConfig { - pub db_url: String, - pub db_user: String, - pub db_password: String, - pub db_keyspace: String, - pub with_db_init: bool + pub bucket_name: String, } #[derive(Deserialize, Debug, Clone)] -pub struct UserDbConfig { +pub struct RoomDbConfig { pub db_host: String, pub db_port: u16, pub db_user: String, pub db_password: String, - pub db_name: String + pub db_name: String, } #[derive(Deserialize, Debug, Clone)] pub struct TokenIssuer { pub iss_host: String, - pub iss_realm: String + pub iss_realm: String, } #[derive(Deserialize, Debug, Clone)] @@ -59,20 +46,23 @@ pub struct KafkaConfig { pub topic: String, pub client_id: String, pub partition: Vec, - pub consumer_group: String + pub consumer_group: String, } //examples: https://github.com/rust-cli/config-rs/blob/main/examples/hierarchical-env/settings.rs impl ISMConfig { - pub fn new(mode: &str) -> Result { //layering the different environment variables, default values first, overwritten by config files and env-vars let config = Config::builder() .add_source(File::with_name("default.config.toml")) .add_source(File::with_name(&format!("{mode}.config.toml")).required(false)) - .add_source(Environment::with_prefix("ism").prefix_separator("_").separator("__")) + .add_source( + Environment::with_prefix("ism") + .prefix_separator("_") + .separator("__"), + ) .build()?; config.try_deserialize() } -} \ No newline at end of file +} diff --git a/src/core/cursor.rs b/src/core/cursor.rs index 898fded..29ad40a 100644 --- a/src/core/cursor.rs +++ b/src/core/cursor.rs @@ -1,8 +1,8 @@ -use std::fmt; use base64::Engine; use base64::engine::general_purpose; -use serde::de::DeserializeOwned; use serde::Serialize; +use serde::de::DeserializeOwned; +use std::fmt; pub trait Cursor: Serialize + DeserializeOwned + Default {} impl Cursor for T where T: Serialize + DeserializeOwned + Default {} @@ -10,20 +10,19 @@ impl Cursor for T where T: Serialize + DeserializeOwned + Default {} #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CursorResults { - pub next_cursor: Option, + pub cursor: Option, pub content: Vec, } pub fn decode_cursor(base64_cursor: Option) -> Result { match base64_cursor { Some(encoded_cursor) => { - let decoded_bytes = general_purpose::URL_SAFE_NO_PAD.decode(encoded_cursor.as_bytes())?; + let decoded_bytes = + general_purpose::URL_SAFE_NO_PAD.decode(encoded_cursor.as_bytes())?; let cursor: T = serde_json::from_slice(&decoded_bytes)?; Ok(cursor) - }, - None => { - Ok(T::default()) } + None => Ok(T::default()), } } @@ -33,6 +32,43 @@ pub fn encode_cursor(cursor: &T) -> Result { Ok(encoded_cursor) } +/// Default number of items returned per page when the client omits `limit`. +pub const DEFAULT_PAGE_SIZE: usize = 20; +/// Upper bound for a client-supplied `limit` — prevents unbounded page sizes. +pub const MAX_PAGE_SIZE: usize = 50; + +/// Clamps a client-supplied page size into `[1, MAX_PAGE_SIZE]`, defaulting to +/// `DEFAULT_PAGE_SIZE` when the value is missing or zero. +pub fn clamp_page_size(requested: Option) -> usize { + match requested { + Some(n) if n >= 1 => (n as usize).min(MAX_PAGE_SIZE), + _ => DEFAULT_PAGE_SIZE, + } +} + +/// Finalizes a keyset page. Callers fetch `page_size + 1` rows; this truncates the +/// slice back to `page_size` and, if there were more rows, encodes the continuation +/// cursor derived from the last item of the returned page. +pub fn next_cursor( + items: &mut Vec, + page_size: usize, + cursor_from: F, +) -> Result, CursorError> +where + C: Cursor, + F: FnOnce(&T) -> C, +{ + if items.len() > page_size { + items.truncate(page_size); + match items.last() { + Some(last) => Ok(Some(encode_cursor(&cursor_from(last))?)), + None => Ok(None), + } + } else { + Ok(None) + } +} + #[derive(Debug)] pub enum CursorError { Base64Decode(base64::DecodeError), @@ -42,8 +78,8 @@ pub enum CursorError { impl fmt::Display for CursorError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - CursorError::Base64Decode(_) => write!(f, "Ungültiger Base64-Cursor"), - CursorError::Json(_) => write!(f, "Cursor-Daten konnten nicht als JSON verarbeitet werden"), + CursorError::Base64Decode(_) => write!(f, "Invalid base64 cursor"), + CursorError::Json(_) => write!(f, "Failed to deserialize cursor data as JSON"), } } } @@ -67,4 +103,4 @@ impl From for CursorError { fn from(err: serde_json::Error) -> Self { CursorError::Json(err) } -} \ No newline at end of file +} diff --git a/src/core/errors.rs b/src/core/errors.rs new file mode 100644 index 0000000..22088a1 --- /dev/null +++ b/src/core/errors.rs @@ -0,0 +1,154 @@ +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use chrono::Utc; +use serde::Serialize; +use thiserror::Error; +use validator::ValidationErrors; + +#[derive(Serialize)] +pub struct ErrorResponse { + timestamp: String, + status: u16, + error: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + #[serde(rename = "errorCode")] + error_code: ErrorCode, +} + +impl ErrorResponse { + pub fn new(status: StatusCode, error_code: ErrorCode, message: impl Into) -> Self { + Self { + timestamp: Utc::now().to_rfc3339(), + status: status.as_u16(), + error: status.canonical_reason().unwrap_or("Unknown").to_string(), + message: message.into(), + path: None, + error_code, + } + } +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[allow(dead_code)] +pub enum ErrorCode { + // Authentication & Authorization + InsufficientPermissions, + + // User & Profile Errors + UserNotFound, + + // Content & Interaction Errors + RoomNotFound, + MessageNotFound, + InvalidContent, + FileProcessingError, + ContentNotFound, + + // General API & Validation Errors + ValidationError, + ServiceUnavailable, + UnexpectedError, +} + +/// Application-level error type used across handlers and services. +/// +/// Variants are split into two groups: +/// +/// **Client-facing** – the message is passed through to the HTTP response body. +/// Use these when the caller needs actionable feedback (bad input, missing resource, no permission). +/// +/// **Internal** – the full error is logged server-side; only a generic message reaches the client. +/// Use these for infrastructure failures (object_storage, cache, S3) where internal details must not leak. +#[derive(Debug, Error)] +pub enum AppError { + // ── Client-facing ──────────────────────────────────────────────────────── + /// 400 – invalid or rejected input from the caller. + #[error("{0}")] + Validation(String), + + /// 404 – the requested resource does not exist. + #[error("{0}")] + NotFound(String), + + /// 403 – the caller is authenticated but lacks the required permission. + #[error("{0}")] + Forbidden(String), + + // ── Internal (logged; generic message sent to client) ──────────────────── + /// PostgreSQL / SQLx failure. + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + /// Redis cache failure. + #[error("Cache error: {0}")] + Cache(#[from] redis::RedisError), + + /// JSON serialisation / deserialisation failure. + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// S3 / MinIO object-storage failure. + #[error("S3 error: {0}")] + S3(String), + + /// Any other internal processing failure not covered by the variants above. + #[error("Processing error: {0}")] + Processing(String), +} + +impl From for AppError { + fn from(errors: ValidationErrors) -> Self { + AppError::Validation(errors.to_string()) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + // Log every internal error with its full details before the message is sanitised. + match &self { + AppError::Database(_) + | AppError::Cache(_) + | AppError::Serialization(_) + | AppError::S3(_) + | AppError::Processing(_) => tracing::error!("{}", self), + _ => {} + } + + let (status, error_code, message) = match self { + // Client-facing — pass the message through unchanged. + AppError::Validation(msg) => (StatusCode::BAD_REQUEST, ErrorCode::ValidationError, msg), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, ErrorCode::ContentNotFound, msg), + AppError::Forbidden(msg) => ( + StatusCode::FORBIDDEN, + ErrorCode::InsufficientPermissions, + msg, + ), + + // Internal — return a safe, generic message. + AppError::Database(_) | AppError::Cache(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + ErrorCode::ServiceUnavailable, + "Internal server error. Please try again later.".to_owned(), + ), + AppError::S3(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + ErrorCode::FileProcessingError, + "File operation failed. Please try again later.".to_owned(), + ), + AppError::Serialization(_) | AppError::Processing(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorCode::UnexpectedError, + "An unexpected error occurred.".to_owned(), + ), + }; + + let body = ErrorResponse::new(status, error_code, message); + (status, Json(body)).into_response() + } +} + +pub type AppResponse = Result; diff --git a/src/core/mod.rs b/src/core/mod.rs index 10bb3f1..449a184 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,6 +1,7 @@ -mod config; mod app_state; +mod config; pub mod cursor; +pub mod errors; -pub use config::{ISMConfig, UserDbConfig, MessageDbConfig, ObjectStorageConfig, TokenIssuer, KafkaConfig}; pub use app_state::*; +pub use config::{ISMConfig, KafkaConfig, ObjectStorageConfig, RoomDbConfig, TokenIssuer}; diff --git a/src/database/message_database.rs b/src/database/message_database.rs deleted file mode 100644 index 78aec7b..0000000 --- a/src/database/message_database.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::error::Error; -use std::sync::Arc; -use chrono::{DateTime, Utc}; -use crate::core::{MessageDbConfig}; -use futures::{TryStreamExt}; -use log::{debug, error, info}; -use scylla::client::pager::TypedRowStream; -use scylla::client::session::Session; -use scylla::client::session_builder::SessionBuilder; -use scylla::errors::{ExecutionError}; -use scylla::response::query_result::QueryResult; -use uuid::Uuid; -use crate::messaging::model::Message; - -#[derive(Debug, Clone)] -pub struct MessageDatabase { - session: Arc, -} - -impl MessageDatabase { - - pub async fn new(config: &MessageDbConfig) -> Self { - let session = match SessionBuilder::new() - .known_node(&config.db_url) - .user(&config.db_user, &config.db_password) - .build() - .await - { - Ok(session) => { - info!("Connection to the message database established."); - session - }, - Err(err) => { - panic!("Failed to create session to the message database: {:?}", err); - } - }; - let repository = MessageDatabase { session: Arc::new(session) }; - if config.with_db_init { - repository.create_keyspace_with_tables().await; - } - - repository - } - - pub async fn fetch_data(&self, timestamp: DateTime, room_id: Uuid) -> Result, Box> { - let session = self.session.clone(); - let mut iter: TypedRowStream = session.query_iter("SELECT chat_room_id, message_id, sender_id, msg_body, created_at, msg_type FROM messaging.chat_messages WHERE chat_room_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT 25", (room_id, timestamp)) - .await?.rows_stream::()?; - let mut messages: Vec = Vec::new(); - while let Some(next) = iter.try_next().await? { messages.push(next) } - Ok(messages) - } - - pub async fn fetch_specific_message(&self, message_id: &Uuid, room_id: &Uuid, created: &DateTime) -> Result> { - let session = self.session.clone(); - let mut iter = session.query_iter("SELECT chat_room_id, message_id, sender_id, msg_body, created_at, msg_type FROM messaging.chat_messages WHERE chat_room_id = ? AND created_at = ? AND message_id = ?", (room_id, created, message_id)) - .await?.rows_stream::()?; - match iter.try_next().await? { - Some(message) => Ok(message), - None => Err("Message not found".into()) - } - } - - pub async fn insert_data(&self, message: Message) -> Result { - let session = self.session.clone(); - session.query_unpaged( - "INSERT INTO messaging.chat_messages (chat_room_id, message_id, sender_id, msg_body, msg_type, created_at) VALUES (?, ?, ?, ?, ?, ?)", - (message.chat_room_id, message.message_id, message.sender_id, message.msg_body, message.msg_type, message.created_at) - ).await - } - - async fn create_keyspace_with_tables(&self) { - let queries = [ - "CREATE KEYSPACE IF NOT EXISTS messaging WITH REPLICATION = {'class' : 'NetworkTopologyStrategy', 'replication_factor' : 1}", - "CREATE TABLE IF NOT EXISTS messaging.chat_messages ( - chat_room_id UUID, - message_id UUID, - sender_id UUID, - msg_body TEXT, - msg_type TEXT, - created_at TIMESTAMP, - PRIMARY KEY ((chat_room_id), created_at, message_id) - )" - ]; - for query in queries.iter() { - if let Err(e) = self.session.query_unpaged(*query, &[]).await { - error!("Error executing query '{}': {:?}", query, e); - } else { - debug!("Successfully executed query: '{}'", query); - } - } - } - - pub async fn clear_chat_room_messages(&self, room_id: &Uuid) -> Result<(), ExecutionError> { - let session = self.session.clone(); - session.query_unpaged("DELETE FROM messaging.chat_messages WHERE chat_room_id = ?", (room_id,)).await?; - Ok(()) - } - - -} - - - - diff --git a/src/database/mod.rs b/src/database/mod.rs deleted file mode 100644 index 945d16a..0000000 --- a/src/database/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod message_database; -mod object_storage; - -pub use message_database::MessageDatabase; -pub use object_storage::ObjectStorage; diff --git a/src/errors.rs b/src/errors.rs deleted file mode 100644 index 4bbf42a..0000000 --- a/src/errors.rs +++ /dev/null @@ -1,278 +0,0 @@ -use std::error::Error; -use std::{fmt}; -use std::fmt::{Display, Formatter}; -use axum::http::StatusCode; -use axum::Json; -use axum::response::{IntoResponse, Response}; -use chrono::Utc; -use redis::RedisError; -use serde::Serialize; -use validator::ValidationErrors; - -#[derive(Serialize)] -pub struct ErrorResponse { - timestamp: String, - status: u16, - error: String, - message: String, - path: Option, - #[serde(rename = "errorCode")] - error_code: ErrorCode, -} - -#[derive(Serialize, Debug)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[allow(dead_code)] -pub enum ErrorCode { - // Authentication & Authorization - InsufficientPermissions, - - // User & Profile Errors - UserNotFound, - - // Content & Interaction Errors - RoomNotFound, - MessageNotFound, - InvalidContent, - FileProcessingError, - - ContentNotFound, - - // General API & Validation Errors - ValidationError, - ServiceUnavailable, - UnexpectedError, -} - -impl ErrorCode { - fn to_str(&self) -> String { - match self { - ErrorCode::UnexpectedError => "Server Error. Please try again later".to_string(), - ErrorCode::UserNotFound => "User not found.".to_string(), - ErrorCode::InsufficientPermissions => "You are not allowed to perform this action".to_string(), - _ => format!("{:?}", self), - } - } -} - -impl Display for ErrorCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.to_str().to_owned()) - } -} - -#[derive(Debug)] -pub struct HttpError { - pub status_code: StatusCode, - pub error_code: ErrorCode, - pub message: String, -} - -impl HttpError { - - pub fn new(status_code: StatusCode, error_code: ErrorCode, message: impl Into) -> Self { - Self { - status_code, - error_code, - message: message.into(), - } - } - - pub fn bad_request(error_code: ErrorCode, message: impl Into) -> Self { - Self { - status_code: StatusCode::BAD_REQUEST, - error_code, - message: message.into(), - } - } - - -} - - -impl IntoResponse for HttpError { - fn into_response(self) -> Response { - - tracing::error!("An error occurred: status={}, code={:?}, msg='{}'", self.status_code, self.error_code, self.message); - - let status = self.status_code; - - let error_response = ErrorResponse { - timestamp: Utc::now().to_rfc3339(), - status: status.as_u16(), - error: status.canonical_reason().unwrap_or("Unknown Status").to_string(), - message: self.message, - path: None, - error_code: self.error_code, - }; - - (status, Json(error_response)).into_response() - } -} - -pub enum AppError { - /// Ein Fehler, der von einer ungültigen Anfrage des Clients herrührt. - ValidationError(String), - - /// Ein angeforderter Datensatz wurde nicht gefunden. - NotFound(String), - - /// Ein Fehler, der aus der Datenbank kommt. Wir verpacken den ursprünglichen Fehler. - /// `Box` ist der Standardweg in Rust, um einen beliebigen Fehler zu speichern. - DatabaseError(Box), - - /// Ein interner Fehler bei der Verarbeitung, z.B. beim Kodieren/Dekodieren. - ProcessingError(String), - - Blocked(String), - - S3Error(String), - - BadRequest(String), - - CacheError(RedisError), - - Generic(Box), - -} - -impl fmt::Debug for AppError { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - Self::ValidationError(msg) => write!(f, "ValidationError: {}", msg), - Self::NotFound(msg) => write!(f, "NotFound: {}", msg), - Self::DatabaseError(err) => write!(f, "DatabaseError: {}", err), - Self::ProcessingError(msg) => write!(f, "ProcessingError: {}", msg), - Self::Blocked(msg) => write!(f, "Blocked: {}", msg), - Self::S3Error(msg) => write!(f, "S3Error: {}", msg), - Self::BadRequest(msg) => write!(f, "BadRequest: {}", msg), - Self::CacheError(err) => write!(f, "CacheError: {}", err), - Self::Generic(err) => write!(f, "An Generic unexpected error occurred: {}", err), - } - } -} - -impl Display for AppError { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - AppError::ValidationError(msg) => write!(f, "Invalid input: {}", msg), - AppError::NotFound(msg) => write!(f, "Entity not found: {}", msg), - AppError::DatabaseError(err) => write!(f, "Ein Datenbankfehler ist aufgetreten: {}", err), - AppError::ProcessingError(msg) => write!(f, "Ein Verarbeitungsfehler ist aufgetreten: {}", msg), - AppError::Blocked(msg) => write!(f, "Blocked: {}", msg), - AppError::S3Error(msg) => write!(f, "S3Error: {}", msg), - AppError::BadRequest(msg) => write!(f, "BadRequest: {}", msg), - AppError::CacheError(err) => write!(f, "CacheError: {}", err), - AppError::Generic(err) => write!(f, "An Generic unexpected error occurred: {}", err), - } - } -} - -impl From for AppError { - fn from(err: sqlx::Error) -> AppError { - AppError::DatabaseError(Box::new(err)) - } -} - -impl From for AppError { - fn from(err: scylla::errors::ExecutionError) -> AppError { - AppError::DatabaseError(Box::new(err)) - } -} - -impl From for AppError { - fn from(err: RedisError) -> AppError { - AppError::CacheError(err) - } -} - -impl From for AppError { - fn from(err: serde_json::Error) -> AppError { - AppError::ProcessingError(err.to_string()) - } -} - -impl Error for AppError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - AppError::DatabaseError(err) => Some(err.as_ref()), - _ => None, - } - } -} - -impl From for AppError { - fn from(errors: ValidationErrors) -> Self { - AppError::BadRequest(errors.to_string()) - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - - let http_error = match self { - AppError::ValidationError(msg) => { - HttpError::new(StatusCode::BAD_REQUEST, ErrorCode::ValidationError, msg) - } - AppError::NotFound(msg) => { - HttpError::new(StatusCode::NOT_FOUND, ErrorCode::ContentNotFound, msg) - } - AppError::DatabaseError(internal_err) => { - tracing::error!("Database error: {:?}", internal_err); - HttpError::new( - StatusCode::SERVICE_UNAVAILABLE, - ErrorCode::UnexpectedError, - "Internal Database Error. Try again." - ) - } - AppError::ProcessingError(msg) => { - tracing::error!("Intern processing error: {}", msg); - HttpError::new( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorCode::UnexpectedError, - "Unexpected server error processing." - ) - } - AppError::Blocked(msg) => { - HttpError::new( - StatusCode::UNAUTHORIZED, - ErrorCode::InsufficientPermissions, - msg - ) - } - AppError::S3Error(msg) => { - HttpError::new( - StatusCode::SERVICE_UNAVAILABLE, - ErrorCode::UnexpectedError, - msg - ) - } - AppError::BadRequest(msg) => { - HttpError::new( - StatusCode::BAD_REQUEST, - ErrorCode::ValidationError, - msg - ) - } - AppError::CacheError(err) => { - tracing::error!("Cache error: {:?}", err.to_string()); - HttpError::new( - StatusCode::SERVICE_UNAVAILABLE, - ErrorCode::UnexpectedError, - "Internal Cache Error. Try again." - ) - } - AppError::Generic(err) => { - HttpError::new( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorCode::UnexpectedError, - format!("An unexpected error occurred: {}", err) - ) - } - }; - - http_error.into_response() - } -} - -pub type AppResponse = Result; \ No newline at end of file diff --git a/src/kafka/event_producer.rs b/src/kafka/event_producer.rs index c716732..431bd11 100644 --- a/src/kafka/event_producer.rs +++ b/src/kafka/event_producer.rs @@ -1,18 +1,22 @@ -use std::time::Duration; +use crate::broadcast::Notification; +use crate::core::KafkaConfig; +use crate::core::errors::AppError; +use crate::kafka::model::PushNotification; use async_trait::async_trait; -use rdkafka::{ClientConfig}; +use rdkafka::ClientConfig; use rdkafka::message::{Header, OwnedHeaders}; use rdkafka::producer::{FutureProducer, FutureRecord}; +use std::time::Duration; use tracing::{debug, error}; use uuid::Uuid; -use crate::broadcast::Notification; -use crate::core::KafkaConfig; -use crate::errors::AppError; -use crate::kafka::model::PushNotification; #[async_trait] pub trait EventProducer: Send + Sync { - async fn send_notification(&self, notification: Notification, to_user: Vec) -> Result<(), AppError>; + async fn send_notification( + &self, + notification: Notification, + to_user: Vec, + ) -> Result<(), AppError>; } pub struct KafkaEventProducer { @@ -34,27 +38,25 @@ impl KafkaEventProducer { #[async_trait] impl EventProducer for KafkaEventProducer { + async fn send_notification( + &self, + notification: Notification, + to_user: Vec, + ) -> Result<(), AppError> { + let payload = serde_json::to_string(&PushNotification { + to_user, + notification, + })?; - - async fn send_notification(&self, notification: Notification, to_user: Vec) -> Result<(), AppError> { - let payload = serde_json::to_string(&PushNotification{to_user, notification}) - .map_err(|e| AppError::from(e))?; - let response = self.producer.send( - FutureRecord::<(), String>::to(&self.config.topic) - .payload(&payload) - .headers( - OwnedHeaders::new() - .insert(Header { - key: "__TypeId__", - value: Some("com.meventure.api.notifications.model.UndeliveredMessage".as_bytes()), - }) - .insert(Header { - key: "contentType", - value: Some("application/json".as_bytes()), - }) - ), - Duration::from_secs(0), - ).await; + let response = self + .producer + .send( + FutureRecord::<(), String>::to(&self.config.topic) + .payload(&payload) + .headers(generate_header()), + Duration::from_secs(0), + ) + .await; match response { Ok(delivery) => { debug!("Delivery result: {:?}", delivery); @@ -62,7 +64,9 @@ impl EventProducer for KafkaEventProducer { } Err((kafka_error, _)) => { error!("Kafka event delivery failed: {:?}", kafka_error.to_string()); - Err(AppError::ProcessingError("Unable to send push notification".to_string())) + Err(AppError::Processing( + "Unable to send push notification".to_string(), + )) } } } @@ -78,7 +82,23 @@ impl LogEventProducer { #[async_trait] impl EventProducer for LogEventProducer { - async fn send_notification(&self, _notification: Notification, _to_user: Vec) -> Result<(), AppError> { + async fn send_notification( + &self, + _notification: Notification, + _to_user: Vec, + ) -> Result<(), AppError> { Ok(()) } -} \ No newline at end of file +} + +fn generate_header() -> OwnedHeaders { + OwnedHeaders::new() + .insert(Header { + key: "__TypeId__", + value: Some("com.meventure.api.notifications.model.UndeliveredMessage".as_bytes()), + }) + .insert(Header { + key: "contentType", + value: Some("application/json".as_bytes()), + }) +} diff --git a/src/kafka/mod.rs b/src/kafka/mod.rs index de101b3..8d78709 100644 --- a/src/kafka/mod.rs +++ b/src/kafka/mod.rs @@ -2,5 +2,5 @@ mod event_producer; mod model; mod push_notification_producer; -pub use event_producer::{EventProducer}; -pub use push_notification_producer::PushNotificationProducer; \ No newline at end of file +pub use event_producer::EventProducer; +pub use push_notification_producer::PushNotificationProducer; diff --git a/src/kafka/model.rs b/src/kafka/model.rs index 8085e51..8e19823 100644 --- a/src/kafka/model.rs +++ b/src/kafka/model.rs @@ -1,9 +1,9 @@ +use crate::broadcast::Notification; use serde::Serialize; use uuid::Uuid; -use crate::broadcast::Notification; #[derive(Serialize)] pub struct PushNotification { pub to_user: Vec, - pub notification: Notification -} \ No newline at end of file + pub notification: Notification, +} diff --git a/src/kafka/push_notification_producer.rs b/src/kafka/push_notification_producer.rs index 80ed72c..1d04f6c 100644 --- a/src/kafka/push_notification_producer.rs +++ b/src/kafka/push_notification_producer.rs @@ -1,28 +1,35 @@ -use async_trait::async_trait; -use tracing::info; -use uuid::Uuid; use crate::broadcast::Notification; use crate::core::KafkaConfig; -use crate::errors::AppError; -use crate::kafka::event_producer::{KafkaEventProducer, LogEventProducer}; +use crate::core::errors::AppError; use crate::kafka::EventProducer; +use crate::kafka::event_producer::{KafkaEventProducer, LogEventProducer}; +use async_trait::async_trait; +use tracing::info; +use uuid::Uuid; pub enum PushNotificationProducer { Kafka(KafkaEventProducer), - Logger(LogEventProducer) + Logger(LogEventProducer), } #[async_trait] impl EventProducer for PushNotificationProducer { - async fn send_notification(&self, notification: Notification, to_user: Vec) -> Result<(), AppError> { + async fn send_notification( + &self, + notification: Notification, + to_user: Vec, + ) -> Result<(), AppError> { match self { - PushNotificationProducer::Kafka(producer) => producer.send_notification(notification, to_user).await, - PushNotificationProducer::Logger(producer) => producer.send_notification(notification, to_user).await, + PushNotificationProducer::Kafka(producer) => { + producer.send_notification(notification, to_user).await + } + PushNotificationProducer::Logger(producer) => { + producer.send_notification(notification, to_user).await + } } } } - impl PushNotificationProducer { pub fn new(use_kafka: bool, kafka_config: KafkaConfig) -> Self { if use_kafka { @@ -32,4 +39,4 @@ impl PushNotificationProducer { PushNotificationProducer::Logger(LogEventProducer::new()) } } -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 9b1ff93..c3c1ff8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,12 @@ -pub mod core; -pub mod database; -pub mod model; +pub mod auth; pub mod broadcast; +pub mod cache; +pub mod core; pub mod kafka; -pub mod keycloak; -pub mod repository; -pub mod user_relationship; -pub mod rooms; pub mod messaging; -pub mod utils; -pub mod errors; +pub mod object_storage; +pub mod rooms; pub mod router; -pub mod cache; -pub mod welcome; \ No newline at end of file +pub mod users; +pub mod utils; +pub mod welcome; diff --git a/src/main.rs b/src/main.rs index 7037836..ea9dc1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,12 @@ +use ism::core::{AppState, ISMConfig}; +use ism::router::init_router; +use ism::welcome::welcome; use std::env; -use dotenv::dotenv; use tokio::net::TcpListener; -use tokio::{signal}; +use tokio::signal; use tracing::info; use tracing_subscriber::EnvFilter; -use ism::core::{AppState, ISMConfig}; use tracing_subscriber::filter::LevelFilter; -use ism::router::init_router; -use ism::welcome::welcome; //learn to code rust axum here: //https://gitlab.com/famedly/conduit/-/tree/next?ref_type=heads @@ -15,22 +14,24 @@ use ism::welcome::welcome; //https://github.com/rust-lang/crates.io/ <---- THE BEST! #[tokio::main(flavor = "multi_thread")] async fn main() { - let config = init_configuration(); welcome(); - //init the app state including database connections, broadcast channels, kafka etc. + //init the app state including object_storage connections, broadcast channels, kafka etc. let app_state = AppState::new(config.clone()).await; //init api router: let app = init_router(app_state).await; let url = format!("{}:{}", config.ism_url, config.ism_port); - let listener = TcpListener::bind(url.clone()) - .await - .unwrap_or_else(|err| panic!("Unable to start TCP-Listener at URL: {}, error is: {}", url, err)); - + let listener = TcpListener::bind(url.clone()).await.unwrap_or_else(|err| { + panic!( + "Unable to start TCP-Listener at URL: {}, error is: {}", + url, err + ) + }); + info!("ISM-Server up and is listening on: {url}"); axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal())//only working when there aren't active connections + .with_graceful_shutdown(shutdown_signal()) //only working when there aren't active connections .await .unwrap(); info!("Stopping ISM..."); @@ -61,19 +62,16 @@ async fn shutdown_signal() { } fn init_configuration() -> ISMConfig { - dotenv().ok(); let run_mode = env::var("ISM_MODE").unwrap_or_else(|_| "development".into()); - let config = ISMConfig::new(&run_mode).unwrap_or_else(|err| panic!("Missing needed env: {}", err)); + let config = + ISMConfig::new(&run_mode).unwrap_or_else(|err| panic!("Missing needed env: {}", err)); let filter = EnvFilter::builder() .with_env_var("ISM_LOG_LEVEL") .with_default_directive(LevelFilter::INFO.into()) - .from_env_lossy() - .add_directive("scylla=info".parse().unwrap()); + .from_env_lossy(); + + tracing_subscriber::fmt().with_env_filter(filter).init(); - tracing_subscriber::fmt() - .with_env_filter(filter) - .init(); - config -} \ No newline at end of file +} diff --git a/src/messaging/chat_repository.rs b/src/messaging/chat_repository.rs new file mode 100644 index 0000000..6e033a5 --- /dev/null +++ b/src/messaging/chat_repository.rs @@ -0,0 +1,102 @@ +use crate::messaging::model::{MessageBody, MessageEntity, MsgType}; +use chrono::{DateTime, Utc}; +use sqlx::{Error, Pool, Postgres}; +use uuid::Uuid; + +#[derive(Clone)] +pub struct ChatRepository { + pool: Pool, +} + +impl ChatRepository { + pub fn new(pool: Pool) -> Self { + ChatRepository { pool } + } + + pub fn get_connection(&self) -> &Pool { + &self.pool + } + + pub async fn insert_message<'e, E>(&self, exec: E, message: &MessageEntity) -> Result<(), Error> + where + E: sqlx::Executor<'e, Database = Postgres>, + { + sqlx::query!( + r#" + INSERT INTO chat_message (message_id, chat_room_id, sender_id, msg_body, msg_type, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + message.message_id, + message.chat_room_id, + message.sender_id, + message.msg_body.clone() as sqlx::types::Json, + message.msg_type.clone() as MsgType, + message.created_at + ).execute(exec).await?; + Ok(()) + } + + pub async fn fetch_messages( + &self, + room_id: Uuid, + before: DateTime, + ) -> Result, Error> { + let messages = sqlx::query_as!( + MessageEntity, + r#" + SELECT + message_id, + chat_room_id, + sender_id, + msg_body AS "msg_body: sqlx::types::Json", + msg_type AS "msg_type: MsgType", + created_at + FROM chat_message + WHERE chat_room_id = $1 AND created_at < $2 + ORDER BY created_at DESC + LIMIT 25 + "#, + room_id, + before + ) + .fetch_all(&self.pool) + .await?; + Ok(messages) + } + + pub async fn fetch_message_by_id( + &self, + message_id: &Uuid, + room_id: &Uuid, + ) -> Result { + let message = sqlx::query_as!( + MessageEntity, + r#" + SELECT + message_id, + chat_room_id, + sender_id, + msg_body AS "msg_body: sqlx::types::Json", + msg_type AS "msg_type: MsgType", + created_at + FROM chat_message + WHERE message_id = $1 AND chat_room_id = $2 + "#, + message_id, + room_id + ) + .fetch_one(&self.pool) + .await?; + Ok(message) + } + + pub async fn delete_room_messages<'e, E>(&self, exec: E, room_id: &Uuid) -> Result<(), Error> + where + E: sqlx::Executor<'e, Database = Postgres>, + { + sqlx::query!("DELETE FROM chat_message WHERE chat_room_id = $1", room_id) + .execute(exec) + .await?; + Ok(()) + } +} diff --git a/src/messaging/handler.rs b/src/messaging/handler.rs index cd531d7..005bf62 100644 --- a/src/messaging/handler.rs +++ b/src/messaging/handler.rs @@ -1,20 +1,19 @@ -use std::sync::Arc; -use axum::{Extension, Json}; -use axum::extract::State; -use validator::Validate; +use crate::auth::decode::KeycloakToken; use crate::core::AppState; -use crate::errors::AppError; -use crate::keycloak::decode::KeycloakToken; +use crate::core::errors::AppError; use crate::messaging::message_service::MessageService; -use crate::messaging::model::{MessageDTO, NewMessage}; +use crate::messaging::model::{MessageDto, NewMessage}; +use axum::extract::State; +use axum::{Extension, Json}; +use std::sync::Arc; +use validator::Validate; pub async fn handle_send_message( State(state): State>, Extension(token): Extension>, - Json(payload): Json -) -> Result, AppError> { - + Json(payload): Json, +) -> Result, AppError> { payload.validate().map_err(AppError::from)?; let response_msg = MessageService::send_message(state, payload, token.subject).await?; Ok(Json(response_msg)) -} \ No newline at end of file +} diff --git a/src/messaging/message_service.rs b/src/messaging/message_service.rs index 7888b9e..aaea752 100644 --- a/src/messaging/message_service.rs +++ b/src/messaging/message_service.rs @@ -1,121 +1,143 @@ -use std::str::FromStr; +use crate::broadcast::NotificationEvent::ChatMessage; +use crate::broadcast::{BroadcastChannel, Notification}; +use crate::core::AppState; +use crate::core::errors::AppError; +use crate::messaging::model::{ + MessageBody, MessageDto, MessageEntity, NewMessage, NewMessageBody, NewReplyBody, + RepliedMessageDetails, ReplyBody, +}; +use crate::rooms::room::LastMessagePreviewText; +use crate::rooms::room_member::RoomContext; use std::sync::Arc; use uuid::Uuid; -use crate::broadcast::{BroadcastChannel}; -use crate::core::AppState; -use crate::errors::{AppError}; -use crate::messaging::model::{Message, MessageBody, MessageDTO, MsgType, NewMessage, NewMessageBody, NewReplyBody, RepliedMessageDetails, ReplyBody}; -use crate::model::LastMessagePreviewText; pub struct MessageService; impl MessageService { - pub async fn send_message( state: Arc, message: NewMessage, - client_id: Uuid - ) -> Result { - - let mut users = state.cache.get_user_for_room(&message.chat_room_id).await?; - - if users.is_empty() { - users = state.room_repository.select_room_participants_ids(&message.chat_room_id).await?; - state.cache.set_user_for_room(&message.chat_room_id, &users).await?; - } - - if !users.contains(&client_id) { - return Err(AppError::Blocked("User hasn't access to this room.".to_string())); + client_id: Uuid, + ) -> Result { + // 1. Load room context from cache, fall back to DB + let context = match state.cache.get_room_context(&message.chat_room_id).await? { + Some(ctx) => ctx, + None => { + let members = state + .room_repository + .select_all_room_member(&message.chat_room_id) + .await?; + let ctx = RoomContext { members }; + state + .cache + .set_room_context(&message.chat_room_id, &ctx) + .await?; + ctx + } }; + // 2. Auth check + sender display name — no extra DB call + let sender = context + .find_member(&client_id) + .ok_or_else(|| AppError::Forbidden("User hasn't access to this room.".to_string()))?; + let sender_display_name = sender.display_name.clone(); + let sender_member = sender.clone(); + + // 3. Build message body let msg_body = match message.msg_body.clone() { - NewMessageBody::Text(text) => { - MessageBody::Text(text) - } - NewMessageBody::Media(media) => { - MessageBody::Media(media) - } + NewMessageBody::Text(text) => MessageBody::Text(text), + NewMessageBody::Media(media) => MessageBody::Media(media), NewMessageBody::Reply(reply) => { - let reply = MessageService::create_reply_message(&reply, &state, &message.chat_room_id).await.map_err(|err| { - AppError::ProcessingError(format!("Can't create reply message: {}", err.to_string())) - })?; + let reply = + MessageService::create_reply_message(&reply, &state, &message.chat_room_id) + .await + .map_err(|err| { + AppError::Processing(format!("Can't create reply message: {}", err)) + })?; MessageBody::Reply(reply) } }; - let msg = Message::new(message.chat_room_id, client_id, msg_body).map_err(|_err| { - AppError::ProcessingError("Can't create chat message.".to_string()) - })?; - - //1. save message to nosql db: - state.message_repository.insert_data(msg.clone()).await?; + let entity = MessageEntity::new(message.chat_room_id, client_id, msg_body); - //2. generate new room preview text and save it to sql db: - let client_entity = state.room_repository.select_joined_user_by_id(&message.chat_room_id, &client_id).await?; - let room_preview_text = MessageService::generate_room_preview_text(&message, client_entity.display_name); - let preview_str = serde_json::to_string(&room_preview_text).map_err(|err| { - AppError::ProcessingError(format!("Can't serialize message: {}", err.to_string())) - })?; + // 4. Generate preview text — display name from context, no DB call + let room_preview_text = + MessageService::generate_room_preview_text(&message, sender_display_name); + // 5. Single atomic transaction: insert message + update room state in one CTE round-trip let mut tx = state.room_repository.start_transaction().await?; - state.room_repository.update_last_room_message(&mut *tx, &message.chat_room_id, &preview_str).await?; - state.room_repository.update_user_read_status(&mut *tx, &message.chat_room_id, &msg.sender_id).await?; + state + .chat_repository + .insert_message(&mut *tx, &entity) + .await?; + state + .room_repository + .apply_message_to_room( + &mut *tx, + &message.chat_room_id, + &room_preview_text, + &entity.sender_id, + entity.created_at, + ) + .await?; tx.commit().await?; - //3. broadcast message to all room members: - let message_dto = msg.to_dto().map_err(|err| { - AppError::ProcessingError(format!("Can't serialize message: {}", err.to_string())) - })?; - let notification = msg.to_notification(room_preview_text)?; - BroadcastChannel::get().send_event_to_all(users, notification).await; - Ok(message_dto) + // 6. Broadcast to all room members + let dto = MessageDto::from(entity); + let notification = Notification::new(ChatMessage { + message: dto.clone(), + room_preview_text, + sender: sender_member, + }); + BroadcastChannel::get() + .send_event_to_all(context.member_ids(), notification) + .await; + Ok(dto) } - async fn create_reply_message(msg: &NewReplyBody, state: &Arc, room_id: &Uuid) -> Result> { - let replied_to = state.message_repository.fetch_specific_message(&msg.reply_msg_id, room_id, &msg.reply_created_at).await?; - - let replied_body: MessageBody = serde_json::from_str(&replied_to.msg_body)?; - - let details = match replied_body { - MessageBody::Text(text) => { - RepliedMessageDetails::Text(text) - } - MessageBody::Media(media) => { - RepliedMessageDetails::Media(media) - } - MessageBody::Reply(reply) => { - RepliedMessageDetails::Reply {reply_text: reply.reply_text} - } - _ => { - return Err(Box::from("Unknown Reply body")) - } + async fn create_reply_message( + msg: &NewReplyBody, + state: &Arc, + room_id: &Uuid, + ) -> Result> { + let replied_to = state + .chat_repository + .fetch_message_by_id(&msg.reply_msg_id, room_id) + .await?; + + let details = match replied_to.msg_body.0 { + MessageBody::Text(text) => RepliedMessageDetails::Text(text), + MessageBody::Media(media) => RepliedMessageDetails::Media(media), + MessageBody::Reply(reply) => RepliedMessageDetails::Reply { + reply_text: reply.reply_text, + }, + _ => return Err(Box::from("Cannot reply to a room change event")), }; - let new_body = ReplyBody { + Ok(ReplyBody { reply_msg_id: replied_to.message_id, reply_sender_id: replied_to.sender_id, - reply_msg_type: MsgType::from_str(&replied_to.msg_type)?, + reply_msg_type: replied_to.msg_type, reply_created_at: replied_to.created_at, reply_msg_details: details, reply_text: msg.reply_text.clone(), - }; - Ok(new_body) + }) } fn generate_room_preview_text(msg: &NewMessage, username: String) -> LastMessagePreviewText { match &msg.msg_body { - NewMessageBody::Text(body) => { - LastMessagePreviewText::Text { sender_username: username, text: body.text.clone()} - } - NewMessageBody::Media(body) => { - LastMessagePreviewText::Media { sender_username: username, media_type: body.media_type.clone()} - } - NewMessageBody::Reply(body) => { - LastMessagePreviewText::Reply { sender_username: username, reply_text: body.reply_text.clone()} - } + NewMessageBody::Text(body) => LastMessagePreviewText::Text { + sender_username: username, + text: body.text.clone(), + }, + NewMessageBody::Media(body) => LastMessagePreviewText::Media { + sender_username: username, + media_type: body.media_type.clone(), + }, + NewMessageBody::Reply(body) => LastMessagePreviewText::Reply { + sender_username: username, + reply_text: body.reply_text.clone(), + }, } } - - - -} \ No newline at end of file +} diff --git a/src/messaging/mod.rs b/src/messaging/mod.rs index 4946201..a92858f 100644 --- a/src/messaging/mod.rs +++ b/src/messaging/mod.rs @@ -1,5 +1,6 @@ -mod notifications; -pub mod routes; +pub mod chat_repository; mod handler; mod message_service; pub mod model; +mod notifications; +pub mod routes; diff --git a/src/messaging/model.rs b/src/messaging/model.rs index 6d65387..fe0cc1c 100644 --- a/src/messaging/model.rs +++ b/src/messaging/model.rs @@ -1,17 +1,11 @@ -use std::error::Error; -use std::fmt; -use std::str::FromStr; +use crate::rooms::room_member::RoomMember; use chrono::{DateTime, Utc}; -use scylla::{DeserializeRow}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use validator::Validate; -use crate::broadcast::Notification; -use crate::broadcast::NotificationEvent::ChatMessage; -use crate::errors::AppError; -use crate::model::{LastMessagePreviewText, RoomMember}; -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(sqlx::Type, Debug, Deserialize, Serialize, Clone, PartialEq)] +#[sqlx(type_name = "msg_type")] pub enum MsgType { Text, Media, @@ -19,126 +13,114 @@ pub enum MsgType { Reply, } -#[derive(DeserializeRow, Debug, Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Message { +#[derive(sqlx::FromRow, Debug, Clone)] +pub struct MessageEntity { pub chat_room_id: Uuid, pub message_id: Uuid, pub sender_id: Uuid, - //it is a JSON string in scyllaDb, because the rust client can't handle JSON to struct at the moment - pub msg_body: String, - //the rust client from scylla can't handle enums at the moment, so we have to use a string and map it to the enum later - pub msg_type: String, - pub created_at: DateTime + pub msg_body: sqlx::types::Json, + pub msg_type: MsgType, + pub created_at: DateTime, } -impl Message { - - pub fn new(room_id: Uuid, sender_id: Uuid, msg_body: MessageBody) -> Result { - let typ = match msg_body { +impl MessageEntity { + pub fn new(room_id: Uuid, sender_id: Uuid, msg_body: MessageBody) -> MessageEntity { + let msg_type = match &msg_body { MessageBody::Text(_) => MsgType::Text, MessageBody::Media(_) => MsgType::Media, MessageBody::Reply(_) => MsgType::Reply, - MessageBody::RoomChange(_) => MsgType::RoomChange + MessageBody::RoomChange(_) => MsgType::RoomChange, }; - let body_json = serde_json::to_string(&msg_body)?; - let msg = Message { + MessageEntity { chat_room_id: room_id, message_id: Uuid::new_v4(), - sender_id: sender_id, - msg_body: body_json, - msg_type: typ.to_string(), - created_at: Utc::now() - }; - Ok(msg) + sender_id, + msg_body: sqlx::types::Json(msg_body), + msg_type, + created_at: Utc::now(), + } } +} - pub fn to_dto(&self) -> Result> { - let message = MessageDTO { - chat_room_id: self.chat_room_id, - message_id: self.message_id, - sender_id: self.sender_id, - msg_body: serde_json::from_str(&self.msg_body)?, - msg_type: self.msg_type.parse()?, - created_at: self.created_at - }; - Ok(message) +impl From for MessageDto { + fn from(e: MessageEntity) -> Self { + MessageDto { + chat_room_id: e.chat_room_id, + message_id: e.message_id, + sender_id: e.sender_id, + msg_body: e.msg_body.0, + msg_type: e.msg_type, + created_at: e.created_at, + } } +} - pub fn to_notification(&self, preview_text: LastMessagePreviewText) -> Result { - let mapped_msg = self.to_dto().map_err(|err| { - AppError::ProcessingError(format!("Can't serialize message: {}", err.to_string())) - })?; - let notification = Notification { - body: ChatMessage {message: mapped_msg.clone(), room_preview_text: preview_text }, - created_at: Utc::now() - }; - Ok(notification) - } - +/// A page of the chat timeline: the messages plus the deduplicated profiles of every +/// user that authored a message in this page (`senders`). Senders are resolved even if +/// they have since left the room, so the client can render every message without a +/// separate user lookup. New live senders arrive embedded in the `ChatMessage` event. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TimelinePage { + pub messages: Vec, + pub senders: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] -pub struct MessageDTO { +pub struct MessageDto { pub chat_room_id: Uuid, pub message_id: Uuid, pub sender_id: Uuid, pub msg_body: MessageBody, pub msg_type: MsgType, - pub created_at: DateTime + pub created_at: DateTime, } -impl MessageDTO { - - pub fn from_json_str(s: &str) -> Result { - serde_json::from_str(s).map_err(|err| { - AppError::ProcessingError(format!("Error parsing message: {}", err)) - }) +impl MessageDto { + pub fn from_json_str(s: &str) -> Result { + serde_json::from_str(s) } - pub fn json_str(&self) -> Result { - serde_json::to_string(self).map_err(|err| { - AppError::ProcessingError(format!("Error parsing message: {}", err)) - }) + pub fn json_str(&self) -> Result { + serde_json::to_string(self) } } - #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(untagged)] pub enum MessageBody { - /** - * This is the most common message type, just a text message. - */ Text(TextBody), - /** - * For linking urls to images, videos or other media. - */ Media(MediaBody), - /** - * Replying to a message, alle message types supported. - */ Reply(ReplyBody), - /** - * For room events like user joining or leaving. - */ - RoomChange(RoomChangeBody) + RoomChange(RoomChangeBody), } #[derive(Deserialize, Serialize, Debug, Clone, Validate)] #[serde(rename_all = "camelCase")] pub struct TextBody { - #[validate(length(min = 1, max = 4000, message = "must be between 1 and 4000 characters long."))] + #[validate(length( + min = 1, + max = 4000, + message = "must be between 1 and 4000 characters long." + ))] pub text: String, } #[derive(Deserialize, Serialize, Debug, Clone, Validate)] #[serde(rename_all = "camelCase")] pub struct MediaBody { - #[validate(length(min = 1, max = 250, message = "must be between 1 and 250 characters long."))] + #[validate(length( + min = 1, + max = 250, + message = "must be between 1 and 250 characters long." + ))] pub media_url: String, - #[validate(length(min = 1, max = 80, message = "must be between 1 and 80 characters long."))] + #[validate(length( + min = 1, + max = 80, + message = "must be between 1 and 80 characters long." + ))] pub media_type: String, pub mime_type: Option, pub alt_text: Option, @@ -152,7 +134,7 @@ pub struct ReplyBody { pub reply_msg_type: MsgType, pub reply_created_at: DateTime, pub reply_msg_details: RepliedMessageDetails, - pub reply_text: String + pub reply_text: String, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -160,26 +142,24 @@ pub struct ReplyBody { pub enum RepliedMessageDetails { Text(TextBody), Media(MediaBody), - Reply {reply_text: String} + Reply { reply_text: String }, } - #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(tag = "type")] pub enum RoomChangeBody { - UserJoined {related_user: RoomMember }, - UserLeft {related_user: RoomMember }, - UserInvited {related_user: RoomMember } + UserJoined { related_user: RoomMember }, + UserLeft { related_user: RoomMember }, + UserInvited { related_user: RoomMember }, } - #[derive(Deserialize, Debug, Clone, Validate)] #[serde(rename_all = "camelCase")] pub struct NewMessage { pub chat_room_id: Uuid, #[validate(nested)] pub msg_body: NewMessageBody, - pub msg_type: MsgType + pub msg_type: MsgType, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -187,7 +167,7 @@ pub struct NewMessage { pub enum NewMessageBody { Text(TextBody), Media(MediaBody), - Reply(NewReplyBody) + Reply(NewReplyBody), } impl Validate for NewMessageBody { @@ -204,45 +184,10 @@ impl Validate for NewMessageBody { #[serde(rename_all = "camelCase")] pub struct NewReplyBody { pub reply_msg_id: Uuid, - pub reply_created_at: DateTime, - #[validate(length(min = 1, max = 4000, message = "must be between 1 and 4000 characters long."))] - pub reply_text: String + #[validate(length( + min = 1, + max = 4000, + message = "must be between 1 and 4000 characters long." + ))] + pub reply_text: String, } - - -impl fmt::Display for MsgType { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - MsgType::Text => write!(f, "Text"), - MsgType::Media => write!(f, "Media"), - MsgType::RoomChange => write!(f, "RoomChange"), - MsgType::Reply => write!(f, "Reply") - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ParseMessageTypeError; - -impl fmt::Display for ParseMessageTypeError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "Ungültiger MessageType-String") - } -} - -impl Error for ParseMessageTypeError {} - - -impl FromStr for MsgType { - type Err = ParseMessageTypeError; - - fn from_str(s: &str) -> Result { - match s { - "Text" => Ok(MsgType::Text), - "Media" => Ok(MsgType::Media), - "RoomChange" => Ok(MsgType::RoomChange), - "Reply" => Ok(MsgType::Reply), - _ => Err(ParseMessageTypeError), - } - } -} \ No newline at end of file diff --git a/src/messaging/notifications.rs b/src/messaging/notifications.rs index 8b66419..8f13a65 100644 --- a/src/messaging/notifications.rs +++ b/src/messaging/notifications.rs @@ -1,89 +1,182 @@ -use std::sync::Arc; -use std::time::Duration; -use axum::{Extension, Json}; -use axum::extract::{Query, State}; +use crate::auth::decode::KeycloakToken; +use crate::broadcast::{BroadcastChannel, Notification, NotificationEvent}; +use crate::cache::redis_cache::ReplayResult; +use crate::core::AppState; +use crate::core::errors::AppResponse; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::response::{IntoResponse, Sse}; +use axum::extract::{Query, State}; use axum::response::sse::Event; +use axum::response::{IntoResponse, Sse}; +use axum::{Extension, Json}; use bytes::Bytes; -use chrono::{DateTime, Utc}; use futures::Stream; -use tokio::time; use log::{debug, error}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::Duration; use tokio::sync::broadcast::error::RecvError; +use tokio::time; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use tracing::warn; use uuid::Uuid; -use crate::broadcast::{BroadcastChannel, Notification}; -use crate::core::AppState; -use crate::errors::{AppError, AppResponse}; -use crate::keycloak::decode::KeycloakToken; + +/// Handshake parameters shared by the SSE and WebSocket endpoints. The client passes the +/// highest sequence number it has already seen; the server replays everything after it. +/// Omitted on a fresh connection (the client loads its initial state via REST instead). +#[derive(Deserialize)] +pub struct StreamHandshakeParams { + #[serde(default)] + last_seq: Option, +} struct ConnectionGuard { user_id: Uuid, } impl Drop for ConnectionGuard { - fn drop(&mut self) { //triggering an unsubscribe, functions like a destructor - let user_id = self.user_id.clone(); + fn drop(&mut self) { + //triggering an unsubscribe, functions like a destructor + let user_id = self.user_id; tokio::spawn(async move { BroadcastChannel::get().unsubscribe(user_id).await; }); } } +/// Build the live notification stream wire format. +fn notification_to_sse(notification: &Notification) -> Event { + Event::default().data(serde_json::to_string(notification).unwrap_or_default()) +} + +/// Control notification telling the client its cached history is unavailable and it must +/// re-fetch authoritative state via REST. +fn resync_notification(reason: &str) -> Notification { + Notification::new(NotificationEvent::Resync { + reason: reason.to_string(), + }) +} + +/// Resolve the connection handshake into (events to replay first, high-water sequence). +/// +/// The high-water sequence is the largest sequence the client is guaranteed to have after the +/// replay; live events with a sequence `<= high_water` are duplicates and get filtered out. +/// A returned `Resync` event sets the high-water back to 0 so the client receives every +/// subsequent live event while it reloads state out-of-band. +async fn resolve_handshake( + bc: &BroadcastChannel, + user_id: &Uuid, + last_seq: Option, +) -> (Vec, u64) { + let last_seq = match last_seq { + Some(seq) => seq, + None => return (vec![], 0), // fresh connection: nothing to replay + }; + + match bc.replay_since(user_id, last_seq).await { + Ok(ReplayResult::Events(events)) => { + let high_water = events + .iter() + .filter_map(|n| n.seq) + .max() + .unwrap_or(last_seq); + (events, high_water) + } + Ok(ReplayResult::ResyncNeeded) => ( + vec![resync_notification( + "history unavailable, please resync via REST", + )], + 0, + ), + Err(err) => { + error!("Failed to fetch replay for {}: {}", user_id, err); + ( + vec![resync_notification("replay error, please resync via REST")], + 0, + ) + } + } +} pub async fn stream_server_events( - Extension(token): Extension> + Extension(token): Extension>, + Query(params): Query, ) -> Sse>> { - use futures::StreamExt; - let receiver = BroadcastChannel::get().subscribe_to_user_events(token.subject.clone()).await; - let _guard = ConnectionGuard { user_id: token.subject.clone() }; + let user_id = token.subject; + let bc = BroadcastChannel::get(); - let stream = BroadcastStream::new(receiver).filter_map(move |notification| { + // Subscribe before reading the replay so live events produced during the handshake are + // buffered and not lost (subscribe-then-replay ordering). + let receiver = bc.subscribe_to_user_events(user_id).await; + let guard = ConnectionGuard { user_id }; - let _moved_guard = &_guard; //lifetime of guard is extended to the stream and will end when the sse connection is closed + let (replay, high_water) = resolve_handshake(bc, &user_id, params.last_seq).await; + let replay_stream = + futures::stream::iter(replay.into_iter().map(|n| Ok(notification_to_sse(&n)))); + + let live_stream = BroadcastStream::new(receiver).filter_map(move |result| { + let _moved_guard = &guard; // tie the guard's lifetime to the live stream async move { - match notification { + match result { Ok(event) => { - let sse = Event::default().data(serde_json::to_string(&event).unwrap()); - Some(Ok(sse)) + // Ephemeral events (seq == None) always pass; durable events already + // covered by the replay window are dropped to avoid duplicates. + if event.seq.map_or(true, |s| s > high_water) { + Some(Ok(notification_to_sse(&event))) + } else { + None + } } - Err(error) => { - error!("{}", error); - None + Err(BroadcastStreamRecvError::Lagged(n)) => { + warn!( + "SSE client {} lagged by {} events, signalling resync", + user_id, n + ); + Some(Ok(notification_to_sse(&resync_notification( + "stream lagged, please resync via REST", + )))) } } } - }); + + let stream = replay_stream.chain(live_stream); + Sse::new(stream).keep_alive( axum::response::sse::KeepAlive::new() .interval(Duration::from_secs(5)) - .text("keep-alive-text") + .text("keep-alive-text"), ) } - pub async fn websocket_server_events( websocket: WebSocketUpgrade, - Extension(token): Extension> + Extension(token): Extension>, + Query(params): Query, ) -> impl IntoResponse { - websocket .on_failed_upgrade(|error| warn!("Error upgrading websocket: {}", error)) - .on_upgrade(move |socket| handle_socket(socket, token.subject.clone())) + .on_upgrade(move |socket| handle_socket(socket, token.subject, params.last_seq)) } -async fn handle_socket(mut socket: WebSocket, user_id: Uuid) { - - let mut broadcast_events = BroadcastChannel::get().subscribe_to_user_events(user_id.clone()).await; +async fn handle_socket(mut socket: WebSocket, user_id: Uuid, last_seq: Option) { + let bc = BroadcastChannel::get(); + let mut broadcast_events = bc.subscribe_to_user_events(user_id).await; let _guard = ConnectionGuard { user_id }; + + // Handshake: replay missing durable events (or send a resync signal) before going live. + let (replay, mut high_water) = resolve_handshake(bc, &user_id, last_seq).await; + for notification in &replay { + let json = serde_json::to_string(notification).unwrap_or_default(); + if socket.send(Message::text(json)).await.is_err() { + debug!("Client disconnected during replay, closing."); + return; + } + } + let mut ping_interval = time::interval(Duration::from_secs(15)); let mut last_pong_received = time::Instant::now(); @@ -93,26 +186,39 @@ async fn handle_socket(mut socket: WebSocket, user_id: Uuid) { notification_result = broadcast_events.recv() => { match notification_result { Ok(event) => { - let json_msg = serde_json::to_string(&event).unwrap(); - let ws_message = Message::text(json_msg); - - if socket.send(ws_message).await.is_err() { - error!("Failed to send message to client"); + // Skip durable events already covered by the replay window. + if event.seq.map_or(false, |s| s <= high_water) { + continue; + } + if let Some(seq) = event.seq { + high_water = seq; + } + let json_msg = serde_json::to_string(&event).unwrap_or_default(); + if socket.send(Message::text(json_msg)).await.is_err() { + error!("Failed to send message to client, closing."); + break; } } Err(RecvError::Closed) => { debug!("Client disconnected or channel closed"); break; } - Err(RecvError::Lagged(_)) => { - debug!("Client is too slow!") + Err(RecvError::Lagged(n)) => { + warn!("WS client {} lagged by {} events, signalling resync", user_id, n); + let resync = serde_json::to_string(&resync_notification("stream lagged, please resync via REST")).unwrap_or_default(); + if socket.send(Message::text(resync)).await.is_err() { + break; + } + // The client will reload via REST, so stop deduplicating against the + // (now stale) high-water mark and forward everything going forward. + high_water = 0; } } } // 2. Regular ping from ism: _ = ping_interval.tick() => { - + if last_pong_received.elapsed() > Duration::from_secs(30) { debug!("Client did not respond to ping in time, closing websocket connection"); break; @@ -147,20 +253,46 @@ async fn handle_socket(mut socket: WebSocket, user_id: Uuid) { } } - #[derive(Deserialize)] pub struct NotificationQueryParam { - timestamp: DateTime + last_seq: u64, +} + +/// Current per-user sequence cursor. A client that has just completed a full REST sync reads this +/// to learn the sequence its snapshot corresponds to, then persists it as the baseline for future +/// short reconnects. The REST-sync itself opens its live stream **without** a `last_seq` parameter +/// (fresh connection, no replay) — this endpoint only seeds the stored cursor. +#[derive(Serialize)] +pub struct NotificationCursor { + seq: u64, +} + +pub async fn get_notification_cursor( + State(state): State>, + Extension(token): Extension>, +) -> AppResponse> { + let seq = state + .cache + .current_sequence(&token.subject) + .await? + .unwrap_or(0); + Ok(Json(NotificationCursor { seq })) } pub async fn get_latest_notification_events( State(state): State>, Extension(token): Extension>, - Query(params): Query + Query(params): Query, ) -> AppResponse>> { - - let notifications = state.cache.get_notifications_for_user(&token.subject, params.timestamp).await.map_err(|_| { - AppError::ProcessingError("Error getting notifications: Cache Error".to_string()) - })?; + let notifications = match state + .cache + .get_notifications_since_seq(&token.subject, params.last_seq) + .await? + { + ReplayResult::Events(events) => events, + ReplayResult::ResyncNeeded => vec![resync_notification( + "history unavailable, please resync via REST", + )], + }; Ok(Json(notifications)) -} \ No newline at end of file +} diff --git a/src/messaging/routes.rs b/src/messaging/routes.rs index 769a532..6f8a7a8 100644 --- a/src/messaging/routes.rs +++ b/src/messaging/routes.rs @@ -1,14 +1,18 @@ -use std::sync::Arc; -use axum::Router; -use axum::routing::{any, get, post}; use crate::core::AppState; use crate::messaging::handler::handle_send_message; -use crate::messaging::notifications::{get_latest_notification_events, stream_server_events, websocket_server_events}; +use crate::messaging::notifications::{ + get_latest_notification_events, get_notification_cursor, stream_server_events, + websocket_server_events, +}; +use axum::Router; +use axum::routing::{any, get, post}; +use std::sync::Arc; pub fn create_messaging_routes() -> Router> { Router::new() //add new routes here .route("/api/notifications", get(get_latest_notification_events)) + .route("/api/notifications/cursor", get(get_notification_cursor)) .route("/api/sse", get(stream_server_events)) .route("/api/wss", any(websocket_server_events)) .route("/api/send-msg", post(handle_send_message)) -} \ No newline at end of file +} diff --git a/src/model/mod.rs b/src/model/mod.rs deleted file mode 100644 index deb6330..0000000 --- a/src/model/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod room; - -pub mod room_member; -mod response_utils; -pub use room_member::*; -pub use room::*; -pub use response_utils::*; diff --git a/src/model/room_member.rs b/src/model/room_member.rs deleted file mode 100644 index 5f8ee0c..0000000 --- a/src/model/room_member.rs +++ /dev/null @@ -1,43 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::Type; -use uuid::Uuid; - -#[derive(Debug, Deserialize, Serialize, sqlx::FromRow, sqlx::Type, Clone)] -#[serde(rename_all = "camelCase")] -pub struct RoomMember { - pub id: Uuid, - pub display_name: String, - pub profile_picture: Option, - pub joined_at: DateTime, - pub last_message_read_at: Option>, - pub membership_status: MembershipStatus -} - -#[derive(Debug, Deserialize, Serialize, Clone, Type, PartialEq)] -#[sqlx(type_name = "membership_status")] -pub enum MembershipStatus { - Joined, - Left, - Invited -} - -impl MembershipStatus { - - pub fn to_str(&self) -> &str { - match self { - MembershipStatus::Joined => "Joined", - MembershipStatus::Left => "Left", - MembershipStatus::Invited => "Invited" - - } - } - - pub fn to_string(&self) -> String { - match self { - MembershipStatus::Joined => String::from("Joined"), - MembershipStatus::Left => String::from("Left"), - MembershipStatus::Invited => String::from("Invited") - } - } -} \ No newline at end of file diff --git a/src/object_storage/mod.rs b/src/object_storage/mod.rs new file mode 100644 index 0000000..a155672 --- /dev/null +++ b/src/object_storage/mod.rs @@ -0,0 +1,3 @@ +mod object_storage; + +pub use object_storage::ObjectStorage; diff --git a/src/database/object_storage.rs b/src/object_storage/object_storage.rs similarity index 56% rename from src/database/object_storage.rs rename to src/object_storage/object_storage.rs index 995b5fe..c04ef4e 100644 --- a/src/database/object_storage.rs +++ b/src/object_storage/object_storage.rs @@ -1,13 +1,13 @@ -use std::sync::Arc; +use crate::core::ObjectStorageConfig; use bytes::Bytes; use log::{debug, info}; -use minio::s3::{Client, ClientBuilder}; use minio::s3::builders::{ObjectContent, ObjectToDelete}; use minio::s3::creds::StaticProvider; use minio::s3::http::BaseUrl; use minio::s3::segmented_bytes::SegmentedBytes; use minio::s3::types::S3Api; -use crate::core::ObjectStorageConfig; +use minio::s3::{Client, ClientBuilder}; +use std::sync::Arc; #[derive(Debug, Clone)] pub struct ObjectStorage { @@ -16,7 +16,6 @@ pub struct ObjectStorage { } impl ObjectStorage { - pub async fn new(config: &ObjectStorageConfig) -> Self { let static_provider = Box::new(StaticProvider::new( &config.access_key, @@ -25,46 +24,74 @@ impl ObjectStorage { )); let url = match config.storage_url.parse::() { Ok(url) => url, - Err(error) => panic!("Unable to parse s3 url: {:?}", error) + Err(error) => panic!("Unable to parse s3 url: {:?}", error), }; - let client: Client = match ClientBuilder::new(url).provider(Some(static_provider)).build() { + let client: Client = match ClientBuilder::new(url) + .provider(Some(static_provider)) + .build() + { Ok(client) => client, - Err(error) => panic!("Unable to initialize client: {:?}", error) + Err(error) => panic!("Unable to initialize client: {:?}", error), }; match client.bucket_exists(&config.bucket_name).send().await { Ok(buckets) => { info!("Established connection to the s3 storage."); if buckets.exists == false { - panic!("The configured bucket does not exist: {:?}", &config.bucket_name); + panic!( + "The configured bucket does not exist: {:?}", + &config.bucket_name + ); } - }, + } Err(error) => { panic!("Unable to check if bucket exists: {:?}", error) } }; - ObjectStorage { session: Arc::new(client), config: config.clone() } + ObjectStorage { + session: Arc::new(client), + config: config.clone(), + } } - pub async fn get_object(&self, object_id: &String) -> Result> { + pub async fn get_object( + &self, + object_id: &String, + ) -> Result> { let session = self.session.clone(); - let response = session.get_object(&self.config.bucket_name, object_id).send().await?; + let response = session + .get_object(&self.config.bucket_name, object_id) + .send() + .await?; let object = response.content.to_segmented_bytes().await?; Ok(object) } - pub async fn delete_object(&self, object_id: &String) -> Result<(), Box> { + pub async fn delete_object( + &self, + object_id: &String, + ) -> Result<(), Box> { let session = self.session.clone(); - let response = session.delete_object(&self.config.bucket_name, ObjectToDelete::from(object_id)).send().await?; + let response = session + .delete_object(&self.config.bucket_name, ObjectToDelete::from(object_id)) + .send() + .await?; debug!("Deleted object, marker: {:?}", response.version_id); Ok(()) } - pub async fn insert_object(&self, object_id: &String, content: Bytes) -> Result<(), Box> { + pub async fn insert_object( + &self, + object_id: &String, + content: Bytes, + ) -> Result<(), Box> { let session = self.session.clone(); let object = ObjectContent::from(content); - let response = session.put_object_content(&self.config.bucket_name, object_id, object).content_type("image/jpeg".to_string()).send().await?; + let response = session + .put_object_content(&self.config.bucket_name, object_id, object) + .content_type("image/jpeg".to_string()) + .send() + .await?; debug!("Saved object with name: {:?}", response.object); Ok(()) } - -} \ No newline at end of file +} diff --git a/src/repository/mod.rs b/src/repository/mod.rs deleted file mode 100644 index b82a084..0000000 --- a/src/repository/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod room_repository; -pub mod user_repository; -mod util; \ No newline at end of file diff --git a/src/repository/room_repository.rs b/src/repository/room_repository.rs deleted file mode 100644 index 4decac6..0000000 --- a/src/repository/room_repository.rs +++ /dev/null @@ -1,349 +0,0 @@ -use chrono::Utc; -use sqlx::{Error, PgConnection, Pool, Postgres, QueryBuilder, Transaction}; -use uuid::Uuid; -use crate::model::room_member::{RoomMember, MembershipStatus}; -use crate::model::{ChatRoomEntity, LastMessagePreviewText, NewRoom, RoomType}; - -#[derive(Clone)] -pub struct RoomRepository { - pool: Pool, -} - -impl RoomRepository { - - - pub fn new(pool: Pool) -> Self { - RoomRepository { pool } - } - - pub async fn start_transaction(&self) -> Result, Error> { - let tx = self.pool.begin().await?; - Ok(tx) - } - - pub fn get_connection(&self) -> &Pool { - &self.pool - } - - pub async fn select_all_user_in_room(&self, room_id: &Uuid) -> Result, sqlx::Error> { - let users = sqlx::query_as!(RoomMember, - r#" - SELECT users.id, - users.display_name, - users.profile_picture, - participants.joined_at, - participants.last_message_read_at, - participants.participant_state AS "membership_status: MembershipStatus" - FROM chat_room_participant AS participants - JOIN app_user AS users ON participants.user_id = users.id - WHERE participants.room_id = $1 - "#, room_id).fetch_all(&self.pool).await?; - Ok(users) - } - - pub async fn select_joined_user_in_room(&self, room_id: &Uuid) -> Result, sqlx::Error> { - let users = sqlx::query_as!(RoomMember, - r#" - SELECT - users.id, - users.display_name, - users.profile_picture, - participants.joined_at, - participants.last_message_read_at, - participants.participant_state AS "membership_status: MembershipStatus" - FROM chat_room_participant AS participants - JOIN app_user AS users ON participants.user_id = users.id - WHERE participants.room_id = $1 AND participants.participant_state = 'Joined' - "#, room_id).fetch_all(&self.pool).await?; - Ok(users) - } - - pub async fn select_joined_user_by_id(&self, room_id: &Uuid, user_id: &Uuid) -> Result { - let users = sqlx::query_as!(RoomMember, - r#" - SELECT - app_user.id, - app_user.display_name, - app_user.profile_picture, - chat_room_participant.joined_at, - chat_room_participant.last_message_read_at, - chat_room_participant.participant_state AS "membership_status: MembershipStatus" - FROM chat_room_participant - JOIN app_user ON chat_room_participant.user_id = app_user.id - WHERE chat_room_participant.room_id = $1 AND chat_room_participant.participant_state = 'Joined' AND chat_room_participant.user_id = $2 - "#, room_id, user_id).fetch_one(&self.pool).await?; - Ok(users) - } - - pub async fn get_joined_rooms(&self, user_id: &Uuid) -> Result, sqlx::Error> { - let rooms = sqlx::query_as!( - ChatRoomEntity, - r#" - SELECT - room.id, - room.room_type AS "room_type: RoomType", - room.created_at, - room.latest_message, - room.latest_message_preview_text, - COALESCE(other_user.display_name, room.room_name) AS room_name, - COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url, - COALESCE(p1.last_message_read_at < room.latest_message, TRUE) AS unread - FROM - chat_room_participant AS p1 - JOIN - chat_room AS room ON p1.room_id = room.id - -- 3. To find the other participant, only for single chat rooms! - LEFT JOIN LATERAL ( - SELECT - p2.user_id - FROM - chat_room_participant p2 - WHERE - p2.room_id = room.id AND p2.user_id != $1 - -- Only take the first match - LIMIT 1 - ) AS other_participant ON room.room_type = 'Single' - -- Only executed when the lateral join has matched something: - LEFT JOIN - app_user AS other_user ON other_user.id = other_participant.user_id - WHERE - p1.user_id = $1 - AND p1.participant_state = 'Joined' - ORDER BY - room.latest_message DESC - "#, - user_id - ).fetch_all(&self.pool).await?; - Ok(rooms) - } - - pub async fn delete_room(&self, conn: &mut PgConnection, room_id: &Uuid) -> Result<(), sqlx::Error> { - sqlx::query!("DELETE FROM chat_room_participant WHERE room_id = $1", room_id).execute(&mut *conn).await?; - sqlx::query!("DELETE FROM chat_room WHERE id = $1",room_id).execute(&mut *conn).await?; - Ok(()) - } - - pub async fn find_specific_joined_room(&self, room_id: &Uuid, user_id: &Uuid) -> Result, sqlx::Error> { - let room = sqlx::query_as!( - ChatRoomEntity, - r#" - SELECT - room.id, - room.room_type AS "room_type: RoomType", - room.created_at, - room.latest_message, - room.latest_message_preview_text, - COALESCE(other_user.display_name, room.room_name) AS room_name, - COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url, - COALESCE(participants.last_message_read_at < room.latest_message, TRUE) AS unread - FROM - chat_room_participant AS participants - JOIN - chat_room AS room ON participants.room_id = room.id - -- 3. To find the other participant, only for single chat rooms! - LEFT JOIN LATERAL ( - SELECT - p2.user_id - FROM - chat_room_participant p2 - WHERE - p2.room_id = room.id AND p2.user_id != $1 - LIMIT 1 - ) AS other_participant ON room.room_type = 'Single' - -- Only executed when the lateral join has matched something: - LEFT JOIN - app_user AS other_user ON other_user.id = other_participant.user_id - WHERE - participants.user_id = $1 - AND room.id = $2 - AND participants.participant_state = 'Joined' - "#, - user_id, - room_id - ).fetch_optional(&self.pool).await?; - Ok(room) - } - - pub async fn insert_room(&self, new_room: NewRoom) -> Result { - - let preview_text = serde_json::to_string( - &LastMessagePreviewText::New - ).map_err(|_| { - sqlx::Error::InvalidArgument("Can't serialize room preview text".to_string()) - })?; - - let room_entity = ChatRoomEntity { - id: Uuid::new_v4(), - room_type: new_room.room_type, - room_name: new_room.room_name, - room_image_url: None, - created_at: Utc::now(), - latest_message: Option::from(Utc::now()), - latest_message_preview_text: Option::from(preview_text), - unread: None - }; - - //https://docs.rs/sqlx/latest/sqlx/struct.Transaction.html - let mut tx = self.pool.begin().await?; - - let room = sqlx::query_as!( - ChatRoomEntity, - r#" - INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, room_name, created_at, room_type as "room_type: RoomType", latest_message, latest_message_preview_text, room_image_url, TRUE as "unread: _" - "#, - room_entity.id, - room_entity.room_type.to_string(), - room_entity.room_name, - room_entity.created_at, - room_entity.latest_message, - room_entity.latest_message_preview_text - ).fetch_one(&mut *tx).await?; - - //https://docs.rs/sqlx-core/0.5.13/sqlx_core/query_builder/struct.QueryBuilder.html#method.push_values - let mut builder: QueryBuilder = QueryBuilder::new( - "INSERT INTO chat_room_participant (user_id, room_id, joined_at, participant_state) " - ); - builder.push_values(&new_room.invited_users, |mut db, user| { - db.push_bind(user) - .push_bind(&room.id) - .push_bind(Utc::now()) - .push_bind(MembershipStatus::Joined.to_string()); - }).build().fetch_all(&mut *tx).await?; - - tx.commit().await?; - Ok(room) - } - - pub async fn select_room(&self, room_id: &Uuid) -> Result { - let room_details = sqlx::query_as!( - ChatRoomEntity, - r#" - SELECT id, room_type as "room_type: RoomType", room_name, created_at, latest_message, room_image_url, latest_message_preview_text, NULL::boolean as "unread: _" - FROM chat_room - WHERE id = $1 - "#, room_id).fetch_one(&self.pool).await?; - Ok(room_details) - } - - pub async fn is_user_in_room(&self, user_id: &Uuid, room_id: &Uuid) -> Result { - let exists = sqlx::query_scalar!( - r#" - SELECT EXISTS( - SELECT 1 - FROM chat_room_participant - WHERE user_id = $1 AND room_id = $2 AND participant_state = 'Joined' - ) - "#, user_id, room_id).fetch_one(&self.pool).await?; - Ok(exists.unwrap_or(false)) - } - - pub async fn find_room_between_users(&self, user_id: &Uuid, other_user_id: &Uuid) -> Result, sqlx::Error> { - let room_details = sqlx::query!( - r#" - SELECT r.id - FROM chat_room r - JOIN chat_room_participant p ON r.id = p.room_id - WHERE r.room_type = 'Single' AND p.user_id IN ($1, $2) AND p.participant_state = 'Joined' - GROUP BY r.id - HAVING COUNT(p.user_id) = 2 - "#, user_id, other_user_id).fetch_optional(&self.pool).await?; - - match room_details { - Some(room) => Ok(Some(room.id)), - None => Ok(None) - } - } - - pub async fn add_user_to_room(&self, conn: &mut PgConnection, user_id: &Uuid, room_id: &Uuid) -> Result { - sqlx::query!( - r#" - INSERT INTO chat_room_participant (user_id, room_id, joined_at, participant_state) - VALUES ($1, $2, $3, $4) - ON CONFLICT (user_id, room_id) - DO UPDATE SET joined_at = $3, participant_state = $4 - "#, - user_id, - room_id, - Utc::now(), - MembershipStatus::Joined.to_string() - ) - .execute(&mut *conn) - .await?; - - let user = sqlx::query_as!(RoomMember, - r#" - SELECT - users.id, - users.display_name, - users.profile_picture, - participants.joined_at, - participants.last_message_read_at, - participants.participant_state AS "membership_status: MembershipStatus" - FROM chat_room_participant AS participants - JOIN app_user AS users ON participants.user_id = users.id - WHERE participants.user_id = $1 AND participants.room_id = $2 - "#, - user_id, - room_id - ).fetch_one(&mut *conn).await?; - Ok(user) - } - - pub async fn select_room_participants_ids(&self, room_id: &Uuid) -> Result, sqlx::Error> { - let result = sqlx::query!(r#"SELECT user_id FROM chat_room_participant WHERE room_id = $1 AND participant_state = 'Joined'"#, room_id).fetch_all(&self.pool).await?; - let user: Vec = result.iter().map(|id| id.user_id).collect(); - Ok(user) - } - - /// If you really just want to accept both, a transaction or a - /// connection as an argument to a function, then it's easier to just accept a - /// mutable reference to a database connection like so: - /// - /// ```rust - /// # use sqlx::{postgres::PgConnection, error::BoxDynError}; - /// # #[cfg(any(postgres_9_6, postgres_14))] - /// async fn run_query(conn: &mut PgConnection) -> Result<(), BoxDynError> { - /// sqlx::query!("SELECT 1 as v").fetch_one(&mut *conn).await?; - /// sqlx::query!("SELECT 2 as v").fetch_one(&mut *conn).await?; - /// - /// Ok(()) - /// } - /// ``` - /// The downside of this approach is that you have to `acquire` a connection - /// from a pool first and can't directly pass the pool as argument. - /// - /// Like this: state.room_repository.get_connection().acquire().await.unwrap(); - /// - /// [workaround]: https://github.com/launchbadge/sqlx/issues/1015#issuecomment-767787777 - pub async fn update_last_room_message(&self, conn: &mut PgConnection, room_id: &Uuid, preview_text: &String) -> Result<(), sqlx::Error> - { - sqlx::query!( - "UPDATE chat_room SET latest_message = NOW(), latest_message_preview_text = $2 WHERE id = $1", - room_id, - preview_text - ).execute(&mut *conn).await?; - Ok(()) - } - - pub async fn update_user_read_status<'e, E>(&self, exec: E, room_id: &Uuid, user_id: &Uuid) -> Result<(), sqlx::Error> - where E: sqlx::Executor<'e, Database = Postgres> - { - sqlx::query!("Update chat_room_participant SET last_message_read_at = NOW() WHERE user_id = $1 AND room_id = $2", user_id, room_id).execute(exec).await?; - Ok(()) - } - - pub async fn update_room_img_url(&self, room_id: &Uuid, image_url: &String) -> Result<(), sqlx::Error> { - sqlx::query!("UPDATE chat_room SET room_image_url = $1 WHERE id = $2", image_url, room_id).execute(&self.pool).await?; - Ok(()) - } - - - pub async fn remove_user_from_room(&self, conn: &mut PgConnection, room_id: &Uuid, user_id: &Uuid, preview_text: &String) -> Result<(), sqlx::Error> { - sqlx::query!("UPDATE chat_room_participant SET participant_state = 'Left' WHERE user_id = $1 AND room_id = $2", user_id, room_id).execute(&mut *conn).await?; - sqlx::query!("UPDATE chat_room SET latest_message = NOW(), latest_message_preview_text = $2 WHERE id = $1", room_id, preview_text).execute(&mut *conn).await?; - Ok(()) - } - -} \ No newline at end of file diff --git a/src/rooms/handler.rs b/src/rooms/handler.rs index d22a8ed..d9143ef 100644 --- a/src/rooms/handler.rs +++ b/src/rooms/handler.rs @@ -1,51 +1,62 @@ -use std::collections::HashSet; -use std::sync::Arc; -use axum::{Extension, Json}; +use crate::auth::decode::KeycloakToken; +use crate::core::AppState; +use crate::core::cursor::{CursorResults, clamp_page_size, decode_cursor}; +use crate::core::errors::AppError; +use crate::messaging::model::TimelinePage; +use crate::rooms::model::UploadResponse; +use crate::rooms::room::{ + ChatRoomDto, ChatRoomWithUserDTO, NewRoom, RoomPaginationCursor, RoomType, +}; +use crate::rooms::room_member::RoomMember; +use crate::rooms::room_service::RoomService; +use crate::rooms::timeline_service::TimelineService; +use crate::users::user_service::UserService; +use crate::utils::check_user_in_room; use axum::extract::{Multipart, Path, Query, State}; +use axum::{Extension, Json}; use bytes::Bytes; use chrono::{DateTime, Utc}; use log::error; use serde::Deserialize; +use std::collections::HashSet; +use std::sync::Arc; use uuid::Uuid; -use crate::core::AppState; -use crate::errors::{AppError}; -use crate::keycloak::decode::KeycloakToken; -use crate::messaging::model::MessageDTO; -use crate::model::{ChatRoomDto, ChatRoomWithUserDTO, NewRoom, RoomMember, RoomType, UploadResponse}; -use crate::rooms::room_service::RoomService; -use crate::rooms::timeline_service::TimelineService; -use crate::user_relationship::user_service::UserService; -use crate::utils::check_user_in_room; #[derive(Deserialize, Debug)] pub struct RoomSearchQueryParam { #[serde(rename = "withUser")] - pub with_user: Uuid + pub with_user: Uuid, +} + +#[derive(Deserialize, Debug)] +pub struct RoomListQueryParams { + /// Optional case-insensitive name filter (other user for single rooms, room name for groups). + pub name: Option, + pub cursor: Option, + pub limit: Option, } #[derive(Deserialize)] pub struct TimelineQueryParam { - timestamp: DateTime + timestamp: DateTime, } pub async fn handle_scroll_chat_timeline( Extension(token): Extension>, State(state): State>, Path(room_id): Path, - Query(params): Query -) -> Result>, AppError> { - + Query(params): Query, +) -> Result, AppError> { check_user_in_room(&state, &token.subject, &room_id).await?; - let messages = TimelineService::scroll_chat_timeline(state, room_id, params.timestamp).await?; - Ok(Json(messages)) + let page = TimelineService::scroll_chat_timeline(state, room_id, params.timestamp).await?; + Ok(Json(page)) } pub async fn handle_get_users_in_room( State(state): State>, Extension(token): Extension>, - Path(room_id): Path + Path(room_id): Path, ) -> Result>, AppError> { - check_user_in_room(&state, &token.subject, &room_id).await?; let users = RoomService::get_users_in_room(state, room_id).await?; Ok(Json(users)) @@ -53,19 +64,23 @@ pub async fn handle_get_users_in_room( pub async fn handle_get_joined_rooms( State(state): State>, - Extension(token): Extension> -) -> Result>, AppError> { - - let rooms = RoomService::get_joined_rooms(state, token.subject).await?; + Extension(token): Extension>, + Query(params): Query, +) -> Result>, AppError> { + let cursor: RoomPaginationCursor = decode_cursor(params.cursor) + .map_err(|_| AppError::Validation("Invalid Cursor-Parameters.".to_string()))?; + let page_size = clamp_page_size(params.limit); + + let rooms = + RoomService::get_joined_rooms(state, token.subject, params.name, cursor, page_size).await?; Ok(Json(rooms)) } pub async fn handle_get_room_with_details( State(state): State>, Extension(token): Extension>, - Path(room_id): Path + Path(room_id): Path, ) -> Result, AppError> { - let room = RoomService::get_room_with_details(state, token.subject, room_id).await?; Ok(Json(room)) } @@ -73,7 +88,7 @@ pub async fn handle_get_room_with_details( pub async fn mark_room_as_read( State(state): State>, Extension(token): Extension>, - Path(room_id): Path + Path(room_id): Path, ) -> Result<(), AppError> { RoomService::mark_room_as_read(state, token.subject, room_id).await?; Ok(()) @@ -82,36 +97,51 @@ pub async fn mark_room_as_read( pub async fn handle_create_room( State(state): State>, Extension(token): Extension>, - Json(mut payload): Json + Json(mut payload): Json, ) -> Result, AppError> { - if !payload.invited_users.contains(&token.subject) { - return Err(AppError::ValidationError("Sender ID is not in the list of invited users.".to_string())); + return Err(AppError::Validation( + "Sender ID is not in the list of invited users.".to_string(), + )); } - - + //filter out all users that have an ignore-relationship with the sender - let ignored = UserService::get_blocked_users(state.clone(), &token.subject, &payload.invited_users).await?; + let ignored = + UserService::get_blocked_users(state.clone(), &token.subject, &payload.invited_users) + .await?; let filter_set: HashSet<_> = ignored.iter().collect(); - payload.invited_users.retain(|uuid| !filter_set.contains(uuid)); - + payload + .invited_users + .retain(|uuid| !filter_set.contains(uuid)); match payload.room_type { RoomType::Single => { if payload.invited_users.len() != 2 { - return Err(AppError::ValidationError("Personal rooms must have exactly two IDs (sender + one other).".to_string())); + return Err(AppError::Validation( + "Personal rooms must have exactly two IDs (sender + one other).".to_string(), + )); } - let other_user = payload.invited_users.iter().find(|&&el| el != token.subject).ok_or_else(|| { - AppError::ValidationError("Personal rooms must contain another user.".to_string()) - })?; - let has_active_chat = RoomService::find_existing_single_room(state.clone(), &token.subject, other_user).await?; + let other_user = payload + .invited_users + .iter() + .find(|&&el| el != token.subject) + .ok_or_else(|| { + AppError::Validation("Personal rooms must contain another user.".to_string()) + })?; + let has_active_chat = + RoomService::find_existing_single_room(state.clone(), &token.subject, other_user) + .await?; if has_active_chat.is_some() { - return Err(AppError::ValidationError("User already has an active personal chat.".to_string())); + return Err(AppError::Validation( + "User already has an active personal chat.".to_string(), + )); } } RoomType::Group => { if payload.invited_users.len() < 2 { - return Err(AppError::ValidationError("Groups must have more than one user.".to_string())); + return Err(AppError::Validation( + "Groups must have more than one user.".to_string(), + )); } } } @@ -122,7 +152,7 @@ pub async fn handle_create_room( pub async fn handle_get_room_list_item_by_id( Extension(token): Extension>, State(state): State>, - Path(room_id): Path + Path(room_id): Path, ) -> Result, AppError> { let room = RoomService::get_room_list_item_by_id(state, token.subject, room_id).await?; Ok(Json(room)) @@ -131,7 +161,7 @@ pub async fn handle_get_room_list_item_by_id( pub async fn handle_leave_room( Extension(token): Extension>, State(state): State>, - Path(room_id): Path + Path(room_id): Path, ) -> Result<(), AppError> { RoomService::leave_room(state, token.subject, room_id).await?; Ok(()) @@ -140,25 +170,25 @@ pub async fn handle_leave_room( pub async fn handle_invite_to_room( Extension(token): Extension>, State(state): State>, - Path((room_id, user_id)): Path<(Uuid, Uuid)> + Path((room_id, user_id)): Path<(Uuid, Uuid)>, ) -> Result<(), AppError> { - - let ignored = UserService::get_blocked_users(state.clone(), &token.subject, &vec!(user_id)).await?; + let ignored = + UserService::get_blocked_users(state.clone(), &token.subject, &vec![user_id]).await?; if ignored.contains(&user_id) { - return Err(AppError::Blocked("User is blocked.".to_string())); + return Err(AppError::Forbidden("User is blocked.".to_string())); } RoomService::invite_to_room(state, token.subject, room_id, user_id).await?; Ok(()) } - pub async fn handle_search_existing_single_room( Extension(token): Extension>, State(state): State>, Query(params): Query, ) -> Result>, AppError> { - let result = RoomService::find_existing_single_room(state, &token.subject, ¶ms.with_user).await?; + let result = + RoomService::find_existing_single_room(state, &token.subject, ¶ms.with_user).await?; Ok(Json(result)) } @@ -166,30 +196,35 @@ pub async fn handle_save_room_image( Extension(token): Extension>, State(state): State>, Path(room_id): Path, - mut multipart: Multipart + mut multipart: Multipart, ) -> Result, AppError> { check_user_in_room(&state, &token.subject, &room_id).await?; let mut image_data: Option = None; loop { match multipart.next_field().await { Ok(Some(field)) => { - if field.name() == Some("image") { + if field.name() == Some("image") { let data = match field.bytes().await { Ok(data) => data, Err(_) => { - return Err(AppError::ValidationError("Error reading the image byte stream.".to_string())) + return Err(AppError::Validation( + "Error reading the image byte stream.".to_string(), + )); } }; image_data = Some(data); break; } - }, + } Ok(None) => { break; //stream finished } - Err(err) => { //read error + Err(err) => { + //read error error!("Bad image upload: {}", err.to_string()); - return Err(AppError::ValidationError("Error reading the image byte stream.".to_string())) + return Err(AppError::Validation( + "Error reading the image byte stream.".to_string(), + )); } } } @@ -198,16 +233,18 @@ pub async fn handle_save_room_image( let response = RoomService::set_room_image(state, room_id, image_data).await?; Ok(Json(response)) } else { - Err(AppError::ValidationError("Required field 'image' not found in the upload.".to_string())) + Err(AppError::Validation( + "Required field 'image' not found in the upload.".to_string(), + )) } } pub async fn handle_get_read_states( Extension(token): Extension>, State(state): State>, - Path(room_id): Path + Path(room_id): Path, ) -> Result>, AppError> { check_user_in_room(&state, &token.subject, &room_id).await?; let read_states = RoomService::get_read_states(state, room_id).await?; Ok(Json(read_states)) -} \ No newline at end of file +} diff --git a/src/rooms/mod.rs b/src/rooms/mod.rs index 9723d24..16eb7a9 100644 --- a/src/rooms/mod.rs +++ b/src/rooms/mod.rs @@ -1,4 +1,8 @@ +mod handler; +mod model; +pub mod room; +pub mod room_member; +pub mod room_repository; +pub mod room_service; pub mod routes; mod timeline_service; -mod handler; -pub mod room_service; \ No newline at end of file diff --git a/src/model/response_utils.rs b/src/rooms/model.rs similarity index 68% rename from src/model/response_utils.rs rename to src/rooms/model.rs index c2ca73f..1810a5b 100644 --- a/src/model/response_utils.rs +++ b/src/rooms/model.rs @@ -1,8 +1,8 @@ -use serde::{Serialize}; +use serde::Serialize; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct UploadResponse { pub image_url: String, - pub image_name: String -} \ No newline at end of file + pub image_name: String, +} diff --git a/src/model/room.rs b/src/rooms/room.rs similarity index 67% rename from src/model/room.rs rename to src/rooms/room.rs index c38c5df..1fd8e87 100644 --- a/src/model/room.rs +++ b/src/rooms/room.rs @@ -1,10 +1,10 @@ +use crate::rooms::room_member::RoomMember; use crate::utils::truncate_and_serialize; use chrono::prelude::*; use serde::{Deserialize, Serialize}; use sqlx::Type; +use sqlx::types::Json; use uuid::Uuid; -use crate::model::room_member::RoomMember; - #[derive(sqlx::FromRow, sqlx::Type, Debug)] pub struct ChatRoomEntity { @@ -14,18 +14,17 @@ pub struct ChatRoomEntity { pub room_image_url: Option, pub created_at: DateTime, pub latest_message: Option>, - pub latest_message_preview_text: Option, - pub unread: Option + pub latest_message_preview_text: Option>, + pub unread: Option, } impl ChatRoomEntity { - pub fn to_dto(&self) -> ChatRoomDto { - - let last_message = match self.latest_message_preview_text.as_ref() { - Some(text) => serde_json::from_str::(text).unwrap_or(LastMessagePreviewText::New), - None => LastMessagePreviewText::New - }; + let last_message = self + .latest_message_preview_text + .as_ref() + .map(|j| j.0.clone()) + .unwrap_or(LastMessagePreviewText::New); ChatRoomDto { id: self.id, @@ -35,7 +34,7 @@ impl ChatRoomEntity { created_at: self.created_at, latest_message: self.latest_message, unread: self.unread, - latest_message_preview_text: last_message + latest_message_preview_text: last_message, } } } @@ -50,7 +49,7 @@ pub struct ChatRoomDto { pub created_at: DateTime, pub latest_message: Option>, pub unread: Option, - pub latest_message_preview_text: LastMessagePreviewText + pub latest_message_preview_text: LastMessagePreviewText, } #[derive(Serialize)] @@ -58,7 +57,7 @@ pub struct ChatRoomDto { pub struct ChatRoomWithUserDTO { #[serde(flatten)] pub room: ChatRoomDto, - pub users: Vec + pub users: Vec, } #[derive(Deserialize, Serialize, Clone, Debug)] @@ -67,64 +66,67 @@ pub enum LastMessagePreviewText { Text { sender_username: String, #[serde(serialize_with = "truncate_and_serialize")] - text: String + text: String, }, Media { sender_username: String, - media_type: String + media_type: String, }, Reply { sender_username: String, #[serde(serialize_with = "truncate_and_serialize")] - reply_text: String + reply_text: String, }, RoomChange { sender_username: String, - room_change_type: RoomChangeType + room_change_type: RoomChangeType, }, - New + New, } +/// Keyset cursor for the joined-rooms list. Rooms are ordered by recent activity +/// (`latest_message DESC`) with `id` as a deterministic tie-breaker. +#[derive(Deserialize, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RoomPaginationCursor { + pub last_seen_latest_message: Option>, + pub last_seen_room_id: Option, +} #[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct NewRoom { pub room_type: RoomType, pub room_name: Option, - pub invited_users: Vec + pub invited_users: Vec, } #[derive(Deserialize, Serialize, Debug, Clone)] pub enum RoomChangeType { LEAVE, JOIN, - INVITE + INVITE, } - #[derive(Debug, Deserialize, Serialize, Clone, Type, PartialEq)] #[sqlx(type_name = "room_type")] pub enum RoomType { Single, - Group + Group, } - impl RoomType { pub fn to_str(&self) -> &str { match self { RoomType::Single => "Single", - RoomType::Group => "Group" + RoomType::Group => "Group", } } pub fn to_string(&self) -> String { match self { RoomType::Single => String::from("Single"), - RoomType::Group => String::from("Group") + RoomType::Group => String::from("Group"), } } - } - - diff --git a/src/rooms/room_member.rs b/src/rooms/room_member.rs new file mode 100644 index 0000000..8d2c127 --- /dev/null +++ b/src/rooms/room_member.rs @@ -0,0 +1,35 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A room participant. A row in `chat_room_participant` always means the user is +/// currently in the room — leaving deletes the row, so there is no membership state. +/// +/// `joined_at` / `last_message_read_at` come from `chat_room_participant` and are +/// `None` for senders that are no longer members (e.g. historical message authors +/// surfaced in a timeline page after they left). +#[derive(Debug, Deserialize, Serialize, sqlx::FromRow, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RoomMember { + pub id: Uuid, + pub display_name: String, + pub profile_picture: Option, + pub joined_at: Option>, + pub last_message_read_at: Option>, +} + +/// Cached per-room participant snapshot used for fast broadcast fan-out. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RoomContext { + pub members: Vec, +} + +impl RoomContext { + pub fn member_ids(&self) -> Vec { + self.members.iter().map(|m| m.id).collect() + } + + pub fn find_member(&self, user_id: &Uuid) -> Option<&RoomMember> { + self.members.iter().find(|m| &m.id == user_id) + } +} diff --git a/src/rooms/room_repository.rs b/src/rooms/room_repository.rs new file mode 100644 index 0000000..9b0534c --- /dev/null +++ b/src/rooms/room_repository.rs @@ -0,0 +1,503 @@ +use crate::rooms::room::{ + ChatRoomEntity, LastMessagePreviewText, NewRoom, RoomPaginationCursor, RoomType, +}; +use crate::rooms::room_member::RoomMember; +use chrono::{DateTime, Utc}; +use sqlx::types::Json; +use sqlx::{Error, PgConnection, Pool, Postgres, QueryBuilder, Transaction}; +use uuid::Uuid; + +#[derive(Clone)] +pub struct RoomRepository { + pool: Pool, +} + +impl RoomRepository { + pub fn new(pool: Pool) -> Self { + RoomRepository { pool } + } + + pub async fn start_transaction(&self) -> Result, Error> { + let tx = self.pool.begin().await?; + Ok(tx) + } + + pub fn get_connection(&self) -> &Pool { + &self.pool + } + + pub async fn select_all_room_member( + &self, + room_id: &Uuid, + ) -> Result, sqlx::Error> { + let users = sqlx::query_as!( + RoomMember, + r#" + SELECT users.id, + users.display_name, + users.profile_picture, + participants.joined_at AS "joined_at?", + participants.last_message_read_at + FROM chat_room_participant AS participants + JOIN app_user AS users ON participants.user_id = users.id + WHERE participants.room_id = $1 + "#, + room_id + ) + .fetch_all(&self.pool) + .await?; + Ok(users) + } + + /// Paginated list of a user's joined rooms, ordered by recent activity. + /// + /// - `name_filter`: optional case-insensitive substring match. For single rooms + /// this matches the other participant's display name, for groups the room name + /// (the same `COALESCE` that produces `room_name` in the result). + /// - Keyset over `(latest_message, id)` so paging is stable under inserts. + /// Callers pass `limit = page_size + 1` to detect a following page. + /// + /// Uses a runtime query (not the `query_as!` macro) because of the optional + /// cursor/name binds — consistent with the relationship queries in `UserRepository`. + pub async fn get_joined_rooms( + &self, + user_id: &Uuid, + name_filter: Option<&str>, + cursor: RoomPaginationCursor, + limit: i64, + ) -> Result, sqlx::Error> { + let rooms = sqlx::query_as!( + ChatRoomEntity, + r#" + SELECT + room.id, + room.room_type AS "room_type: RoomType", + room.created_at, + room.latest_message, + room.latest_message_preview_text AS "latest_message_preview_text: Json", + COALESCE(other_user.display_name, room.room_name) AS room_name, + COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url, + COALESCE(p1.last_message_read_at < room.latest_message, TRUE) AS unread + FROM + chat_room_participant AS p1 + JOIN + chat_room AS room ON p1.room_id = room.id + -- To find the other participant, only for single chat rooms! + LEFT JOIN LATERAL ( + SELECT + p2.user_id + FROM + chat_room_participant p2 + WHERE + p2.room_id = room.id AND p2.user_id != $1 + -- Only take the first match + LIMIT 1 + ) AS other_participant ON room.room_type = 'Single' + -- Only executed when the lateral join has matched something: + LEFT JOIN + app_user AS other_user ON other_user.id = other_participant.user_id + WHERE + p1.user_id = $1 + AND ($2::text IS NULL OR COALESCE(other_user.display_name, room.room_name) ILIKE concat('%', $2, '%')) + AND ( + $3::timestamptz IS NULL + OR room.latest_message < $3 + OR (room.latest_message = $3 AND room.id < $4) + ) + ORDER BY + room.latest_message DESC, room.id DESC + LIMIT $5 + "#, + user_id, + name_filter, + cursor.last_seen_latest_message, + cursor.last_seen_room_id, + limit + ).fetch_all(&self.pool).await?; + Ok(rooms) + } + + pub async fn delete_room( + &self, + conn: &mut PgConnection, + room_id: &Uuid, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM chat_room_participant WHERE room_id = $1", + room_id + ) + .execute(&mut *conn) + .await?; + sqlx::query!("DELETE FROM chat_room WHERE id = $1", room_id) + .execute(&mut *conn) + .await?; + Ok(()) + } + + pub async fn find_specific_joined_room( + &self, + room_id: &Uuid, + user_id: &Uuid, + ) -> Result, sqlx::Error> { + let room = sqlx::query_as!( + ChatRoomEntity, + r#" + SELECT + room.id, + room.room_type AS "room_type: RoomType", + room.created_at, + room.latest_message, + room.latest_message_preview_text AS "latest_message_preview_text: Json", + COALESCE(other_user.display_name, room.room_name) AS room_name, + COALESCE(other_user.profile_picture, room.room_image_url) AS room_image_url, + COALESCE(participants.last_message_read_at < room.latest_message, TRUE) AS unread + FROM + chat_room_participant AS participants + JOIN + chat_room AS room ON participants.room_id = room.id + -- 3. To find the other participant, only for single chat rooms! + LEFT JOIN LATERAL ( + SELECT + p2.user_id + FROM + chat_room_participant p2 + WHERE + p2.room_id = room.id AND p2.user_id != $1 + LIMIT 1 + ) AS other_participant ON room.room_type = 'Single' + -- Only executed when the lateral join has matched something: + LEFT JOIN + app_user AS other_user ON other_user.id = other_participant.user_id + WHERE + participants.user_id = $1 + AND room.id = $2 + "#, + user_id, + room_id + ).fetch_optional(&self.pool).await?; + Ok(room) + } + + pub async fn insert_room(&self, new_room: NewRoom) -> Result { + let room_entity = ChatRoomEntity { + id: Uuid::new_v4(), + room_type: new_room.room_type, + room_name: new_room.room_name, + room_image_url: None, + created_at: Utc::now(), + latest_message: Some(Utc::now()), + latest_message_preview_text: Some(Json(LastMessagePreviewText::New)), + unread: None, + }; + + //https://docs.rs/sqlx/latest/sqlx/struct.Transaction.html + let mut tx = self.pool.begin().await?; + + let room = sqlx::query_as!( + ChatRoomEntity, + r#" + INSERT INTO chat_room (id, room_type, room_name, created_at, latest_message, latest_message_preview_text) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, room_name, created_at, room_type as "room_type: RoomType", latest_message, latest_message_preview_text AS "latest_message_preview_text: Json", room_image_url, TRUE as "unread: _" + "#, + room_entity.id, + room_entity.room_type.to_string(), + room_entity.room_name, + room_entity.created_at, + room_entity.latest_message, + room_entity.latest_message_preview_text as Option> + ).fetch_one(&mut *tx).await?; + + //https://docs.rs/sqlx-core/0.5.13/sqlx_core/query_builder/struct.QueryBuilder.html#method.push_values + let mut builder: QueryBuilder = + QueryBuilder::new("INSERT INTO chat_room_participant (user_id, room_id, joined_at) "); + builder + .push_values(&new_room.invited_users, |mut db, user| { + db.push_bind(user).push_bind(&room.id).push_bind(Utc::now()); + }) + .build() + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + Ok(room) + } + + pub async fn select_room(&self, room_id: &Uuid) -> Result { + let room_details = sqlx::query_as!( + ChatRoomEntity, + r#" + SELECT + id, + room_type as "room_type: RoomType", + room_name, + created_at, + latest_message, + room_image_url, + latest_message_preview_text AS "latest_message_preview_text: Json", + NULL::boolean as "unread: _" + FROM chat_room + WHERE id = $1 + "#, room_id).fetch_one(&self.pool).await?; + Ok(room_details) + } + + pub async fn is_user_in_room( + &self, + user_id: &Uuid, + room_id: &Uuid, + ) -> Result { + let exists = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 + FROM chat_room_participant + WHERE user_id = $1 AND room_id = $2 + ) + "#, + user_id, + room_id + ) + .fetch_one(&self.pool) + .await?; + Ok(exists.unwrap_or(false)) + } + + pub async fn find_room_between_users( + &self, + user_id: &Uuid, + other_user_id: &Uuid, + ) -> Result, sqlx::Error> { + let room_details = sqlx::query!( + r#" + SELECT r.id + FROM chat_room r + JOIN chat_room_participant p ON r.id = p.room_id + WHERE r.room_type = 'Single' AND p.user_id IN ($1, $2) + GROUP BY r.id + HAVING COUNT(p.user_id) = 2 + "#, + user_id, + other_user_id + ) + .fetch_optional(&self.pool) + .await?; + + match room_details { + Some(room) => Ok(Some(room.id)), + None => Ok(None), + } + } + + pub async fn add_user_to_room( + &self, + conn: &mut PgConnection, + user_id: &Uuid, + room_id: &Uuid, + ) -> Result { + sqlx::query!( + r#" + INSERT INTO chat_room_participant (user_id, room_id, joined_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, room_id) + DO UPDATE SET joined_at = $3 + "#, + user_id, + room_id, + Utc::now() + ) + .execute(&mut *conn) + .await?; + + let user = sqlx::query_as!( + RoomMember, + r#" + SELECT + users.id, + users.display_name, + users.profile_picture, + participants.joined_at AS "joined_at?", + participants.last_message_read_at + FROM chat_room_participant AS participants + JOIN app_user AS users ON participants.user_id = users.id + WHERE participants.user_id = $1 AND participants.room_id = $2 + "#, + user_id, + room_id + ) + .fetch_one(&mut *conn) + .await?; + Ok(user) + } + + pub async fn select_room_participants_ids( + &self, + room_id: &Uuid, + ) -> Result, sqlx::Error> { + let result = sqlx::query!( + r#"SELECT user_id FROM chat_room_participant WHERE room_id = $1"#, + room_id + ) + .fetch_all(&self.pool) + .await?; + let user: Vec = result.iter().map(|id| id.user_id).collect(); + Ok(user) + } + + /// If you really just want to accept both, a transaction or a + /// connection as an argument to a function, then it's easier to just accept a + /// mutable reference to a object_storage connection like so: + /// + /// ```rust + /// # use sqlx::{postgres::PgConnection, error::BoxDynError}; + /// # #[cfg(any(postgres_9_6, postgres_14))] + /// async fn run_query(conn: &mut PgConnection) -> Result<(), BoxDynError> { + /// sqlx::query!("SELECT 1 as v").fetch_one(&mut *conn).await?; + /// sqlx::query!("SELECT 2 as v").fetch_one(&mut *conn).await?; + /// + /// Ok(()) + /// } + /// ``` + /// The downside of this approach is that you have to `acquire` a connection + /// from a pool first and can't directly pass the pool as argument. + /// + /// Like this: state.room_repository.get_connection().acquire().await.unwrap(); + /// + /// [workaround]: https://github.com/launchbadge/sqlx/issues/1015#issuecomment-767787777 + pub async fn update_last_room_message( + &self, + conn: &mut PgConnection, + room_id: &Uuid, + preview_text: &LastMessagePreviewText, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE chat_room SET latest_message = NOW(), latest_message_preview_text = $2 WHERE id = $1", + room_id, + Json(preview_text) as Json<&LastMessagePreviewText> + ).execute(&mut *conn).await?; + Ok(()) + } + + pub async fn update_user_read_status<'e, E>( + &self, + exec: E, + room_id: &Uuid, + user_id: &Uuid, + ) -> Result<(), sqlx::Error> + where + E: sqlx::Executor<'e, Database = Postgres>, + { + sqlx::query!("Update chat_room_participant SET last_message_read_at = NOW() WHERE user_id = $1 AND room_id = $2", user_id, room_id).execute(exec).await?; + Ok(()) + } + + pub async fn update_room_img_url( + &self, + room_id: &Uuid, + image_url: &String, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE chat_room SET room_image_url = $1 WHERE id = $2", + image_url, + room_id + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn remove_user_from_room( + &self, + conn: &mut PgConnection, + room_id: &Uuid, + user_id: &Uuid, + preview_text: &LastMessagePreviewText, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM chat_room_participant + WHERE user_id = $1 AND room_id = $2 + "#, + user_id, + room_id + ) + .execute(&mut *conn) + .await?; + + sqlx::query!( + r#" + UPDATE chat_room + SET latest_message = NOW(),latest_message_preview_text = $2 + WHERE id = $1 + "#, + room_id, + Json(preview_text) as Json<&LastMessagePreviewText> + ) + .execute(&mut *conn) + .await?; + Ok(()) + } + + /// Resolves the given user ids to `RoomMember`s for a room, used to bundle the + /// authors of a timeline page. Uses a LEFT JOIN on the participant table so that + /// senders who have since left the room (no participant row) still resolve from + /// `app_user`, with `joined_at` / `last_message_read_at` as `None`. + pub async fn select_message_senders( + &self, + room_id: &Uuid, + sender_ids: &[Uuid], + ) -> Result, sqlx::Error> { + let senders = sqlx::query_as!( + RoomMember, + r#" + SELECT + users.id, + users.display_name, + users.profile_picture, + participants.joined_at AS "joined_at?", + participants.last_message_read_at AS "last_message_read_at?" + FROM app_user AS users + LEFT JOIN chat_room_participant AS participants + ON participants.user_id = users.id AND participants.room_id = $1 + WHERE users.id = ANY($2) + "#, + room_id, + sender_ids + ) + .fetch_all(&self.pool) + .await?; + Ok(senders) + } + + /// Atomically updates both the room's latest_message timestamp/preview and + /// the sender's read status in a single CTE round-trip. + pub async fn apply_message_to_room( + &self, + conn: &mut PgConnection, + room_id: &Uuid, + preview_text: &LastMessagePreviewText, + sender_id: &Uuid, + timestamp: DateTime, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + WITH room_update AS ( + UPDATE chat_room + SET latest_message = $3, + latest_message_preview_text = $2 + WHERE id = $1 + ) + UPDATE chat_room_participant + SET last_message_read_at = $3 + WHERE user_id = $4 AND room_id = $1 + "#, + room_id, + Json(preview_text) as Json<&LastMessagePreviewText>, + timestamp, + sender_id, + ) + .execute(&mut *conn) + .await?; + Ok(()) + } +} diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index e5b1430..1f7c3a3 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -1,252 +1,388 @@ -use std::sync::Arc; -use bytes::Bytes; -use chrono::Utc; -use log::{error}; -use uuid::Uuid; -use crate::broadcast::{BroadcastChannel, Notification}; use crate::broadcast::NotificationEvent::{LeaveRoom, RoomChangeEvent, UserReadChat}; +use crate::broadcast::{BroadcastChannel, Notification}; use crate::core::AppState; -use crate::errors::{AppError}; -use crate::messaging::model::{Message, MessageBody, RoomChangeBody}; -use crate::model::{ChatRoomDto, ChatRoomEntity, ChatRoomWithUserDTO, LastMessagePreviewText, MembershipStatus, NewRoom, RoomChangeType, RoomMember, RoomType, UploadResponse}; +use crate::core::cursor::{CursorResults, next_cursor}; +use crate::core::errors::AppError; +use crate::messaging::model::{MessageBody, MessageDto, MessageEntity, RoomChangeBody}; +use crate::rooms::model::UploadResponse; +use crate::rooms::room::{ + ChatRoomDto, ChatRoomEntity, ChatRoomWithUserDTO, LastMessagePreviewText, NewRoom, + RoomChangeType, RoomPaginationCursor, RoomType, +}; +use crate::rooms::room_member::RoomMember; use crate::utils::crop_image_from_center; +use bytes::Bytes; +use log::error; +use std::sync::Arc; +use uuid::Uuid; pub struct RoomService; impl RoomService { - - pub async fn get_users_in_room(state: Arc, room_id: Uuid, ) -> Result, AppError> { - let users = state.room_repository.select_all_user_in_room(&room_id).await.map_err(|_| AppError::NotFound("Room not found:".to_string()))?; + pub async fn get_users_in_room( + state: Arc, + room_id: Uuid, + ) -> Result, AppError> { + let users = state + .room_repository + .select_all_room_member(&room_id) + .await + .map_err(|_| AppError::NotFound("Room not found:".to_string()))?; Ok(users) } - pub async fn get_joined_rooms(state: Arc, client_id: Uuid, ) -> Result, AppError> { - let rooms = state.room_repository.get_joined_rooms(&client_id).await?; - Ok(rooms.iter().map(|room| room.to_dto()).collect()) + pub async fn get_joined_rooms( + state: Arc, + client_id: Uuid, + name_filter: Option, + cursor: RoomPaginationCursor, + page_size: usize, + ) -> Result, AppError> { + let mut rooms = state + .room_repository + .get_joined_rooms( + &client_id, + name_filter.as_deref(), + cursor, + (page_size + 1) as i64, + ) + .await?; + + let next_cursor = next_cursor(&mut rooms, page_size, |room| RoomPaginationCursor { + last_seen_latest_message: room.latest_message, + last_seen_room_id: Some(room.id), + }) + .map_err(|e| AppError::Processing(format!("Cursor encoding failed: {}", e)))?; + + Ok(CursorResults { + cursor: next_cursor, + content: rooms.iter().map(|room| room.to_dto()).collect(), + }) } - pub async fn get_room_with_details(state: Arc, client_id: Uuid, room_id: Uuid) -> Result { - - let (chat_room, users) = tokio::try_join!( //executing 2 queries async - state.room_repository.find_specific_joined_room(&room_id, &client_id), - state.room_repository.select_all_user_in_room(&room_id) + pub async fn get_room_with_details( + state: Arc, + client_id: Uuid, + room_id: Uuid, + ) -> Result { + let (chat_room, users) = tokio::try_join!( + //executing 2 queries async + state + .room_repository + .find_specific_joined_room(&room_id, &client_id), + state.room_repository.select_all_room_member(&room_id) )?; match chat_room { Some(room) => { - let room_details = ChatRoomWithUserDTO { room: room.to_dto(), users }; + let room_details = ChatRoomWithUserDTO { + room: room.to_dto(), + users, + }; Ok(room_details) - }, - None => Err(AppError::NotFound("Room not found:".to_string())) + } + None => Err(AppError::NotFound("Room not found:".to_string())), } } - pub async fn mark_room_as_read(state: Arc, client_id: Uuid, room_id: Uuid) -> Result<(), AppError> { + pub async fn mark_room_as_read( + state: Arc, + client_id: Uuid, + room_id: Uuid, + ) -> Result<(), AppError> { let pl = state.room_repository.get_connection(); - state.room_repository.update_user_read_status(pl, &room_id, &client_id).await?; + state + .room_repository + .update_user_read_status(pl, &room_id, &client_id) + .await?; let room = state.room_repository.select_room(&room_id).await?; - if let Some(latest_msg_time) = room.latest_message { - let user = state.room_repository.select_joined_user_by_id(&room_id, &client_id).await?; - if let Some(read_time) = user.last_message_read_at { - if read_time >= latest_msg_time { - let users_in_room = state.room_repository.select_room_participants_ids(&room_id).await?; - BroadcastChannel::get().send_event_to_all( - users_in_room, - Notification { - body: UserReadChat { user_id: client_id, room_id }, - created_at: Utc::now() - } - ).await; - } - } - } else { - let users_in_room = state.room_repository.select_room_participants_ids(&room_id).await?; - BroadcastChannel::get().send_event_to_all( - users_in_room, - Notification { - body: UserReadChat { user_id: client_id, room_id }, - created_at: Utc::now() - } - ).await; + if room.latest_message.is_none() { + return Ok(()); } + let users_in_room = state + .room_repository + .select_room_participants_ids(&room_id) + .await?; + BroadcastChannel::get() + .send_event_to_all( + users_in_room, + Notification::new(UserReadChat { + user_id: client_id, + room_id, + }), + ) + .await; Ok(()) } - pub async fn get_read_states(state: Arc, room_id: Uuid) -> Result, AppError> { - let users = state.room_repository.select_joined_user_in_room(&room_id).await?; + pub async fn get_read_states( + state: Arc, + room_id: Uuid, + ) -> Result, AppError> { + let users = state + .room_repository + .select_all_room_member(&room_id) + .await?; let room = state.room_repository.select_room(&room_id).await?; - let read_users: Vec = users.into_iter().filter(|user| { - user_has_read(user, room.latest_message) - }).collect(); + let read_users: Vec = users + .into_iter() + .filter(|user| user_has_read(user, room.latest_message)) + .collect(); Ok(read_users) } - pub async fn create_room(state: Arc, client_id: Uuid, new_room: NewRoom) -> Result { + pub async fn create_room( + state: Arc, + client_id: Uuid, + new_room: NewRoom, + ) -> Result { let room_entity = state.room_repository.insert_room(new_room.clone()).await?; - let creator_entity = state.user_repository.find_user_by_id(&client_id).await?.ok_or_else(|| { - AppError::NotFound("UserID not found.".to_string()) - })?; + let creator_entity = state + .user_repository + .find_user_by_id(&client_id) + .await? + .ok_or_else(|| AppError::NotFound("UserID not found.".to_string()))?; let users = new_room.invited_users; if room_entity.room_type == RoomType::Single { let other_user = match users.iter().find(|&&entry| entry != client_id) { Some(other_user) => other_user, - None => return Err(AppError::ValidationError("Can't find other user.".to_string())) + None => return Err(AppError::Validation("Can't find other user.".to_string())), }; //sending 2 specific room views to the users, because private rooms are shown like another user - let (room_client, room_receiver) = tokio::try_join!( //executing 2 queries async - state.room_repository.find_specific_joined_room(&room_entity.id, &client_id), - state.room_repository.find_specific_joined_room(&room_entity.id, other_user) + let (room_client, room_receiver) = tokio::try_join!( + //executing 2 queries async + state + .room_repository + .find_specific_joined_room(&room_entity.id, &client_id), + state + .room_repository + .find_specific_joined_room(&room_entity.id, other_user) )?; if let (Some(creator_room), Some(participator_room)) = (room_client, room_receiver) { - let broadcast = BroadcastChannel::get(); - broadcast.send_event(Notification { - body: crate::broadcast::NotificationEvent::NewRoom {room: participator_room.to_dto(), created_by: creator_entity.clone()}, - created_at: Utc::now() - }, other_user).await; - - broadcast.send_event(Notification { - body: crate::broadcast::NotificationEvent::NewRoom {room: creator_room.to_dto(), created_by: creator_entity}, - created_at: Utc::now() - }, &client_id).await; + broadcast + .send_event( + Notification::new(crate::broadcast::NotificationEvent::NewRoom { + room: participator_room.to_dto(), + created_by: creator_entity.clone(), + }), + other_user, + ) + .await; + + broadcast + .send_event( + Notification::new(crate::broadcast::NotificationEvent::NewRoom { + room: creator_room.to_dto(), + created_by: creator_entity, + }), + &client_id, + ) + .await; Ok(creator_room.to_dto()) } else { - Err(AppError::ProcessingError("Newly created room is null.".to_string())) + Err(AppError::Processing( + "Newly created room is null.".to_string(), + )) } - } else { //is group room + } else { + //is group room let room_dto = room_entity.to_dto(); - BroadcastChannel::get().send_event_to_all( - users, - Notification { - body: crate::broadcast::NotificationEvent::NewRoom {room: room_dto.clone(), created_by: creator_entity.clone()}, - created_at: Utc::now() - } - ).await; + BroadcastChannel::get() + .send_event_to_all( + users, + Notification::new(crate::broadcast::NotificationEvent::NewRoom { + room: room_dto.clone(), + created_by: creator_entity.clone(), + }), + ) + .await; Ok(room_dto) } } - pub async fn get_room_list_item_by_id(state: Arc, client_id: Uuid, room_id: Uuid) -> Result { - let room = state.room_repository.find_specific_joined_room(&room_id, &client_id).await?.ok_or_else(|| { - AppError::NotFound("Room not found.".to_string()) - })?; + pub async fn get_room_list_item_by_id( + state: Arc, + client_id: Uuid, + room_id: Uuid, + ) -> Result { + let room = state + .room_repository + .find_specific_joined_room(&room_id, &client_id) + .await? + .ok_or_else(|| AppError::NotFound("Room not found.".to_string()))?; Ok(room.to_dto()) } - pub async fn leave_room(state: Arc, client_id: Uuid, room_id: Uuid) -> Result<(), AppError> { - let (room, users) = tokio::try_join!( //executing 2 queries async + pub async fn leave_room( + state: Arc, + client_id: Uuid, + room_id: Uuid, + ) -> Result<(), AppError> { + let (room, users) = tokio::try_join!( + //executing 2 queries async state.room_repository.select_room(&room_id), - state.room_repository.select_joined_user_in_room(&room_id) + state.room_repository.select_all_room_member(&room_id) )?; let leaving_user = match users.iter().find(|user| user.id == client_id) { Some(user) => user.clone(), None => { - return Err(AppError::Blocked("Client is not in this room.".to_string())) + return Err(AppError::Forbidden( + "Client is not in this room.".to_string(), + )); } }; - if room.room_type == RoomType::Single { //if someone leaves a single room, the whole room is getting wiped! + if room.room_type == RoomType::Single { + //if someone leaves a single room, the whole room is getting wiped! handle_leave_private_room(state, room, users).await?; Ok(()) - } else { //handle the group leave logic + } else { + //handle the group leave logic handle_leave_group_room(state, room, users, leaving_user).await?; Ok(()) } } - pub async fn invite_to_room(state: Arc, client_id: Uuid, room_id: Uuid, user_id: Uuid) -> Result<(), AppError> { - let (room, users, creator) = tokio::try_join!( //executing 3 queries async + pub async fn invite_to_room( + state: Arc, + client_id: Uuid, + room_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError> { + let (room, users, creator) = tokio::try_join!( + //executing 3 queries async state.room_repository.select_room(&room_id), - state.room_repository.select_joined_user_in_room(&room_id), + state.room_repository.select_all_room_member(&room_id), state.user_repository.find_user_by_id(&client_id) )?; - let creator_entity = creator.ok_or_else(|| { - AppError::NotFound("UserID not found.".to_string()) - })?; - + let creator_entity = + creator.ok_or_else(|| AppError::NotFound("UserID not found.".to_string()))?; if room.room_type == RoomType::Single { - return Err(AppError::ValidationError("Private rooms doesn't allow invites!.".to_string())) + return Err(AppError::Validation( + "Private rooms doesn't allow invites!.".to_string(), + )); }; //we have to check if the inviter is in the room and the invited user isn't! - users.iter().find(|user| user.id == client_id).ok_or_else(|| { - AppError::Blocked("Client is not in this room.".to_string()) - })?; - + users + .iter() + .find(|user| user.id == client_id) + .ok_or_else(|| AppError::Forbidden("Client is not in this room.".to_string()))?; let user_to_exclude = users.iter().find(|user| user.id == user_id); if user_to_exclude.is_some() { - return Err(AppError::BadRequest("User is already in this room.".to_string())) + return Err(AppError::Validation( + "User is already in this room.".to_string(), + )); } //1. add him to the room let mut tx = state.room_repository.start_transaction().await?; - let user = state.room_repository.add_user_to_room(&mut *tx, &user_id, &room_id).await?; - let preview_text = LastMessagePreviewText::RoomChange { sender_username: user.display_name.clone(), room_change_type: RoomChangeType::JOIN}; - let preview_str = serde_json::to_string(&preview_text).map_err(|_| { - AppError::ProcessingError("Can't serialize room preview text".to_string()) - })?; - state.room_repository.update_last_room_message(&mut *tx, &room_id, &preview_str).await?; - tx.commit().await?; + let user = state + .room_repository + .add_user_to_room(&mut *tx, &user_id, &room_id) + .await?; + let preview_text = LastMessagePreviewText::RoomChange { + sender_username: user.display_name.clone(), + room_change_type: RoomChangeType::JOIN, + }; + state + .room_repository + .update_last_room_message(&mut *tx, &room_id, &preview_text) + .await?; //2. build room change message and send it to all previous users in the room - let message = Message::new(room_id, user.id, MessageBody::RoomChange(RoomChangeBody::UserJoined {related_user: user.clone()})) - .map_err(|_| AppError::ProcessingError("Unable to create room message".to_string()))?; + let message = MessageEntity::new( + room_id, + user.id, + MessageBody::RoomChange(RoomChangeBody::UserJoined { + related_user: user.clone(), + }), + ); + state + .chat_repository + .insert_message(&mut *tx, &message) + .await?; let send_to: Vec = users.iter().map(|user| user.id).collect(); - save_room_change_message_and_broadcast(message, &state, send_to, preview_text).await?; - state.cache.add_user_to_room_cache(&user.id, &room_id).await?; + tx.commit().await?; - //sending new room event to invited user - let room_for_user = state.room_repository.find_specific_joined_room(&room_id, &user_id).await?.ok_or_else(|| { - AppError::ProcessingError("Unable to find room for the invited user.".to_string()) - })?; + save_room_change_message_and_broadcast(message, send_to, preview_text).await?; + state.cache.invalidate_room_context(&room_id).await?; - BroadcastChannel::get().send_event( - Notification { - body: crate::broadcast::NotificationEvent::NewRoom {room: room_for_user.to_dto(), created_by: creator_entity}, - created_at: Utc::now() - }, - &user.id - ).await; + //sending new room event to invited user + let room_for_user = state + .room_repository + .find_specific_joined_room(&room_id, &user_id) + .await? + .ok_or_else(|| { + AppError::Processing("Unable to find room for the invited user.".to_string()) + })?; + + BroadcastChannel::get() + .send_event( + Notification::new(crate::broadcast::NotificationEvent::NewRoom { + room: room_for_user.to_dto(), + created_by: creator_entity, + }), + &user.id, + ) + .await; Ok(()) } - pub async fn find_existing_single_room(state: Arc, client_id: &Uuid, with_user: &Uuid) -> Result, AppError> { - let room_id = state.room_repository.find_room_between_users(client_id, with_user).await?; + pub async fn find_existing_single_room( + state: Arc, + client_id: &Uuid, + with_user: &Uuid, + ) -> Result, AppError> { + let room_id = state + .room_repository + .find_room_between_users(client_id, with_user) + .await?; Ok(room_id) } - pub async fn set_room_image(state: Arc, room_id: Uuid, image_data: Bytes) -> Result { - + pub async fn set_room_image( + state: Arc, + room_id: Uuid, + image_data: Bytes, + ) -> Result { let img = crop_image_from_center(&image_data, 500, 500).map_err(|err| { error!("Unable to crop image: {}", err.to_string()); - AppError::ProcessingError("Unable to crop image.".to_string()) + AppError::Processing("Unable to crop image.".to_string()) })?; let object_id = format!("{}/{}", state.env.object_db_config.bucket_name, room_id); - if let Err(err) = state.s3_bucket.insert_object(&room_id.to_string(), img).await { + if let Err(err) = state + .s3_bucket + .insert_object(&room_id.to_string(), img) + .await + { error!("{}", err.to_string()); - return Err(AppError::S3Error("Unable save image in s3 bucket.".to_string())) + return Err(AppError::S3("Unable save image in s3 bucket.".to_string())); }; - state.room_repository.update_room_img_url(&room_id, &object_id).await?; + state + .room_repository + .update_room_img_url(&room_id, &object_id) + .await?; let response = UploadResponse { image_url: object_id.clone(), image_name: format!("{}.jpeg", object_id), }; Ok(response) } - } // Helper used by `get_read_states` — extracted for easier unit testing of the read logic. @@ -258,115 +394,148 @@ fn user_has_read(user: &RoomMember, room_latest: Option, room: ChatRoomEntity, users: Vec) -> Result<(), AppError> { +async fn handle_leave_private_room( + state: Arc, + room: ChatRoomEntity, + users: Vec, +) -> Result<(), AppError> { let mut tx = state.room_repository.start_transaction().await?; - state.room_repository.delete_room(&mut *tx, &room.id).await?; + state + .chat_repository + .delete_room_messages(&mut *tx, &room.id) + .await?; + state + .room_repository + .delete_room(&mut *tx, &room.id) + .await?; tx.commit().await?; - state.message_repository.clear_chat_room_messages(&room.id).await?; - state.cache.set_user_for_room(&room.id, &vec![]).await?; + state.cache.invalidate_room_context(&room.id).await?; let send_to: Vec = users.iter().map(|user| user.id).collect(); - BroadcastChannel::get().send_event_to_all( - send_to, - Notification { - body: LeaveRoom {room_id: room.id}, - created_at: Utc::now() - } - ).await; + BroadcastChannel::get() + .send_event_to_all(send_to, Notification::new(LeaveRoom { room_id: room.id })) + .await; Ok(()) } -async fn handle_leave_group_room(state: Arc, room: ChatRoomEntity, users: Vec, mut leaving_user: RoomMember) -> Result<(), AppError> { +async fn handle_leave_group_room( + state: Arc, + room: ChatRoomEntity, + users: Vec, + leaving_user: RoomMember, +) -> Result<(), AppError> { let mut tx = state.room_repository.start_transaction().await?; - let preview_message = LastMessagePreviewText::RoomChange { sender_username: leaving_user.display_name.clone(), room_change_type: RoomChangeType::LEAVE }; - let preview_text = serde_json::to_string(&preview_message).map_err(|err| { - AppError::ProcessingError(format!("Unable to serialize last message preview text: {}", err.to_string())) - })?; - - state.room_repository.remove_user_from_room(&mut *tx, &room.id, &leaving_user.id, &preview_text).await?; - leaving_user.membership_status = MembershipStatus::Left; - - if users.len() == 1 { //last user, delete this room now - state.message_repository.clear_chat_room_messages(&room.id).await?; - state.room_repository.delete_room(&mut *tx, &room.id).await?; + let preview_message = LastMessagePreviewText::RoomChange { + sender_username: leaving_user.display_name.clone(), + room_change_type: RoomChangeType::LEAVE, + }; + state + .room_repository + .remove_user_from_room(&mut *tx, &room.id, &leaving_user.id, &preview_message) + .await?; + + if users.len() == 1 { + //last user, delete this room now + state + .chat_repository + .delete_room_messages(&mut *tx, &room.id) + .await?; + state + .room_repository + .delete_room(&mut *tx, &room.id) + .await?; tx.commit().await?; - state.cache.set_user_for_room(&room.id, &vec![]).await?; + state.cache.invalidate_room_context(&room.id).await?; - BroadcastChannel::get().send_event( - Notification { - body: LeaveRoom {room_id: room.id}, - created_at: Utc::now() - }, - &leaving_user.id - ).await; + BroadcastChannel::get() + .send_event( + Notification::new(LeaveRoom { room_id: room.id }), + &leaving_user.id, + ) + .await; //delete room image if it exists: if let Some(_url) = room.room_image_url { - state.s3_bucket.delete_object(&room.id.to_string()).await - .map_err(|_| AppError::ProcessingError("Unable to delete image from room".to_string()))?; + state + .s3_bucket + .delete_object(&room.id.to_string()) + .await + .map_err(|_| { + AppError::Processing("Unable to delete image from room".to_string()) + })?; } Ok(()) - } else { //find and handle the leaving user - - let message = Message::new(room.id, leaving_user.id, MessageBody::RoomChange(RoomChangeBody::UserLeft {related_user: leaving_user.clone()})) - .map_err(|_err| AppError::ProcessingError("Unable to create room message".to_string()))?; - - let send_to: Vec = users.iter().filter(|user| user.id != leaving_user.id).map(|user| user.id).collect(); - save_room_change_message_and_broadcast(message, &state, send_to, preview_message).await?; + } else { + //find and handle the leaving user + + let message = MessageEntity::new( + room.id, + leaving_user.id, + MessageBody::RoomChange(RoomChangeBody::UserLeft { + related_user: leaving_user.clone(), + }), + ); + state + .chat_repository + .insert_message(&mut *tx, &message) + .await?; tx.commit().await?; - state.cache.remove_user_from_room_cache(&leaving_user.id, &room.id).await?; + let send_to: Vec = users + .iter() + .filter(|user| user.id != leaving_user.id) + .map(|user| user.id) + .collect(); + save_room_change_message_and_broadcast(message, send_to, preview_message).await?; + + state.cache.invalidate_room_context(&room.id).await?; //send ack to the leaving user - BroadcastChannel::get().send_event( - Notification { - body: LeaveRoom {room_id: room.id}, - created_at: Utc::now() - }, - &leaving_user.id - ).await; + BroadcastChannel::get() + .send_event( + Notification::new(LeaveRoom { room_id: room.id }), + &leaving_user.id, + ) + .await; Ok(()) } } -async fn save_room_change_message_and_broadcast(message: Message, state: &Arc, to_users: Vec, preview_text: LastMessagePreviewText) -> Result<(), AppError> { - state.message_repository.insert_data(message.clone()).await?; - - let mapped_msg = message.to_dto().map_err(|_| { - AppError::ProcessingError("Unable to cast message to dto.".to_string()) - })?; - - let notification = Notification { - body: RoomChangeEvent{message: mapped_msg, room_preview_text: preview_text}, - created_at: Utc::now() - }; - - BroadcastChannel::get().send_event_to_all(to_users, notification).await; +async fn save_room_change_message_and_broadcast( + message: MessageEntity, + to_users: Vec, + preview_text: LastMessagePreviewText, +) -> Result<(), AppError> { + let mapped_msg = MessageDto::from(message); + let notification = Notification::new(RoomChangeEvent { + message: mapped_msg, + room_preview_text: preview_text, + }); + BroadcastChannel::get() + .send_event_to_all(to_users, notification) + .await; Ok(()) } - #[cfg(test)] mod tests { use super::*; - use chrono::{Utc, Duration}; + use crate::rooms::room_member::RoomMember; + use chrono::{Duration, Utc}; use uuid::Uuid; - use crate::model::room_member::{RoomMember, MembershipStatus}; fn make_member(read_at: Option>) -> RoomMember { RoomMember { id: Uuid::new_v4(), display_name: "test".to_string(), profile_picture: None, - joined_at: Utc::now(), + joined_at: Some(Utc::now()), last_message_read_at: read_at, - membership_status: MembershipStatus::Joined } } @@ -374,7 +543,10 @@ mod tests { fn user_has_read_when_no_latest_message() { let user = make_member(None); let result = user_has_read(&user, None); - assert!(result, "When room has no latest message, every user should be considered read"); + assert!( + result, + "When room has no latest message, every user should be considered read" + ); } #[test] @@ -399,4 +571,4 @@ mod tests { let user = make_member(None); assert!(!user_has_read(&user, Some(latest))); } -} \ No newline at end of file +} diff --git a/src/rooms/routes.rs b/src/rooms/routes.rs index 3846b18..eb3596d 100644 --- a/src/rooms/routes.rs +++ b/src/rooms/routes.rs @@ -1,22 +1,41 @@ -use std::sync::Arc; +use crate::core::AppState; +use crate::rooms::handler::{ + handle_create_room, handle_get_joined_rooms, handle_get_read_states, + handle_get_room_list_item_by_id, handle_get_room_with_details, handle_get_users_in_room, + handle_invite_to_room, handle_leave_room, handle_save_room_image, handle_scroll_chat_timeline, + handle_search_existing_single_room, mark_room_as_read, +}; use axum::Router; use axum::routing::{get, post}; -use crate::core::AppState; -use crate::rooms::handler::{handle_create_room, handle_get_joined_rooms, handle_get_room_list_item_by_id, handle_get_room_with_details, handle_get_users_in_room, handle_invite_to_room, handle_leave_room, handle_save_room_image, handle_scroll_chat_timeline, handle_search_existing_single_room, mark_room_as_read, handle_get_read_states}; - +use std::sync::Arc; pub fn create_room_routes() -> Router> { Router::new() .route("/api/rooms/create-room", post(handle_create_room)) .route("/api/rooms/{room_id}/users", get(handle_get_users_in_room)) - .route("/api/rooms/{room_id}/detailed", get(handle_get_room_with_details)) - .route("/api/rooms/{room_id}/timeline", get(handle_scroll_chat_timeline)) + .route( + "/api/rooms/{room_id}/detailed", + get(handle_get_room_with_details), + ) + .route( + "/api/rooms/{room_id}/timeline", + get(handle_scroll_chat_timeline), + ) .route("/api/rooms/{room_id}", get(handle_get_room_list_item_by_id)) .route("/api/rooms/{room_id}/leave", post(handle_leave_room)) .route("/api/rooms/search", get(handle_search_existing_single_room)) - .route("/api/rooms/{room_id}/invite/{user_id}", post(handle_invite_to_room)) - .route("/api/rooms/{room_id}/upload-img", post(handle_save_room_image)) + .route( + "/api/rooms/{room_id}/invite/{user_id}", + post(handle_invite_to_room), + ) + .route( + "/api/rooms/{room_id}/upload-img", + post(handle_save_room_image), + ) .route("/api/rooms", get(handle_get_joined_rooms)) .route("/api/rooms/{room_id}/mark-read", post(mark_room_as_read)) - .route("/api/rooms/{room_id}/read-states", get(handle_get_read_states)) + .route( + "/api/rooms/{room_id}/read-states", + get(handle_get_read_states), + ) } diff --git a/src/rooms/timeline_service.rs b/src/rooms/timeline_service.rs index 661dd87..48d6c58 100644 --- a/src/rooms/timeline_service.rs +++ b/src/rooms/timeline_service.rs @@ -1,33 +1,43 @@ -use std::sync::Arc; +use crate::core::AppState; +use crate::core::errors::AppResponse; +use crate::messaging::model::{MessageBody, MessageDto, TimelinePage}; use chrono::{DateTime, Utc}; -use log::error; +use std::sync::Arc; use uuid::Uuid; -use crate::core::AppState; -use crate::errors::AppError; -use crate::messaging::model::MessageDTO; pub struct TimelineService; impl TimelineService { - pub async fn scroll_chat_timeline( state: Arc, room_id: Uuid, - timestamp: DateTime - ) -> Result, AppError> { - - let data = state.message_repository.fetch_data(timestamp, room_id).await - .map_err(|err| AppError::DatabaseError(err))?; - - let mut mapped: Vec = vec![]; - data.into_iter().for_each(|message| { - match message.to_dto() { - Ok(dto) => mapped.push(dto), - Err(err) => { - error!("Failed to convert message to DTO: {}", err); - } + timestamp: DateTime, + ) -> AppResponse { + let entities = state + .chat_repository + .fetch_messages(room_id, timestamp) + .await?; + + // Collect the distinct authors of this page so the client can render every + // message without a separate lookup — including authors that have since left. + // Reply messages reference the original author (`reply_sender_id`), who may be + // outside this page, so include them too. + let mut sender_ids: Vec = Vec::with_capacity(entities.len()); + for message in &entities { + sender_ids.push(message.sender_id); + if let MessageBody::Reply(reply) = &message.msg_body.0 { + sender_ids.push(reply.reply_sender_id); } - }); - Ok(mapped) + } + sender_ids.sort(); + sender_ids.dedup(); + + let senders = state + .room_repository + .select_message_senders(&room_id, &sender_ids) + .await?; + let messages = entities.into_iter().map(MessageDto::from).collect(); + + Ok(TimelinePage { messages, senders }) } -} \ No newline at end of file +} diff --git a/src/router.rs b/src/router.rs index d9611f0..1eeb287 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,22 +1,26 @@ -use std::sync::Arc; -use axum::http::{HeaderValue, Method, StatusCode}; -use axum::{Router}; +use crate::auth::PassthroughMode; +use crate::auth::instance::{KeycloakAuthInstance, KeycloakConfig}; +use crate::auth::layer::KeycloakAuthLayer; +use crate::core::{AppState, TokenIssuer}; +use crate::messaging::routes::create_messaging_routes; +use crate::rooms::routes::create_room_routes; +use crate::users::routes::create_user_routes; +use axum::Router; +use axum::body::to_bytes; use axum::extract::DefaultBodyLimit; +use axum::extract::{MatchedPath, Request}; +use axum::http::Uri; use axum::http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; -use axum::response::IntoResponse; -use axum::routing::{get}; +use axum::http::{HeaderValue, Method, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; use http::header::{CONNECTION, CONTENT_LENGTH, ORIGIN}; +use std::sync::Arc; +use tower::ServiceBuilder; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; -use tower::ServiceBuilder; use url::Url; -use crate::core::{AppState, TokenIssuer}; -use crate::keycloak::instance::{KeycloakAuthInstance, KeycloakConfig}; -use crate::keycloak::layer::KeycloakAuthLayer; -use crate::keycloak::PassthroughMode; -use crate::messaging::routes::create_messaging_routes; -use crate::rooms::routes::create_room_routes; -use crate::user_relationship::routes::create_user_routes; /** * Initializing the api routes. @@ -25,32 +29,76 @@ pub async fn init_router(app_state: AppState) -> Router { let origin = app_state.env.cors_origin.clone(); let cors = CorsLayer::new() .allow_origin(origin.parse::().expect("Invalid CORS Origin")) - .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE, CONTENT_LENGTH, CONNECTION, ORIGIN]) + .allow_headers([ + AUTHORIZATION, + ACCEPT, + CONTENT_TYPE, + CONTENT_LENGTH, + CONNECTION, + ORIGIN, + ]) .allow_credentials(true) .allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE]); let public_routing = Router::new() .route("/", get(|| async { "Hello, world! I'm your new ISM. 🤗" })) - .route("/health", get(|| async { (StatusCode::OK, "Healthy").into_response() })); + .route( + "/health", + get(|| async { (StatusCode::OK, "Healthy").into_response() }), + ); - let protected_routing = Router::new() //add new routes here .merge(create_room_routes()) .merge(create_user_routes()) .merge(create_messaging_routes()) - //layering bottom to top middleware .layer( ServiceBuilder::new() //layering top to bottom middleware .layer(TraceLayer::new_for_http()) //1 - .layer(cors)//2 + .layer(cors) //2 .layer(init_auth(app_state.env.token_issuer.clone())) //3.. - .layer(DefaultBodyLimit::max(5 * 1024 * 1024)) //max 5mb files + .layer(DefaultBodyLimit::max(5 * 1024 * 1024)), //max 5mb files ) + .layer(axum::middleware::from_fn(inject_request_path)) .with_state(Arc::new(app_state)); public_routing.merge(protected_routing) } +async fn inject_request_path( + matched_path: Option, + uri: Uri, + req: Request, + next: Next, +) -> Response { + let path = matched_path + .map(|mp| mp.as_str().to_owned()) + .unwrap_or_else(|| uri.path().to_owned()); + + let response = next.run(req).await; + + if !response.status().is_client_error() && !response.status().is_server_error() { + return response; + } + + let (mut parts, body) = response.into_parts(); + let bytes = match to_bytes(body, 64 * 1024).await { + Ok(b) => b, + Err(_) => return Response::from_parts(parts, axum::body::Body::empty()), + }; + + if let Ok(mut json) = serde_json::from_slice::(&bytes) { + if let Some(obj) = json.as_object_mut() { + obj.insert("path".to_owned(), serde_json::json!(path)); + } + if let Ok(new_body) = serde_json::to_vec(&json) { + parts.headers.remove(CONTENT_LENGTH); + return Response::from_parts(parts, axum::body::Body::from(new_body)); + } + } + + Response::from_parts(parts, axum::body::Body::from(bytes)) +} + fn init_auth(config: TokenIssuer) -> KeycloakAuthLayer { let keycloak_auth_instance = KeycloakAuthInstance::new( KeycloakConfig::builder() @@ -64,4 +112,4 @@ fn init_auth(config: TokenIssuer) -> KeycloakAuthLayer { .persist_raw_claims(true) .expected_audiences(vec![String::from("account")]) .build() -} \ No newline at end of file +} diff --git a/src/user_relationship/query_param.rs b/src/user_relationship/query_param.rs deleted file mode 100644 index c4a9ea2..0000000 --- a/src/user_relationship/query_param.rs +++ /dev/null @@ -1,7 +0,0 @@ -use serde::Deserialize; - -#[derive(Deserialize, Debug)] -pub struct UserSearchParams { - pub username: String, - pub cursor: Option, -} \ No newline at end of file diff --git a/src/user_relationship/routes.rs b/src/user_relationship/routes.rs deleted file mode 100644 index 0a66d5d..0000000 --- a/src/user_relationship/routes.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::sync::Arc; -use axum::Router; -use axum::routing::{delete, get, post}; -use crate::core::AppState; -use crate::user_relationship::handler::{handle_accept_friend_request, handle_add_friend, handle_get_friends, handle_get_open_friend_requests, handle_ignore_user, handle_reject_friend_request, handle_remove_friend, handle_search_user_by_id, handle_search_user_by_name, handle_undo_ignore_user}; - -pub fn create_user_routes() -> Router> { - - Router::new() - .route("/api/users/{user_id}", get(handle_search_user_by_id)) - .route("/api/users/search", get(handle_search_user_by_name)) - .route("/api/users/friends/requests", get(handle_get_open_friend_requests)) - .route("/api/users/friends", get(handle_get_friends)) - .route("/api/users/friends/add/{user_id}", post(handle_add_friend)) - .route("/api/users/friends/accept-request/{sender_id}", post(handle_accept_friend_request)) - .route("/api/users/friends/reject-request/{sender_id}", delete(handle_reject_friend_request)) - .route("/api/users/friends/{friend_id}", delete(handle_remove_friend)) - .route("/api/users/ignore/{user_id}", post(handle_ignore_user)) - .route("/api/users/ignore/{user_id}", delete(handle_undo_ignore_user)) - -} \ No newline at end of file diff --git a/src/user_relationship/user_service.rs b/src/user_relationship/user_service.rs deleted file mode 100644 index f7f7513..0000000 --- a/src/user_relationship/user_service.rs +++ /dev/null @@ -1,377 +0,0 @@ -use std::sync::Arc; -use chrono::Utc; -use uuid::Uuid; -use crate::broadcast::{BroadcastChannel, Notification}; -use crate::broadcast::NotificationEvent::{FriendRequestAccepted, FriendRequestReceived}; -use crate::core::AppState; -use crate::core::cursor::{encode_cursor, CursorResults}; -use crate::errors::{AppError}; -use crate::user_relationship::model::{Relationship, RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, UserWithRelationshipDto}; - - -pub struct UserService; - -impl UserService { - - /// Asynchronously queries a list of users based on a given username query, including their relationship type with the current user. - /// - /// This function fetches users whose names match the given `username_query` and paginates the results based on the supplied `cursor`. - /// The results returned are wrapped in a `CursorResults` structure, facilitating pagination with cursors. - /// - /// # Pagination Behavior - /// - A fixed page size of 20 is used for each query. An additional record is fetched to determine if there are more results beyond the current page. - /// - If more than `page_size` results are retrieved, the last record (used to identify the continuation cursor) is removed before returning the page content. - /// - pub async fn query_user_by_name( - state: Arc, - current_user_id: &Uuid, - username_query: &str, - cursor: UserPaginationCursor - ) -> Result, AppError> { - - let page_size: usize = 20; - let query_page_size = page_size + 1; - - let mut users = state.user_repository - .find_user_by_name_with_relationship_type(current_user_id, username_query, query_page_size as i64, cursor) - .await?; - - let next_cursor_string = if users.len() > page_size { - users.pop(); - users.last().map(|last_user| { - let next_page_cursor_struct = UserPaginationCursor { - last_seen_id: Some(last_user.r_user.id.clone()), - last_seen_name: Some(last_user.r_user.display_name.clone()), - }; - encode_cursor(&next_page_cursor_struct).map_err(|e| AppError::ProcessingError(format!("Cursor encoding failed: {}", e))) - }).transpose()? - } else { - None - }; - - let mapped_users = users.iter().map(|item| { - item.to_dto(current_user_id) - }).collect(); - - Ok(CursorResults { - next_cursor: next_cursor_string, - content: mapped_users, - }) - } - - pub async fn query_user_by_id( - state: Arc, - current_user_id: &Uuid, - user_id: &Uuid, - ) -> Result { - - let db_user = state - .user_repository - .find_user_by_id_with_relationship_type(current_user_id, user_id) - .await?; - - let user = db_user.ok_or_else(|| { - AppError::NotFound(format!("User with ID {} not found.", user_id)) - })?; - - Ok(user.to_dto(current_user_id)) - } - - pub async fn get_open_friend_requests( - state: Arc, - current_user_id: &Uuid, - ) -> Result, AppError> { - let users = state.user_repository.select_open_friend_requests(current_user_id).await?; - Ok(users) - } - - pub async fn get_friends( - state: Arc, - current_user_id: &Uuid, - ) -> Result, AppError> { - let users = state.user_repository.find_users_with_specific_relationship(current_user_id, RelationshipState::FRIEND).await?; - Ok(users) - } - - pub async fn add_friend( - state: Arc, - sender_id: Uuid, - receiver_id: Uuid, - ) -> Result<(), AppError> { - let mut tx = state.user_repository.start_transaction().await?; - let relationship = state.user_repository.search_for_relationship(&mut tx, &sender_id, &receiver_id).await?; - if relationship.is_some() { //don't handle this request further when the users are in a relationship - return match relationship.unwrap().state { - RelationshipState::A_BLOCKED => Err(AppError::ValidationError("Relationship between users is blocked.".to_string())), - RelationshipState::B_BLOCKED => Err(AppError::ValidationError("Relationship between users is blocked.".to_string())), - RelationshipState::ALL_BLOCKED => Err(AppError::ValidationError("Relationship between users is blocked.".to_string())), - RelationshipState::FRIEND => Ok(()), - RelationshipState::A_INVITED => Ok(()), - RelationshipState::B_INVITED => Ok(()), - } - } - let (user_a_id, user_b_id) = if sender_id < receiver_id { - (sender_id, receiver_id) - } else { - (receiver_id, sender_id) - }; - - let relationship_state = if sender_id == user_a_id { - RelationshipState::A_INVITED - } else { - RelationshipState::B_INVITED - }; - - let init_relationship = UserRelationshipEntity { - user_a_id, - user_b_id, - state: relationship_state, - relationship_change_timestamp: Utc::now(), - }; - - state.user_repository.insert_relationship(&mut tx, &init_relationship).await?; - - tx.commit().await?; - let client_dto = state.user_repository.find_user_by_id(&sender_id).await?.ok_or_else(|| { - AppError::NotFound(format!("User with ID {} not found.", sender_id)) - })?; - BroadcastChannel::get().send_event( - Notification { - body: FriendRequestReceived {from_user: client_dto}, - created_at: Utc::now() - }, - &receiver_id - ).await; - Ok(()) - } - - pub async fn accept_friend_request( - state: Arc, - client_id: Uuid, - sender_id: Uuid, - ) -> Result<(), AppError> { - let mut tx = state.user_repository.start_transaction().await?; - let relationship = state.user_repository.search_for_relationship(&mut tx, &client_id, &sender_id).await?.ok_or_else(|| { - AppError::NotFound("Relationship between these users not found.".to_string()) - })?; - - let is_accepter_user_a = client_id == relationship.user_a_id; - match (relationship.state, is_accepter_user_a) { - (RelationshipState::B_INVITED, true) => {}, //valid state - (RelationshipState::A_INVITED, false) => {}, //valid state - _ => { //everything else is invalid - return Err(AppError::ValidationError( - "Cannot accept this request. Invalid state or user.".to_string(), - )); - } - } - state.user_repository.update_relationship_state( - &mut tx, - &relationship.user_a_id, - &relationship.user_b_id, - RelationshipState::FRIEND - ).await?; - - state.user_repository.increment_friends_count(&mut tx, &relationship.user_a_id).await?; - state.user_repository.increment_friends_count(&mut tx, &relationship.user_b_id).await?; - tx.commit().await?; - - let client_dto = state.user_repository.find_user_by_id(&client_id).await?.ok_or_else(|| { - AppError::NotFound(format!("User with ID {} not found.", client_id)) - })?; - - BroadcastChannel::get().send_event( - Notification { - body: FriendRequestAccepted {from_user: client_dto}, - created_at: Utc::now() - }, - &sender_id - ).await; - - Ok(()) - } - - pub async fn reject_friend_request( - state: Arc, - client_id: Uuid, - sender_id: Uuid, - ) -> Result<(), AppError> { - let mut tx = state.user_repository.start_transaction().await?; - let relationship = state.user_repository.search_for_relationship(&mut tx, &client_id, &sender_id).await?.ok_or_else(|| { - AppError::NotFound("Relationship between these users not found.".to_string()) - })?; - - let is_rejecter_user_a = client_id == relationship.user_a_id; - match (relationship.state.clone(), is_rejecter_user_a) { - (RelationshipState::B_INVITED, true) => {}, //valid state - (RelationshipState::A_INVITED, false) => {}, //valid state - _ => { //everything else is invalid - return Err(AppError::ValidationError( - "Cannot reject this request. Invalid state or user.".to_string(), - )); - } - } - state.user_repository.delete_relationship_state(&mut tx, relationship).await?; - tx.commit().await?; - Ok(()) - } - - pub async fn remove_friend( - state: Arc, - client_id: Uuid, - sender_id: Uuid, - ) -> Result<(), AppError> { - let mut tx = state.user_repository.start_transaction().await?; - let relationship = state.user_repository.search_for_relationship(&mut tx, &client_id, &sender_id).await?.ok_or_else(|| { - AppError::NotFound("Relationship between these users not found.".to_string()) - })?; - - if relationship.state == RelationshipState::FRIEND { - state.user_repository.decrement_friends_count(&mut tx, &relationship.user_a_id).await?; - state.user_repository.decrement_friends_count(&mut tx, &relationship.user_b_id).await?; - state.user_repository.delete_relationship_state(&mut tx, relationship).await?; - tx.commit().await?; - } else { - return Err(AppError::ValidationError("These users aren't in a friend relationship.".to_string())); - } - Ok(()) - } - - pub async fn ignore_user( - state: Arc, - client_id: Uuid, - ignored_user_id: Uuid, - ) -> Result { - let mut tx = state.user_repository.start_transaction().await?; - let relationship = state.user_repository.search_for_relationship(&mut tx, &client_id, &ignored_user_id).await?; - - if let Some(rel) = relationship { - - let is_client_user_a = client_id == rel.user_a_id; - - let new_state = match (rel.state, is_client_user_a) { - (RelationshipState::ALL_BLOCKED, _) => return Ok(Relationship::ClientBlocked), //Both blocked - (RelationshipState::A_BLOCKED, true) => return Ok(Relationship::ClientBlocked), //client is A and blocked B - (RelationshipState::B_BLOCKED, false) => return Ok(Relationship::ClientBlocked), //client is B and blocked A - (RelationshipState::A_BLOCKED, false) => RelationshipState::ALL_BLOCKED, - (RelationshipState::B_BLOCKED, true) => RelationshipState::ALL_BLOCKED, - (RelationshipState::FRIEND, _) => { - state.user_repository.decrement_friends_count(&mut tx, &rel.user_a_id).await?; - state.user_repository.decrement_friends_count(&mut tx, &rel.user_b_id).await?; - - if is_client_user_a { - RelationshipState::A_BLOCKED - } else { - RelationshipState::B_BLOCKED - } - }, - (RelationshipState::A_INVITED, _) | (RelationshipState::B_INVITED, _) => { - if is_client_user_a { - RelationshipState::A_BLOCKED - } else { - RelationshipState::B_BLOCKED - } - } - }; - let entity = state.user_repository.update_relationship_state( - &mut tx, - &rel.user_a_id, - &rel.user_b_id, - new_state - ).await?; - tx.commit().await?; - Ok(entity.resolve_relationship_state(&client_id)) - } else { //no relationship found, create one - let (user_a_id, user_b_id) = if client_id < ignored_user_id { - (client_id, ignored_user_id) - } else { - (ignored_user_id, client_id) - }; - - let relationship_state = if client_id == user_a_id { - RelationshipState::A_BLOCKED - } else { - RelationshipState::B_BLOCKED - }; - - let init_relationship = UserRelationshipEntity { - user_a_id, - user_b_id, - state: relationship_state.clone(), - relationship_change_timestamp: Utc::now(), - }; - state.user_repository.insert_relationship(&mut tx, &init_relationship).await?; - tx.commit().await?; - Ok(init_relationship.resolve_relationship_state(&client_id)) - } - } - - pub async fn undo_ignore( - state: Arc, - client_id: Uuid, - ignored_user_id: Uuid, - ) -> Result, AppError> { - let mut tx = state.user_repository.start_transaction().await?; - let relationship = state - .user_repository - .search_for_relationship(&mut tx, &client_id, &ignored_user_id) - .await? - .ok_or_else(|| { - AppError::NotFound("No block relationship found to undo.".to_string()) - })?; - let is_client_user_a = client_id == relationship.user_a_id; - let state = match (relationship.state.clone(), is_client_user_a) { - (RelationshipState::ALL_BLOCKED, true) => { // Client was A, only B blocking now - let entity = state.user_repository.update_relationship_state( - &mut tx, - &relationship.user_a_id, - &relationship.user_b_id, - RelationshipState::B_BLOCKED, - ).await?; - Some(entity) - }, - (RelationshipState::ALL_BLOCKED, false) => { // Client was B, only A blocking now - let entity = state.user_repository.update_relationship_state( - &mut tx, - &relationship.user_a_id, - &relationship.user_b_id, - RelationshipState::A_BLOCKED, - ).await?; - Some(entity) - }, - - (RelationshipState::A_BLOCKED, true) | (RelationshipState::B_BLOCKED, false) => { // Fall 2: only client blocked, remove relationship - state.user_repository.delete_relationship_state( - &mut tx, - relationship - ).await?; - None - }, - (RelationshipState::A_BLOCKED, false) | (RelationshipState::B_BLOCKED, true) => { //client was blocked by another user - return Err(AppError::Blocked( - "You cannot undo a block placed on you by another user.".to_string(), - )); - }, - _ => { // some other state, no undo possible - return Err(AppError::ValidationError( - "No active block from your side found to undo.".to_string(), - )); - } - }; - tx.commit().await?; - match state { - Some(entity) => { Ok(Some(entity.resolve_relationship_state(&client_id))) }, - None => Ok(None) - } - } - - pub async fn get_blocked_users( - state: Arc, - current_user_id: &Uuid, - users_to_validate: &Vec - ) -> Result, AppError> { - let users = state.user_repository.find_blocked_relationships(current_user_id, users_to_validate).await?; - Ok(users) - } - -} \ No newline at end of file diff --git a/src/user_relationship/utils.rs b/src/user_relationship/utils.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/user_relationship/handler.rs b/src/users/handler.rs similarity index 53% rename from src/user_relationship/handler.rs rename to src/users/handler.rs index 585d449..665ce84 100644 --- a/src/user_relationship/handler.rs +++ b/src/users/handler.rs @@ -1,28 +1,24 @@ -use std::sync::Arc; +use crate::auth::decode::KeycloakToken; +use crate::core::AppState; +use crate::core::cursor::{CursorResults, clamp_page_size, decode_cursor}; +use crate::core::errors::{AppError, AppResponse}; +use crate::rooms::room_service::RoomService; +use crate::users::model::{ + RelationshipStateResponse, User, UserPaginationCursor, UserWithRelationshipDto, +}; +use crate::users::query_param::{RelationshipQueryParams, UserSearchParams}; +use crate::users::user_service::UserService; use axum::extract::{Path, Query, State}; use axum::{Extension, Json}; +use std::sync::Arc; use uuid::Uuid; -use crate::core::AppState; -use crate::core::cursor::{decode_cursor, CursorResults}; -use crate::errors::{AppError, AppResponse}; -use crate::keycloak::decode::KeycloakToken; -use crate::rooms::room_service::RoomService; -use crate::user_relationship::model::{RelationshipStateResponse, User, UserPaginationCursor, UserWithRelationshipDto}; -use crate::user_relationship::query_param::UserSearchParams; -use crate::user_relationship::user_service::UserService; - pub async fn handle_search_user_by_id( State(state): State>, Path(user_id): Path, Extension(token): Extension>, -) -> Result, AppError> { - - let user_dto = UserService::query_user_by_id( - state, - &token.subject, - &user_id - ).await?; +) -> AppResponse> { + let user_dto = UserService::query_user_by_id(state, &token.subject, &user_id).await?; Ok(Json(user_dto)) } @@ -30,18 +26,15 @@ pub async fn handle_search_user_by_id( pub async fn handle_search_user_by_name( State(state): State>, Extension(token): Extension>, - Query(params): Query -) -> Result>, AppError> { - + Query(params): Query, +) -> AppResponse>> { let cursor: UserPaginationCursor = decode_cursor(params.cursor) - .map_err(|_| AppError::ValidationError("Invalid Cursor-Parameters.".to_string()))?; + .map_err(|_| AppError::Validation("Invalid Cursor-Parameters.".to_string()))?; + let page_size = clamp_page_size(params.limit); - let search_results = UserService::query_user_by_name( - state, - &token.subject, - ¶ms.username, - cursor - ).await?; + let search_results = + UserService::query_user_by_name(state, &token.subject, ¶ms.username, cursor, page_size) + .await?; Ok(Json(search_results)) } @@ -49,12 +42,20 @@ pub async fn handle_search_user_by_name( pub async fn handle_get_open_friend_requests( State(state): State>, Extension(token): Extension>, -) -> Result>, AppError> { + Query(params): Query, +) -> AppResponse>> { + let cursor: UserPaginationCursor = decode_cursor(params.cursor) + .map_err(|_| AppError::Validation("Invalid Cursor-Parameters.".to_string()))?; + let page_size = clamp_page_size(params.limit); let results = UserService::get_open_friend_requests( state, - &token.subject - ).await?; + &token.subject, + params.username, + cursor, + page_size, + ) + .await?; Ok(Json(results)) } @@ -62,9 +63,14 @@ pub async fn handle_get_open_friend_requests( pub async fn handle_get_friends( State(state): State>, Extension(token): Extension>, -) -> Result>, AppError> { + Query(params): Query, +) -> AppResponse>> { + let cursor: UserPaginationCursor = decode_cursor(params.cursor) + .map_err(|_| AppError::Validation("Invalid Cursor-Parameters.".to_string()))?; + let page_size = clamp_page_size(params.limit); - let results = UserService::get_friends(state, &token.subject).await?; + let results = + UserService::get_friends(state, &token.subject, params.username, cursor, page_size).await?; Ok(Json(results)) } @@ -72,21 +78,21 @@ pub async fn handle_add_friend( State(state): State>, Path(user_id): Path, Extension(token): Extension>, -) -> Result<(), AppError> { - +) -> AppResponse<()> { if token.subject == user_id { - return Err(AppError::ValidationError("Cannot friendship yourself.".to_string())); + return Err(AppError::Validation( + "Cannot friendship yourself.".to_string(), + )); } UserService::add_friend(state, token.subject, user_id).await?; Ok(()) } - pub async fn handle_accept_friend_request( State(state): State>, Path(sender_id): Path, Extension(token): Extension>, -) -> Result<(), AppError> { +) -> AppResponse<()> { UserService::accept_friend_request(state, token.subject, sender_id).await?; Ok(()) } @@ -95,7 +101,7 @@ pub async fn handle_reject_friend_request( State(state): State>, Path(sender_id): Path, Extension(token): Extension>, -) -> Result<(), AppError> { +) -> AppResponse<()> { UserService::reject_friend_request(state, token.subject, sender_id).await?; Ok(()) } @@ -104,7 +110,7 @@ pub async fn handle_remove_friend( State(state): State>, Path(friend_id): Path, Extension(token): Extension>, -) -> Result<(), AppError> { +) -> AppResponse<()> { UserService::remove_friend(state, token.subject, friend_id).await?; Ok(()) } @@ -113,18 +119,19 @@ pub async fn handle_ignore_user( State(state): State>, Path(user_id): Path, Extension(token): Extension>, -)-> AppResponse> { - +) -> AppResponse> { if token.subject == user_id { - return Err(AppError::ValidationError("Cannot ignore yourself.".to_string())); + return Err(AppError::Validation("Cannot ignore yourself.".to_string())); } - let updated_state = UserService::ignore_user(state.clone(), token.subject.clone(), user_id.clone()).await?; - let room = RoomService::find_existing_single_room(state.clone(), &token.subject, &user_id).await?; + let updated_state = + UserService::ignore_user(state.clone(), token.subject.clone(), user_id.clone()).await?; + let room = + RoomService::find_existing_single_room(state.clone(), &token.subject, &user_id).await?; if let Some(room) = room { RoomService::leave_room(state, token.subject, room).await?; } let response = RelationshipStateResponse { - state: Some(updated_state) + state: Some(updated_state), }; Ok(Json(response)) } @@ -133,10 +140,10 @@ pub async fn handle_undo_ignore_user( State(state): State>, Path(user_id): Path, Extension(token): Extension>, -)-> AppResponse> { +) -> AppResponse> { let updated_state = UserService::undo_ignore(state, token.subject, user_id).await?; let response = RelationshipStateResponse { - state: updated_state + state: updated_state, }; Ok(Json(response)) -} \ No newline at end of file +} diff --git a/src/user_relationship/mod.rs b/src/users/mod.rs similarity index 60% rename from src/user_relationship/mod.rs rename to src/users/mod.rs index f39dc0b..70618ce 100644 --- a/src/user_relationship/mod.rs +++ b/src/users/mod.rs @@ -1,6 +1,7 @@ -pub mod model; -mod utils; mod handler; -pub mod routes; +pub mod model; mod query_param; -pub mod user_service; \ No newline at end of file +pub mod routes; +pub mod user_repository; +pub mod user_service; +mod utils; diff --git a/src/user_relationship/model.rs b/src/users/model.rs similarity index 93% rename from src/user_relationship/model.rs rename to src/users/model.rs index 0c9130d..ef99a3f 100644 --- a/src/user_relationship/model.rs +++ b/src/users/model.rs @@ -1,9 +1,9 @@ -use std::error::Error; -use std::fmt; -use std::fmt::{Display, Formatter}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Row, Type}; +use std::error::Error; +use std::fmt; +use std::fmt::{Display, Formatter}; use uuid::Uuid; #[derive(Serialize, Clone)] @@ -18,26 +18,20 @@ pub struct UserRelationshipEntity { pub user_a_id: Uuid, pub user_b_id: Uuid, pub state: RelationshipState, - pub relationship_change_timestamp: DateTime + pub relationship_change_timestamp: DateTime, } impl UserRelationshipEntity { - - pub fn resolve_relationship_state( - &self, - client_id: &Uuid - ) -> Relationship { - + pub fn resolve_relationship_state(&self, client_id: &Uuid) -> Relationship { let relationship = self; match relationship.state { - RelationshipState::FRIEND => Relationship::Friend, RelationshipState::A_BLOCKED => { if relationship.user_a_id == *client_id { Relationship::ClientBlocked - } else { + } else { Relationship::ClientGotBlocked } } @@ -77,7 +71,6 @@ impl UserRelationshipEntity { } } - #[derive(Debug)] pub struct UserWithRelationshipEntity { pub r_user: User, @@ -87,11 +80,13 @@ pub struct UserWithRelationshipEntity { relationship_change_timestamp: Option>, } - impl UserWithRelationshipEntity { - pub fn get_relationship(&self) -> Option { - if self.user_a_id.is_some() && self.user_b_id.is_some() && self.relationship_state.is_some() && self.relationship_change_timestamp.is_some() { + if self.user_a_id.is_some() + && self.user_b_id.is_some() + && self.relationship_state.is_some() + && self.relationship_change_timestamp.is_some() + { Some(UserRelationshipEntity { user_a_id: self.user_a_id.unwrap(), user_b_id: self.user_b_id.unwrap(), @@ -102,20 +97,18 @@ impl UserWithRelationshipEntity { None } } - + pub fn to_dto(&self, client_id: &Uuid) -> UserWithRelationshipDto { - let rel_type = match self.get_relationship() { Some(rel) => Some(rel.resolve_relationship_state(client_id)), - None => None + None => None, }; - + UserWithRelationshipDto { user: self.r_user.clone(), relationship_type: rel_type, } } - } impl<'r, R: Row> FromRow<'r, R> for UserWithRelationshipEntity @@ -126,9 +119,7 @@ where i64: sqlx::Decode<'r, R::Database> + sqlx::Type, DateTime: sqlx::Decode<'r, R::Database> + sqlx::Type, { - fn from_row(row: &'r R) -> Result { - let r_user = User::from_row(row)?; let state_str: Option = row.try_get("state")?; @@ -158,8 +149,6 @@ pub struct UserWithRelationshipDto { pub relationship_type: Option, } - - #[allow(non_camel_case_types)] #[derive(Debug, Deserialize, Serialize, Clone, Type, PartialEq, Copy)] #[sqlx(rename_all = "SCREAMING_SNAKE_CASE")] @@ -169,7 +158,7 @@ pub enum RelationshipState { ALL_BLOCKED, FRIEND, A_INVITED, - B_INVITED + B_INVITED, } #[derive(Debug)] @@ -183,7 +172,6 @@ impl fmt::Display for InvalidState { impl Error for InvalidState {} impl TryFrom for RelationshipState { - type Error = InvalidState; fn try_from(value: String) -> Result { match value.as_str() { @@ -218,7 +206,7 @@ pub enum Relationship { InviteSent, ClientBlocked, ClientGotBlocked, - Friend + Friend, } #[derive(Debug, Serialize, Deserialize, Clone, FromRow)] @@ -231,7 +219,7 @@ pub struct User { pub description: Option, pub friends_count: i64, pub posts_count: i64, - pub role: String + pub role: String, } #[derive(Deserialize, Serialize, Default)] @@ -244,5 +232,5 @@ pub struct UserPaginationCursor { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct RelationshipStateResponse { - pub state: Option -} \ No newline at end of file + pub state: Option, +} diff --git a/src/users/query_param.rs b/src/users/query_param.rs new file mode 100644 index 0000000..fef5625 --- /dev/null +++ b/src/users/query_param.rs @@ -0,0 +1,17 @@ +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub struct UserSearchParams { + pub username: String, + pub cursor: Option, + pub limit: Option, +} + +/// Query params for the paginated friends / friend-requests lists. +/// `username` is an optional case-insensitive name filter. +#[derive(Deserialize, Debug)] +pub struct RelationshipQueryParams { + pub username: Option, + pub cursor: Option, + pub limit: Option, +} diff --git a/src/users/routes.rs b/src/users/routes.rs new file mode 100644 index 0000000..393a6c5 --- /dev/null +++ b/src/users/routes.rs @@ -0,0 +1,39 @@ +use crate::core::AppState; +use crate::users::handler::{ + handle_accept_friend_request, handle_add_friend, handle_get_friends, + handle_get_open_friend_requests, handle_ignore_user, handle_reject_friend_request, + handle_remove_friend, handle_search_user_by_id, handle_search_user_by_name, + handle_undo_ignore_user, +}; +use axum::Router; +use axum::routing::{delete, get, post}; +use std::sync::Arc; + +pub fn create_user_routes() -> Router> { + Router::new() + .route("/api/users/{user_id}", get(handle_search_user_by_id)) + .route("/api/users/search", get(handle_search_user_by_name)) + .route( + "/api/users/friends/requests", + get(handle_get_open_friend_requests), + ) + .route("/api/users/friends", get(handle_get_friends)) + .route("/api/users/friends/add/{user_id}", post(handle_add_friend)) + .route( + "/api/users/friends/accept-request/{sender_id}", + post(handle_accept_friend_request), + ) + .route( + "/api/users/friends/reject-request/{sender_id}", + delete(handle_reject_friend_request), + ) + .route( + "/api/users/friends/{friend_id}", + delete(handle_remove_friend), + ) + .route("/api/users/ignore/{user_id}", post(handle_ignore_user)) + .route( + "/api/users/ignore/{user_id}", + delete(handle_undo_ignore_user), + ) +} diff --git a/src/repository/user_repository.rs b/src/users/user_repository.rs similarity index 71% rename from src/repository/user_repository.rs rename to src/users/user_repository.rs index c115620..043431a 100644 --- a/src/repository/user_repository.rs +++ b/src/users/user_repository.rs @@ -1,6 +1,9 @@ -use sqlx::{query_as, Error, PgConnection, Pool, Postgres, Transaction}; +use crate::users::model::{ + RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, + UserWithRelationshipEntity, +}; +use sqlx::{Error, PgConnection, Pool, Postgres, Transaction, query_as}; use uuid::Uuid; -use crate::user_relationship::model::{RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, UserWithRelationshipEntity}; #[derive(Clone)] pub struct UserRepository { @@ -8,7 +11,6 @@ pub struct UserRepository { } impl UserRepository { - pub fn new(pool: Pool) -> Self { UserRepository { pool } } @@ -18,7 +20,11 @@ impl UserRepository { Ok(tx) } - pub async fn find_user_by_id_with_relationship_type(&self, client_id: &Uuid, searched_user_id: &Uuid) -> Result, Error> { + pub async fn find_user_by_id_with_relationship_type( + &self, + client_id: &Uuid, + searched_user_id: &Uuid, + ) -> Result, Error> { let user = query_as::<_, UserWithRelationshipEntity>( r#"SELECT r_user.id, @@ -48,8 +54,8 @@ impl UserRepository { pub async fn find_user_by_id(&self, user_id: &Uuid) -> Result, Error> { let user = query_as!( - User, - r#"SELECT + User, + r#"SELECT r_user.id, r_user.display_name, r_user.profile_picture, @@ -60,12 +66,21 @@ impl UserRepository { r_user.role FROM app_user r_user WHERE r_user.id = $1 - "#, user_id - ).fetch_optional(&self.pool).await?; + "#, + user_id + ) + .fetch_optional(&self.pool) + .await?; Ok(user) } - pub async fn find_user_by_name_with_relationship_type(&self, client_id: &Uuid, username: &str, page_size: i64, cursor: UserPaginationCursor) -> Result, Error> { + pub async fn find_user_by_name_with_relationship_type( + &self, + client_id: &Uuid, + username: &str, + page_size: i64, + cursor: UserPaginationCursor, + ) -> Result, Error> { let user = query_as::<_, UserWithRelationshipEntity>( r#"SELECT r_user.id, @@ -76,7 +91,7 @@ impl UserRepository { r_user.friends_count, r_user.posts_count, r_user.role, - user_relationship.user_a_id, + user_relationship.user_a_id, user_relationship.user_b_id, user_relationship.state, user_relationship.relationship_change_timestamp @@ -101,9 +116,17 @@ impl UserRepository { Ok(user) } - pub async fn select_open_friend_requests(&self, client_id: &Uuid) -> Result, Error> { - let requests = sqlx::query_as!( - User, + /// Paginated incoming friend requests, ordered by display name. Optional + /// case-insensitive name filter via the indexed `raw_name` column; keyset over + /// `(display_name, id)`. Callers pass `limit = page_size + 1` to detect a next page. + pub async fn select_open_friend_requests( + &self, + client_id: &Uuid, + username: Option<&str>, + cursor: UserPaginationCursor, + limit: i64, + ) -> Result, Error> { + let requests = query_as::<_, User>( r#"SELECT u.id, u.display_name, @@ -117,19 +140,35 @@ impl UserRepository { INNER JOIN user_relationship ur ON (ur.user_a_id = u.id AND ur.user_b_id = $1 AND ur.state = 'A_INVITED') OR (ur.user_b_id = u.id AND ur.user_a_id = $1 AND ur.state = 'B_INVITED') + WHERE + ($2::text IS NULL OR u.raw_name LIKE lower(concat('%', $2, '%'))) + AND ($3::text IS NULL OR (u.display_name, u.id) > ($3, $4)) + ORDER BY u.display_name ASC, u.id ASC + LIMIT $5 "#, - client_id - ).fetch_all(&self.pool).await?; + ) + .bind(client_id) + .bind(username) + .bind(cursor.last_seen_name) + .bind(cursor.last_seen_id) + .bind(limit) + .fetch_all(&self.pool) + .await?; Ok(requests) } + /// Paginated list of users in a specific relationship state (e.g. friends), + /// ordered by display name. Optional case-insensitive name filter via the + /// indexed `raw_name`; keyset over `(display_name, id)`. pub async fn find_users_with_specific_relationship( &self, client_id: &Uuid, state: RelationshipState, + username: Option<&str>, + cursor: UserPaginationCursor, + limit: i64, ) -> Result, Error> { - let users = sqlx::query_as!( - User, + let users = query_as::<_, User>( r#" SELECT u.id, @@ -152,15 +191,29 @@ impl UserRepository { ) WHERE rl.state = $2 + AND ($3::text IS NULL OR u.raw_name LIKE lower(concat('%', $3, '%'))) + AND ($4::text IS NULL OR (u.display_name, u.id) > ($4, $5)) + ORDER BY u.display_name ASC, u.id ASC + LIMIT $6 "#, - client_id, - state.to_string() - ).fetch_all(&self.pool).await?; + ) + .bind(client_id) + .bind(state.to_string()) + .bind(username) + .bind(cursor.last_seen_name) + .bind(cursor.last_seen_id) + .bind(limit) + .fetch_all(&self.pool) + .await?; Ok(users) } - pub async fn search_for_relationship(&self, conn: &mut PgConnection, client_id: &Uuid, other_id: &Uuid) -> Result, Error> - { + pub async fn search_for_relationship( + &self, + conn: &mut PgConnection, + client_id: &Uuid, + other_id: &Uuid, + ) -> Result, Error> { let relationship = sqlx::query_as!( UserRelationshipEntity, r#" @@ -179,8 +232,12 @@ impl UserRepository { Ok(relationship) } - pub async fn insert_relationship(&self, conn: &mut PgConnection, user_relationship: &UserRelationshipEntity) -> Result<(), Error> { - sqlx::query!( + pub async fn insert_relationship( + &self, + conn: &mut PgConnection, + user_relationship: &UserRelationshipEntity, + ) -> Result<(), Error> { + sqlx::query!( r#" INSERT INTO user_relationship (user_a_id, user_b_id, state, relationship_change_timestamp) VALUES ($1, $2, $3, $4) @@ -215,14 +272,16 @@ impl UserRepository { new_state.to_string(), user_a_id, user_b_id - ).fetch_one(&mut *conn).await?; + ) + .fetch_one(&mut *conn) + .await?; Ok(entity) } pub async fn delete_relationship_state( &self, conn: &mut PgConnection, - user_relationship: UserRelationshipEntity + user_relationship: UserRelationshipEntity, ) -> Result<(), sqlx::Error> { sqlx::query!( r#" @@ -231,7 +290,9 @@ impl UserRepository { "#, user_relationship.user_a_id, user_relationship.user_b_id - ).execute(&mut *conn).await?; + ) + .execute(&mut *conn) + .await?; Ok(()) } @@ -247,7 +308,9 @@ impl UserRepository { WHERE id = $1 "#, user_id - ).execute(tx).await?; + ) + .execute(tx) + .await?; Ok(()) } @@ -263,11 +326,17 @@ impl UserRepository { WHERE id = $1 "#, user_id - ).execute(tx).await?; + ) + .execute(tx) + .await?; Ok(()) } - pub async fn find_blocked_relationships(&self, client_id: &Uuid, users_to_validate: &Vec) -> Result, Error> { + pub async fn find_blocked_relationships( + &self, + client_id: &Uuid, + users_to_validate: &Vec, + ) -> Result, Error> { let blocked_states_str: [&str; 3] = ["A_BLOCKED", "B_BLOCKED", "ALL_BLOCKED"]; let blocked_states_string_vec: Vec = blocked_states_str.map(String::from).to_vec(); @@ -282,9 +351,10 @@ impl UserRepository { client_id, users_to_validate, &blocked_states_string_vec - ).fetch_all(&self.pool).await?; + ) + .fetch_all(&self.pool) + .await?; let blocked_users: Vec = blocked_users_optional.into_iter().flatten().collect(); Ok(blocked_users) } - -} \ No newline at end of file +} diff --git a/src/users/user_service.rs b/src/users/user_service.rs new file mode 100644 index 0000000..e044961 --- /dev/null +++ b/src/users/user_service.rs @@ -0,0 +1,498 @@ +use crate::broadcast::NotificationEvent::{FriendRequestAccepted, FriendRequestReceived}; +use crate::broadcast::{BroadcastChannel, Notification}; +use crate::core::AppState; +use crate::core::cursor::{CursorResults, next_cursor}; +use crate::core::errors::AppError; +use crate::users::model::{ + Relationship, RelationshipState, User, UserPaginationCursor, UserRelationshipEntity, + UserWithRelationshipDto, +}; +use chrono::Utc; +use std::sync::Arc; +use uuid::Uuid; + +pub struct UserService; + +impl UserService { + /// Asynchronously queries a list of users based on a given username query, including their relationship type with the current user. + /// + /// This function fetches users whose names match the given `username_query` and paginates the results based on the supplied `cursor`. + /// The results returned are wrapped in a `CursorResults` structure, facilitating pagination with cursors. + /// + /// # Pagination Behavior + /// - A fixed page size of 20 is used for each query. An additional record is fetched to determine if there are more results beyond the current page. + /// - If more than `page_size` results are retrieved, the last record (used to identify the continuation cursor) is removed before returning the page content. + /// + pub async fn query_user_by_name( + state: Arc, + current_user_id: &Uuid, + username_query: &str, + cursor: UserPaginationCursor, + page_size: usize, + ) -> Result, AppError> { + let mut users = state + .user_repository + .find_user_by_name_with_relationship_type( + current_user_id, + username_query, + (page_size + 1) as i64, + cursor, + ) + .await?; + + let next_cursor_string = + next_cursor(&mut users, page_size, |last_user| UserPaginationCursor { + last_seen_id: Some(last_user.r_user.id), + last_seen_name: Some(last_user.r_user.display_name.clone()), + }) + .map_err(|e| AppError::Processing(format!("Cursor encoding failed: {}", e)))?; + + let mapped_users = users + .iter() + .map(|item| item.to_dto(current_user_id)) + .collect(); + + Ok(CursorResults { + cursor: next_cursor_string, + content: mapped_users, + }) + } + + pub async fn query_user_by_id( + state: Arc, + current_user_id: &Uuid, + user_id: &Uuid, + ) -> Result { + let db_user = state + .user_repository + .find_user_by_id_with_relationship_type(current_user_id, user_id) + .await?; + + let user = db_user + .ok_or_else(|| AppError::NotFound(format!("User with ID {} not found.", user_id)))?; + + Ok(user.to_dto(current_user_id)) + } + + pub async fn get_open_friend_requests( + state: Arc, + current_user_id: &Uuid, + username: Option, + cursor: UserPaginationCursor, + page_size: usize, + ) -> Result, AppError> { + let mut users = state + .user_repository + .select_open_friend_requests( + current_user_id, + username.as_deref(), + cursor, + (page_size + 1) as i64, + ) + .await?; + + let next_cursor_string = + next_cursor(&mut users, page_size, |last_user| UserPaginationCursor { + last_seen_id: Some(last_user.id), + last_seen_name: Some(last_user.display_name.clone()), + }) + .map_err(|e| AppError::Processing(format!("Cursor encoding failed: {}", e)))?; + + Ok(CursorResults { + cursor: next_cursor_string, + content: users, + }) + } + + pub async fn get_friends( + state: Arc, + current_user_id: &Uuid, + username: Option, + cursor: UserPaginationCursor, + page_size: usize, + ) -> Result, AppError> { + let mut users = state + .user_repository + .find_users_with_specific_relationship( + current_user_id, + RelationshipState::FRIEND, + username.as_deref(), + cursor, + (page_size + 1) as i64, + ) + .await?; + + let next_cursor_string = + next_cursor(&mut users, page_size, |last_user| UserPaginationCursor { + last_seen_id: Some(last_user.id), + last_seen_name: Some(last_user.display_name.clone()), + }) + .map_err(|e| AppError::Processing(format!("Cursor encoding failed: {}", e)))?; + + Ok(CursorResults { + cursor: next_cursor_string, + content: users, + }) + } + + pub async fn add_friend( + state: Arc, + sender_id: Uuid, + receiver_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state + .user_repository + .search_for_relationship(&mut tx, &sender_id, &receiver_id) + .await?; + if relationship.is_some() { + //don't handle this request further when the users are in a relationship + return match relationship.unwrap().state { + RelationshipState::A_BLOCKED => Err(AppError::Validation( + "Relationship between users is blocked.".to_string(), + )), + RelationshipState::B_BLOCKED => Err(AppError::Validation( + "Relationship between users is blocked.".to_string(), + )), + RelationshipState::ALL_BLOCKED => Err(AppError::Validation( + "Relationship between users is blocked.".to_string(), + )), + RelationshipState::FRIEND => Ok(()), + RelationshipState::A_INVITED => Ok(()), + RelationshipState::B_INVITED => Ok(()), + }; + } + let (user_a_id, user_b_id) = if sender_id < receiver_id { + (sender_id, receiver_id) + } else { + (receiver_id, sender_id) + }; + + let relationship_state = if sender_id == user_a_id { + RelationshipState::A_INVITED + } else { + RelationshipState::B_INVITED + }; + + let init_relationship = UserRelationshipEntity { + user_a_id, + user_b_id, + state: relationship_state, + relationship_change_timestamp: Utc::now(), + }; + + state + .user_repository + .insert_relationship(&mut tx, &init_relationship) + .await?; + + tx.commit().await?; + let client_dto = state + .user_repository + .find_user_by_id(&sender_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("User with ID {} not found.", sender_id)))?; + BroadcastChannel::get() + .send_event( + Notification::new(FriendRequestReceived { + from_user: client_dto, + }), + &receiver_id, + ) + .await; + Ok(()) + } + + pub async fn accept_friend_request( + state: Arc, + client_id: Uuid, + sender_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state + .user_repository + .search_for_relationship(&mut tx, &client_id, &sender_id) + .await? + .ok_or_else(|| { + AppError::NotFound("Relationship between these users not found.".to_string()) + })?; + + let is_accepter_user_a = client_id == relationship.user_a_id; + match (relationship.state, is_accepter_user_a) { + (RelationshipState::B_INVITED, true) => {} //valid state + (RelationshipState::A_INVITED, false) => {} //valid state + _ => { + //everything else is invalid + return Err(AppError::Validation( + "Cannot accept this request. Invalid state or user.".to_string(), + )); + } + } + state + .user_repository + .update_relationship_state( + &mut tx, + &relationship.user_a_id, + &relationship.user_b_id, + RelationshipState::FRIEND, + ) + .await?; + + state + .user_repository + .increment_friends_count(&mut tx, &relationship.user_a_id) + .await?; + state + .user_repository + .increment_friends_count(&mut tx, &relationship.user_b_id) + .await?; + tx.commit().await?; + + let client_dto = state + .user_repository + .find_user_by_id(&client_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("User with ID {} not found.", client_id)))?; + + BroadcastChannel::get() + .send_event( + Notification::new(FriendRequestAccepted { + from_user: client_dto, + }), + &sender_id, + ) + .await; + + Ok(()) + } + + pub async fn reject_friend_request( + state: Arc, + client_id: Uuid, + sender_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state + .user_repository + .search_for_relationship(&mut tx, &client_id, &sender_id) + .await? + .ok_or_else(|| { + AppError::NotFound("Relationship between these users not found.".to_string()) + })?; + + let is_rejecter_user_a = client_id == relationship.user_a_id; + match (relationship.state.clone(), is_rejecter_user_a) { + (RelationshipState::B_INVITED, true) => {} //valid state + (RelationshipState::A_INVITED, false) => {} //valid state + _ => { + //everything else is invalid + return Err(AppError::Validation( + "Cannot reject this request. Invalid state or user.".to_string(), + )); + } + } + state + .user_repository + .delete_relationship_state(&mut tx, relationship) + .await?; + tx.commit().await?; + Ok(()) + } + + pub async fn remove_friend( + state: Arc, + client_id: Uuid, + sender_id: Uuid, + ) -> Result<(), AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state + .user_repository + .search_for_relationship(&mut tx, &client_id, &sender_id) + .await? + .ok_or_else(|| { + AppError::NotFound("Relationship between these users not found.".to_string()) + })?; + + if relationship.state == RelationshipState::FRIEND { + state + .user_repository + .decrement_friends_count(&mut tx, &relationship.user_a_id) + .await?; + state + .user_repository + .decrement_friends_count(&mut tx, &relationship.user_b_id) + .await?; + state + .user_repository + .delete_relationship_state(&mut tx, relationship) + .await?; + tx.commit().await?; + } else { + return Err(AppError::Validation( + "These users aren't in a friend relationship.".to_string(), + )); + } + Ok(()) + } + + pub async fn ignore_user( + state: Arc, + client_id: Uuid, + ignored_user_id: Uuid, + ) -> Result { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state + .user_repository + .search_for_relationship(&mut tx, &client_id, &ignored_user_id) + .await?; + + if let Some(rel) = relationship { + let is_client_user_a = client_id == rel.user_a_id; + + let new_state = match (rel.state, is_client_user_a) { + (RelationshipState::ALL_BLOCKED, _) => return Ok(Relationship::ClientBlocked), //Both blocked + (RelationshipState::A_BLOCKED, true) => return Ok(Relationship::ClientBlocked), //client is A and blocked B + (RelationshipState::B_BLOCKED, false) => return Ok(Relationship::ClientBlocked), //client is B and blocked A + (RelationshipState::A_BLOCKED, false) => RelationshipState::ALL_BLOCKED, + (RelationshipState::B_BLOCKED, true) => RelationshipState::ALL_BLOCKED, + (RelationshipState::FRIEND, _) => { + state + .user_repository + .decrement_friends_count(&mut tx, &rel.user_a_id) + .await?; + state + .user_repository + .decrement_friends_count(&mut tx, &rel.user_b_id) + .await?; + + if is_client_user_a { + RelationshipState::A_BLOCKED + } else { + RelationshipState::B_BLOCKED + } + } + (RelationshipState::A_INVITED, _) | (RelationshipState::B_INVITED, _) => { + if is_client_user_a { + RelationshipState::A_BLOCKED + } else { + RelationshipState::B_BLOCKED + } + } + }; + let entity = state + .user_repository + .update_relationship_state(&mut tx, &rel.user_a_id, &rel.user_b_id, new_state) + .await?; + tx.commit().await?; + Ok(entity.resolve_relationship_state(&client_id)) + } else { + //no relationship found, create one + let (user_a_id, user_b_id) = if client_id < ignored_user_id { + (client_id, ignored_user_id) + } else { + (ignored_user_id, client_id) + }; + + let relationship_state = if client_id == user_a_id { + RelationshipState::A_BLOCKED + } else { + RelationshipState::B_BLOCKED + }; + + let init_relationship = UserRelationshipEntity { + user_a_id, + user_b_id, + state: relationship_state.clone(), + relationship_change_timestamp: Utc::now(), + }; + state + .user_repository + .insert_relationship(&mut tx, &init_relationship) + .await?; + tx.commit().await?; + Ok(init_relationship.resolve_relationship_state(&client_id)) + } + } + + pub async fn undo_ignore( + state: Arc, + client_id: Uuid, + ignored_user_id: Uuid, + ) -> Result, AppError> { + let mut tx = state.user_repository.start_transaction().await?; + let relationship = state + .user_repository + .search_for_relationship(&mut tx, &client_id, &ignored_user_id) + .await? + .ok_or_else(|| { + AppError::NotFound("No block relationship found to undo.".to_string()) + })?; + let is_client_user_a = client_id == relationship.user_a_id; + let state = match (relationship.state.clone(), is_client_user_a) { + (RelationshipState::ALL_BLOCKED, true) => { + // Client was A, only B blocking now + let entity = state + .user_repository + .update_relationship_state( + &mut tx, + &relationship.user_a_id, + &relationship.user_b_id, + RelationshipState::B_BLOCKED, + ) + .await?; + Some(entity) + } + (RelationshipState::ALL_BLOCKED, false) => { + // Client was B, only A blocking now + let entity = state + .user_repository + .update_relationship_state( + &mut tx, + &relationship.user_a_id, + &relationship.user_b_id, + RelationshipState::A_BLOCKED, + ) + .await?; + Some(entity) + } + + (RelationshipState::A_BLOCKED, true) | (RelationshipState::B_BLOCKED, false) => { + // Fall 2: only client blocked, remove relationship + state + .user_repository + .delete_relationship_state(&mut tx, relationship) + .await?; + None + } + (RelationshipState::A_BLOCKED, false) | (RelationshipState::B_BLOCKED, true) => { + //client was blocked by another user + return Err(AppError::Forbidden( + "You cannot undo a block placed on you by another user.".to_string(), + )); + } + _ => { + // some other state, no undo possible + return Err(AppError::Validation( + "No active block from your side found to undo.".to_string(), + )); + } + }; + tx.commit().await?; + match state { + Some(entity) => Ok(Some(entity.resolve_relationship_state(&client_id))), + None => Ok(None), + } + } + + pub async fn get_blocked_users( + state: Arc, + current_user_id: &Uuid, + users_to_validate: &Vec, + ) -> Result, AppError> { + let users = state + .user_repository + .find_blocked_relationships(current_user_id, users_to_validate) + .await?; + Ok(users) + } +} diff --git a/src/repository/util.rs b/src/users/utils.rs similarity index 100% rename from src/repository/util.rs rename to src/users/utils.rs diff --git a/src/utils.rs b/src/utils.rs index cbafd74..352d532 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,23 +1,27 @@ -use std::sync::Arc; +use crate::core::AppState; +use crate::core::errors::AppError; use bytes::Bytes; -use uuid::Uuid; -use std::io::Cursor; use image::{GenericImageView, ImageError}; use serde::Serializer; -use crate::errors::{AppError}; -use crate::core::AppState; - +use std::io::Cursor; +use std::sync::Arc; +use uuid::Uuid; pub async fn check_user_in_room( state: &Arc, user_id: &Uuid, room_id: &Uuid, ) -> Result<(), AppError> { - let is_in = state.room_repository.is_user_in_room(user_id, room_id).await?; + let is_in = state + .room_repository + .is_user_in_room(user_id, room_id) + .await?; if is_in { Ok(()) } else { - Err(AppError::Blocked("Invalid permissions to interact with this room".to_string())) + Err(AppError::Forbidden( + "Invalid permissions to interact with this room".to_string(), + )) } } @@ -26,16 +30,15 @@ pub fn crop_image_from_center( target_width: u32, target_height: u32, ) -> Result { - let img = match image::load_from_memory(data) { Ok(img) => img, - Err(err) => return Err(err) + Err(err) => return Err(err), }; let (original_width, original_height) = img.dimensions(); if original_width < target_width || original_height < target_height { - return Ok(data.clone()) + return Ok(data.clone()); }; let x = (original_width - target_width) / 2; @@ -43,11 +46,9 @@ pub fn crop_image_from_center( let cropped = img.crop_imm(x, y, target_width, target_height).to_rgb8(); let mut buffer = Cursor::new(Vec::new()); - match cropped.write_to(&mut buffer, image::ImageFormat::Jpeg){ - Ok(_) => { - Ok(Bytes::from(buffer.into_inner())) - }, - Err(err) => Err(err) + match cropped.write_to(&mut buffer, image::ImageFormat::Jpeg) { + Ok(_) => Ok(Bytes::from(buffer.into_inner())), + Err(err) => Err(err), } } diff --git a/src/welcome.rs b/src/welcome.rs index 871f37f..582dfd0 100644 --- a/src/welcome.rs +++ b/src/welcome.rs @@ -2,7 +2,6 @@ use std::env; use tracing::info; pub fn welcome() { - let version = env!("CARGO_PKG_VERSION"); let run_mode = env::var("ISM_MODE").unwrap_or_else(|_| "development".into()); @@ -20,4 +19,4 @@ pub fn welcome() { println!("Version: {} | Run-Mode: {}", version, run_mode); println!(); info!("Starting up ISM in {run_mode} mode."); -} \ No newline at end of file +}