Remove Redis backend support

This commit is contained in:
Jacob Schmidt 2026-04-17 17:09:21 -05:00
parent 06c634c642
commit 0b2b6265f3
67 changed files with 475 additions and 6110 deletions

View File

@ -1,134 +1,49 @@
# Forge Architecture & Data Flow Diagram
# Forge Architecture
## 🏗️ **System Architecture Overview**
```mermaid
graph TD
subgraph ForgeSystem [FORGE SYSTEM]
subgraph Clients [Clients #40;Read-Only#41;]
ClientA[CLIENT A]
ClientB[CLIENT B]
ClientN[CLIENT N]
subgraph OptimisticCache [Optimistic Cache]
ActorObj[Actor Object<br/>- loadout<br/>- position<br/>- stats]
end
ClientA --- OptimisticCache
ClientB --- OptimisticCache
ClientN --- OptimisticCache
end
subgraph Server [ArmA 3 SERVER #40;Hot Cache#41;]
Registry["GVAR(Registry)<br/>In-Memory HashMap<br/>UID -> {loadout, position, stats...}"]
SessionMgmt[Session Management<br/>- Token Generation<br/>- UID Resolution<br/>- Player State]
end
subgraph Rust [EXTENSION #40;Cold Storage#41;]
ConnPool["Connection Pool<br/>(bb8-redis)<br/>2-10 connections"]
RedisOps[Redis Operations<br/>- actor_get/set/update<br/>- Async I/O]
end
subgraph Redis [DATABASE #40;Saved to Disc#41;]
ActorDataStore[Actor Data Store<br/>actor:UID -> JSON]
Modules[Additional Modules<br/>garage, locker, bank, org]
end
Clients -->|Event Driven<br/>#40;CBA A3 Events#41;| Server
Server -->|Extension Calls<br/>#40;Rust FFI#41;| Rust
Rust -->|Redis Protocol<br/>#40;bb8-redis#41;| Redis
end
```
## 🔄 **Data Flow Sequence**
### **1. Player Connection & Initial Data Load**
```mermaid
sequenceDiagram
participant Client
participant Server as Server (Hot Cache)
participant Extension as Extension (Cold Storage)
participant Redis as Redis (Database)
Note over Client, Redis: 1. Player Connection & Initial Data Load
Client->>Server: 1. Connect
Client->>Server: 2. Request Actor Data
Server->>Server: 3. Check Cache (Cache Miss)
Server->>Extension: 4. Extension Call
Extension->>Redis: 5. Redis Query
Redis-->>Extension: 6. JSON Data
Extension-->>Server: 7. Actor Data
Server->>Server: 8. Store in Hot Cache
Server-->>Client: 9. Secure Response
Client->>Client: 10. Update Local Cache
```
### **2. Subsequent Data Access (Cache Hit)**
```mermaid
sequenceDiagram
participant Client
participant Server as Server (Hot Cache)
participant Extension as Extension (Cold Storage)
participant Redis as Redis (Database)
Note over Client, Redis: 2. Subsequent Data Access (Cache Hit)
Client->>Server: 1. Request Actor Data
Server->>Server: 2. Check Cache (Cache Hit!)
Server-->>Client: 3. Instant Response
Client->>Client: 4. Update Local Cache
```
### **3. Data Update (Write-Through)**
```mermaid
sequenceDiagram
participant Client
participant Server as Server (Hot Cache)
participant Extension as Extension (Cold Storage)
participant Redis as Redis (Database)
Note over Client, Redis: 3. Data Update (Write-Through)
Client->>Server: 1. Action (Move, etc)
Server->>Server: 2. Validate & Update Cache
Server->>Extension: 3. Persist to Database
Extension->>Redis: 4. Redis Update
Redis-->>Extension: 5. Confirmation
Extension-->>Server: 6. Success
Server-->>Client: 7. Sync to All Clients
```
## 🚀 **Performance Characteristics**
### **Access Times**
- **Hot Cache (Server)**: `< 1ms` (HashMap lookup)
- **Cold Storage (Redis)**: `1-5ms` (Network + Redis)
- **Client Cache**: `< 0.1ms` (Local object access)
### **Cache Hit Ratios**
- **Hot Cache**: `~95%` (Active players)
- **Cold Storage**: `~5%` (New connections, cache misses)
### **Memory Usage**
- **Server Registry**: `~1KB per active player`
- **Client Cache**: `~500B per player object`
- **Redis**: `~2KB per player (persistent)`
## 🔒 **Security & Session Management**
## Runtime Flow
```mermaid
flowchart TD
subgraph SessionMgmt [SERVER-SIDE #40;Session MGT#41;]
Conn[Player Connection] --> Token[Session Token Generation<br/>#40;Generated on server#41;]
Token --> UID[UID Resolution<br/>#40;Steam UID mapping#41;]
UID --> State[Player State Tracking<br/>#40;Tracked in Registry#41;]
State --> Access[Data Access Authorized<br/>#40;Authorized via session#41;]
end
Client[Arma Client Addons] --> Server[Arma Server Addons]
Server --> Bridge[Extension Bridge]
Bridge --> Extension[Rust arma-rs Extension]
Extension --> Services[Service Layer]
Services --> Repositories[Repository Traits]
Repositories --> Surreal[(SurrealDB)]
```
## Persistence Startup
```mermaid
sequenceDiagram
participant Arma as Arma Server
participant Ext as Forge Extension
participant Db as SurrealDB
Arma->>Ext: init
Ext->>Db: connect
Ext->>Db: apply schema modules
Db-->>Ext: ready
Arma->>Ext: status
Ext-->>Arma: connected
```
## Data Access
```mermaid
sequenceDiagram
participant SQF as SQF Addon
participant Ext as Extension Command
participant Service as Service
participant Repo as Repository
participant Db as SurrealDB
SQF->>Ext: domain command
Ext->>Service: validate and execute
Service->>Repo: repository call
Repo->>Db: query/upsert/delete
Db-->>Repo: result
Repo-->>Service: domain model
Service-->>Ext: response
Ext-->>SQF: serialized result
```

View File

@ -12,7 +12,6 @@ resolver = "3"
[workspace.dependencies]
arma-rs = { version = "1.11.15", features = ["chrono", "serde_json", "uuid"] }
chrono = "0.4.42"
redis = "1.0.0-rc.1"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tokio = { version = "1.47.1", features = ["full"] }

345
README.md
View File

@ -1,313 +1,54 @@
# Forge Framework
# Forge
**Forge** is a high-performance, production-ready framework for Arma 3 persistent game servers, built with Rust and Redis for optimal performance and reliability.
Forge is a framework for Arma 3 persistent game servers. It combines SQF
addons, a Rust `arma-rs` extension, shared service crates, and web-based client
interfaces for player data, organizations, banking, garages, lockers, phones,
CAD, stores, and task workflows.
## Overview
## Storage
Forge provides a complete solution for managing persistent player data, organizations, and game state in Arma 3 multiplayer environments. It combines the performance of Rust with the flexibility of Redis to deliver sub-millisecond response times while maintaining data consistency across server restarts.
### Key Features
- **🚀 High Performance**: Sub-millisecond data access through intelligent caching
- **🔒 Data Integrity**: Strict validation and type safety at every layer
- **🏗️ Clean Architecture**: Layered design following SOLID principles
- **📦 Modular Design**: Easy to extend with new entities and features
- **🔄 Real-time Sync**: Automatic state synchronization across all clients
- **💾 Persistent Storage**: Redis-backed storage with automatic failover
- **🧪 Testable**: Mock-friendly architecture for comprehensive testing
## Architecture
Forge follows a **layered architecture** pattern:
```mermaid
graph TD
Extension[Extension Layer<br/>ArmA 3 Interface <---> Rust]
Services[Services Layer<br/>#40;Business Logic#41;]
Repositories[Repositories Layer<br/>#40;Data Persistence#41;]
Models[Models Layer<br/>#40;Data Structures & Validation#41;]
Extension --> Services
Services --> Repositories
Repositories --> Models
```
**Communication Flow**:
- **Clients** → Use events (`CBA_Events`) to communicate with server
- **Server** → Calls Rust extension via `callExtension`
- **Extension** → Manages Redis connection pool and data operations
For detailed architecture information, see [Diagram](Architecture_Diagram.md).
## Project Structure
```
forge/
├── arma/
│ ├── client/ # Client-side SQF mod
│ │ ├── addons/
│ │ │ ├── main/ # Core initialization & config
│ │ │ ├── common/ # Shared utilities & helpers
│ │ │ ├── actor/ # Actor/player UI, class & events
│ │ │ ├── org/ # Organization UI, class & events
│ │ │ └── bank/ # Banking UI, class & events
│ │ ├── include/ # Header files
│ │ └── tools/ # Build tools
│ ├── server/
│ │ ├── addons/
│ │ │ ├── main/ # Core initialization & config
│ │ │ ├── common/ # Shared utilities & helpers
│ │ │ ├── actor/ # Actor/player Registry, Store & events
│ │ │ └── org/ # Organization Registry, Store & events
│ │ ├── include/ # Header files
│ │ ├── tools/ # Build tools
│ │ └── extension/ # Rust extension (Arma 3 interface)
│ │ ├── src/
│ │ │ ├── actor.rs # Actor/player commands
│ │ │ ├── org.rs # Organization commands
│ │ │ ├── redis/ # Redis operations module
│ │ │ └── adapters/ # Repository adapters
│ │ └── README.md
├── lib/
│ ├── models/ # Data structures & validation
│ ├── repositories/ # Data persistence layer
│ ├── services/ # Business logic layer
│ ├── shared/ # Common utilities & traits
│ └── README.md
└── FORGE_Architecture_Diagram.md
```
## Quick Start
### Prerequisites
- Rust 1.70+ with `cargo`
- Redis 6.0+
- HEMTT
1. Clone the repository from Gitea
2. Install HEMTT
The latest version of HEMTT can be installed by running:
```cmd
winget install hemtt
```
### Coding Guidelines
This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).
### Building the Extension
```bash
# Build for release
cargo build --release
# The compiled extension will be at:
# target/release/forge_server.dll (Windows)
# target/release/forge_server.so (Linux)
```
### Configuration
Create `@forge_server/config.toml`:
Durable persistence is backed by SurrealDB. The server extension loads schema
modules at startup and routes domain repositories through the SurrealDB client.
```toml
[redis]
host = "127.0.0.1"
port = 6379
password = "" # Optional
max_connections = 10
min_connections = 2
idle_timeout = 300
[surreal]
endpoint = "127.0.0.1:8000"
namespace = "forge"
database = "main"
username = "root"
password = "root"
connect_timeout_ms = 5000
```
### SQF Usage
## Workspace
```text
arma/
client/ Client-side addons and browser UIs
server/ Server-side addons and extension crate
bin/
icom/ Interprocess communication helper
lib/
models/ Shared domain models
repositories/ Repository traits and in-memory test stores
services/ Domain business logic
shared/ Cross-crate helpers
tools/ Web UI build tooling
```
## Common Commands
```powershell
cargo test
npm run build:webui
.\build-arma.ps1
```
## Extension Status
```sqf
// Create an actor
private _data = createHashMapFromArray [
["name", "John Doe"],
["bank", 1000],
["level", 1]
];
private _result = "forge_server" callExtension ["actor:create", [getPlayerUID player, toJSON _data]];
// Get actor data
private _result = "forge_server" callExtension ["actor:get", [getPlayerUID player]];
private _actorData = fromJSON (_result select 0);
// Update actor
private _update = createHashMapFromArray [["bank", 1500]];
"forge_server" callExtension ["actor:update", [getPlayerUID player, toJSON _update]];
"forge_server" callExtension ["status", []];
"forge_server" callExtension ["surreal:status", []];
```
## Core Modules
### Models
Defines strict data structures with built-in validation:
- `Actor`: Player data (stats, inventory, position)
- `Org`: Organization/clan data (members, roles, metadata)
[Documentation](lib/models/README.md)
### Repositories
Manages data persistence with Redis:
- Hash-based storage for structured data
- Set-based storage for collections
- Generic over Redis client implementations
[Documentation](lib/repositories/README.md)
### Services
Implements business logic and orchestration:
- Get-or-create patterns
- Data validation and transformation
- Complex workflows
[Documentation](lib/services/README.md)
### Extension
Arma 3 interface layer:
- Command routing and parsing
- Session management
- Error handling and logging
[Documentation](arma/server/extension/README.md)
### Client Mod
Client-side SQF addon that provides:
- **UI Components**: Player interfaces for inventory, organizations, banking
- **Event Handlers**: CBA event listeners for server communication
- **Optimistic Caching**: Local data caching for instant UI updates
- **State Management**: Client-side state synchronization
- **Input Validation**: Client-side validation before server requests
The client mod communicates with the server using **CBA Events**, ensuring:
- No direct extension calls from clients (security)
- Event-driven architecture for scalability
- Automatic state synchronization across all clients
- Reduced server load through client-side caching
## Available Commands
### Actor Commands
| Command | Description |
| -------------- | -------------------------- |
| `actor:get` | Retrieve actor data by UID |
| `actor:create` | Create a new actor |
| `actor:update` | Update actor fields |
| `actor:exists` | Check if actor exists |
| `actor:delete` | Delete actor data |
### Organization Commands
| Command | Description |
| ------------------- | ------------------------------- |
| `org:get` | Retrieve organization data |
| `org:create` | Create a new organization |
| `org:update` | Update organization fields |
| `org:exists` | Check if organization exists |
| `org:delete` | Delete organization |
| `org:add_member` | Add member to organization |
| `org:remove_member` | Remove member from organization |
| `org:get_members` | Get all organization members |
### Redis Operations
Direct Redis operations for advanced use cases:
- **Common**: Key-value operations (set, get, incr, decr, del)
- **Hash**: Structured data (hset, hget, hgetall, hdel)
- **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop)
- **Set**: Unique collections (sadd, smembers, srem, sismember)
[Documentation](arma/server/extension/src/redis/README.md)
## Performance
- **Hot Cache (Server)**: < 1ms (HashMap lookup)
- **Cold Storage (Redis)**: 1-5ms (Network + Redis query)
- **Cache Hit Ratio**: ~95% for active players
- **Memory Usage**: ~1KB per active player (server), ~2KB per player (Redis)
## Contributing
We welcome contributions! Please see the contributing guides for each layer:
- [Extension Contributing Guide](arma/server/extension/README.md#contributing)
- [Services Contributing Guide](lib/services/README.md#contributing)
- [Repositories Contributing Guide](lib/repositories/README.md#contributing)
- [Models Contributing Guide](lib/models/README.md#contributing)
- [Library Contributing Guide](lib/README.md#contributing)
- [Adapter Contributing Guide](arma/server/extension/src/adapters/#contributing)
### Development Workflow
1. **Define Model**: Create data structure with validation
2. **Create Repository**: Implement persistence layer
3. **Build Service**: Add business logic
4. **Expose in Extension**: Create SQF-callable commands
5. **Test**: Verify each layer independently
## Error Handling
All commands return consistent error messages:
```sqf
private _result = "forge_server" callExtension ["actor:get", ["invalid_uid"]];
private _response = _result select 0;
if (_response find "Error:" == 0) then {
diag_log format ["Operation failed: %1", _response];
} else {
private _data = fromJSON _response;
// Use data
};
```
## Logging
Logs are automatically created in `@forge_server/logs/`:
- `actor.log` - Actor operations
- `org.log` - Organization operations
- `redis.log` - Redis connection and operations
- `debug.log` - General debug information
## License
View the License [here](LICENSE.md).
## Support
- **Issues**: [Gitea Issues](https://gitea.innovativedevsolutions.org/IDSolutions/forge/issues)
- **Documentation**: See individual module READMEs
- **Architecture**: [Diagram](Architecture_Diagram.md)
## Roadmap
- [ ] Admin system
- [ ] Arsenal system
- [ ] Banking system
- [ ] Economy system
- [ ] Garage system
- [ ] Locker system
- [ ] Mission template
---
Built using **Rust**, **Redis**, and **Arma 3**
Both commands report the persistence connection state.

View File

@ -61,10 +61,6 @@ private _transportResponseFunctions = [
"org:assets:get",
"org:fleet:get"
];
private _requiresRedis = !(_functionLower in ["status", "version"])
&& (_functionLower find "icom:" == 0)
&& (_functionLower find "terrain:" == 0);
private _callExtensionCommand = {
params [["_command", "", [""]], ["_commandArguments", [], [[]]]];
@ -110,28 +106,6 @@ private _callExtensionCommand = {
[_response, _responseSuccess]
};
private _checkRedisAvailability = {
("forge_server" callExtension ["status", []]) params [
"_redisStatus",
"_statusExtCode",
"_statusArmaCode"
];
private _statusSuccess = (_statusExtCode == 0) && (_statusArmaCode == 0 || _statusArmaCode == 301);
if (!_statusSuccess) exitWith {
["WARNING", "Unable to determine Redis status before extension call", nil, nil] call EFUNC(common,log);
["Error: Redis status check failed", false]
};
if (_redisStatus != "connected") exitWith {
["WARNING", format ["Blocked extension call '%1' because Redis status is '%2'", _function, _redisStatus], nil, nil] call EFUNC(common,log);
[format ["Error: Redis is %1", _redisStatus], false]
};
["", true]
};
private _buildTransportArgumentsJson = {
private _rawArguments = _this;
if !(_rawArguments isEqualType []) then {
@ -156,17 +130,6 @@ private _buildTransportArgumentsJson = {
format ["[%1]", _encodedArguments joinString ","]
};
if (_requiresRedis) exitWith {
[_function, _arguments] call _checkRedisAvailability params ["_redisResult", "_redisSuccess"];
if (!_redisSuccess) exitWith { [_redisResult, false] };
if (_functionLower in ["status", "version"]) exitWith {
[_function, _arguments] call _callExtensionCommand
};
[_function, _arguments] call _callExtensionCommand
};
if (_functionLower in ["status", "version"]) exitWith {
[_function, _arguments] call _callExtensionCommand
};

View File

@ -1,41 +1,10 @@
# Crate Server Configuration
# Copy this file to config.toml and modify as needed
# Place this file in the same directory as your crate_server_x64.dll
# Forge Server Configuration
# Copy this file to config.toml and place it beside forge_server_x64.dll.
[redis]
# Redis server connection settings
host = "127.0.0.1"
port = 6379
db = 0 # Redis database number (0-15)
# Optional authentication
# username = "your_username"
# password = "your_password"
# Optional connection pool settings
max_connections = 10 # Maximum number of connections in pool
min_connections = 2 # Minimum number of idle connections
idle_timeout = 60 # Idle connection timeout in seconds
connect_timeout_ms = 2000 # Pool connect timeout in milliseconds
pool_get_timeout_ms = 2000 # Pool checkout timeout in milliseconds
command_timeout_ms = 2000 # Redis command timeout in milliseconds
# Example configurations for different environments:
# Development (local Redis)
# host = "127.0.0.1"
# port = 6379
# max_connections = 5
# min_connections = 1
# Production (remote Redis with auth)
# host = "redis.example.com"
# port = 6379
# username = "arma_server"
# password = "secure_password_here"
# max_connections = 20
# min_connections = 5
# idle_timeout = 30
# connect_timeout_ms = 5000
# pool_get_timeout_ms = 5000
# command_timeout_ms = 5000
[surreal]
endpoint = "127.0.0.1:8000"
namespace = "forge"
database = "main"
username = "root"
password = "root"
connect_timeout_ms = 5000

View File

@ -1,285 +1,39 @@
# Forge Server - Redis Client Module
# Forge Server Extension
A high-performance arma-rs extension for Arma 3, featuring a **low-level Redis data access layer** that provides raw Redis operations as a foundation for higher-level game modules.
Forge Server is an arma-rs extension for Arma 3 server-side persistence and
domain services. It exposes game-facing commands and stores durable state in
SurrealDB.
## 🎯 Overview
## Architecture
The Forge Server Redis module is designed as a **foundational data access layer** that:
SQF modules call `forge_server` through `fnc_extCall`. Small requests use the
direct `callExtension` path, while large payloads are staged through the
transport layer.
- **Returns raw Redis responses** for maximum performance and flexibility
- **Serves as the foundation** for higher-level game modules (actor, garage, locker, bank, etc.)
- **Provides connection pooling** and error handling for Redis operations
- **Enables persistent data storage** across server restarts
- **Supports cross-server data sharing** in multi-server environments
## 🏗️ Layered Architecture
```
SQF Scripts
↓ (JSON responses)
Game Modules (actor, garage, locker, bank)
↓ (raw Redis responses)
Redis Client Module
↓ (Redis protocol)
Redis Server
```text
SQF module
-> extension bridge
-> domain command
-> service layer
-> repository
-> SurrealDB
```
**This module handles the bottom layer** - raw Redis operations with connection pooling and error handling.
## Configuration
## 🏗️ Internal Architecture
```
forge_server_x64.dll Extension
├── lib.rs # Core extension initialization & global runtime
├── config.example.toml # Example configuration file
└── redis/ # Redis Client module
├── mod.rs # Group definitions & module exports
├── client.rs # Connection pool management
├── config.rs # Configuration system
├── macros.rs # redis_operation! macro for boilerplate elimination
├── common.rs # String/key operations
├── hash.rs # Hash operations (HSET, HGET, etc.)
├── list.rs # List operations (LPUSH, LPOP, etc.)
└── set.rs # Set operations (SADD, SMEMBERS, etc.)
```
### Key Components
- **lib.rs**: Manages global Redis pool and single Tokio runtime
- **macros.rs**: Provides `redis_operation!` macro to eliminate boilerplate
- **Operation modules**: Focus purely on Redis logic using the macro
- **Synchronous Interface**: All functions appear synchronous to Arma while using async Redis internally
## 🚀 Features
### Raw Redis Operations
- **String Operations**: SET, GET, INCR, DECR, DEL, KEYS
- **Hash Operations**: HSET, HGET, HMSET, HGETALL, HDEL, HKEYS, HVALS, HLEN
- **List Operations**: LSET, LGET, LLEN, LRANGE, LPUSH, RPUSH, LPOP, RPOP, LTRIM, LREM
- **Set Operations**: SADD, SMEMBERS, SCARD, SREM, SISMEMBER, SPOP, SRANDMEMBER
### Performance Features
- **Connection Pooling**: bb8-redis pool with configurable size and timeouts
- **Single Runtime**: One shared Tokio runtime for all async operations
- **Macro-Based**: `redis_operation!` macro eliminates boilerplate while maintaining performance
- **Synchronous Interface**: Functions block until completion, compatible with Arma's threading model
- **Raw Responses**: Returns native Redis values for maximum performance
- **Thread Safety**: Safe concurrent access from multiple Arma threads
## ⚙️ Configuration System
Forge Server uses a TOML-based configuration system for flexible Redis connection management.
### Configuration File
Create a `config.toml` file in your extension directory:
Copy `config.example.toml` to `config.toml` next to the extension DLL.
```toml
[redis]
host = "127.0.0.1"
port = 6379
# db = 0 # Optional: Redis database number
# username = "user" # Optional: Redis username
# password = "password" # Optional: Redis password
# max_connections = 10 # Optional: Maximum connections in pool
# min_connections = 2 # Optional: Minimum idle connections in pool
# idle_timeout = 60 # Optional: Connection idle timeout (seconds)
[surreal]
endpoint = "127.0.0.1:8000"
namespace = "forge"
database = "main"
username = "root"
password = "root"
connect_timeout_ms = 5000
```
### Fallback Behavior
## References
The extension uses a robust fallback system:
1. **Loads `config.toml`** if present in the extension directory
2. **Falls back to defaults** if configuration fails or file is missing
3. **Only fails** if both config and defaults cannot establish connection
**Default Settings:**
- **Host**: `127.0.0.1`
- **Port**: `6379`
- **Max Connections**: `10`
- **Min Connections**: `2`
- **Idle Timeout**: `60 seconds`
### Common Configurations
**Development (Local Redis)**:
```toml
[redis]
host = "127.0.0.1"
port = 6379
max_connections = 5
min_connections = 1
```
**Production (Remote Redis with Authentication)**:
```toml
[redis]
host = "redis.example.com"
port = 6379
username = "arma_server"
password = "secure_password"
max_connections = 20
min_connections = 5
idle_timeout = 60
```
### Troubleshooting
**Connection Issues:**
- Verify Redis server is running: `redis-cli ping`
- Check host/port settings in `config.toml`
- Ensure firewall allows connection
**Authentication Issues:**
- Verify username/password in config
- Check Redis server auth settings
**Config File Issues:**
- Check TOML syntax with online validators
- Ensure quotes are properly closed
- Verify file permissions
**Connection Pool Benefits:**
- Pre-warmed connections for zero-latency operations
- Automatic connection recovery on network issues
- Resource-efficient connection sharing
- Configurable pool sizing for different deployment scenarios
## 🔧 Installation
1. **Prerequisites**:
- Redis server (local or remote)
- Arma 3 server with extension support
2. **Extension Setup**:
- Build the extension: `cargo build --release`
- Copy the compiled `forge_server_x64.dll` to your Arma 3 server
- Copy `config.example.toml` to `config.toml` and configure as needed
- Load in server config or mission
3. **Redis Server**:
```bash
# Start Redis server
redis-server
# Verify connection
redis-cli ping
```
## 📝 Documentation
- **[API Reference](./api-reference.md)** - Complete Redis command reference
- **[Usage Examples](./usage-examples.md)** - Practical SQF integration examples
## 📊 Performance
- **Connection Pool**: 2-10 persistent connections using bb8-redis
- **Single Runtime**: One shared Tokio runtime eliminates overhead from multiple runtimes
- **Macro Efficiency**: Zero-cost abstraction macros expand to optimal code at compile time
- **Synchronous Blocking**: Functions use `block_on()` for Arma compatibility without sacrificing async I/O benefits
- **Response Format**: Raw Redis responses for minimal overhead
- **Thread Safety**: Multiple Arma threads can safely call operations concurrently
- **Memory Efficient**: Minimal resource usage per operation
## 🔄 Response Format
This module returns **raw Redis responses** as strings for maximum performance:
### Success Responses
- **String values**: `"John"` (raw string)
- **Numbers**: `"42"` (number as string)
- **Lists/Arrays**: `"item1,item2,item3"` (comma-separated)
- **Hashes**: `"key1,value1,key2,value2"` (comma-separated key-value pairs)
- **Boolean**: `"1"` or `"0"` (for exists checks)
- **Status**: `"OK"` (for successful SET operations)
### Error Responses
- **Format**: `"Error: <error description>"`
- **Pool errors**: `"Error: Redis pool not initialized"`
- **Connection errors**: `"Error: <connection error>"`
- **Redis errors**: `"Error: <Redis operation error>"`
### Higher-Level JSON Formatting
Game modules (actor, garage, etc.) will wrap these raw responses in structured JSON for SQF consumption.
## ⚙️ Macro-Based Implementation
This extension uses a **macro-based architecture** to eliminate boilerplate while maintaining performance:
### The `redis_operation!` Macro
```rust
pub fn set_key(key: String, value: String) -> String {
redis_operation!(conn => {
match conn.set::<_, _, ()>(&key, &value).await {
Ok(()) => "OK".to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
```
### What the Macro Handles
- **Pool Management**: Retrieves Redis connection pool
- **Error Handling**: Returns "Error: ..." for pool/connection failures
- **Async Bridging**: Uses shared Tokio runtime via `block_on()`
- **Connection Acquisition**: Gets connection from pool with error handling
- **Cleanup**: Automatic connection return to pool
### Benefits
- **Reduced Code**: 70% less boilerplate per function
- **Consistency**: Identical error handling across all operations
- **Maintainability**: Changes to connection logic in one place
- **Performance**: No runtime overhead from abstraction
- **Synchronous Interface**: Functions block until Redis operation completes
## 🛠️ Development
- **Language**: Rust (Edition 2024)
- **Dependencies**: arma-rs, bb8-redis, redis, tokio
- **Architecture**: Macro-based design with single runtime and connection pool
- **Key Patterns**:
- Global state management in `lib.rs`
- Boilerplate elimination via `redis_operation!` macro
- Synchronous interfaces over async operations
- Raw Redis responses for minimal overhead
- **Testing**: Unit tests for core functionality
## 🚨 Error Handling
The extension provides comprehensive error handling:
- Connection failures
- Redis operation errors
- Invalid parameters
- Pool initialization errors
All errors include descriptive messages for debugging.
## 🔍 Monitoring
Connection pool status and Redis operations can be monitored through:
- Extension logs
- Redis server logs
- Connection pool metrics
---
**Built with ❤️ for the Arma 3 community**
- [API Reference](./api-reference.md)
- [Usage Examples](./usage-examples.md)

View File

@ -1,426 +1,35 @@
# Redis Client API Reference
# Forge Server API Reference
Complete reference for **raw Redis operations** available in the Forge Server extension. This module returns native Redis values without JSON formatting.
The Forge server extension exposes domain-oriented commands through
`callExtension`. Persistent data is stored through the configured SurrealDB
connection and schema modules.
> **Note**: This is a low-level data access layer. Higher-level game modules (actor, garage, etc.) will provide structured JSON responses for SQF consumption.
## 🏗️ Implementation
All Redis operations are implemented using the `redis_operation!` macro for:
- **Consistent Error Handling**: All functions return identical error formats
- **Connection Management**: Automatic pool and connection handling
- **Synchronous Interface**: Functions block until Redis operations complete
- **Performance**: Zero-cost abstraction with compile-time optimization
## 🔗 Command Structure
All redis client commands follow the pattern: `"forge_server" callExtension ["redis:command", [parameters]]`
## 📝 Common Operations
### SET - Store a key-value pair
**Command**: `redis:common:set`
**Parameters**: `[key, value]`
## Core Commands
```sqf
"forge_server" callExtension ["redis:common:set", ["player_name", "John"]]
"forge_server" callExtension ["version", []];
"forge_server" callExtension ["status", []];
"forge_server" callExtension ["surreal:status", []];
```
**Raw Response**: `"OK"`
### GET - Retrieve a value by key
**Command**: `redis:common:get`
**Parameters**: `[key]`
```sqf
"forge_server" callExtension ["redis:common:get", ["player_name"]]
```
**Raw Response**: `"John"` (the actual stored value)
### INCR - Increment a numeric value
**Command**: `redis:common:incr`
**Parameters**: `[key, increment_amount]`
```sqf
"forge_server" callExtension ["redis:common:incr", ["player_score", 10]]
```
**Raw Response**: `"110"` (the new value as string)
### DECR - Decrement a numeric value
**Command**: `redis:common:decr`
**Parameters**: `[key, decrement_amount]`
```sqf
"forge_server" callExtension ["redis:common:decr", ["player_lives", 1]]
```
**Raw Response**: `"2"` (the new value as string)
### DEL - Delete a key
**Command**: `redis:common:del`
**Parameters**: `[key]`
```sqf
"forge_server" callExtension ["redis:common:del", ["temp_data"]]
```
**Raw Response**: `"1"` (number of keys deleted)
### KEYS - List all keys matching pattern
**Command**: `redis:common:keys`
**Parameters**: `[]` (currently returns all keys with "\*" pattern)
```sqf
"forge_server" callExtension ["redis:common:keys", []]
```
**Raw Response**: `"player_name,player_score,mission_state"` (comma-separated list)
## 🗂️ Hash Operations
### HSET - Set hash field
**Command**: `redis:hash:set`
**Parameters**: `[hash_key, field, value]`
```sqf
"forge_server" callExtension ["redis:hash:set", ["player:123", "name", "John"]]
```
**Raw Response**: `"1"` (number of fields added)
### HGET - Get hash field
**Command**: `redis:hash:get`
**Parameters**: `[hash_key, field]`
```sqf
"forge_server" callExtension ["redis:hash:get", ["player:123", "name"]]
```
**Raw Response**: `"John"` (the field value)
### HMSET - Set multiple hash fields
**Command**: `redis:hash:mset`
**Parameters**: `[hash_key, [[field1, value1], [field2, value2], ...]]`
```sqf
"forge_server" callExtension ["redis:hash:mset", ["player:123", [["name", "John"], ["score", "100"]]]]
```
**Raw Response**: `"OK"`
### HGETALL - Get all hash fields
**Command**: `redis:hash:getall`
**Parameters**: `[hash_key]`
```sqf
"forge_server" callExtension ["redis:hash:getall", ["player:123"]]
```
**Raw Response**: `"name,John,score,100,level,5"` (comma-separated key-value pairs)
### HDEL - Delete hash field
**Command**: `redis:hash:del`
**Parameters**: `[hash_key, field]`
```sqf
"forge_server" callExtension ["redis:hash:del", ["player:123", "temp_field"]]
```
**Raw Response**: `"1"` (number of fields removed)
### HKEYS - Get all hash field names
**Command**: `redis:hash:keys`
**Parameters**: `[hash_key]`
```sqf
"forge_server" callExtension ["redis:hash:keys", ["player:123"]]
```
**Raw Response**: `"name,score,level"` (comma-separated field names)
### HVALS - Get all hash values
**Command**: `redis:hash:vals`
**Parameters**: `[hash_key]`
```sqf
"forge_server" callExtension ["redis:hash:vals", ["player:123"]]
```
**Raw Response**: `"John,100,5"` (comma-separated values)
### HLEN - Get hash field count
**Command**: `redis:hash:len`
**Parameters**: `[hash_key]`
```sqf
"forge_server" callExtension ["redis:hash:len", ["player:123"]]
```
**Raw Response**: `"3"` (number of fields in hash)
## 📋 List Operations
### LSET - Set list element by index
**Command**: `redis:list:set`
**Parameters**: `[list_key, index, value]`
```sqf
"forge_server" callExtension ["redis:list:set", ["mission_queue", 0, "patrol_alpha"]]
```
**Raw Response**: `"OK"`
### LGET - Get list element by index
**Command**: `redis:list:get`
**Parameters**: `[list_key, index]`
```sqf
"forge_server" callExtension ["redis:list:get", ["mission_queue", 0]]
```
**Raw Response**: `"patrol_alpha"` (the element value)
### LLEN - Get list length
**Command**: `redis:list:len`
**Parameters**: `[list_key]`
```sqf
"forge_server" callExtension ["redis:list:len", ["mission_queue"]]
```
**Raw Response**: `"5"` (list length)
### LRANGE - Get list elements in range
**Command**: `redis:list:range`
**Parameters**: `[list_key, start_index, end_index]`
```sqf
"forge_server" callExtension ["redis:list:range", ["mission_queue", 0, 2]]
```
**Raw Response**: `"patrol_alpha,escort_beta,defend_gamma"` (comma-separated values)
### LPUSH - Add element to list head
**Command**: `redis:list:lpush`
**Parameters**: `[list_key, value]`
```sqf
"forge_server" callExtension ["redis:list:lpush", ["recent_actions", "player_joined"]]
```
**Raw Response**: `"6"` (new list length)
### RPUSH - Add element to list tail
**Command**: `redis:list:rpush`
**Parameters**: `[list_key, value]`
```sqf
"forge_server" callExtension ["redis:list:rpush", ["mission_queue", "new_objective"]]
```
**Raw Response**: `"6"` (new list length)
### LPOP - Remove and return element from list head
**Command**: `redis:list:lpop`
**Parameters**: `[list_key, count]`
```sqf
"forge_server" callExtension ["redis:list:lpop", ["recent_actions", 1]]
```
**Raw Response**: `"player_joined"` (removed element) or `"item1,item2"` (if count > 1)
### RPOP - Remove and return element from list tail
**Command**: `redis:list:rpop`
**Parameters**: `[list_key, count]`
```sqf
"forge_server" callExtension ["redis:list:rpop", ["mission_queue", 1]]
```
**Raw Response**: `"new_objective"` (removed element) or `"item1,item2"` (if count > 1)
### LTRIM - Trim list to specified range
**Command**: `redis:list:trim`
**Parameters**: `[list_key, start_index, end_index]`
```sqf
"forge_server" callExtension ["redis:list:trim", ["recent_actions", 0, 9]] // Keep only last 10 items
```
**Raw Response**: `"OK"`
### LREM - Remove elements from list
**Command**: `redis:list:del`
**Parameters**: `[list_key, count, value]`
```sqf
"forge_server" callExtension ["redis:list:del", ["mission_queue", 1, "completed_mission"]]
```
**Raw Response**: `"1"` (number of elements removed)
## 🎯 Set Operations
### SADD - Add element to set
**Command**: `redis:set:add`
**Parameters**: `[set_key, value]`
```sqf
"forge_server" callExtension ["redis:set:add", ["online_players", "player_123"]]
```
**Raw Response**: `"1"` (1 if element was added, 0 if already existed)
### SMEMBERS - Get all set members
**Command**: `redis:set:members`
**Parameters**: `[set_key]`
```sqf
"forge_server" callExtension ["redis:set:members", ["online_players"]]
```
**Raw Response**: `"player_123,player_456,player_789"` (comma-separated members)
### SCARD - Get set size
**Command**: `redis:set:card`
**Parameters**: `[set_key]`
```sqf
"forge_server" callExtension ["redis:set:card", ["online_players"]]
```
**Raw Response**: `"3"` (number of elements in set)
### SREM - Remove element from set
**Command**: `redis:set:del`
**Parameters**: `[set_key, value]`
```sqf
"forge_server" callExtension ["redis:set:del", ["online_players", "player_456"]]
```
**Raw Response**: `"1"` (1 if element was removed, 0 if didn't exist)
### SISMEMBER - Check if element is in set
**Command**: `redis:set:ismember`
**Parameters**: `[set_key, value]`
```sqf
"forge_server" callExtension ["redis:set:ismember", ["online_players", "player_123"]]
```
**Raw Response**: `"1"` (1 if member exists, 0 if not)
### SPOP - Remove and return random element
**Command**: `redis:set:pop`
**Parameters**: `[set_key]`
```sqf
"forge_server" callExtension ["redis:set:pop", ["available_missions"]]
```
**Raw Response**: `"mission_alpha"` (the removed element)
### SRANDMEMBER - Get random element without removing
**Command**: `redis:set:randmember`
**Parameters**: `[set_key]`
```sqf
"forge_server" callExtension ["redis:set:randmember", ["available_missions"]]
```
**Raw Response**: `"mission_beta"` (a random element)
### SRANDMEMBER - Get multiple random elements
**Command**: `redis:set:randmembers`
**Parameters**: `[set_key, count]`
```sqf
"forge_server" callExtension ["redis:set:randmembers", ["available_missions", 3]]
```
**Raw Response**: `"mission_alpha,mission_gamma,mission_delta"` (comma-separated random elements)
## ⚠️ Error Responses
All commands may return error responses in this format:
**Raw Error Response**: `"Error: <description>"`
### Common Error Types
- **Pool not initialized**: `"Error: Redis pool not initialized"`
- **Connection failed**: `"Error: Connection refused (os error 61)"`
- **Key not found**: `"Error: key not found"` (for operations on non-existent keys)
- **Invalid type**: `"Error: WRONGTYPE Operation against a key holding the wrong kind of value"`
- **Index out of range**: `"Error: index out of range"` (for list operations)
### Error Handling in Game Modules
Higher-level game modules should check if the response starts with `"Error: "` to distinguish between successful responses and errors.
```json
{
"status": "error",
"error": "Failed to connect to Redis server"
}
```
Common error types:
- **Connection errors**: Redis server unavailable
- **Operation errors**: Invalid data type for operation
- **Parameter errors**: Missing or invalid parameters
- **Pool errors**: Connection pool exhausted
## 📊 Response Fields
### Common Fields
- `status`: Always present - "success" or "error"
- `key`: The Redis key being operated on
- `error`: Error message (only on error responses)
### Success-Specific Fields
- `data`: The retrieved data (for GET operations)
- `value`: The stored value (for SET operations)
- `was_new`: Boolean indicating if operation created new data
- `removed_count`: Number of elements removed
- `fields_set`: Number of fields set in hash operations
`status` and `surreal:status` return `initializing`, `connected`, or `failed`.
## Domain Commands
Game systems should call the domain APIs instead of raw database operations:
- `actor:*`
- `bank:*`
- `garage:*`
- `locker:*`
- `org:*`
- `phone:*`
- `store:*`
- `task:*`
- `cad:*`
- `owned:garage:*`
- `owned:locker:*`
- `transport:*`
Large request and response payloads are routed through the transport layer when
needed by `forge_server_addons_extension_fnc_extCall`.

View File

@ -1,437 +1,47 @@
# Redis Client Usage Examples
# Forge Server Usage Examples
Practical examples of using the **raw Redis client module** as a foundation for higher-level game modules. These examples show low-level Redis operations that would typically be wrapped by game-specific modules (actor, garage, locker, bank).
These examples use the domain command surface exposed by the extension.
Persistence is handled by the server through SurrealDB.
> **Note**: These examples show raw Redis responses. In practice, your game modules would wrap these calls and return structured JSON to SQF scripts.
## 🚀 Function Behavior
All Redis functions are **synchronous from SQF's perspective**:
- Functions **block** until Redis operation completes
- **No callbacks** or async handling needed in SQF
- **Direct return values** either data or error strings
- **Thread-safe** multiple scripts can call simultaneously
The extension handles all async complexity internally using a macro-based architecture.
## 🎮 Player Management
### Player Join/Leave Tracking
## Status Check
```sqf
// When player joins
_playerUID = getPlayerUID player;
_playerName = name player;
// Store player info in hash
"forge_server" callExtension ["redis:hash:set", [format ["player:%1", _playerUID], "name", _playerName]];
"forge_server" callExtension ["redis:hash:set", [format ["player:%1", _playerUID], "join_time", str time]];
// Add to online players set
"forge_server" callExtension ["redis:set:add", ["online_players", _playerUID]];
// When player leaves
"forge_server" callExtension ["redis:set:del", ["online_players", _playerUID]];
"forge_server" callExtension ["redis:hash:set", [format ["player:%1", _playerUID], "leave_time", str time]];
```
### Player Statistics System
```sqf
// Initialize player stats
fnc_initPlayerStats = {
params ["_playerUID"];
_playerKey = format ["stats:%1", _playerUID];
"forge_server" callExtension ["redis:hash:mset", [_playerKey, [
["kills", "0"],
["deaths", "0"],
["score", "0"],
["playtime", "0"]
]]];
};
// Update player kill
fnc_addPlayerKill = {
params ["_playerUID"];
_playerKey = format ["stats:%1", _playerUID];
"forge_server" callExtension ["redis:hash:incr", [_playerKey, "kills", 1]];
"forge_server" callExtension ["redis:hash:incr", [_playerKey, "score", 10]];
};
// Get player stats (raw response)
fnc_getPlayerStats = {
params ["_playerUID"];
_playerKey = format ["stats:%1", _playerUID];
_rawResult = "forge_server" callExtension ["redis:hash:getall", [_playerKey]];
// _rawResult is now "kills,15,deaths,3,score,150,playtime,7200"
// Game modules would parse this into structured data
// For now, return raw comma-separated response
_rawResult select 0;
["status", []] call forge_server_extension_fnc_extCall params ["_status", "_ok"];
if (_ok && {_status isEqualTo "connected"}) then {
systemChat "Forge persistence is online.";
};
```
## 🏆 Leaderboards and Rankings
### Global Kill Leaderboard
## Actor Fetch
```sqf
// Add score to sorted leaderboard (using list for simplicity)
fnc_updateLeaderboard = {
params ["_playerName", "_kills"];
// Store individual score
"forge_server" callExtension ["redis:common:set", [format ["kills:%1", _playerName], str _kills]];
// Add to leaderboard tracking
"forge_server" callExtension ["redis:set:add", ["leaderboard_players", _playerName]];
};
// Get top 10 players (raw response handling)
fnc_getTopPlayers = {
// Get all leaderboard players - returns comma-separated list
_playersResult = "forge_server" callExtension ["redis:set:members", ["leaderboard_players"]];
_rawPlayers = _playersResult select 0;
// Check for error
if (_rawPlayers find "Error:" == 0) exitWith { [] };
// Split comma-separated player list
_players = _rawPlayers splitString ",";
_scoreArray = [];
// Get scores for all players
{
_killsResult = "forge_server" callExtension ["redis:common:get", [format ["kills:%1", _x]]];
_rawKills = _killsResult select 0;
// Check for valid response (not an error)
if (_rawKills find "Error:" != 0) then {
_scoreArray pushBack [_x, parseNumber _rawKills];
};
} forEach _players;
// Sort by score (highest first)
_scoreArray sort false;
_scoreArray resize (10 min (count _scoreArray)); // Top 10
_scoreArray;
private _uid = getPlayerUID player;
["actor:get", [_uid]] call forge_server_extension_fnc_extCall params ["_payload", "_ok"];
if (_ok) then {
private _actor = fromJSON _payload;
systemChat format ["Loaded actor %1", _actor getOrDefault ["uid", _uid]];
};
```
## 🎯 Mission State Management
### Objective System
## Store Checkout
```sqf
// Set mission objectives
fnc_initMissionObjectives = {
"forge_server" callExtension ["redis:list:rpush", ["objectives", "Secure Alpha Base"]];
"forge_server" callExtension ["redis:list:rpush", ["objectives", "Extract Intel"]];
"forge_server" callExtension ["redis:list:rpush", ["objectives", "Eliminate HVT"]];
// Set current objective pointer
"forge_server" callExtension ["redis:common:set", ["current_objective", "0"]];
};
// Complete current objective
fnc_completeObjective = {
// Get current objective index - returns raw string
_indexResult = "forge_server" callExtension ["redis:common:get", ["current_objective"]];
_rawIndex = _indexResult select 0;
// Check for error
if (_rawIndex find "Error:" == 0) exitWith {};
_currentIndex = parseNumber _rawIndex;
// Get objective name - returns raw string
_objResult = "forge_server" callExtension ["redis:list:get", ["objectives", _currentIndex]];
_objectiveName = _objResult select 0;
// Check for valid response
if (_objectiveName find "Error:" != 0) then {
// Move to completed objectives - returns new list length
"forge_server" callExtension ["redis:list:rpush", ["completed_objectives", _objectiveName]];
// Move to next objective - returns "OK"
"forge_server" callExtension ["redis:common:set", ["current_objective", str (_currentIndex + 1)]];
// Broadcast completion
[format ["Objective Complete: %1", _objectiveName]] remoteExec ["hint"];
};
};
// Get mission progress - raw responses
fnc_getMissionProgress = {
_totalResult = "forge_server" callExtension ["redis:list:len", ["objectives"]];
_completedResult = "forge_server" callExtension ["redis:list:len", ["completed_objectives"]];
_rawTotal = _totalResult select 0;
_rawCompleted = _completedResult select 0;
// Check for errors
if (_rawTotal find "Error:" == 0 || _rawCompleted find "Error:" == 0) exitWith {
"Mission Progress: Unknown";
};
_total = parseNumber _rawTotal;
_completed = parseNumber _rawCompleted;
format ["Mission Progress: %1/%2 objectives completed", _completed, _total];
};
```
## 🚁 Vehicle and Equipment Tracking
### Vehicle Pool System
```sqf
// Initialize vehicle pool
fnc_initVehiclePool = {
params ["_vehicleClass", "_count"];
for "_i" from 1 to _count do {
_vehicleId = format ["%1_%2", _vehicleClass, _i];
"forge_server" callExtension ["redis:set:add", ["available_vehicles", _vehicleId]];
"forge_server" callExtension ["redis:hash:mset", [format ["vehicle:%1", _vehicleId], [
["class", _vehicleClass],
["status", "available"],
["condition", "100"]
]]];
};
};
// Request vehicle
fnc_requestVehicle = {
params ["_playerUID"];
// Get random available vehicle
_result = "forge_server" callExtension ["redis:set:pop", ["available_vehicles"]];
_data = fromJSON (_result select 0);
if ((_data select "status") == "success") then {
_vehicleId = _data select "data";
// Mark as in use
"forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "in_use"]];
"forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "user", _playerUID]];
"forge_server" callExtension ["redis:set:add", ["used_vehicles", _vehicleId]];
_vehicleId;
} else {
""; // No vehicles available
};
};
// Return vehicle
fnc_returnVehicle = {
params ["_vehicleId", "_condition"];
// Update condition
"forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "condition", str _condition]];
// Return to pool if condition is good
if (_condition > 50) then {
"forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "available"]];
"forge_server" callExtension ["redis:hash:del", [format ["vehicle:%1", _vehicleId], "user"]];
"forge_server" callExtension ["redis:set:del", ["used_vehicles", _vehicleId]];
"forge_server" callExtension ["redis:set:add", ["available_vehicles", _vehicleId]];
} else {
"forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "maintenance"]];
"forge_server" callExtension ["redis:set:add", ["maintenance_vehicles", _vehicleId]];
};
};
```
## 📊 Server Analytics
### Player Session Tracking
```sqf
// Track player session start
fnc_startPlayerSession = {
params ["_playerUID"];
_sessionId = format ["%1_%2", _playerUID, floor time];
_sessionKey = format ["session:%1", _sessionId];
"forge_server" callExtension ["redis:hash:mset", [_sessionKey, [
["player_uid", _playerUID],
["start_time", str time],
["server_id", serverName],
["player_count", str (count allPlayers)]
]]];
// Store current session for player
"forge_server" callExtension ["redis:common:set", [format ["current_session:%1", _playerUID], _sessionId]];
_sessionId;
};
// End player session
fnc_endPlayerSession = {
params ["_playerUID", "_sessionStats"];
// Get current session
_result = "forge_server" callExtension ["redis:common:get", [format ["current_session:%1", _playerUID]]];
_data = fromJSON (_result select 0);
if ((_data select "status") == "success") then {
_sessionId = _data select "data";
_sessionKey = format ["session:%1", _sessionId];
// Update session with end data
"forge_server" callExtension ["redis:hash:mset", [_sessionKey, [
["end_time", str time],
["duration", str (_sessionStats select "duration")],
["kills", str (_sessionStats select "kills")],
["deaths", str (_sessionStats select "deaths")]
]]];
// Clean up current session tracking
"forge_server" callExtension ["redis:common:del", [format ["current_session:%1", _playerUID]]];
};
};
```
## 🔄 Cross-Server Communication
### Message Queue System
```sqf
// Send message to other servers
fnc_sendCrossServerMessage = {
params ["_targetServer", "_messageType", "_messageData"];
_message = createHashMap;
_message set ["from_server", serverName];
_message set ["type", _messageType];
_message set ["data", _messageData];
_message set ["timestamp", str time];
_queueKey = format ["messages:%1", _targetServer];
"forge_server" callExtension ["redis:list:rpush", [_queueKey, str _message]];
};
// Check for incoming messages
fnc_checkMessages = {
_queueKey = format ["messages:%1", serverName];
// Get next message
_result = "forge_server" callExtension ["redis:list:lpop", [_queueKey, 1]];
_data = fromJSON (_result select 0);
if ((_data select "status") == "success") then {
_messages = _data select "data";
if (count _messages > 0) then {
_messageStr = _messages select 0;
_message = fromJSON _messageStr;
// Process message based on type
_type = _message select "type";
_messageData = _message select "data";
switch (_type) do {
case "player_transfer": {
[_messageData] call fnc_handlePlayerTransfer;
};
case "server_status": {
[_messageData] call fnc_handleServerStatus;
};
case "admin_broadcast": {
[_messageData select "message"] remoteExec ["hint"];
};
};
};
};
};
// Run message checker periodically
[] spawn {
while {true} do {
call fnc_checkMessages;
sleep 5; // Check every 5 seconds
};
};
```
## 🛠️ Utility Functions
### Redis Helper Functions
```sqf
// Parse Redis response safely
fnc_parseRedisResponse = {
params ["_response"];
try {
_data = fromJSON (_response select 0);
if ((_data select "status") == "success") then {
_data select "data";
} else {
diag_log format ["Redis Error: %1", _data select "error"];
nil;
};
} catch {
diag_log format ["JSON Parse Error: %1", _exception];
nil;
};
};
// Batch Redis operations
fnc_redisBatch = {
params ["_operations"];
_results = [];
{
_op = _x;
_result = "forge_server" callExtension [_op select 0, _op select 1];
_results pushBack (fromJSON (_result select 0));
} forEach _operations;
_results;
};
// Example batch usage:
_batchOps = [
["redis:common:set", ["key1", "value1"]],
["redis:common:set", ["key2", "value2"]],
["redis:common:get", ["key1"]]
private _checkout = createHashMapFromArray [
["requesterUid", getPlayerUID player],
["requesterName", name player],
["orgId", "default"],
["requesterIsDefaultOrgCeo", false],
["paymentMethod", "bank"],
["items", [
createHashMapFromArray [
["classname", "FirstAidKit"],
["category", "item"],
["priceValue", 50],
["quantity", 2]
]
]],
["vehicles", []]
];
_results = [_batchOps] call fnc_redisBatch;
["store:checkout", [toJSON _checkout]] call forge_server_extension_fnc_extCall;
```
## 🎯 Best Practices
### Error Handling Pattern
```sqf
fnc_safeRedisCall = {
params ["_command", "_params", ["_defaultValue", nil]];
try {
_result = "forge_server" callExtension [_command, _params];
_data = fromJSON (_result select 0);
if ((_data select "status") == "success") then {
_data select "data";
} else {
diag_log format ["Redis operation failed: %1 - %2", _command, _data select "error"];
_defaultValue;
};
} catch {
diag_log format ["Redis call exception: %1 - %2", _command, _exception];
_defaultValue;
};
};
// Usage:
_playerName = ["redis:common:get", ["player_name"], "Unknown"] call fnc_safeRedisCall;
```
These examples demonstrate real-world usage patterns for the Redis extension in Arma 3 environments, covering player management, mission state, analytics, and cross-server communication.

View File

@ -10,14 +10,12 @@ crate-type = ["cdylib"]
[dependencies]
arma-rs = { workspace = true }
base64 = "0.22.1"
bb8-redis = "0.26.0"
chrono = { workspace = true }
forge-icom = { path = "../../../bin/icom" }
forge-models = { path = "../../../lib/models", features = ["actor"] }
forge-repositories = { path = "../../../lib/repositories" }
forge-services = { path = "../../../lib/services" }
forge-shared = { path = "../../../lib/shared" }
redis = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
surrealdb = { version = "2", default-features = false, features = ["protocol-http", "rustls"] }

View File

@ -1,405 +1,41 @@
# Forge Arma 3 Server Extension
# Forge Server Extension
This extension provides the core server-side functionality for the Forge framework, handling persistent data storage, actor management, and game state synchronization through a high-performance Rust backend.
The Forge server extension is the Rust backend for server-side game systems.
It exposes domain commands through `arma-rs`, runs a shared Tokio runtime, and
persists durable state through SurrealDB.
## Architecture
## Responsibilities
The extension follows a layered architecture designed for reliability, performance, and maintainability:
- Register extension command groups for actor, bank, garage, locker, org,
phone, store, task, CAD, terrain, and transport systems.
- Load extension configuration from `@forge_server/config.toml`.
- Connect to SurrealDB and apply schema modules on startup.
- Keep SQF-facing command handlers thin while service crates own domain rules.
- **Extension Layer**: Handles the raw Arma 3 `callExtension` interface, parameter parsing, and command routing.
- **Service Layer**: Implements business logic, validation, and orchestration of operations (e.g., `ActorService`).
- **Repository Layer**: Manages data persistence and retrieval using Redis (e.g., `RedisActorRepository`).
- **Model Layer**: Defines strict data structures and validation rules (e.g., `Actor` model).
## Configuration
This separation ensures that game logic is decoupled from data storage and that all data entering the system is validated before persistence.
### Module Documentation
For detailed information about specific modules, see:
- **[Redis Operations](src/redis/README.md)**: Comprehensive guide to Redis commands (hash, list, set, common operations)
- **[Adapters](src/adapters/README.md)**: Adapter pattern implementation bridging repositories with Redis
## Organization Management
The Organization module handles guild/clan management, allowing players to form groups, manage members, and persist organizational data. It supports role management, automatic UID resolution, and robust error handling.
### Available Commands
| Command | Description |
| ------------------- | ------------------------------------------------------- |
| `org:get` | Retrieve organization data by key or ID. |
| `org:create` | Create a new organization with provided JSON data. |
| `org:update` | Update an existing organization with partial JSON data. |
| `org:delete` | Permanently remove an organization and its data. |
| `org:exists` | Check if an organization exists. |
| `org:get_members` | Retrieve a list of organization members. |
| `org:add_member` | Add a member to an organization. |
| `org:remove_member` | Remove a member from an organization. |
### SQF Examples
#### Retrieving an Organization
```sqf
// Get organization by ID
private _result = "forge_server" callExtension ["org:get", ["elite_squad"]];
private _orgData = fromJSON (_result select 0);
// Access data
private _name = _orgData get "name";
private _leader = _orgData get "leader";
```toml
[surreal]
endpoint = "127.0.0.1:8000"
namespace = "forge"
database = "main"
username = "root"
password = "root"
connect_timeout_ms = 5000
```
#### Creating an Organization
## Status
```sqf
// Prepare data using HashMap
private _data = createHashMapFromArray [
["name", "Elite Squad"],
["description", "Best players"],
["leader", getPlayerUID player],
["max_members", 50],
["type", "military"]
];
// Create the organization
private _result = "forge_server" callExtension ["org:create", ["elite_squad", toJSON _data]];
if ((_result select 0) find "Error:" == 0) then {
diag_log format ["Failed to create org: %1", _result select 0];
} else {
private _createdOrg = fromJSON (_result select 0);
systemChat format ["Created organization: %1", _createdOrg get "name"];
};
"forge_server" callExtension ["status", []];
"forge_server" callExtension ["surreal:status", []];
```
#### Updating an Organization
Status values are `initializing`, `connected`, or `failed`.
```sqf
// Prepare partial update
private _update = createHashMapFromArray [
["description", "Updated description"],
["max_members", 100]
];
## Build
// Apply update
private _result = "forge_server" callExtension ["org:update", ["elite_squad", toJSON _update]];
```powershell
cargo test -p forge-server
cargo build -p forge-server
```
#### Managing Members
```sqf
// Get members
private _result = "forge_server" callExtension ["org:get_members", ["elite_squad"]];
private _members = fromJSON (_result select 0);
// Add a member
private _addResult = "forge_server" callExtension ["org:add_member", ["elite_squad", "76561198123456789"]];
// Remove a member
private _removeResult = "forge_server" callExtension ["org:remove_member", ["elite_squad", "76561198123456789"]];
```
#### Checking Existence
```sqf
private _exists = "forge_server" callExtension ["org:exists", ["elite_squad"]];
if ((_exists select 0) == "true") then {
systemChat "Organization exists.";
};
```
#### Deleting an Organization
```sqf
// Permanently delete organization
private _result = "forge_server" callExtension ["org:delete", ["elite_squad"]];
if ((_result select 0) == "OK") then {
systemChat "Organization deleted.";
};
```
## Actor Management
The Actor module handles all player-related operations, including data retrieval, creation, updates, and existence checks. It features automatic Steam UID resolution and robust error handling.
### Available Commands
| Command | Description |
| -------------- | ------------------------------------------------ |
| `actor:get` | Retrieve actor data by key or UID. |
| `actor:create` | Create a new actor with provided JSON data. |
| `actor:update` | Update an existing actor with partial JSON data. |
| `actor:exists` | Check if an actor exists in the database. |
| `actor:delete` | Permanently remove an actor and their data. |
### SQF Examples
The extension is designed to work seamlessly with modern Arma 3 SQF features like `HashMap` and `toJSON`/`fromJSON`.
#### Retrieving an Actor
```sqf
// Get actor by Steam UID
private _result = "forge_server" callExtension ["actor:get", ["76561198123456789"]];
private _actorData = fromJSON (_result select 0);
// Access data
private _name = _actorData get "name";
private _bank = _actorData get "bank";
```
#### Creating an Actor
```sqf
// Prepare data using HashMap
private _data = createHashMapFromArray [
["name", "John Doe"],
["bank", 1000],
["cash", 100],
["level", 1],
["class", "civilian"]
];
// Create the actor
private _result = "forge_server" callExtension ["actor:create", ["player123", toJSON _data]];
if ((_result select 0) find "Error:" == 0) then {
diag_log format ["Failed to create actor: %1", _result select 0];
} else {
private _createdActor = fromJSON (_result select 0);
systemChat format ["Welcome, %1!", _createdActor get "name"];
};
```
#### Updating an Actor
```sqf
// Prepare partial update
private _update = createHashMapFromArray [
["bank", 1500],
["level", 2]
];
// Apply update
private _result = "forge_server" callExtension ["actor:update", ["player123", toJSON _update]];
```
#### Checking Existence
```sqf
private _exists = "forge_server" callExtension ["actor:exists", ["player123"]];
if ((_exists select 0) == "true") then {
systemChat "Player profile found.";
} else {
systemChat "Player profile not found.";
};
```
#### Deleting an Actor
```sqf
// Permanently delete actor data
private _result = "forge_server" callExtension ["actor:delete", ["player123"]];
if ((_result select 0) == "OK") then {
systemChat "Actor deleted successfully.";
};
```
## Error Handling
The extension uses a consistent error reporting format. If an operation fails, the returned string will start with `Error: ` followed by a descriptive message.
- **Consistent Responses**: All commands return JSON on success or an error message on failure.
- **No Fallbacks**: `actor:get` and `org:get` will return error messages if the requested entity cannot be found, rather than fallback objects with dummy data.
- **Validation**: All input data is validated against the strict schema defined in the models. Invalid data will result in an error message.
### Example Error Handling
```sqf
private _result = "forge_server" callExtension ["actor:get", ["76561198123456789"]];
private _response = _result select 0;
if (_response find "Error:" == 0) then {
diag_log format ["Failed to get actor: %1", _response];
} else {
private _actorData = fromJSON _response;
systemChat format ["Welcome, %1!", _actorData get "name"];
};
```
## Performance
- **Asynchronous Core**: Built on `tokio`, the extension performs heavy I/O operations (like database writes) without blocking the Arma 3 simulation thread.
- **Connection Pooling**: Uses a Redis connection pool to efficiently manage database connections.
- **Lazy Initialization**: Services are initialized only when first needed, reducing startup time.
- **Minimal Serialization**: Only necessary data is serialized and transferred between Rust and SQF to minimize overhead.
## Contributing
We welcome contributions to the Forge Extension! This guide will help you understand how to add new commands and maintain the existing codebase.
### Adding a Command to an Existing Module
To add a new command to an existing module (e.g., `actor:set_position`), follow these steps:
1. **Register the Command**: In the module file (e.g., `src/actor.rs`), add the command to the `group()` function.
```rust
pub fn group() -> Group {
Group::new()
.command("get", get_actor)
.command("exists", exists_actor)
.command("create", create_actor)
.command("update", update_actor)
.command("delete", delete_actor)
.command("set_position", set_actor_position) // New command
}
```
2. **Implement the Handler Function**: Create the function that handles the command logic.
```rust
use crate::log::log;
/// Sets the position of an actor.
pub fn set_actor_position(call_context: CallContext, key: String, position: String) -> String {
log("actor", "DEBUG", &format!("Setting position for key: {}", key));
// 1. Resolve UID
let resolved_uid = match resolve_uid(&key, &call_context) {
Some(uid) => uid,
None => {
let error_msg = format!("Error: Failed to resolve UID for key: {}", key);
log("actor", "ERROR", &error_msg);
return error_msg;
}
};
// 2. Parse and validate input
let position_data: Vec<f64> = match serde_json::from_str(&position) {
Ok(data) => data,
Err(e) => {
let error_msg = format!("Error: Invalid position JSON: {}", e);
log("actor", "ERROR", &error_msg);
return error_msg;
}
};
// 3. Get the actor, update position, and save
match ACTOR_SERVICE.get_actor(resolved_uid.clone()) {
Ok(mut actor) => {
actor.set_position(position_data);
match ACTOR_SERVICE.update_actor(actor.clone()) {
Ok(_) => {
log("actor", "INFO", &format!("Updated position for: {}", resolved_uid));
match serde_json::to_string(&actor) {
Ok(json) => json,
Err(e) => format!("Error: Failed to serialize actor: {}", e),
}
}
Err(e) => format!("Error: {}", e),
}
}
Err(e) => format!("Error: {}", e),
}
}
```
### Creating a New Module
To create a new module (e.g., `vehicle`), follow these steps:
1. **Create the Module File**: Add `src/vehicle.rs`.
2. **Create the Global Service Instance**: Define a lazily initialized singleton service.
```rust
use std::sync::LazyLock;
use forge_services::VehicleService;
use forge_repositories::RedisVehicleRepository;
use crate::adapters::ExtensionRedisClient;
static VEHICLE_SERVICE: LazyLock<VehicleService<RedisVehicleRepository<ExtensionRedisClient>>> =
LazyLock::new(|| {
let redis_client = ExtensionRedisClient::new();
let repository = RedisVehicleRepository::new(redis_client);
VehicleService::new(repository)
});
```
3. **Register the Command**: In the module file, register the command in the `group()` function.
```rust
pub fn group() -> Group {
Group::new()
.command("get", get_vehicle)
.command("create", create_vehicle)
// ... other commands
}
```
4. **Use Logging**: Import and use the generic `log` function in your handler functions.
```rust
use crate::log::log;
pub fn get_vehicle(key: String) -> String {
log("vehicle", "DEBUG", &format!("Getting vehicle for key: {}", key));
// Call service layer
match VEHICLE_SERVICE.get_vehicle(key.clone()) {
Ok(vehicle) => {
log("vehicle", "INFO", &format!("Successfully retrieved vehicle: {}", key));
match serde_json::to_string(&vehicle) {
Ok(json) => {
log("vehicle", "DEBUG", &format!("Serialized vehicle to JSON: {}", json));
json
}
Err(e) => {
let error_msg = format!("Error: Failed to serialize vehicle: {}", e);
log("vehicle", "ERROR", &error_msg);
error_msg
}
}
}
Err(e) => {
let error_msg = format!("Error: {}", e);
log("vehicle", "ERROR", &format!("Failed to get vehicle '{}': {}", key, e));
error_msg
}
}
}
```
The `log` function takes three parameters:
- `category`: The log category (e.g., "vehicle", "actor", "org")
- `level`: The log level ("INFO", "DEBUG", "WARN", "ERROR")
- `message`: The message to log
Log files are created automatically in `@forge_server/logs/{category}.log`.
5. **Register the Module** (if new): If you created a new module, add it to `src/lib.rs`.
```rust
pub mod vehicle;
// In the extension function, register the group
extension.group("vehicle", vehicle::group());
```
### Testing
- **In-Game Testing**: Test your commands in Arma 3 to ensure they work correctly with SQF.
- **Error Cases**: Test error scenarios (invalid input, missing entities, etc.) to ensure proper error messages.
### Best Practices
- **Return Types**: Always return `String` (JSON on success, error message on failure).
- **Error Messages**: Prefix all error messages with `"Error: "` for consistency.
- **Logging**: Use the `log(category, level, message)` function to track operations.
- **Service Layer**: Delegate business logic to the service layer. The extension layer should only handle parameter parsing and response formatting.
- **Validation**: Validate inputs before calling the service layer to provide clear error messages.

View File

@ -1,59 +1,14 @@
# Crate Server Configuration
# Copy this file to config.toml and modify as needed
# Place this file in the same directory as your crate_server_x64.dll
[storage]
# Redis remains the default while modules are migrated incrementally.
# Current SurrealDB-backed durable repositories:
# actor, bank, garage, locker, owned garage, owned locker, org, phone.
backend = "redis" # "redis" or "surreal"
[redis]
# Redis server connection settings
host = "127.0.0.1"
port = 6379
db = 0 # Redis database number (0-15)
# Optional authentication
# username = "your_username"
# password = "your_password"
# Optional connection pool settings
max_connections = 10 # Maximum number of connections in pool
min_connections = 2 # Minimum number of idle connections
idle_timeout = 60 # Idle connection timeout in seconds
connect_timeout_ms = 2000 # Pool connect timeout in milliseconds
pool_get_timeout_ms = 2000 # Pool checkout timeout in milliseconds
command_timeout_ms = 2000 # Redis command timeout in milliseconds
# Forge Server Configuration
# Copy this file to config.toml and place it beside forge_server_x64.dll.
[surreal]
# SurrealDB HTTP endpoint. Use "127.0.0.1:8000" for a local SurrealDB server.
# SurrealDB HTTP endpoint. Use "127.0.0.1:8000" for a local server.
endpoint = "127.0.0.1:8000"
namespace = "forge"
database = "main"
# Optional authentication
# Optional authentication.
username = "root"
password = "root"
connect_timeout_ms = 5000
# Example configurations for different environments:
# Development (local Redis)
# host = "127.0.0.1"
# port = 6379
# max_connections = 5
# min_connections = 1
# Production (remote Redis with auth)
# host = "redis.example.com"
# port = 6379
# username = "arma_server"
# password = "secure_password_here"
# max_connections = 20
# min_connections = 5
# idle_timeout = 30
# connect_timeout_ms = 5000
# pool_get_timeout_ms = 5000
# command_timeout_ms = 5000

View File

@ -15,7 +15,7 @@ use crate::storage::ActorStorageRepository;
/// Global actor service instance.
///
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
/// Lazily initialized singleton combining repository and service layers.
static ACTOR_SERVICE: LazyLock<ActorService<ActorStorageRepository>> =
LazyLock::new(|| ActorService::new(ActorStorageRepository::configured()));
static HOT_ACTOR_SERVICE: LazyLock<

View File

@ -1,270 +0,0 @@
# Adapters Module
This module provides adapter implementations that bridge the repository layer with the extension's Redis operations. Adapters translate between the generic `RedisClient` trait and the extension-specific Redis module.
## Architecture
The adapters module follows the **Adapter Pattern**, allowing the repository layer to remain decoupled from the specific Redis implementation:
```mermaid
graph TD
Repo[Repository Layer<br/>#40;forge-repositories#41;]
Trait[RedisClient Trait<br/>#40;forge-shared#41;]
Adapter[ExtensionRedisClient<br/>#40;adapter#41;]
Redis[Redis Module<br/>#40;extension#41;]
Repo --> Trait
Trait --> Adapter
Adapter --> Redis
```
This design enables:
- **Testability**: Repositories can use mock adapters for testing
- **Flexibility**: Different Redis implementations can be swapped without changing repositories
- **Separation of Concerns**: Repository logic is independent of Redis connection details
## ExtensionRedisClient
The `ExtensionRedisClient` is the primary adapter that implements the `RedisClient` trait from `forge_shared`.
### Responsibilities
- **Translate Calls**: Convert trait method calls to Redis module function calls
- **Error Handling**: Parse Redis operation results and convert to `Result` types
- **Data Transformation**: Handle response parsing (e.g., JSON arrays for lists/sets)
- **Logging**: Log debug information for Redis operations
### Implemented Operations
#### Hash Operations
| Method | Description | Returns |
| -------------- | ------------------------------ | ------------------------ |
| `hash_mset` | Set multiple fields atomically | `Result<(), String>` |
| `hash_get_all` | Get all fields and values | `Result<String, String>` |
| `hash_get` | Get a single field value | `Result<String, String>` |
| `hash_del` | Delete a field | `Result<(), String>` |
#### List Operations
| Method | Description | Returns |
| ------------ | --------------------- | ----------------------------- |
| `list_rpush` | Append to list | `Result<(), String>` |
| `list_range` | Get range of elements | `Result<Vec<String>, String>` |
| `list_del` | Remove by value | `Result<(), String>` |
#### Set Operations
| Method | Description | Returns |
| ------------- | --------------- | ----------------------------- |
| `set_add` | Add member | `Result<(), String>` |
| `set_members` | Get all members | `Result<Vec<String>, String>` |
| `set_del` | Remove member | `Result<(), String>` |
#### Common Operations
| Method | Description | Returns |
| ------------ | ------------------- | ---------------------- |
| `key_exists` | Check if key exists | `Result<bool, String>` |
| `delete_key` | Delete key | `Result<(), String>` |
### Usage Example
```rust
use crate::adapters::ExtensionRedisClient;
use forge_shared::RedisClient;
// Create the adapter
let client = ExtensionRedisClient::new();
// Use it with the RedisClient trait
let fields = vec![
("name".to_string(), "John".to_string()),
("age".to_string(), "30".to_string()),
];
client.hash_mset("user:123".to_string(), fields)?;
// Retrieve data
let data = client.hash_get_all("user:123".to_string())?;
```
## Error Handling
The adapter translates Redis string responses to Rust `Result` types:
- **Success**: Returns `Ok(value)` with the appropriate type
- **Error**: Returns `Err(message)` if the response starts with "Error:"
```rust
// Redis module returns "OK" → Adapter returns Ok(())
// Redis module returns "Error: Connection failed" → Adapter returns Err("Error: Connection failed")
```
### Response Parsing
For operations that return collections, the adapter parses JSON responses:
```rust
// list_range returns JSON: ["item1", "item2", "item3"]
let items = client.list_range("mylist".to_string(), 0, -1)?;
// items: Vec<String> = vec!["item1", "item2", "item3"]
```
## Contributing
We welcome contributions to the adapters module! Follow these guidelines to add new adapter methods or create new adapters.
### Adding a New Method to ExtensionRedisClient
To add a new method (e.g., `hash_exists`), follow these steps:
1. **Check the Trait**: Ensure the method is defined in the `RedisClient` trait in `forge_shared`.
```rust
// In forge_shared/src/redis_client.rs
pub trait RedisClient: Send + Sync {
fn hash_exists(&self, key: String, field: String) -> Result<bool, String>;
}
```
2. **Implement the Method**: Add the implementation to `ExtensionRedisClient`.
```rust
impl RedisClient for ExtensionRedisClient {
fn hash_exists(&self, key: String, field: String) -> Result<bool, String> {
// Call the Redis module function
let result = redis::hash::hash_exists(key, field);
// Parse the response
match result.as_str() {
"1" => Ok(true),
"0" => Ok(false),
_ if result.starts_with("Error:") => Err(result),
_ => Err(format!("Unexpected response: {}", result)),
}
}
}
```
3. **Add Logging** (if needed): For debugging, log the operation.
```rust
fn hash_exists(&self, key: String, field: String) -> Result<bool, String> {
let result = redis::hash::hash_exists(key, field);
log("debug", "DEBUG", &format!("hash_exists({}, {}): {}", key, field, result));
match result.as_str() {
"1" => Ok(true),
"0" => Ok(false),
_ if result.starts_with("Error:") => Err(result),
_ => Err(format!("Unexpected response: {}", result)),
}
}
```
4. **Handle Response Types**: Match the return type to the trait signature.
- **Unit type** (`()`): Return `Ok(())` on success
- **Boolean**: Parse "1"/"0" to `true`/`false`
- **String**: Return the value directly
- **Vec**: Parse JSON array response
- **Number**: Parse string to number
### Creating a New Adapter
To create a new adapter (e.g., `MockRedisClient` for testing):
1. **Create the Module File**: Add `src/adapters/mock_client.rs`.
2. **Define the Struct**: Create the adapter struct.
```rust
use forge_shared::RedisClient;
use std::collections::HashMap;
use std::sync::RwLock;
/// Mock Redis client for testing.
///
/// Uses RwLock to allow multiple concurrent readers while maintaining thread safety.
pub struct MockRedisClient {
data: RwLock<HashMap<String, String>>,
}
impl MockRedisClient {
pub fn new() -> Self {
Self {
data: RwLock::new(HashMap::new()),
}
}
}
```
3. **Implement the Trait**: Implement all `RedisClient` methods.
```rust
impl RedisClient for MockRedisClient {
fn hash_mset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), String> {
// Acquire write lock only when modifying data
let mut data = self.data.write().unwrap();
for (field, value) in fields {
let hash_key = format!("{}:{}", key, field);
data.insert(hash_key, value);
}
Ok(())
}
fn hash_get(&self, key: String, field: String) -> Result<String, String> {
// Acquire read lock - multiple threads can read concurrently
let data = self.data.read().unwrap();
let hash_key = format!("{}:{}", key, field);
Ok(data.get(&hash_key)
.map(|v| v.clone())
.unwrap_or_default())
}
// ... implement other methods
}
```
4. **Register the Module**: Add to `src/adapters/mod.rs`.
```rust
pub mod redis_client;
pub mod mock_client;
pub use redis_client::ExtensionRedisClient;
pub use mock_client::MockRedisClient;
```
### Concurrency Best Practices
> [!IMPORTANT]
> Choose the right synchronization primitive based on your access patterns and performance requirements.
**Recommended Synchronization Primitives:**
| Primitive | Use Case | Performance | Dependency |
| --------------------- | ---------------------------------------- | ----------------------- | ---------------- |
| **`RwLock<HashMap>`** | Read-heavy workloads, concurrent readers | Good (multiple readers) | Standard library |
| **`Mutex<HashMap>`** | Write-heavy or exclusive access required | Fair (single lock) | Standard library |
| **`DashMap`** | Extreme high-frequency reads/writes | Excellent (lock-free) | External crate |
**When to use each:**
- **`RwLock`**: Best for most use cases. Allows multiple concurrent readers, only blocks on writes. Use this by default.
- **`Mutex`**: Only when you need exclusive access or operations are very lightweight (< 1μs).
- **`DashMap`**: When profiling shows `RwLock` is a bottleneck and you need lock-free performance.
**Why avoid `Mutex` for read-heavy workloads?**
- Blocks all threads (readers and writers) on every access
- No concurrent reads possible
- Can cause performance bottlenecks in high-concurrency scenarios
### Best Practices
- **Error Consistency**: Always check for "Error:" prefix in Redis responses
- **Type Safety**: Ensure return types match the trait signature exactly
- **Logging**: Log operations at DEBUG level for troubleshooting
- **Response Parsing**: Handle all possible response formats (success, error, unexpected)
- **Documentation**: Document the purpose and behavior of each method
- **Testing**: Test adapters with both success and error scenarios

View File

@ -1,3 +0,0 @@
pub mod redis_client;
pub use redis_client::ExtensionRedisClient;

View File

@ -1,208 +0,0 @@
use crate::log::log;
use crate::redis;
use forge_shared::RedisClient;
/// Redis client implementation that bridges the repository layer with the extension's Redis module.
pub struct ExtensionRedisClient;
impl ExtensionRedisClient {
/// Creates a new instance of the Redis client adapter.
pub fn new() -> Self {
Self
}
}
impl RedisClient for ExtensionRedisClient {
/// Sets multiple fields in a Redis hash.
fn hash_mset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), String> {
let result = redis::hash::hash_mset(key, fields);
log("debug", "DEBUG", &result);
if result == "OK" { Ok(()) } else { Err(result) }
}
/// Retrieves all fields and values from a Redis hash.
fn hash_get_all(&self, key: String) -> Result<String, String> {
let result = redis::hash::hash_get_all(key);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(result)
}
}
/// Retrieves a single field value from a Redis hash.
fn hash_get(&self, key: String, field: String) -> Result<String, String> {
let result = redis::hash::hash_get(key, field);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(result)
}
}
/// Deletes a specific field from a Redis hash.
fn hash_del(&self, key: String, field: String) -> Result<(), String> {
let result = redis::hash::hash_del(key, field);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(())
}
}
/// Appends a value to the end of a Redis list.
fn list_rpush(&self, key: String, value: String) -> Result<(), String> {
let result = redis::list::list_rpush(key, value);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(())
}
}
/// Retrieves a range of elements from a Redis list.
fn list_range(&self, key: String, start: isize, end: isize) -> Result<Vec<String>, String> {
let result = redis::list::list_range(key, start, end);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
// Parse the JSON array response
match serde_json::from_str::<Vec<String>>(&result) {
Ok(values) => Ok(values),
Err(e) => Err(format!("Failed to parse list response: {}", e)),
}
}
}
/// Removes elements from a Redis list by value.
fn list_del(&self, key: String, count: isize, value: String) -> Result<(), String> {
let result = redis::list::list_del(key, count, value);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(())
}
}
/// # Set operations
/// Adds a member to a Redis set.
fn set_add(&self, key: String, member: String) -> Result<(), String> {
let result = redis::set::set_add(key, member);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(())
}
}
/// Retrieves all members from a Redis set.
fn set_members(&self, key: String) -> Result<Vec<String>, String> {
let result = redis::set::set_members(key);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else if result.trim().is_empty() {
Ok(Vec::new())
} else {
serde_json::from_str::<Vec<String>>(&result).or_else(|_| {
Ok(result
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.collect())
})
}
}
/// Removes a member from a Redis set.
fn set_del(&self, key: String, member: String) -> Result<(), String> {
let result = redis::set::set_del(key, member);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(())
}
}
/// Checks if a Redis key exists.
fn key_exists(&self, key: String) -> Result<bool, String> {
let result = redis::common::key_exists(key);
log("debug", "DEBUG", &result);
match result.as_str() {
"1" => Ok(true),
"0" => Ok(false),
_ => Err(format!("Unexpected Redis response: {}", result)),
}
}
/// Retrieves the value of a Redis key.
fn get_key(&self, key: String) -> Result<String, String> {
let result = redis::common::get_key(key);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(result)
}
}
/// Sets a value in a Redis key.
fn set_key(&self, key: String, value: String) -> Result<(), String> {
let result = redis::common::set_key(key, value);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(())
}
}
/// Increments a numeric Redis key.
fn incr_key(&self, key: String, count: usize) -> Result<i64, String> {
let result = redis::common::incr_key(key, count);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
result
.parse::<i64>()
.map_err(|error| format!("Failed to parse increment response: {}", error))
}
}
/// Deletes a Redis key and all its associated data.
fn delete_key(&self, key: String) -> Result<(), String> {
let result = redis::common::delete_key(key);
log("debug", "DEBUG", &result);
if result.starts_with("Error:") {
Err(result)
} else {
Ok(())
}
}
}

View File

@ -19,7 +19,7 @@ use crate::storage::BankStorageRepository;
/// Global bank service instance.
///
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
/// Lazily initialized singleton combining repository and service layers.
static BANK_SERVICE: LazyLock<BankService<BankStorageRepository>> =
LazyLock::new(|| BankService::new(BankStorageRepository::configured()));
static HOT_BANK_SERVICE: LazyLock<

View File

@ -0,0 +1,79 @@
//! Extension configuration for SurrealDB-backed persistence.
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::log::log;
static CONFIG_CACHE: OnceLock<Config> = OnceLock::new();
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub surreal: SurrealConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SurrealConfig {
pub endpoint: String,
pub namespace: String,
pub database: String,
pub username: Option<String>,
pub password: Option<String>,
pub connect_timeout_ms: Option<u64>,
}
impl Default for SurrealConfig {
fn default() -> Self {
Self {
endpoint: "127.0.0.1:8000".to_string(),
namespace: "forge".to_string(),
database: "main".to_string(),
username: Some("root".to_string()),
password: Some("root".to_string()),
connect_timeout_ms: Some(5000),
}
}
}
pub fn load() -> Config {
CONFIG_CACHE
.get_or_init(|| {
let config_path = std::env::current_exe()
.ok()
.and_then(|exe| {
exe.parent()
.map(|dir| dir.join("@forge_server").join("config.toml"))
})
.filter(|path| path.exists())
.unwrap_or_else(|| PathBuf::from("@forge_server/config.toml"));
match fs::read_to_string(&config_path) {
Ok(contents) => {
log("main", "INFO", "Config file found. Loading.");
match toml::from_str::<Config>(&contents) {
Ok(config) => config,
Err(error) => {
log(
"main",
"ERROR",
&format!(
"Failed to parse config file '{}': {}. Using defaults.",
config_path.display(),
error
),
);
Config::default()
}
}
}
Err(_) => {
log("main", "INFO", "Config file not found. Using defaults.");
Config::default()
}
}
})
.clone()
}

View File

@ -80,20 +80,18 @@ pub async fn initialize(ctx: Context, address: String, server_id: String) {
if let Some(client) = ICOM_CLIENT.get() {
let result = client
.listen_for_events(|msg| {
match msg {
Message::Event {
event_name, data, ..
} => {
log::log(
"icom",
"INFO",
&format!("Received event '{}': {}", event_name, data),
);
if let Message::Event {
event_name, data, ..
} = msg
{
log::log(
"icom",
"INFO",
&format!("Received event '{}': {}", event_name, data),
);
// Forward event to Arma
forward(&event_name, &data);
}
_ => {}
// Forward event to Arma
forward(&event_name, &data);
}
Ok(())
})

View File

@ -1,20 +1,20 @@
//! Entry point and runtime bootstrap for the Forge Arma server extension.
//!
//! Initializes a global async runtime, the Redis connection pool, and registers
//! Initializes a global async runtime, SurrealDB persistence, and registers
//! all command groups. Provides status/version commands and maintains a shared
//! Arma `Context` for engine interop.
//!
#![allow(future_incompatible)] // Future-incompatible lint is triggered by arma_rs
use arma_rs::{Context, Extension, Group, arma};
use std::sync::{LazyLock, OnceLock, RwLock as StdRwLock};
use std::sync::LazyLock;
use tokio::runtime::{Builder, Runtime};
use tokio::sync::RwLock as TokioRwLock;
pub mod actor;
pub mod adapters;
pub mod bank;
pub mod cad;
pub mod config;
pub mod garage;
pub mod helpers;
pub mod icom;
@ -22,7 +22,6 @@ pub mod locker;
mod log;
pub mod org;
pub mod phone;
pub mod redis;
pub mod schema;
pub mod storage;
pub mod store;
@ -37,10 +36,6 @@ pub mod v_locker;
/// commands that need engine interop. Stored inside an async `RwLock` to
/// allow mutation by the startup task and later reads.
static CONTEXT: LazyLock<TokioRwLock<Option<Context>>> = LazyLock::new(|| TokioRwLock::new(None));
/// Global Redis connection pool, created once and shared by all commands.
/// Initialized asynchronously after `init()` returns so the extension starts
/// quickly without blocking the main thread.
static REDIS_POOL: OnceLock<redis::client::RedisClient> = OnceLock::new();
/// Global multi-threaded Tokio runtime used to execute async operations from
/// command handlers and startup tasks.
pub(crate) static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
@ -50,16 +45,6 @@ pub(crate) static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
.expect("Failed to create tokio runtime")
});
#[derive(Clone, Copy, PartialEq)]
/// Connection state for the Redis pool so SQF can gate behavior on readiness.
enum ConnectionState {
Initializing,
Connected,
Failed,
}
static CONNECTION_STATE: LazyLock<StdRwLock<ConnectionState>> =
LazyLock::new(|| StdRwLock::new(ConnectionState::Initializing));
pub(crate) fn enqueue_persistence_task<F>(module: &'static str, job: F)
where
F: FnOnce() -> Result<(), String> + Send + 'static,
@ -77,14 +62,12 @@ where
#[arma]
/// Initializes the extension, registers commands/groups, and asynchronously
/// creates the Redis connection pool on the global runtime.
/// connects SurrealDB on the global runtime.
fn init() -> Extension {
let config = redis::config::load();
let storage_backend = config.storage.backend;
let config = config::load();
let ext = Extension::build()
.command("version", get_version)
.command("status", get_status)
.group("redis", redis::group())
.group("surreal", surreal::group())
.group("actor", actor::group())
.group("bank", bank::group())
@ -106,34 +89,20 @@ fn init() -> Extension {
)
.finish();
// Spawn initialization tasks for Redis and ICOM
// These run asynchronously and don't block extension startup
// Redis initialization will set the global CONTEXT
if storage_backend == redis::config::StorageBackend::Surreal {
let surreal_config = config.surreal.clone();
surreal::prepare();
RUNTIME.spawn(async move {
surreal::initialize(surreal_config).await;
});
}
let surreal_config = config.surreal.clone();
surreal::prepare();
RUNTIME.spawn(async move {
redis::initialize(config.redis).await;
surreal::initialize(surreal_config).await;
});
ext
}
/// Returns current Redis connection state as a string: `initializing`,
/// Returns current persistence connection state as a string: `initializing`,
/// `connected`, or `failed`. Intended for SQF polling before issuing
/// operations that require Redis.
/// operations that require persistence.
fn get_status() -> String {
let state = *CONNECTION_STATE.read().unwrap();
match state {
ConnectionState::Initializing => "initializing".into(),
ConnectionState::Connected => "connected".into(),
ConnectionState::Failed => "failed".into(),
}
surreal::status()
}
/// Returns the extension version string for diagnostics and tooling.

View File

@ -40,7 +40,7 @@ pub fn log(category: &str, level: &str, message: &str) {
.create(true)
.append(true)
.open(path)
.expect(&format!("Failed to open {} log file", category))
.unwrap_or_else(|_| panic!("Failed to open {} log file", category))
});
let _ = file.write_all(log_entry.as_bytes());

View File

@ -21,7 +21,7 @@ use crate::storage::OrgStorageRepository;
/// Global organization service instance.
///
/// Lazily initialized singleton combining Redis adapter, repository, and service layers.
/// Lazily initialized singleton combining repository and service layers.
static ORG_SERVICE: LazyLock<OrgService<OrgStorageRepository>> =
LazyLock::new(|| OrgService::new(OrgStorageRepository::configured()));
static HOT_ORG_SERVICE: LazyLock<
@ -504,7 +504,7 @@ pub fn get_members(key: String) -> String {
/// Adds a new member to an organization by their UID.
///
/// Resolves organization key to ID and adds the member UID.
/// Redis sets automatically prevent duplicate members.
/// Member collections automatically prevent duplicate members.
pub fn add_member(key: String, member_uid: String) -> String {
match ORG_SERVICE.add_member(key, member_uid) {
Ok(_) => "OK".to_string(),

View File

@ -1,281 +0,0 @@
# Redis Module
This module provides comprehensive Redis operations for the Forge extension, enabling persistent data storage and retrieval from SQF scripts.
## Architecture
The Redis module is organized into specialized operation groups:
- **Common**: Basic key-value operations
- **Hash**: Structured data storage (field-value pairs)
- **List**: Ordered collections and queues
- **Set**: Unique collections and membership tracking
## Connection Management
### Connection Pool
The module uses `bb8` for connection pooling, providing:
- **Automatic connection reuse**: Reduces overhead
- **Configurable pool size**: Control max/min connections
- **Idle timeout**: Prevents stale connections
- **Lazy initialization**: Pool created on first use
### Configuration
Redis connection settings are loaded from `@forge_server/config.toml`:
```toml
[redis]
host = "127.0.0.1"
port = 6379
password = "" # Optional
max_connections = 10
min_connections = 2
idle_timeout = 300 # seconds
```
## Common Operations
Basic key-value operations for simple data storage.
### Available Commands
| Command | Description | Returns |
| ------------------- | ------------------------- | ---------------------- |
| `redis:common:set` | Set a string value | "OK" |
| `redis:common:get` | Get a string value | Value or empty string |
| `redis:common:incr` | Increment a numeric value | New value |
| `redis:common:decr` | Decrement a numeric value | New value |
| `redis:common:del` | Delete a key | Number of keys removed |
| `redis:common:keys` | List all keys | Comma-separated keys |
### SQF Examples
```sqf
// Set a value
"forge_server" callExtension ["redis:common:set", ["player_count", "42"]];
// Get a value
private _result = "forge_server" callExtension ["redis:common:get", ["player_count"]];
private _count = _result select 0; // "42"
// Increment
"forge_server" callExtension ["redis:common:incr", ["player_count", 1]];
// Delete
"forge_server" callExtension ["redis:common:del", ["player_count"]];
```
## Hash Operations
Hash operations store structured data as field-value pairs, ideal for objects and entities.
### Available Commands
| Command | Description | Returns |
| ------------------- | ------------------------------ | ------------------------ |
| `redis:hash:set` | Set a single field | 1 if new, 0 if updated |
| `redis:hash:mset` | Set multiple fields atomically | "OK" |
| `redis:hash:get` | Get a field value | Value or empty string |
| `redis:hash:getall` | Get all fields and values | Comma-separated pairs |
| `redis:hash:del` | Delete a field | Number of fields removed |
| `redis:hash:keys` | Get all field names | Comma-separated keys |
| `redis:hash:vals` | Get all values | Comma-separated values |
| `redis:hash:len` | Get number of fields | Field count |
| `redis:hash:exists` | Check if field exists | "1" or "0" |
### SQF Examples
```sqf
// Set a single field
"forge_server" callExtension ["redis:hash:set", ["actor:76561198123456789", "name", "John Doe"]];
// Set multiple fields atomically
private _fields = [
["name", "John Doe"],
["bank", "1000"],
["level", "5"]
];
"forge_server" callExtension ["redis:hash:mset", ["actor:76561198123456789", _fields]];
// Get a field
private _result = "forge_server" callExtension ["redis:hash:get", ["actor:76561198123456789", "name"]];
private _name = _result select 0; // "John Doe"
// Get all fields
private _result = "forge_server" callExtension ["redis:hash:getall", ["actor:76561198123456789"]];
// Returns: "name, John Doe, bank, 1000, level, 5"
// Check if field exists
private _result = "forge_server" callExtension ["redis:hash:exists", ["actor:76561198123456789", "name"]];
private _exists = (_result select 0) == "1";
```
## List Operations
List operations manage ordered collections, useful for queues, logs, and sequential data.
### Available Commands
| Command | Description | Returns |
| ------------------ | --------------------- | ------------------------------ |
| `redis:list:set` | Set element at index | "OK" |
| `redis:list:get` | Get element at index | Value (base64 decoded) |
| `redis:list:len` | Get list length | Element count |
| `redis:list:range` | Get range of elements | JSON array |
| `redis:list:lpush` | Prepend to list | New length |
| `redis:list:rpush` | Append to list | New length |
| `redis:list:lpop` | Remove from beginning | JSON array of removed elements |
| `redis:list:rpop` | Remove from end | JSON array of removed elements |
| `redis:list:trim` | Trim to range | "OK" |
| `redis:list:del` | Remove by value | Number removed |
### SQF Examples
```sqf
// Append to list
"forge_server" callExtension ["redis:list:rpush", ["event_log", "Player joined"]];
"forge_server" callExtension ["redis:list:rpush", ["event_log", "Player spawned"]];
// Get range
private _result = "forge_server" callExtension ["redis:list:range", ["event_log", 0, -1]];
private _events = parseJSON (_result select 0); // Array of all events
// Pop from end
private _result = "forge_server" callExtension ["redis:list:rpop", ["event_log", 1]];
private _lastEvent = parseJSON (_result select 0); // ["Player spawned"]
// Trim to last 100 entries
"forge_server" callExtension ["redis:list:trim", ["event_log", -100, -1]];
```
> [!NOTE]
> List values are automatically base64 encoded/decoded to handle special characters safely.
## Set Operations
Set operations manage unique collections, perfect for membership tracking and preventing duplicates.
### Available Commands
| Command | Description | Returns |
| ----------------------- | ---------------------- | ---------------------------- |
| `redis:set:add` | Add member to set | 1 if new, 0 if exists |
| `redis:set:members` | Get all members | Comma-separated members |
| `redis:set:card` | Get member count | Cardinality |
| `redis:set:ismember` | Check membership | "1" or "0" |
| `redis:set:randmember` | Get random member | Member value |
| `redis:set:randmembers` | Get N random members | Comma-separated members |
| `redis:set:pop` | Remove random member | Removed member |
| `redis:set:del` | Remove specific member | 1 if removed, 0 if not found |
### SQF Examples
```sqf
// Add members to a set
"forge_server" callExtension ["redis:set:add", ["org:elite_squad:members", "76561198123456789"]];
"forge_server" callExtension ["redis:set:add", ["org:elite_squad:members", "76561198987654321"]];
// Check membership
private _result = "forge_server" callExtension ["redis:set:ismember", ["org:elite_squad:members", "76561198123456789"]];
private _isMember = (_result select 0) == "1";
// Get all members
private _result = "forge_server" callExtension ["redis:set:members", ["org:elite_squad:members"]];
private _memberUIDs = (_result select 0) splitString ",";
// Get member count
private _result = "forge_server" callExtension ["redis:set:card", ["org:elite_squad:members"]];
private _memberCount = parseNumber (_result select 0);
// Remove member
"forge_server" callExtension ["redis:set:del", ["org:elite_squad:members", "76561198123456789"]];
```
## Helper Utilities
### Base64 Encoding
List operations use base64 encoding to safely store complex strings:
```rust
use crate::redis::helpers::{encode_b64, decode_b64};
let encoded = encode_b64("Complex [string] with {special} chars");
let decoded = decode_b64(&encoded)?; // Original string
```
### Value Parsing
The `parse_redis_value` function intelligently converts Redis strings to JSON types:
```rust
use crate::redis::helpers::parse_redis_value;
parse_redis_value("42"); // Number(42)
parse_redis_value("true"); // Bool(true)
parse_redis_value("{\"key\":1}"); // Object
parse_redis_value("text"); // String("text")
```
## Macro Usage
The `redis_operation!` macro handles all connection and async boilerplate:
```rust
use crate::redis_operation;
use bb8_redis::redis::AsyncCommands;
pub fn my_redis_command(key: String) -> String {
redis_operation!(conn => {
match conn.get::<_, String>(&key).await {
Ok(value) => value,
Err(e) => format!("Error: {}", e),
}
})
}
```
The macro automatically:
- Acquires a connection from the pool
- Handles lazy initialization if needed
- Executes the operation asynchronously
- Returns the result to SQF
## Error Handling
All Redis operations return strings:
- **Success**: Operation result (e.g., "OK", value, count)
- **Error**: String starting with "Error: " followed by the error message
```sqf
private _result = "forge_server" callExtension ["redis:common:get", ["mykey"]];
private _value = _result select 0;
if (_value find "Error:" == 0) then {
diag_log format ["Redis error: %1", _value];
} else {
// Use the value
systemChat format ["Value: %1", _value];
};
```
## Performance Considerations
- **Connection Pooling**: Reuses connections to minimize overhead
- **Async Operations**: Non-blocking I/O prevents server lag
- **Atomic Operations**: `hash:mset` sets multiple fields in one operation
- **Batch Operations**: Use lists and sets for bulk data
## Best Practices
1. **Use Hashes for Objects**: Store actor/org data as hash fields
2. **Use Sets for Membership**: Track organization members, online players
3. **Use Lists for Logs**: Event logs, chat history, audit trails
4. **Prefix Keys**: Use namespaces like `actor:`, `org:`, `vehicle:`
5. **Handle Errors**: Always check for "Error:" prefix in results
6. **Atomic Updates**: Use `hash:mset` instead of multiple `hash:set` calls

View File

@ -1,48 +0,0 @@
use super::config::RedisConfig;
use bb8_redis::{RedisConnectionManager, bb8};
use std::error::Error;
use std::time::Duration;
/// Redis connection pool type alias.
pub type RedisClient = bb8::Pool<RedisConnectionManager>;
/// Creates a Redis connection pool with the specified configuration.
pub async fn create_redis_pool(
config: &RedisConfig,
) -> Result<RedisClient, Box<dyn Error + Send + Sync>> {
// Generate the Redis connection string from configuration
let connection_string = config.connection_string();
// Create the connection manager that will handle individual connections
let manager = RedisConnectionManager::new(connection_string)?;
// Start building the connection pool with default settings
let mut pool_builder = bb8::Pool::builder();
// Configure maximum number of connections if specified
// This prevents overwhelming the Redis server with too many connections
if let Some(max_conn) = config.max_connections {
pool_builder = pool_builder.max_size(max_conn as u32);
}
// Configure minimum idle connections if specified
// This ensures quick response times by keeping connections ready
if let Some(min_conn) = config.min_connections {
pool_builder = pool_builder.min_idle(Some(min_conn as u32));
}
// Configure idle connection timeout if specified
// This prevents keeping stale connections that might be closed by the server
if let Some(idle_timeout) = config.idle_timeout {
pool_builder = pool_builder.idle_timeout(Some(Duration::from_secs(idle_timeout)));
}
// Bound connection acquisition from the pool so game thread calls fail fast
if let Some(connect_timeout_ms) = config.connect_timeout_ms {
pool_builder = pool_builder.connection_timeout(Duration::from_millis(connect_timeout_ms));
}
// Build the final connection pool with all configured parameters
let pool = pool_builder.build(manager).await?;
Ok(pool)
}

View File

@ -1,74 +0,0 @@
//! Common Redis operations for basic key-value functionality.
use crate::redis_operation;
use bb8_redis::redis::AsyncCommands;
/// Sets a string value for the specified Redis key.
pub fn set_key(key: String, value: String) -> String {
redis_operation!(conn => {
match conn.set(&key, &value).await {
Ok(()) => "OK".to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Retrieves the string value for the specified Redis key.
pub fn get_key(key: String) -> String {
redis_operation!(conn => {
match conn.get::<_, String>(&key).await {
Ok(value) => value,
Err(e) => format!("Error: {}", e),
}
})
}
/// Increments a numeric value stored at the specified key.
pub fn incr_key(key: String, count: usize) -> String {
redis_operation!(conn => {
match conn.incr::<_, _, i64>(&key, count).await {
Ok(value) => value.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Decrements a numeric value stored at the specified key.
pub fn decr_key(key: String, count: usize) -> String {
redis_operation!(conn => {
match conn.decr::<_, _, i64>(&key, count).await {
Ok(value) => value.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Checks if a Redis key exists.
pub fn key_exists(key: String) -> String {
redis_operation!(conn => {
match conn.exists::<_, i32>(&key).await {
Ok(exists) => exists.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Deletes a Redis key and its associated value.
pub fn delete_key(key: String) -> String {
redis_operation!(conn => {
match conn.del::<_, usize>(&key).await {
Ok(removed) => removed.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Lists all Redis keys matching the wildcard pattern "*".
pub fn list_keys() -> String {
redis_operation!(conn => {
match conn.keys::<_, Vec<String>>("*").await {
Ok(keys) => keys.join(","),
Err(e) => format!("Error: {}", e),
}
})
}

View File

@ -1,215 +0,0 @@
//! Configuration management for Redis connection and application settings.
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::log::log;
static CONFIG_CACHE: OnceLock<Config> = OnceLock::new();
/// Main configuration structure for the entire application.
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
/// Durable storage backend selector.
#[serde(default)]
pub storage: StorageConfig,
/// Redis configuration with automatic defaults if not specified
#[serde(default)]
pub redis: RedisConfig,
/// SurrealDB configuration with automatic defaults if not specified
#[serde(default)]
pub surreal: SurrealConfig,
}
impl Default for Config {
/// Creates a default configuration with sensible values for development.
fn default() -> Self {
Self {
storage: StorageConfig::default(),
redis: RedisConfig::default(),
surreal: SurrealConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum StorageBackend {
Redis,
Surreal,
}
impl Default for StorageBackend {
fn default() -> Self {
Self::Redis
}
}
/// Durable storage backend selection.
#[derive(Debug, Clone, Deserialize)]
pub struct StorageConfig {
#[serde(default)]
pub backend: StorageBackend,
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
backend: StorageBackend::Redis,
}
}
}
/// Redis connection and connection pool configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct RedisConfig {
/// Redis server hostname or IP address
pub host: String,
/// Redis server port number
pub port: u16,
/// Redis database number (0-15)
pub db: u8,
/// Username for Redis ACL authentication (Redis 6.0+)
pub username: Option<String>,
/// Password for Redis authentication
pub password: Option<String>,
/// Maximum number of connections in the pool
pub max_connections: Option<usize>,
/// Minimum number of idle connections to maintain
pub min_connections: Option<usize>,
/// Idle connection timeout in seconds
pub idle_timeout: Option<u64>,
/// Maximum time to wait for pool connection checkout in milliseconds
pub pool_get_timeout_ms: Option<u64>,
/// Maximum time to wait for individual Redis command execution in milliseconds
pub command_timeout_ms: Option<u64>,
/// Maximum time to wait for pool connection establishment in milliseconds
pub connect_timeout_ms: Option<u64>,
}
impl Default for RedisConfig {
/// Creates default Redis configuration suitable for local development.
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 6379,
db: 0,
username: None,
password: None,
max_connections: Some(10),
min_connections: Some(2),
idle_timeout: Some(60),
pool_get_timeout_ms: Some(2000),
command_timeout_ms: Some(2000),
connect_timeout_ms: Some(2000),
}
}
}
/// SurrealDB connection configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct SurrealConfig {
/// SurrealDB HTTP endpoint, for example `127.0.0.1:8000`.
pub endpoint: String,
/// SurrealDB namespace.
pub namespace: String,
/// SurrealDB database.
pub database: String,
/// Optional root username for authentication.
pub username: Option<String>,
/// Optional root password for authentication.
pub password: Option<String>,
/// Maximum time to wait for initial connection in milliseconds.
pub connect_timeout_ms: Option<u64>,
}
impl Default for SurrealConfig {
fn default() -> Self {
Self {
endpoint: "127.0.0.1:8000".to_string(),
namespace: "forge".to_string(),
database: "main".to_string(),
username: Some("root".to_string()),
password: Some("root".to_string()),
connect_timeout_ms: Some(5000),
}
}
}
impl RedisConfig {
/// Generates a Redis connection string from the configuration.
pub fn connection_string(&self) -> String {
// Build authentication part of the URL
let auth_part = match (&self.username, &self.password) {
(Some(username), Some(password)) => format!("{}:{}@", username, password),
(None, Some(password)) => format!(":{}@", password),
(Some(username), None) => format!("{}@", username),
(None, None) => String::new(),
};
let mut conn_str = format!("redis://{}{}", auth_part, self.host);
if self.port != 6379 {
conn_str.push_str(&format!(":{}", self.port));
}
if self.db != 0 {
conn_str.push_str(&format!("/{}", self.db));
}
log(
"main",
"INFO",
&format!("Redis connection string: {}", conn_str),
);
conn_str
}
}
/// Loads configuration from the `config.toml` file with graceful fallback to defaults.
pub fn load() -> Config {
CONFIG_CACHE
.get_or_init(|| {
let config_path = std::env::current_exe()
.ok()
.and_then(|exe| {
exe.parent()
.map(|dir| dir.join("@forge_server").join("config.toml"))
})
.filter(|p| p.exists())
.unwrap_or_else(|| PathBuf::from("@forge_server/config.toml"));
match fs::read_to_string(&config_path) {
Ok(contents) => {
log("main", "INFO", &format!("Config file found! Loading..."));
match toml::from_str::<Config>(&contents) {
Ok(config) => config,
Err(error) => {
log(
"main",
"ERROR",
&format!(
"Failed to parse config file '{}': {}. Using defaults.",
config_path.display(),
error
),
);
Config::default()
}
}
}
Err(_) => {
log(
"main",
"INFO",
&format!("Config file not found. Using default configuration."),
);
Config::default()
}
}
})
.clone()
}

View File

@ -1,99 +0,0 @@
//! Redis hash operations for structured data storage.
use crate::redis_operation;
use bb8_redis::redis::AsyncCommands;
use std::collections::HashMap;
/// Sets a single field in a Redis hash.
pub fn hash_set(key: String, field: String, value: String) -> String {
redis_operation!(conn => {
match conn.hset::<_, _, _, i32>(&key, &field, &value).await {
Ok(added) => added.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Sets multiple fields in a Redis hash atomically.
pub fn hash_mset(key: String, items: Vec<(String, String)>) -> String {
redis_operation!(conn => {
match conn.hset_multiple(&key, &items).await {
Ok(()) => "OK".to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Retrieves the value of a specific field from a Redis hash.
pub fn hash_get(key: String, field: String) -> String {
redis_operation!(conn => {
match conn.hget::<_, _, Option<String>>(&key, &field).await {
Ok(Some(value)) => value,
Ok(None) => String::new(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Retrieves all fields and values from a Redis hash.
pub fn hash_get_all(key: String) -> String {
redis_operation!(conn => {
match conn.hgetall::<_, HashMap<String, String>>(&key).await {
Ok(hash_map) => match serde_json::to_string(&hash_map) {
Ok(json) => json,
Err(e) => format!("Error: Failed to serialize hash map: {}", e),
},
Err(e) => format!("Error: {}", e),
}
})
}
/// Removes a field from a Redis hash.
pub fn hash_del(key: String, field: String) -> String {
redis_operation!(conn => {
match conn.hdel::<_, _, i32>(&key, &field).await {
Ok(removed) => removed.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Retrieves all field names from a Redis hash.
pub fn hash_keys(key: String) -> String {
redis_operation!(conn => {
match conn.hkeys::<_, Vec<String>>(&key).await {
Ok(fields) => fields.join(","),
Err(e) => format!("Error: {}", e),
}
})
}
/// Retrieves all values from a Redis hash.
pub fn hash_values(key: String) -> String {
redis_operation!(conn => {
match conn.hvals::<_, Vec<String>>(&key).await {
Ok(values) => values.join(","),
Err(e) => format!("Error: {}", e),
}
})
}
/// Returns the number of fields in a Redis hash.
pub fn hash_len(key: String) -> String {
redis_operation!(conn => {
match conn.hlen::<_, i32>(&key).await {
Ok(len) => len.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Tests if a field exists in a Redis hash.
pub fn hash_exists(key: String, field: String) -> String {
redis_operation!(conn => {
match conn.hexists::<_, _, bool>(&key, &field).await {
Ok(exists) => if exists { "1" } else { "0" }.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}

View File

@ -1,73 +0,0 @@
//! Helper utilities for Redis data processing and encoding.
use serde_json;
/// Intelligently parses a Redis string value into the appropriate JSON type.
#[allow(dead_code)]
pub fn parse_redis_value(value: &str) -> serde_json::Value {
// Handle empty strings as null values
if value.is_empty() {
return serde_json::Value::Null;
}
// Try to parse as JSON first (handles objects, arrays, and JSON primitives)
if let Ok(json_val) = serde_json::from_str(value) {
// Special handling: unwrap single-element arrays
if let serde_json::Value::Array(arr) = &json_val {
if arr.len() == 1 {
return arr[0].clone();
}
}
return json_val;
}
// Try to parse as integer
if let Ok(int_val) = value.parse::<i64>() {
return serde_json::Value::Number(serde_json::Number::from(int_val));
}
// Try to parse as float
if let Ok(float_val) = value.parse::<f64>() {
if let Some(num) = serde_json::Number::from_f64(float_val) {
return serde_json::Value::Number(num);
}
}
// Try to parse as boolean (case-insensitive)
match value.to_lowercase().as_str() {
"true" => return serde_json::Value::Bool(true),
"false" => return serde_json::Value::Bool(false),
_ => {}
}
// Fallback: treat as string
serde_json::Value::String(value.to_string())
}
/// Converts a JSON value to a string by wrapping it in an array.
#[allow(dead_code)]
pub fn parse_json_value(value: &serde_json::Value) -> String {
// Wrap the value in a single-element array
let wrapped = serde_json::Value::Array(vec![value.clone()]);
// Serialize the wrapped array to a JSON string
wrapped.to_string()
}
/// Encodes a string to base64 for safe Redis storage.
pub fn encode_b64(data: &str) -> String {
use base64::{Engine as _, engine::general_purpose};
general_purpose::STANDARD.encode(data.as_bytes())
}
/// Decodes a base64 string back to its original form.
pub fn decode_b64(encoded: &str) -> Result<String, String> {
use base64::{Engine as _, engine::general_purpose};
match general_purpose::STANDARD.decode(encoded) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(string) => Ok(string),
Err(e) => Err(format!("Invalid UTF-8: {}", e)),
},
Err(e) => Err(format!("Invalid base64: {}", e)),
}
}

View File

@ -1,167 +0,0 @@
//! Redis list operations for ordered collections and queues.
use crate::redis_operation;
use bb8_redis::redis::AsyncCommands;
/// Sets the value of an element at a specific index in a Redis list.
pub fn list_set(key: String, index: isize, value: String) -> String {
use crate::redis::helpers::encode_b64;
let encoded_value = encode_b64(&value);
redis_operation!(conn => {
match conn.lset(&key, index, &encoded_value).await {
Ok(()) => "OK".to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Retrieves the value of an element at a specific index in a Redis list.
pub fn list_get(key: String, index: isize) -> String {
use crate::redis::helpers::decode_b64;
redis_operation!(conn => {
match conn.lindex::<_, String>(&key, index).await {
Ok(encoded_value) => {
match decode_b64(&encoded_value) {
Ok(decoded) => decoded,
Err(e) => format!("Error decoding base64: {}", e),
}
},
Err(e) => format!("Error: {}", e),
}
})
}
/// Returns the length (number of elements) of a Redis list.
pub fn list_len(key: String) -> String {
redis_operation!(conn => {
match conn.llen::<_, i32>(&key).await {
Ok(len) => len.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Retrieves a range of elements from a Redis list.
pub fn list_range(key: String, start: isize, end: isize) -> String {
use crate::redis::helpers::decode_b64;
redis_operation!(conn => {
match conn.lrange::<_, Vec<String>>(&key, start, end).await {
Ok(encoded_values) => {
let mut decoded_values = Vec::new();
for encoded in encoded_values {
match decode_b64(&encoded) {
Ok(decoded) => decoded_values.push(decoded),
Err(e) => return format!("Error decoding base64: {}", e),
}
}
match serde_json::to_string(&decoded_values) {
Ok(json_array) => json_array,
Err(e) => format!("Error: Failed to serialize to JSON: {}", e),
}
},
Err(e) => format!("Error: {}", e),
}
})
}
/// Prepends a value to the beginning (left) of a Redis list.
pub fn list_lpush(key: String, value: String) -> String {
use crate::redis::helpers::encode_b64;
let encoded_value = encode_b64(&value);
redis_operation!(conn => {
match conn.lpush::<_, _, usize>(&key, &encoded_value).await {
Ok(len) => len.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Appends a value to the end (right) of a Redis list.
pub fn list_rpush(key: String, value: String) -> String {
use crate::redis::helpers::encode_b64;
let encoded_value = encode_b64(&value);
redis_operation!(conn => {
match conn.rpush::<_, _, usize>(&key, &encoded_value).await {
Ok(len) => len.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Removes and returns elements from the beginning (left) of a Redis list.
pub fn list_lpop(key: String, count: usize) -> String {
use crate::redis::helpers::decode_b64;
redis_operation!(conn => {
let count_option = if count == 0 {
None
} else {
std::num::NonZeroUsize::new(count)
};
match conn.lpop::<_, Vec<String>>(&key, count_option).await {
Ok(encoded_values) => {
let mut decoded_values = Vec::new();
for encoded in encoded_values {
match decode_b64(&encoded) {
Ok(decoded) => decoded_values.push(decoded),
Err(e) => return format!("Error decoding base64: {}", e),
}
}
match serde_json::to_string(&decoded_values) {
Ok(json_array) => json_array,
Err(e) => format!("Error: Failed to serialize to JSON: {}", e),
}
},
Err(e) => format!("Error: {}", e),
}
})
}
/// Removes and returns elements from the end (right) of a Redis list.
pub fn list_rpop(key: String, count: usize) -> String {
use crate::redis::helpers::decode_b64;
redis_operation!(conn => {
let count_option = if count == 0 {
None
} else {
std::num::NonZeroUsize::new(count)
};
match conn.rpop::<_, Vec<String>>(&key, count_option).await {
Ok(encoded_values) => {
let mut decoded_values = Vec::new();
for encoded in encoded_values {
match decode_b64(&encoded) {
Ok(decoded) => decoded_values.push(decoded),
Err(e) => return format!("Error decoding base64: {}", e),
}
}
match serde_json::to_string(&decoded_values) {
Ok(json_array) => json_array,
Err(e) => format!("Error: Failed to serialize to JSON: {}", e),
}
},
Err(e) => format!("Error: {}", e),
}
})
}
/// Trims a Redis list to keep only elements within the specified range.
pub fn list_trim(key: String, start: isize, end: isize) -> String {
redis_operation!(conn => {
match conn.ltrim(&key, start, end).await {
Ok(()) => "OK".to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Removes elements from a Redis list by value.
pub fn list_del(key: String, count: isize, value: String) -> String {
use crate::redis::helpers::encode_b64;
let encoded_value = encode_b64(&value);
redis_operation!(conn => {
match conn.lrem::<_, _, i32>(&key, count, &encoded_value).await {
Ok(removed) => removed.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}

View File

@ -1,91 +0,0 @@
//! Macros for Redis operation boilerplate reduction.
/// Macro for Redis operations that handles all connection and async boilerplate.
#[macro_export]
macro_rules! redis_operation {
($conn:ident => $operation:block) => {{
use tokio::time::{Duration, timeout};
use $crate::redis;
use $crate::{CONNECTION_STATE, ConnectionState, REDIS_POOL, RUNTIME};
let timeout_config = redis::config::load().redis;
let pool_get_timeout =
Duration::from_millis(timeout_config.pool_get_timeout_ms.unwrap_or(2000));
let command_timeout =
Duration::from_millis(timeout_config.command_timeout_ms.unwrap_or(2000));
let init_timeout = Duration::from_millis(timeout_config.connect_timeout_ms.unwrap_or(2000));
// Get the Redis connection pool (initialized at startup)
let pool = match REDIS_POOL.get() {
Some(pool) => pool,
None => {
if *CONNECTION_STATE.read().unwrap() == ConnectionState::Failed {
return "Error: Redis connection unavailable".to_string();
}
// Attempt lazy initialization if not already initialized
let rt = &RUNTIME;
let init_result = rt.block_on(async move {
let cfg = redis::config::load();
match timeout(init_timeout, redis::client::create_redis_pool(&cfg.redis)).await
{
Ok(Ok(pool)) => {
let _ = REDIS_POOL.set(pool);
Ok(())
}
Ok(Err(_e)) => {
let default_cfg = redis::RedisConfig::default();
match timeout(
init_timeout,
redis::client::create_redis_pool(&default_cfg),
)
.await
{
Ok(Ok(pool)) => {
let _ = REDIS_POOL.set(pool);
Ok(())
}
Ok(Err(e)) => Err(format!("{}", e)),
Err(_) => {
Err("Redis fallback initialization timed out".to_string())
}
}
}
Err(_) => Err("Redis initialization timed out".to_string()),
}
});
match init_result {
Ok(()) => {
*CONNECTION_STATE.write().unwrap() = ConnectionState::Connected;
match REDIS_POOL.get() {
Some(pool) => pool,
None => return "Error: Redis pool not initialized".to_string(),
}
}
Err(err) => {
*CONNECTION_STATE.write().unwrap() = ConnectionState::Failed;
return format!("Error: {}", err);
}
}
}
};
// Use the global tokio runtime to execute async operations
let rt = &RUNTIME;
rt.block_on(async move {
// Acquire a connection from the pool
let mut $conn = match timeout(pool_get_timeout, pool.get()).await {
Ok(Ok(conn)) => conn,
Ok(Err(e)) => return format!("Error: {}", e),
Err(_) => return "Error: Redis connection checkout timed out".to_string(),
};
// Execute the user-provided Redis operation
match timeout(command_timeout, async move { $operation }).await {
Ok(result) => result,
Err(_) => "Error: Redis operation timed out".to_string(),
}
})
}};
}

View File

@ -1,138 +0,0 @@
//! Redis operations and utilities for the Arma 3 server extension.
use arma_rs::Group;
use tokio::time::{Duration, timeout};
pub use client::create_redis_pool;
pub use config::RedisConfig;
pub use helpers::{decode_b64, encode_b64};
use crate::{CONNECTION_STATE, ConnectionState, REDIS_POOL, log};
pub mod client;
pub mod common;
pub mod config;
pub mod hash;
pub mod helpers;
pub mod list;
pub mod macros;
pub mod set;
/// Initialize Redis connection pool with fallback to default config
///
/// This function attempts to connect to Redis using the provided config,
/// with a 5-second timeout. If the primary config fails, it tries the
/// default config as a fallback.
pub async fn initialize(config: RedisConfig) {
// Use timeout to prevent hanging if Redis is unavailable
let pool_result = timeout(Duration::from_secs(5), create_redis_pool(&config)).await;
let pool = match pool_result {
Err(_) => {
log::log(
"redis",
"ERROR",
"Redis connection timed out after 5 seconds",
);
*CONNECTION_STATE.write().unwrap() = ConnectionState::Failed;
return; // Exit early
}
Ok(Ok(pool)) => {
log::log("redis", "INFO", "Connected to Redis server");
pool
}
Ok(Err(e)) => {
log::log(
"redis",
"WARN",
&format!("Failed to connect to Redis (primary config): {}", e),
);
// Try default config as fallback with timeout
let default_config = RedisConfig::default();
match timeout(Duration::from_secs(5), create_redis_pool(&default_config)).await {
Err(_) => {
log::log(
"redis",
"ERROR",
"Redis (default config) timed out after 5 seconds",
);
*CONNECTION_STATE.write().unwrap() = ConnectionState::Failed;
return;
}
Ok(Ok(pool)) => {
log::log("redis", "INFO", "Connected to Redis using default config");
pool
}
Ok(Err(e)) => {
log::log(
"redis",
"ERROR",
&format!("Failed to connect to Redis (all attempts): {}", e),
);
*CONNECTION_STATE.write().unwrap() = ConnectionState::Failed;
return; // Exit early, don't set pool
}
}
}
};
if REDIS_POOL.set(pool).is_ok() {
*CONNECTION_STATE.write().unwrap() = ConnectionState::Connected;
} else {
log::log("redis", "ERROR", "Failed to set Redis pool (already set)");
*CONNECTION_STATE.write().unwrap() = ConnectionState::Failed;
}
}
pub fn group() -> Group {
Group::new()
.group(
"common",
Group::new()
.command("set", common::set_key)
.command("get", common::get_key)
.command("incr", common::incr_key)
.command("decr", common::decr_key)
.command("del", common::delete_key)
.command("keys", common::list_keys),
)
.group(
"hash",
Group::new()
.command("set", hash::hash_set)
.command("mset", hash::hash_mset)
.command("get", hash::hash_get)
.command("getall", hash::hash_get_all)
.command("del", hash::hash_del)
.command("keys", hash::hash_keys)
.command("vals", hash::hash_values)
.command("len", hash::hash_len)
.command("exists", hash::hash_exists),
)
.group(
"list",
Group::new()
.command("set", list::list_set)
.command("get", list::list_get)
.command("len", list::list_len)
.command("range", list::list_range)
.command("lpush", list::list_lpush)
.command("rpush", list::list_rpush)
.command("lpop", list::list_lpop)
.command("rpop", list::list_rpop)
.command("trim", list::list_trim)
.command("del", list::list_del),
)
.group(
"set",
Group::new()
.command("add", set::set_add)
.command("members", set::set_members)
.command("card", set::set_card)
.command("ismember", set::set_is_member)
.command("randmember", set::set_random_member)
.command("randmembers", set::set_random_members)
.command("pop", set::set_pop)
.command("del", set::set_del),
)
}

View File

@ -1,87 +0,0 @@
//! Redis set operations for unique collections and membership tracking.
use crate::redis_operation;
use bb8_redis::redis::AsyncCommands;
/// Adds a value to a Redis set.
pub fn set_add(key: String, value: String) -> String {
redis_operation!(conn => {
match conn.sadd::<_, _, i32>(&key, &value).await {
Ok(added) => added.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Retrieves all members of a Redis set.
pub fn set_members(key: String) -> String {
redis_operation!(conn => {
match conn.smembers::<_, Vec<String>>(&key).await {
Ok(members) => members.join(","),
Err(e) => format!("Error: {}", e),
}
})
}
/// Returns the number of members in a Redis set (cardinality).
pub fn set_card(key: String) -> String {
redis_operation!(conn => {
match conn.scard::<_, i32>(&key).await {
Ok(card) => card.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Removes a value from a Redis set.
pub fn set_del(key: String, value: String) -> String {
redis_operation!(conn => {
match conn.srem::<_, _, i32>(&key, &value).await {
Ok(removed) => removed.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Tests if a value is a member of a Redis set.
pub fn set_is_member(key: String, value: String) -> String {
redis_operation!(conn => {
match conn.sismember::<_, _, bool>(&key, &value).await {
Ok(is_member) => if is_member { "1" } else { "0" }.to_string(),
Err(e) => format!("Error: {}", e),
}
})
}
/// Removes and returns a random member from a Redis set.
pub fn set_pop(key: String) -> String {
redis_operation!(conn => {
match conn.spop::<_, String>(&key).await {
Ok(value) => value,
Err(e) => format!("Error: {}", e),
}
})
}
/// Returns a random member from a Redis set without removing it.
pub fn set_random_member(key: String) -> String {
redis_operation!(conn => {
match conn.srandmember::<_, String>(&key).await {
Ok(value) => value,
Err(e) => format!("Error: {}", e),
}
})
}
/// Returns multiple random members from a Redis set without removing them.
pub fn set_random_members(key: String, count: isize) -> String {
redis_operation!(conn => {
match conn
.srandmember_multiple::<_, Vec<String>>(&key, count.try_into().unwrap_or(0))
.await
{
Ok(values) => values.join(","),
Err(e) => format!("Error: {}", e),
}
})
}

View File

@ -27,15 +27,11 @@ use forge_models::{
};
use forge_repositories::{
ActorRepository, BankRepository, GarageRepository, LockerRepository, OrgRepository,
PhoneRepository, RedisActorRepository, RedisBankRepository, RedisGarageRepository,
RedisLockerRepository, RedisOrgRepository, RedisPhoneRepository, RedisVGarageRepository,
RedisVLockerRepository, VGarageRepository, VLockerRepository,
PhoneRepository, VGarageRepository, VLockerRepository,
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use crate::RUNTIME;
use crate::adapters::ExtensionRedisClient;
use crate::redis::config::{StorageBackend, load};
use crate::surreal;

View File

@ -2,53 +2,42 @@ use super::common::*;
use super::*;
pub enum ActorStorageRepository {
Redis(RedisActorRepository<ExtensionRedisClient>),
Surreal(SurrealActorRepository),
}
impl ActorStorageRepository {
pub fn configured() -> Self {
match load().storage.backend {
StorageBackend::Surreal => Self::Surreal(SurrealActorRepository),
StorageBackend::Redis => {
Self::Redis(RedisActorRepository::new(ExtensionRedisClient::new()))
}
}
Self::Surreal(SurrealActorRepository)
}
}
impl ActorRepository for ActorStorageRepository {
fn create(&self, actor: &Actor) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.create(actor),
Self::Surreal(repository) => repository.create(actor),
}
}
fn get_by_id(&self, id: &str) -> Result<Option<Actor>, String> {
match self {
Self::Redis(repository) => repository.get_by_id(id),
Self::Surreal(repository) => repository.get_by_id(id),
}
}
fn update(&self, actor: &Actor) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.update(actor),
Self::Surreal(repository) => repository.update(actor),
}
}
fn delete(&self, id: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.delete(id),
Self::Surreal(repository) => repository.delete(id),
}
}
fn exists(&self, id: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.exists(id),
Self::Surreal(repository) => repository.exists(id),
}
}

View File

@ -2,53 +2,42 @@ use super::common::*;
use super::*;
pub enum BankStorageRepository {
Redis(RedisBankRepository<ExtensionRedisClient>),
Surreal(SurrealBankRepository),
}
impl BankStorageRepository {
pub fn configured() -> Self {
match load().storage.backend {
StorageBackend::Surreal => Self::Surreal(SurrealBankRepository),
StorageBackend::Redis => {
Self::Redis(RedisBankRepository::new(ExtensionRedisClient::new()))
}
}
Self::Surreal(SurrealBankRepository)
}
}
impl BankRepository for BankStorageRepository {
fn create(&self, bank: &Bank) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.create(bank),
Self::Surreal(repository) => repository.create(bank),
}
}
fn get_by_id(&self, id: &str) -> Result<Option<Bank>, String> {
match self {
Self::Redis(repository) => repository.get_by_id(id),
Self::Surreal(repository) => repository.get_by_id(id),
}
}
fn update(&self, bank: &Bank) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.update(bank),
Self::Surreal(repository) => repository.update(bank),
}
}
fn delete(&self, id: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.delete(id),
Self::Surreal(repository) => repository.delete(id),
}
}
fn exists(&self, id: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.exists(id),
Self::Surreal(repository) => repository.exists(id),
}
}

View File

@ -2,53 +2,42 @@ use super::common::*;
use super::*;
pub enum GarageStorageRepository {
Redis(RedisGarageRepository<ExtensionRedisClient>),
Surreal(SurrealGarageRepository),
}
impl GarageStorageRepository {
pub fn configured() -> Self {
match load().storage.backend {
StorageBackend::Surreal => Self::Surreal(SurrealGarageRepository),
StorageBackend::Redis => {
Self::Redis(RedisGarageRepository::new(ExtensionRedisClient::new()))
}
}
Self::Surreal(SurrealGarageRepository)
}
}
impl GarageRepository for GarageStorageRepository {
fn create(&self, uid: &str, garage: &Garage) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.create(uid, garage),
Self::Surreal(repository) => repository.create(uid, garage),
}
}
fn update(&self, uid: &str, garage: &Garage) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.update(uid, garage),
Self::Surreal(repository) => repository.update(uid, garage),
}
}
fn get(&self, uid: &str) -> Result<Option<Garage>, String> {
match self {
Self::Redis(repository) => repository.get(uid),
Self::Surreal(repository) => repository.get(uid),
}
}
fn delete(&self, uid: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.delete(uid),
Self::Surreal(repository) => repository.delete(uid),
}
}
fn exists(&self, uid: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.exists(uid),
Self::Surreal(repository) => repository.exists(uid),
}
}
@ -152,60 +141,48 @@ impl GarageRepository for SurrealGarageRepository {
}
pub enum VGarageStorageRepository {
Redis(RedisVGarageRepository<ExtensionRedisClient>),
Surreal(SurrealVGarageRepository),
}
impl VGarageStorageRepository {
pub fn configured() -> Self {
match load().storage.backend {
StorageBackend::Surreal => Self::Surreal(SurrealVGarageRepository),
StorageBackend::Redis => {
Self::Redis(RedisVGarageRepository::new(ExtensionRedisClient::new()))
}
}
Self::Surreal(SurrealVGarageRepository)
}
}
impl VGarageRepository for VGarageStorageRepository {
fn create(&self, uid: &str, garage: &VGarage) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.create(uid, garage),
Self::Surreal(repository) => repository.create(uid, garage),
}
}
fn update(&self, uid: &str, garage: &VGarage) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.update(uid, garage),
Self::Surreal(repository) => repository.update(uid, garage),
}
}
fn fetch(&self, uid: &str) -> Result<Option<VGarage>, String> {
match self {
Self::Redis(repository) => repository.fetch(uid),
Self::Surreal(repository) => repository.fetch(uid),
}
}
fn get(&self, uid: &str, field: &str) -> Result<Vec<String>, String> {
match self {
Self::Redis(repository) => repository.get(uid, field),
Self::Surreal(repository) => repository.get(uid, field),
}
}
fn delete(&self, uid: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.delete(uid),
Self::Surreal(repository) => repository.delete(uid),
}
}
fn exists(&self, uid: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.exists(uid),
Self::Surreal(repository) => repository.exists(uid),
}
}

View File

@ -2,53 +2,42 @@ use super::common::*;
use super::*;
pub enum LockerStorageRepository {
Redis(RedisLockerRepository<ExtensionRedisClient>),
Surreal(SurrealLockerRepository),
}
impl LockerStorageRepository {
pub fn configured() -> Self {
match load().storage.backend {
StorageBackend::Surreal => Self::Surreal(SurrealLockerRepository),
StorageBackend::Redis => {
Self::Redis(RedisLockerRepository::new(ExtensionRedisClient::new()))
}
}
Self::Surreal(SurrealLockerRepository)
}
}
impl LockerRepository for LockerStorageRepository {
fn create(&self, uid: &str, locker: &Locker) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.create(uid, locker),
Self::Surreal(repository) => repository.create(uid, locker),
}
}
fn update(&self, uid: &str, locker: &Locker) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.update(uid, locker),
Self::Surreal(repository) => repository.update(uid, locker),
}
}
fn get(&self, uid: &str) -> Result<Option<Locker>, String> {
match self {
Self::Redis(repository) => repository.get(uid),
Self::Surreal(repository) => repository.get(uid),
}
}
fn delete(&self, uid: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.delete(uid),
Self::Surreal(repository) => repository.delete(uid),
}
}
fn exists(&self, uid: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.exists(uid),
Self::Surreal(repository) => repository.exists(uid),
}
}
@ -141,60 +130,48 @@ impl LockerRepository for SurrealLockerRepository {
}
pub enum VLockerStorageRepository {
Redis(RedisVLockerRepository<ExtensionRedisClient>),
Surreal(SurrealVLockerRepository),
}
impl VLockerStorageRepository {
pub fn configured() -> Self {
match load().storage.backend {
StorageBackend::Surreal => Self::Surreal(SurrealVLockerRepository),
StorageBackend::Redis => {
Self::Redis(RedisVLockerRepository::new(ExtensionRedisClient::new()))
}
}
Self::Surreal(SurrealVLockerRepository)
}
}
impl VLockerRepository for VLockerStorageRepository {
fn create(&self, uid: &str, locker: &VLocker) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.create(uid, locker),
Self::Surreal(repository) => repository.create(uid, locker),
}
}
fn update(&self, uid: &str, locker: &VLocker) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.update(uid, locker),
Self::Surreal(repository) => repository.update(uid, locker),
}
}
fn fetch(&self, uid: &str) -> Result<Option<VLocker>, String> {
match self {
Self::Redis(repository) => repository.fetch(uid),
Self::Surreal(repository) => repository.fetch(uid),
}
}
fn get(&self, uid: &str, field: &str) -> Result<Vec<String>, String> {
match self {
Self::Redis(repository) => repository.get(uid, field),
Self::Surreal(repository) => repository.get(uid, field),
}
}
fn delete(&self, uid: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.delete(uid),
Self::Surreal(repository) => repository.delete(uid),
}
}
fn exists(&self, uid: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.exists(uid),
Self::Surreal(repository) => repository.exists(uid),
}
}

View File

@ -2,74 +2,60 @@ use super::common::*;
use super::*;
pub enum OrgStorageRepository {
Redis(RedisOrgRepository<ExtensionRedisClient>),
Surreal(SurrealOrgRepository),
}
impl OrgStorageRepository {
pub fn configured() -> Self {
match load().storage.backend {
StorageBackend::Surreal => Self::Surreal(SurrealOrgRepository),
StorageBackend::Redis => {
Self::Redis(RedisOrgRepository::new(ExtensionRedisClient::new()))
}
}
Self::Surreal(SurrealOrgRepository)
}
}
impl OrgRepository for OrgStorageRepository {
fn create(&self, org: &Org) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.create(org),
Self::Surreal(repository) => repository.create(org),
}
}
fn get_by_id(&self, id: &str) -> Result<Option<Org>, String> {
match self {
Self::Redis(repository) => repository.get_by_id(id),
Self::Surreal(repository) => repository.get_by_id(id),
}
}
fn update(&self, org: &Org) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.update(org),
Self::Surreal(repository) => repository.update(org),
}
}
fn delete(&self, id: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.delete(id),
Self::Surreal(repository) => repository.delete(id),
}
}
fn exists(&self, id: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.exists(id),
Self::Surreal(repository) => repository.exists(id),
}
}
fn add_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.add_member(org_id, member_uid),
Self::Surreal(repository) => repository.add_member(org_id, member_uid),
}
}
fn get_members(&self, org_id: &str) -> Result<Vec<MemberSummary>, String> {
match self {
Self::Redis(repository) => repository.get_members(org_id),
Self::Surreal(repository) => repository.get_members(org_id),
}
}
fn remove_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.remove_member(org_id, member_uid),
Self::Surreal(repository) => repository.remove_member(org_id, member_uid),
}
}
@ -79,7 +65,6 @@ impl OrgRepository for OrgStorageRepository {
org_id: &str,
) -> Result<HashMap<String, HashMap<String, OrgAssetEntry>>, String> {
match self {
Self::Redis(repository) => repository.get_assets(org_id),
Self::Surreal(repository) => repository.get_assets(org_id),
}
}
@ -90,14 +75,12 @@ impl OrgRepository for OrgStorageRepository {
assets: &HashMap<String, HashMap<String, OrgAssetEntry>>,
) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.update_assets(org_id, assets),
Self::Surreal(repository) => repository.update_assets(org_id, assets),
}
}
fn get_fleet(&self, org_id: &str) -> Result<HashMap<String, OrgFleetEntry>, String> {
match self {
Self::Redis(repository) => repository.get_fleet(org_id),
Self::Surreal(repository) => repository.get_fleet(org_id),
}
}
@ -108,7 +91,6 @@ impl OrgRepository for OrgStorageRepository {
fleet: &HashMap<String, OrgFleetEntry>,
) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.update_fleet(org_id, fleet),
Self::Surreal(repository) => repository.update_fleet(org_id, fleet),
}
}

View File

@ -2,116 +2,96 @@ use super::common::*;
use super::*;
pub enum PhoneStorageRepository {
Redis(RedisPhoneRepository<ExtensionRedisClient>),
Surreal(SurrealPhoneRepository),
}
impl PhoneStorageRepository {
pub fn configured() -> Self {
match load().storage.backend {
StorageBackend::Surreal => Self::Surreal(SurrealPhoneRepository),
StorageBackend::Redis => {
Self::Redis(RedisPhoneRepository::new(ExtensionRedisClient::new()))
}
}
Self::Surreal(SurrealPhoneRepository)
}
}
impl PhoneRepository for PhoneStorageRepository {
fn init(&self, uid: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.init(uid),
Self::Surreal(repository) => repository.init(uid),
}
}
fn add_contact(&self, uid: &str, contact_uid: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.add_contact(uid, contact_uid),
Self::Surreal(repository) => repository.add_contact(uid, contact_uid),
}
}
fn remove_contact(&self, uid: &str, contact_uid: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.remove_contact(uid, contact_uid),
Self::Surreal(repository) => repository.remove_contact(uid, contact_uid),
}
}
fn list_contacts(&self, uid: &str) -> Result<Vec<String>, String> {
match self {
Self::Redis(repository) => repository.list_contacts(uid),
Self::Surreal(repository) => repository.list_contacts(uid),
}
}
fn remove_phone(&self, uid: &str) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.remove_phone(uid),
Self::Surreal(repository) => repository.remove_phone(uid),
}
}
fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.append_message(uid, message),
Self::Surreal(repository) => repository.append_message(uid, message),
}
}
fn list_messages(&self, uid: &str) -> Result<Vec<PhoneMessage>, String> {
match self {
Self::Redis(repository) => repository.list_messages(uid),
Self::Surreal(repository) => repository.list_messages(uid),
}
}
fn mark_message_read(&self, uid: &str, message_id: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.mark_message_read(uid, message_id),
Self::Surreal(repository) => repository.mark_message_read(uid, message_id),
}
}
fn delete_message(&self, uid: &str, message_id: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.delete_message(uid, message_id),
Self::Surreal(repository) => repository.delete_message(uid, message_id),
}
}
fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> {
match self {
Self::Redis(repository) => repository.append_email(uid, email),
Self::Surreal(repository) => repository.append_email(uid, email),
}
}
fn list_emails(&self, uid: &str) -> Result<Vec<PhoneEmail>, String> {
match self {
Self::Redis(repository) => repository.list_emails(uid),
Self::Surreal(repository) => repository.list_emails(uid),
}
}
fn mark_email_read(&self, uid: &str, email_id: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.mark_email_read(uid, email_id),
Self::Surreal(repository) => repository.mark_email_read(uid, email_id),
}
}
fn delete_email(&self, uid: &str, email_id: &str) -> Result<bool, String> {
match self {
Self::Redis(repository) => repository.delete_email(uid, email_id),
Self::Surreal(repository) => repository.delete_email(uid, email_id),
}
}
fn next_sequence(&self) -> Result<u64, String> {
match self {
Self::Redis(repository) => repository.next_sequence(),
Self::Surreal(repository) => repository.next_sequence(),
}
}

View File

@ -7,8 +7,8 @@ use surrealdb::engine::remote::http::{Client, Http};
use surrealdb::opt::auth::Root;
use tokio::time::{Duration, sleep, timeout};
use crate::config::SurrealConfig;
use crate::log;
use crate::redis::config::SurrealConfig;
use crate::schema;
pub type SurrealDb = Surreal<Client>;
@ -144,7 +144,8 @@ async fn wait_for_client() -> Result<&'static SurrealDb, String> {
return Ok(db);
}
match *SURREAL_CONNECTION_STATE.read().unwrap() {
let state = *SURREAL_CONNECTION_STATE.read().unwrap();
match state {
SurrealConnectionState::Disabled => {
return Err("SurrealDB connection is disabled".to_string());
}

View File

@ -131,10 +131,10 @@ fn parse_transport_argument_value(value: serde_json::Value) -> Result<Vec<String
.collect()),
serde_json::Value::String(value) => {
let trimmed = value.trim();
if trimmed.starts_with('[') || trimmed.starts_with('{') || trimmed.eq("null") {
if let Ok(nested_value) = serde_json::from_str::<serde_json::Value>(trimmed) {
return parse_transport_argument_value(nested_value);
}
if (trimmed.starts_with('[') || trimmed.starts_with('{') || trimmed.eq("null"))
&& let Ok(nested_value) = serde_json::from_str::<serde_json::Value>(trimmed)
{
return parse_transport_argument_value(nested_value);
}
Ok(vec![value])

View File

@ -4,21 +4,13 @@ use std::fs;
use std::path::PathBuf;
/// ICOM server configuration.
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Config {
/// Server bind address configuration
#[serde(default)]
pub server: ServerConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
server: ServerConfig::default(),
}
}
}
/// Server bind configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {

View File

@ -6,7 +6,7 @@ The garage system provides complete vehicle storage, retrieval, and management f
## Data Storage
- Each player's garage is stored as a single JSON object (map) at Redis key: `garage:{playerUID}`
- Each player's garage is persisted by the server extension through SurrealDB.
- The map is keyed by the vehicle's unique plate (UUID)
- Each vehicle tracks: plate (UUID), classname, overall damage, fuel, and detailed hit points
- **Plates are auto-generated** when vehicles are added via `garage:add`

View File

@ -6,7 +6,7 @@ The locker system provides persistent item storage for Arma 3 players. Each play
## Data Storage
- Each player's locker is stored as a single JSON object (map) at Redis key: `locker:{playerUID}`
- Each player's locker is persisted by the server extension through SurrealDB.
- The map is keyed by the item's **classname** (String)
- Each item tracks: `category`, `classname`, and `amount`
- **Maximum Capacity**: 25 unique items per locker

View File

@ -1,264 +1,26 @@
# Forge Library
# Forge Shared Libraries
This directory contains the core business logic and data layers for the Forge framework, organized into modular, reusable crates that follow clean architecture principles.
The `lib` workspace contains reusable Rust crates for Forge domain models,
repository traits, services, and shared helpers.
## Architecture Overview
## Crates
The library follows a **layered architecture** pattern, ensuring separation of concerns and maintainability:
- `forge-models`: serializable domain models shared by services and extension
routes.
- `forge-repositories`: repository traits plus in-memory implementations used
by tests and transient hot-state stores.
- `forge-services`: business logic for actor, bank, garage, locker, org,
phone, store, task, and CAD workflows.
- `forge-shared`: validation and cross-crate helpers.
```mermaid
graph TD
Extension[Extension Layer<br/>#40;ArmA 3 Interface#41;]
Services[Services Layer<br/>#40;Business Logic#41;]
Repositories[Repositories Layer<br/>#40;Data Persistence#41;]
Models[Models Layer<br/>#40;Data Structures#41;]
Durable persistence is implemented in the server extension with SurrealDB
repository implementations.
Extension --> Services
Services --> Repositories
Repositories --> Models
## Test
```powershell
cargo test -p forge-models
cargo test -p forge-repositories
cargo test -p forge-services
cargo test -p forge-shared
```
## Modules
### Models (`lib/models`)
**Purpose**: Defines strict data structures and validation rules for domain entities.
**Responsibilities**:
- Define entity structures (`Actor`, `Org`)
- Implement validation logic
- Handle serialization/deserialization (JSON, Arma)
- Enforce business rules at the data level
**Key Features**:
- Strong typing with Rust structs
- Built-in validation on creation and updates
- Automatic email generation for actors
- Arma-specific type conversions
**Documentation**: [models/README.md](models/README.md)
### Repositories (`lib/repositories`)
**Purpose**: Manages data persistence and retrieval using Redis.
**Responsibilities**:
- Abstract database operations
- Implement CRUD operations
- Handle data serialization to Redis formats
- Manage Redis keys and data structures
**Key Features**:
- Generic over Redis client implementations
- Hash-based storage for structured data
- Set-based storage for collections (e.g., org members)
- Thread-safe operations (`Send + Sync`)
**Documentation**: [repositories/README.md](repositories/README.md)
### Services (`lib/services`)
**Purpose**: Implements business logic, validation, and orchestration of operations.
**Responsibilities**:
- Coordinate between repositories
- Enforce business rules
- Handle complex workflows
- Provide high-level APIs for the extension layer
**Key Features**:
- Generic over repository implementations
- Stateless service design
- Get-or-create patterns for entities
- Comprehensive error handling
**Documentation**: [services/README.md](services/README.md)
### Shared (`lib/shared`)
**Purpose**: Provides common utilities, traits, and helper functions used across all layers.
**Responsibilities**:
- Define shared traits (`RedisClient`)
- Provide utility functions
- Common type definitions
- Cross-cutting concerns
**Key Features**:
- `RedisClient` trait for repository abstraction
- JSON/Redis value parsing utilities
- Arma value conversion helpers
- Reusable helper functions
## How It All Works Together
### Example: Creating an Actor
Here's how the layers interact when creating a new actor:
1. **Extension Layer** receives SQF command:
```rust
// arma/server/extension/src/actor.rs
pub fn create_actor(key: String, data: String) -> String {
// Parse JSON and call service
ACTOR_SERVICE.create_actor(uid, json_data)
}
```
2. **Service Layer** validates and orchestrates:
```rust
// lib/services/src/actor.rs
impl<R: ActorRepository> ActorService<R> {
pub fn create_actor(&self, uid: String, data: String) -> Result<Actor, String> {
// Create actor model (validates data)
let actor = Actor::new(uid, data)?;
// Persist via repository
self.repository.create(&actor)?;
Ok(actor)
}
}
```
3. **Repository Layer** persists to Redis:
```rust
// lib/repositories/src/actor.rs
impl<C: RedisClient> ActorRepository for RedisActorRepository<C> {
fn create(&self, actor: &Actor) -> Result<(), String> {
// Convert actor to Redis hash
let fields = actor.to_redis_fields();
// Store in Redis
self.client.hash_mset(format!("actor:{}", actor.uid), fields)
}
}
```
4. **Model Layer** ensures data integrity:
```rust
// lib/models/src/actor.rs
impl Actor {
pub fn new(uid: String, data: String) -> Result<Self, String> {
// Validate all fields
Self::validate(&uid, &data)?;
// Create actor with validated data
Ok(Actor { uid, /* ... */ })
}
}
```
## Contributing
We welcome contributions to the Forge library! Follow these guidelines to maintain consistency and quality.
### Adding a New Model
See [models/README.md - Contributing](models/README.md#contributing)
**Summary**:
1. Define struct with validation rules
2. Implement `new` and `validate` methods
3. Add serialization traits (`Serialize`, `Deserialize`)
4. Implement Arma conversions (`FromArma`, `IntoArma`)
### Adding a New Repository
See [repositories/README.md - Contributing](repositories/README.md#contributing)
**Summary**:
1. Define repository trait with `Send + Sync`
2. Implement trait for `RedisXRepository<C: RedisClient>`
3. Use `forge_shared::RedisClient` for operations
4. Register module in `lib.rs`
### Adding a New Service
See [services/README.md - Contributing](services/README.md#contributing)
**Summary**:
1. Create service struct generic over repository
2. Implement constructor and business logic methods
3. Delegate data operations to repository
4. Register module in `lib.rs`
### Best Practices
#### Separation of Concerns
- **Models**: Only data structures and validation
- **Repositories**: Only data persistence logic
- **Services**: Only business logic and orchestration
- **Shared**: Only common utilities and traits
#### Error Handling
- Use `Result<T, String>` for all fallible operations
- Provide descriptive error messages
- Propagate errors up the stack with `?`
#### Testing
- **Models**: Test validation rules
- **Repositories**: Test with mock Redis clients
- **Services**: Test with mock repositories
- **Integration**: Test full stack in extension layer
#### Dependencies
- Models should have minimal dependencies
- Repositories depend on models and shared
- Services depend on repositories and models
- Avoid circular dependencies
#### Thread Safety
- All repository traits require `Send + Sync`
- Services are stateless and thread-safe
- Use appropriate synchronization primitives when needed
## Module Dependencies
```mermaid
graph TD
Shared[Shared<br/>#40;No Dependencies#41;]
Models[Models<br/>#40;Depends on Shared#41;]
Repositories[Repositories<br/>#40;Depends on Models, Shared#41;]
Services[Services<br/>#40;Depends on Repositories, Models#41;]
Extension[Extension<br/>#40;Depends on Services, Repositories, Models, Shared#41;]
Shared --> Services
Services --> Repositories
Repositories --> Models
Models --> Extension
```
## Development Workflow
1. **Define Model**: Start with data structure and validation
2. **Create Repository**: Implement persistence layer
3. **Build Service**: Add business logic
4. **Expose in Extension**: Create SQF-callable commands
5. **Test**: Verify each layer independently and together
## Additional Resources
- [Extension Documentation](../arma/server/extension/README.md)
- [Redis Operations](../arma/server/extension/src/redis/README.md)
- [Adapters](../arma/server/extension/src/adapters/README.md)

View File

@ -77,10 +77,10 @@ impl Actor {
return Err(ActorValidationError::InvalidUid(self.uid.clone()));
}
if let Some(ref name) = self.name {
if name.trim().is_empty() || name.len() > 50 {
return Err(ActorValidationError::InvalidName(name.clone()));
}
if let Some(ref name) = self.name
&& (name.trim().is_empty() || name.len() > 50)
{
return Err(ActorValidationError::InvalidName(name.clone()));
}
if let Some(ref pos) = self.position {
@ -102,18 +102,16 @@ impl Actor {
return Err(ActorValidationError::InvalidDirection(self.direction));
}
if !self.phone_number.is_empty() {
if !self.phone_number.starts_with("0160") || self.phone_number.len() != 10 {
return Err(ActorValidationError::InvalidPhoneNumber(
self.phone_number.clone(),
));
}
if !self.phone_number.is_empty()
&& (!self.phone_number.starts_with("0160") || self.phone_number.len() != 10)
{
return Err(ActorValidationError::InvalidPhoneNumber(
self.phone_number.clone(),
));
}
if !self.email.is_empty() {
if !self.email.contains('@') || !self.email.ends_with(".mil") {
return Err(ActorValidationError::InvalidEmail(self.email.clone()));
}
if !self.email.is_empty() && (!self.email.contains('@') || !self.email.ends_with(".mil")) {
return Err(ActorValidationError::InvalidEmail(self.email.clone()));
}
if !self.organization.is_empty() && self.organization.len() > 100 {

View File

@ -66,7 +66,7 @@ impl Bank {
bank: 0.0,
cash: 0.0,
earnings: 0.0,
pin: pin,
pin,
transactions: Vec::new(),
};

View File

@ -52,6 +52,12 @@ impl HitPoints {
}
}
impl Default for HitPoints {
fn default() -> Self {
Self::new()
}
}
impl Vehicle {
pub fn new<S: Into<String>>(
plate: S,

View File

@ -75,11 +75,16 @@ impl VGarage {
VehicleCategory::Other => &mut self.other,
};
if let Some(pos) = target_array.iter().position(|x| x == classname) {
Some(target_array.remove(pos))
} else {
None
}
target_array
.iter()
.position(|x| x == classname)
.map(|pos| target_array.remove(pos))
}
}
impl Default for VGarage {
fn default() -> Self {
Self::new()
}
}

View File

@ -91,11 +91,10 @@ impl VLocker {
EquipmentCategory::Backpacks => &mut self.backpacks,
};
if let Some(pos) = target_array.iter().position(|x| x == classname) {
Some(target_array.remove(pos))
} else {
None
}
target_array
.iter()
.position(|x| x == classname)
.map(|pos| target_array.remove(pos))
}
}

View File

@ -9,8 +9,3 @@ forge-shared = { path = "../shared" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true }
# Redis dependencies (only in repository layer)
redis = { workspace = true }
bb8-redis = "0.25.0-rc.1"
base64 = "0.22.1"

View File

@ -1,207 +1,20 @@
# Forge Repositories
This crate provides the data access layer for the Forge application, implementing the repository pattern to abstract database operations from business logic.
This crate defines repository traits used by the service layer. It also
provides in-memory implementations for tests and transient server state.
## Architecture
Durable repository implementations live in the server extension because they
depend on extension configuration and the SurrealDB runtime client.
The repository layer sits between the service layer and the database, providing a clean abstraction for data persistence.
## Contents
```mermaid
graph TD
Services[Services Layer]
Repositories[Repositories Layer<br/>#40;This Module#41;]
Database[Database]
- Actor, bank, garage, locker, org, phone, task, CAD, owned garage, and owned
locker repository traits.
- In-memory stores for unit tests and hot-state services.
Services --> Repositories
Repositories --> Database
```
## Guidelines
### Dual Storage Strategy
The implementation uses a dual storage strategy in Redis to optimize for different access patterns:
- **Hash Maps (`HMSET`/`HGETALL`):** Used for entity data (Actors, Organizations) to allow O(1) access to specific fields and efficient partial updates.
- **Sets (`SADD`/`SMEMBERS`):** Used for relationships (e.g., Organization Members) to ensure uniqueness and provide efficient membership testing.
## Key Features
- **Redis Integration:** Efficient hash-based storage for data
- **JSON Serialization:** Automatic conversion between Rust structs and Redis
- **Type Safety:** Strong typing with error handling
- **Performance Optimized:** Hash operations for fast field-level access
- **Flexible Client:** Generic over Redis client implementations
- **Atomic Operations:** Uses Redis atomicity guarantees for data integrity
## Actor Repository
The `ActorRepository` handles persistence for player data.
### Storage Format
Actors are stored in Redis as hash maps:
```text
actor:{uid} -> Hash {
"uid": "76561198123456789",
"name": "PlayerName",
"bank": "1500.0",
...
}
```
### Usage Example
```rust
use forge_repositories::ActorRepository;
use forge_models::Actor;
async fn example_usage<R: ActorRepository>(repo: &R) -> Result<(), String> {
// 1. Create
let actor = Actor::new("76561198123456789".to_string())?;
repo.create(&actor)?;
// 2. Retrieve
if let Some(retrieved) = repo.get_by_id("76561198123456789")? {
println!("Found actor: {}", retrieved.name());
}
// 3. Update
// Updates are atomic and preserve fields not present in the update
let mut actor_to_update = repo.get_by_id("76561198123456789")?.unwrap();
// ... modify actor ...
repo.update(&actor_to_update)?;
// 4. Check Existence
if repo.exists("76561198123456789")? {
println!("Actor exists");
}
// 5. Delete
repo.delete("76561198123456789")?;
Ok(())
}
```
## Organization Repository
The `OrgRepository` handles persistence for organizations (guilds/clans) and their members.
### Storage Format
- **Org Data:** `org:{org_id}` (Hash)
- **Members:** `org:{org_id}:members` (Set)
### Usage Example
```rust
use forge_repositories::OrgRepository;
use forge_models::{Org, MemberSummary};
async fn example_usage<R: OrgRepository>(repo: &R) -> Result<(), String> {
// 1. Create Organization
let org = Org::new("elite_squad".to_string(), "leader_uid".to_string(), "Elite Squad".to_string())?;
repo.create(&org)?;
// 2. Manage Members
// Add a member (idempotent, handles duplicates)
repo.add_member("elite_squad", "member_uid_1")?;
// Get all members
let members = repo.get_members("elite_squad")?;
for member in members {
println!("Member: {} ({})", member.name, member.uid);
}
// Remove a member
repo.remove_member("elite_squad", "member_uid_1")?;
// 3. Update Organization
let mut org_update = repo.get_by_id("elite_squad")?.unwrap();
// ... modify org ...
repo.update(&org_update)?;
// 4. Delete Organization
// Note: This removes the org data but may require separate cleanup for members depending on implementation
repo.delete("elite_squad")?;
Ok(())
}
```
## Performance & Implementation Details
### Atomicity
- **Upserts:** `create` and `update` operations use `HMSET` which is atomic. This means either all fields are updated or none are.
- **Schema Evolution:** New fields added to the Rust structs are automatically persisted to Redis. Old fields in Redis that are no longer in the struct are **preserved** (not deleted) during updates, allowing for safe backward compatibility.
### Thread Safety
All repository implementations are `Send + Sync`, allowing them to be safely shared across threads. The underlying `RedisClient` handles connection pooling and concurrent access.
### Error Handling
Repositories return `Result<T, String>` (or custom error types) to propagate database failures, serialization errors, or validation issues up to the service layer.
## Contributing
We welcome contributions to the Forge Repository Layer! This guide will help you understand how to add new repositories and maintain the existing codebase.
### Adding a New Repository
To add a new repository (e.g., `ItemRepository`), follow these steps:
1. **Create the Module**: Create a new file in `src/` (e.g., `src/item.rs`).
2. **Define the Trait**: Define a trait that specifies the data access operations. Ensure it requires `Send + Sync`.
```rust
pub trait ItemRepository: Send + Sync {
fn create(&self, item: &Item) -> Result<(), String>;
fn get_by_id(&self, id: &str) -> Result<Option<Item>, String>;
// ... other methods
}
```
3. **Implement for Redis**: Implement the trait for a generic `RedisClient`. Use `forge_shared` helpers for value conversion if needed.
```rust
use forge_shared::{RedisClient, parse_json_value, parse_redis_value};
pub struct RedisItemRepository<C: RedisClient> {
client: C,
}
impl<C: RedisClient> RedisItemRepository<C> {
pub fn new(client: C) -> Self {
Self { client }
}
}
impl<C: RedisClient> ItemRepository for RedisItemRepository<C> {
fn create(&self, item: &Item) -> Result<(), String> {
let redis_key = format!("item:{}", item.id);
// ... serialization logic ...
// Use self.client to interact with Redis
self.client.hash_mset(redis_key, fields)
}
// ... other methods
}
```
4. **Register the Module**: Add your new module to `src/lib.rs` and export the trait and implementation.
```rust
pub mod item;
pub use item::{ItemRepository, RedisItemRepository};
```
### Testing
- **Integration Tests**: Write integration tests that use a real Redis instance (if available) or a mock.
- **Mocking**: For unit testing services, you don't need to test the repository implementation itself, but you should ensure the repository trait is easy to mock.
### Best Practices
- **Abstraction**: Keep the repository trait agnostic of the underlying database technology (e.g., don't expose Redis-specific types in the trait signature).
- **Serialization**: Handle serialization/deserialization within the repository implementation. The service layer should work with domain models, not raw JSON or database rows.
- **Keyspace**: Use a consistent naming convention for Redis keys (e.g., `entity:id`).
- **Atomicity**: Use Redis transactions or atomic commands where possible to ensure data consistency.
- Keep traits storage-agnostic.
- Return domain models instead of raw database records.
- Keep serialization and database-specific mapping in concrete implementations.
- Prefer focused in-memory tests for service behavior.

View File

@ -6,14 +6,13 @@
//! For full documentation and examples, see the [crate README](../README.md).
use forge_models::Actor;
use forge_shared::{RedisClient, parse_json_value, parse_redis_value};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// Repository trait defining the contract for actor data operations.
///
/// This trait abstracts the data persistence layer, allowing different
/// implementations (Redis, PostgreSQL, etc.) while maintaining a consistent
/// implementations while maintaining a consistent
/// interface for the service layer. All implementations must be thread-safe.
pub trait ActorRepository: Send + Sync {
/// Creates a new actor in the repository.
@ -81,126 +80,3 @@ impl ActorHotRepository for InMemoryActorHotRepository {
Ok(())
}
}
/// Redis-based implementation of the ActorRepository trait.
///
/// This implementation uses Redis hash maps to store actor data, providing
/// efficient field-level access and atomic operations. Each actor is stored
/// as a separate hash with the key format `actor:{uid}`.
pub struct RedisActorRepository<C: RedisClient> {
/// The Redis client used for all database operations.
///
/// This client handles the actual communication with Redis, including
/// connection management, command execution, and error handling.
client: C,
}
impl<C: RedisClient> RedisActorRepository<C> {
/// Creates a new Redis actor repository with the provided client.
pub fn new(client: C) -> Self {
Self { client }
}
}
impl<C: RedisClient> ActorRepository for RedisActorRepository<C> {
/// Creates a new actor in Redis using hash map storage.
///
/// Stores each actor as a Redis hash map with the key format `actor:{uid}`.
/// Each field of the actor struct becomes a field in the Redis hash.
fn create(&self, actor: &Actor) -> Result<(), String> {
// Generate Redis key using actor UID
let redis_key = format!("actor:{}", actor.uid());
// Serialize actor to JSON string
let actor_json = serde_json::to_string(actor)
.map_err(|e| format!("Failed to serialize actor: {}", e))?;
// Parse JSON string back to Value for field extraction
let json_value: serde_json::Value = serde_json::from_str(&actor_json)
.map_err(|e| format!("Failed to parse actor JSON: {}", e))?;
// Extract fields from JSON object
if let serde_json::Value::Object(actor_map) = json_value {
// Convert each field to Redis-compatible format
let fields: Vec<(String, String)> = actor_map
.into_iter()
.map(|(field, value)| (field, parse_json_value(&value)))
.collect();
// Store all fields atomically using Redis HMSET
self.client.hash_mset(redis_key, fields)
} else {
Err("Failed to convert actor to object".to_string())
}
}
/// Retrieves an actor from Redis by their unique identifier.
///
/// Uses Redis HGETALL to retrieve all fields of the actor hash map,
/// then reconstructs the Actor struct through JSON deserialization.
fn get_by_id(&self, id: &str) -> Result<Option<Actor>, String> {
// Generate Redis key using actor UID
let redis_key = format!("actor:{}", id);
// Retrieve all hash fields from Redis
let actor_string = self.client.hash_get_all(redis_key)?;
// Return None if no data found (actor doesn't exist)
if actor_string.is_empty() {
return Ok(None);
}
let redis_map: std::collections::HashMap<String, String> =
serde_json::from_str(&actor_string)
.map_err(|e| format!("Failed to parse actor hash response: {}", e))?;
let mut json_map = serde_json::Map::new();
for (key, value) in redis_map {
let json_value = parse_redis_value(&value);
json_map.insert(key, json_value);
}
// Reconstruct Actor from JSON object
let json_obj = serde_json::Value::Object(json_map);
match serde_json::from_value::<Actor>(json_obj) {
Ok(actor) => Ok(Some(actor)),
// Return None for any deserialization errors (corrupted data)
Err(_) => Ok(None),
}
}
/// Updates an existing actor with the provided data.
///
/// Uses `HMSET` to atomically update the provided fields. Fields present in Redis
/// but missing from the input are preserved.
fn update(&self, actor: &Actor) -> Result<(), String> {
// Delegate to create() which handles both creation and updates
// Redis HMSET naturally supports upsert behavior
self.create(actor)
}
/// Permanently deletes an actor and all associated data from Redis.
///
/// Removes the entire Redis hash containing the actor's data.
/// This operation is irreversible.
fn delete(&self, id: &str) -> Result<(), String> {
// Generate Redis key using actor UID
let redis_key = format!("actor:{}", id);
// Delete the entire hash key from Redis
// This removes all fields and the key itself atomically
self.client.delete_key(redis_key)
}
/// Checks if an actor exists in Redis without retrieving the data.
///
/// Uses Redis EXISTS command for a lightweight check.
fn exists(&self, id: &str) -> Result<bool, String> {
// Generate Redis key using actor UID
let redis_key = format!("actor:{}", id);
// Check if the key exists in Redis
// This is a lightweight operation that doesn't retrieve data
self.client.key_exists(redis_key)
}
}

View File

@ -6,14 +6,13 @@
//! For full documentation and examples, see the [crate README](../README.md).
use forge_models::Bank;
use forge_shared::{RedisClient, parse_json_value, parse_redis_value};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// Repository trait defining the contract for bank data operations.
///
/// This trait abstracts the data persistence layer, allowing different
/// implementations (Redis, PostgreSQL, etc.) while maintaining a consistent
/// implementations while maintaining a consistent
/// interface for the service layer. All implementations must be thread-safe.
pub trait BankRepository: Send + Sync {
/// Creates a new bank in the repository.
@ -73,126 +72,3 @@ impl BankHotRepository for InMemoryBankHotRepository {
Ok(())
}
}
/// Redis-based implementation of the BankRepository trait.
///
/// This implementation uses Redis hash maps to store bank data, providing
/// efficient field-level access and atomic operations. Each bank is stored
/// as a separate hash with the key format `bank:{uid}`.
pub struct RedisBankRepository<C: RedisClient> {
/// The Redis client used for all database operations.
///
/// This client handles the actual communication with Redis, including
/// connection management, command execution, and error handling.
client: C,
}
impl<C: RedisClient> RedisBankRepository<C> {
/// Creates a new Redis bank repository with the provided client.
pub fn new(client: C) -> Self {
Self { client }
}
}
impl<C: RedisClient> BankRepository for RedisBankRepository<C> {
/// Creates a new bank in Redis using hash map storage.
///
/// Stores each bank as a Redis hash map with the key format `bank:{uid}`.
/// Each field of the bank struct becomes a field in the Redis hash.
fn create(&self, bank: &Bank) -> Result<(), String> {
// Generate Redis key using bank UID
let redis_key = format!("bank:{}", bank.uid());
// Serialize bank to JSON string
let bank_json =
serde_json::to_string(bank).map_err(|e| format!("Failed to serialize bank: {}", e))?;
// Parse JSON string back to Value for field extraction
let json_value: serde_json::Value = serde_json::from_str(&bank_json)
.map_err(|e| format!("Failed to parse bank JSON: {}", e))?;
// Extract fields from JSON object
if let serde_json::Value::Object(bank_map) = json_value {
// Convert each field to Redis-compatible format
let fields: Vec<(String, String)> = bank_map
.into_iter()
.map(|(field, value)| (field, parse_json_value(&value)))
.collect();
// Store all fields atomically using Redis HMSET
self.client.hash_mset(redis_key, fields)
} else {
Err("Failed to convert bank to object".to_string())
}
}
/// Retrieves an bank from Redis by their unique identifier.
///
/// Uses Redis HGETALL to retrieve all fields of the bank hash map,
/// then reconstructs the Bank struct through JSON deserialization.
fn get_by_id(&self, id: &str) -> Result<Option<Bank>, String> {
// Generate Redis key using bank UID
let redis_key = format!("bank:{}", id);
// Retrieve all hash fields from Redis
let bank_string = self.client.hash_get_all(redis_key)?;
// Return None if no data found (bank doesn't exist)
if bank_string.is_empty() {
return Ok(None);
}
let redis_map: std::collections::HashMap<String, String> =
serde_json::from_str(&bank_string)
.map_err(|e| format!("Failed to parse bank hash response: {}", e))?;
let mut json_map = serde_json::Map::new();
for (key, value) in redis_map {
let json_value = parse_redis_value(&value);
json_map.insert(key, json_value);
}
// Reconstruct Bank from JSON object
let json_obj = serde_json::Value::Object(json_map);
match serde_json::from_value::<Bank>(json_obj) {
Ok(bank) => Ok(Some(bank)),
// Return None for any deserialization errors (corrupted data)
Err(_) => Ok(None),
}
}
/// Updates an existing bank with the provided data.
///
/// Uses `HMSET` to atomically update the provided fields. Fields present in Redis
/// but missing from the input are preserved.
fn update(&self, bank: &Bank) -> Result<(), String> {
// Delegate to create() which handles both creation and updates
// Redis HMSET naturally supports upsert behavior
self.create(bank)
}
/// Permanently deletes an bank and all associated data from Redis.
///
/// Removes the entire Redis hash containing the bank's data.
/// This operation is irreversible.
fn delete(&self, id: &str) -> Result<(), String> {
// Generate Redis key using bank UID
let redis_key = format!("bank:{}", id);
// Delete the entire hash key from Redis
// This removes all fields and the key itself atomically
self.client.delete_key(redis_key)
}
/// Checks if an bank exists in Redis without retrieving the data.
///
/// Uses Redis EXISTS command for a lightweight check.
fn exists(&self, id: &str) -> Result<bool, String> {
// Generate Redis key using bank UID
let redis_key = format!("bank:{}", id);
// Check if the key exists in Redis
// This is a lightweight operation that doesn't retrieve data
self.client.key_exists(redis_key)
}
}

View File

@ -3,8 +3,7 @@
//! This module provides the data access layer for vehicle garage management.
//! Each player's garage is stored as a single JSON string containing all their vehicles.
use forge_models::{Garage, Vehicle};
use forge_shared::RedisClient;
use forge_models::Garage;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
@ -67,68 +66,3 @@ impl GarageHotRepository for InMemoryGarageHotRepository {
Ok(())
}
}
/// Redis-based implementation of the GarageRepository trait.
///
/// Stores each player's garage as a single JSON string array with the key format `garage:{uid}`.
pub struct RedisGarageRepository<C: RedisClient> {
client: C,
}
impl<C: RedisClient> RedisGarageRepository<C> {
pub fn new(client: C) -> Self {
Self { client }
}
}
impl<C: RedisClient> GarageRepository for RedisGarageRepository<C> {
fn create(&self, uid: &str, garage: &Garage) -> Result<(), String> {
let redis_key = format!("garage:{}", uid);
// Serialize just the vehicles array
let garage_json = serde_json::to_string(&garage.vehicles)
.map_err(|e| format!("Failed to serialize garage: {}", e))?;
// Store as a simple string value
self.client.set_key(redis_key, garage_json)
}
fn update(&self, uid: &str, garage: &Garage) -> Result<(), String> {
let redis_key = format!("garage:{}", uid);
// Serialize just the vehicles array
let garage_json = serde_json::to_string(&garage.vehicles)
.map_err(|e| format!("Failed to serialize garage: {}", e))?;
// Update the existing garage
self.client.set_key(redis_key, garage_json)
}
fn get(&self, uid: &str) -> Result<Option<Garage>, String> {
let redis_key = format!("garage:{}", uid);
// Get the JSON string from Redis
let garage_string = self.client.get_key(redis_key)?;
// Return None if no data found
if garage_string.is_empty() {
return Ok(None);
}
// Deserialize the vehicles data
match serde_json::from_str::<HashMap<String, Vehicle>>(&garage_string) {
Ok(vehicles) => Ok(Some(Garage { vehicles })),
Err(e) => Err(format!("Failed to deserialize garage: {}", e)),
}
}
fn delete(&self, uid: &str) -> Result<(), String> {
let redis_key = format!("garage:{}", uid);
self.client.delete_key(redis_key)
}
fn exists(&self, uid: &str) -> Result<bool, String> {
let redis_key = format!("garage:{}", uid);
self.client.key_exists(redis_key)
}
}

View File

@ -9,26 +9,13 @@ pub mod task;
pub mod v_garage;
pub mod v_locker;
pub use actor::{
ActorHotRepository, ActorRepository, InMemoryActorHotRepository, RedisActorRepository,
};
pub use bank::{BankHotRepository, BankRepository, InMemoryBankHotRepository, RedisBankRepository};
pub use actor::{ActorHotRepository, ActorRepository, InMemoryActorHotRepository};
pub use bank::{BankHotRepository, BankRepository, InMemoryBankHotRepository};
pub use cad::{CadRepository, InMemoryCadRepository};
pub use garage::{
GarageHotRepository, GarageRepository, InMemoryGarageHotRepository, RedisGarageRepository,
};
pub use locker::{
InMemoryLockerHotRepository, LockerHotRepository, LockerRepository, RedisLockerRepository,
};
pub use org::{InMemoryOrgHotRepository, OrgHotRepository, OrgRepository, RedisOrgRepository};
pub use phone::{InMemoryPhoneRepository, PhoneRepository, RedisPhoneRepository};
pub use garage::{GarageHotRepository, GarageRepository, InMemoryGarageHotRepository};
pub use locker::{InMemoryLockerHotRepository, LockerHotRepository, LockerRepository};
pub use org::{InMemoryOrgHotRepository, OrgHotRepository, OrgRepository};
pub use phone::{InMemoryPhoneRepository, PhoneRepository};
pub use task::{InMemoryTaskRepository, TaskRepository};
pub use v_garage::{
InMemoryVGarageHotRepository, RedisVGarageRepository, VGarageHotRepository, VGarageRepository,
};
pub use v_locker::{
InMemoryVLockerHotRepository, RedisVLockerRepository, VLockerHotRepository, VLockerRepository,
};
// Re-export RedisClient from shared library for convenience
pub use forge_shared::RedisClient;
pub use v_garage::{InMemoryVGarageHotRepository, VGarageHotRepository, VGarageRepository};
pub use v_locker::{InMemoryVLockerHotRepository, VLockerHotRepository, VLockerRepository};

View File

@ -3,8 +3,7 @@
//! This module provides the data access layer for locker management.
//! Each player's locker is stored as a single JSON string containing all their items.
use forge_models::{Item, Locker};
use forge_shared::RedisClient;
use forge_models::Locker;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
@ -67,68 +66,3 @@ impl LockerHotRepository for InMemoryLockerHotRepository {
Ok(())
}
}
/// Redis-based implementation of the LockerRepository trait.
///
/// Stores each player's locker as a single JSON string array with the key format `locker:{uid}`.
pub struct RedisLockerRepository<C: RedisClient> {
client: C,
}
impl<C: RedisClient> RedisLockerRepository<C> {
pub fn new(client: C) -> Self {
Self { client }
}
}
impl<C: RedisClient> LockerRepository for RedisLockerRepository<C> {
fn create(&self, uid: &str, locker: &Locker) -> Result<(), String> {
let redis_key = format!("locker:{}", uid);
// Serialize just the items array
let locker_json = serde_json::to_string(&locker.items)
.map_err(|e| format!("Failed to serialize locker: {}", e))?;
// Store as a simple string value
self.client.set_key(redis_key, locker_json)
}
fn update(&self, uid: &str, locker: &Locker) -> Result<(), String> {
let redis_key = format!("locker:{}", uid);
// Serialize just the items array
let locker_json = serde_json::to_string(&locker.items)
.map_err(|e| format!("Failed to serialize locker: {}", e))?;
// Update the existing locker
self.client.set_key(redis_key, locker_json)
}
fn get(&self, uid: &str) -> Result<Option<Locker>, String> {
let redis_key = format!("locker:{}", uid);
// Get the JSON string from Redis
let locker_string = self.client.get_key(redis_key)?;
// Return None if no data found
if locker_string.is_empty() {
return Ok(None);
}
// Deserialize the items data
match serde_json::from_str::<HashMap<String, Item>>(&locker_string) {
Ok(items) => Ok(Some(Locker { items })),
Err(e) => Err(format!("Failed to deserialize locker: {}", e)),
}
}
fn delete(&self, uid: &str) -> Result<(), String> {
let redis_key = format!("locker:{}", uid);
self.client.delete_key(redis_key)
}
fn exists(&self, uid: &str) -> Result<bool, String> {
let redis_key = format!("locker:{}", uid);
self.client.key_exists(redis_key)
}
}

View File

@ -6,14 +6,13 @@
//! For full documentation and examples, see the [crate README](../README.md).
use forge_models::{HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry};
use forge_shared::{RedisClient, parse_json_value, parse_redis_value};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// Repository trait defining the contract for organization data operations.
///
/// This trait abstracts the data persistence layer, allowing different
/// implementations (Redis, PostgreSQL, etc.) while maintaining a consistent
/// implementations while maintaining a consistent
/// interface for the service layer. All implementations must be thread-safe.
pub trait OrgRepository: Send + Sync {
/// Creates a new organization in the repository.
@ -113,316 +112,3 @@ impl OrgHotRepository for InMemoryOrgHotRepository {
Ok(())
}
}
/// Redis-based implementation of the OrgRepository trait.
///
/// Uses Redis hash maps for organization data providing
/// efficient field-level access and atomic operations. Each organization is stored
/// as a seperate hash with the key format `org:{org_id}`.
/// Member lists are stored as sets with the key format `org:{org_id}:members`.
pub struct RedisOrgRepository<C: RedisClient> {
/// The Redis client used for all database operations.
///
/// This client handles the actual communication with Redis, including
/// connection management, command execution, and error handling for
/// both organization and member data operations.
client: C,
}
impl<C: RedisClient> RedisOrgRepository<C> {
/// Creates a new Redis organization repository with the provided client.
pub fn new(client: C) -> Self {
Self { client }
}
}
impl<C: RedisClient> OrgRepository for RedisOrgRepository<C> {
/// Creates a new organization in Redis using hash map storage.
///
/// Stores each organization as a Redis hash map with the key format `{org_id}:org`.
/// Each field of the organization struct becomes a field in the Redis hash.
fn create(&self, org: &Org) -> Result<(), String> {
// Generate Redis key using organization ID
let redis_key = format!("org:{}", org.id());
// Serialize organization to JSON string
let org_json =
serde_json::to_string(org).map_err(|e| format!("Failed to serialize org: {}", e))?;
// Parse JSON string back to Value for field extraction
let json_value: serde_json::Value = serde_json::from_str(&org_json)
.map_err(|e| format!("Failed to parse org JSON: {}", e))?;
// Extract fields from JSON object
if let serde_json::Value::Object(org_map) = json_value {
// Convert each field to Redis-compatible format
let fields: Vec<(String, String)> = org_map
.into_iter()
.map(|(field, value)| (field, parse_json_value(&value)))
.collect();
// Store all fields atomically using Redis HMSET
self.client.hash_mset(redis_key, fields)
} else {
Err("Failed to convert org to object".to_string())
}
}
/// Retrieves an organization from Redis by its unique identifier.
///
/// Uses Redis HGETALL to retrieve all fields of the organization hash map,
/// then reconstructs the Org struct through JSON deserialization.
fn get_by_id(&self, id: &str) -> Result<Option<Org>, String> {
// Generate Redis key using organization ID
let redis_key = format!("org:{}", id);
// Retrieve all hash fields from Redis
let org_string = self.client.hash_get_all(redis_key)?;
// Return None if no data found (organization doesn't exist)
if org_string.is_empty() {
return Ok(None);
}
let redis_map: std::collections::HashMap<String, String> =
serde_json::from_str(&org_string)
.map_err(|e| format!("Failed to parse org hash response: {}", e))?;
let mut json_map = serde_json::Map::new();
for (key, value) in redis_map {
let json_value = parse_redis_value(&value);
json_map.insert(key, json_value);
}
// Reconstruct Org from JSON object
if matches!(
json_map.get("credit_lines"),
Some(serde_json::Value::Array(lines)) if lines.is_empty()
) {
json_map.insert(
"credit_lines".to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
}
let json_obj = serde_json::Value::Object(json_map);
match serde_json::from_value::<Org>(json_obj) {
Ok(org) => Ok(Some(org)),
// Return None for any deserialization errors (corrupted data)
Err(_) => Ok(None),
}
}
/// Updates an existing organization with the provided data.
///
/// Uses `HMSET` to atomically update the provided fields. Fields present in Redis
/// but missing from the input are preserved.
fn update(&self, org: &Org) -> Result<(), String> {
// Delegate to create() which handles both creation and updates
// Redis HMSET naturally supports upsert behavior
self.create(org)
}
/// Permanently deletes an organization and all associated data from Redis.
///
/// Removes the organization hash and related subordinate keys.
/// This operation is irreversible.
fn delete(&self, id: &str) -> Result<(), String> {
let redis_keys = [
format!("org:{}", id),
format!("org:{}:members", id),
format!("org:{}:assets", id),
format!("org:{}:fleet", id),
];
for redis_key in redis_keys {
self.client.delete_key(redis_key)?;
}
Ok(())
}
/// Checks if an organization exists in Redis without retrieving the data.
///
/// Uses Redis EXISTS command for a lightweight check.
fn exists(&self, id: &str) -> Result<bool, String> {
// Generate Redis key using organization ID
let redis_key = format!("org:{}", id);
// Check if the key exists in Redis
// This is a lightweight operation that doesn't retrieve data
self.client.key_exists(redis_key)
}
/// Adds a new member to the organization.
///
/// Stores member data in a Redis list associated with the organization.
/// Validates that the organization exists before adding the member.
fn add_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> {
// Check if organization exists
if !self.exists(org_id)? {
return Err(format!("Organization {} does not exist", org_id));
}
// Generate Redis key for organization member set
let redis_key = format!("org:{}:members", org_id);
// Add member UID to set using SADD
self.client.set_add(redis_key, member_uid.to_string())
}
/// Retrieves all members of the organization.
///
/// Uses Redis SMEMBERS to get all member UIDs, then retrieves member details.
/// Returns a list of `MemberSummary` objects.
fn get_members(&self, org_id: &str) -> Result<Vec<MemberSummary>, String> {
// Generate Redis key for organization member set
let redis_key = format!("org:{}:members", org_id);
// Retrieve all member UIDs from the set; fall back to empty on error
let uids: Vec<String> = match self.client.set_members(redis_key) {
Ok(v) => v,
Err(_) => Vec::new(),
};
// Pre-allocate result vector
let mut result: Vec<MemberSummary> = Vec::with_capacity(uids.len());
for uid in uids {
if uid.trim().is_empty() {
continue;
}
// Lookup actor name by UID; fall back to "Unknown" on error/missing
let actor_key = format!("actor:{}", uid);
let raw_name = match self.client.hash_get(actor_key, "name".to_string()) {
Ok(n) => n,
_ => String::new(),
};
let name = match parse_redis_value(&raw_name) {
serde_json::Value::String(s) => s,
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => "Unknown".to_string(),
};
let name = if name.trim().is_empty() {
"Unknown".to_string()
} else {
name
};
result.push(MemberSummary { uid, name });
}
Ok(result)
}
/// Removes a specific member UID from an organization.
///
/// Uses Redis SREM to remove the UID from the organization's member set.
fn remove_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> {
// Generate Redis key for organization member set
let redis_key = format!("org:{}:members", org_id);
// Remove the UID from the set using SREM
self.client.set_del(redis_key, member_uid.to_string())
}
fn get_assets(
&self,
org_id: &str,
) -> Result<HashMap<String, HashMap<String, OrgAssetEntry>>, String> {
let redis_key = format!("org:{}:assets", org_id);
let assets_string = self.client.hash_get_all(redis_key)?;
if assets_string.is_empty() {
return Ok(HashMap::new());
}
let redis_map: HashMap<String, String> = serde_json::from_str(&assets_string)
.map_err(|e| format!("Failed to parse org asset hash response: {}", e))?;
let mut assets = HashMap::new();
for (category, value) in redis_map {
let json_value = parse_redis_value(&value);
let category_assets =
serde_json::from_value::<HashMap<String, OrgAssetEntry>>(json_value)
.map_err(|e| format!("Failed to parse asset category '{}': {}", category, e))?;
assets.insert(category, category_assets);
}
Ok(assets)
}
fn update_assets(
&self,
org_id: &str,
assets: &HashMap<String, HashMap<String, OrgAssetEntry>>,
) -> Result<(), String> {
let redis_key = format!("org:{}:assets", org_id);
if assets.is_empty() {
return self.client.delete_key(redis_key);
}
let fields: Vec<(String, String)> = assets
.iter()
.map(|(category, value)| {
let json_value = serde_json::to_value(value)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
(category.clone(), parse_json_value(&json_value))
})
.collect();
self.client.delete_key(redis_key.clone())?;
self.client.hash_mset(redis_key, fields)
}
fn get_fleet(&self, org_id: &str) -> Result<HashMap<String, OrgFleetEntry>, String> {
let redis_key = format!("org:{}:fleet", org_id);
let fleet_string = self.client.hash_get_all(redis_key)?;
if fleet_string.is_empty() {
return Ok(HashMap::new());
}
let redis_map: HashMap<String, String> = serde_json::from_str(&fleet_string)
.map_err(|e| format!("Failed to parse org fleet hash response: {}", e))?;
let mut fleet = HashMap::new();
for (fleet_key, value) in redis_map {
let json_value = parse_redis_value(&value);
let fleet_entry = serde_json::from_value::<OrgFleetEntry>(json_value)
.map_err(|e| format!("Failed to parse fleet entry '{}': {}", fleet_key, e))?;
fleet.insert(fleet_key, fleet_entry);
}
Ok(fleet)
}
fn update_fleet(
&self,
org_id: &str,
fleet: &HashMap<String, OrgFleetEntry>,
) -> Result<(), String> {
let redis_key = format!("org:{}:fleet", org_id);
if fleet.is_empty() {
return self.client.delete_key(redis_key);
}
let fields: Vec<(String, String)> = fleet
.iter()
.map(|(fleet_key, value)| {
let json_value = serde_json::to_value(value)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
(fleet_key.clone(), parse_json_value(&json_value))
})
.collect();
self.client.delete_key(redis_key.clone())?;
self.client.hash_mset(redis_key, fields)
}
}

View File

@ -1,5 +1,4 @@
use forge_models::{PhoneEmail, PhoneMessage};
use forge_shared::{RedisClient, parse_json_value, parse_redis_value};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
@ -230,373 +229,3 @@ impl PhoneRepository for InMemoryPhoneRepository {
Ok(state.sequence)
}
}
pub struct RedisPhoneRepository<C: RedisClient> {
client: C,
}
impl<C: RedisClient> RedisPhoneRepository<C> {
pub fn new(client: C) -> Self {
Self { client }
}
fn contact_key(uid: &str) -> String {
format!("phone:{}:contacts", uid)
}
fn user_messages_key(uid: &str) -> String {
format!("phone:{}:messages", uid)
}
fn message_thread_key(uid: &str, other_uid: &str) -> String {
format!("phone:{}:thread:{}", uid, other_uid)
}
fn message_record_key(message_id: &str) -> String {
format!("phone:message:{}", message_id)
}
fn message_read_key(uid: &str) -> String {
format!("phone:{}:message_read", uid)
}
fn user_emails_key(uid: &str) -> String {
format!("phone:{}:emails", uid)
}
fn email_record_key(email_id: &str) -> String {
format!("phone:email:{}", email_id)
}
fn email_read_key(uid: &str) -> String {
format!("phone:{}:email_read", uid)
}
fn sequence_key() -> String {
"phone:sequence".to_string()
}
fn save_message_record(&self, message: &PhoneMessage) -> Result<(), String> {
let json_value = serde_json::to_value(message)
.map_err(|error| format!("Failed to serialize phone message: {}", error))?;
let Some(fields) = json_value.as_object() else {
return Err("Failed to convert phone message to object.".to_string());
};
let fields = fields
.iter()
.filter(|(key, _)| key.as_str() != "read")
.map(|(key, value)| (key.clone(), parse_json_value(value)))
.collect();
self.client
.hash_mset(Self::message_record_key(&message.id), fields)
}
fn load_message_record(
&self,
uid: &str,
message_id: &str,
) -> Result<Option<PhoneMessage>, String> {
let raw_record = self
.client
.hash_get_all(Self::message_record_key(message_id))?;
if raw_record.trim().is_empty() || raw_record.trim() == "{}" {
return Ok(None);
}
let redis_map: HashMap<String, String> = serde_json::from_str(&raw_record)
.map_err(|error| format!("Failed to parse phone message hash response: {}", error))?;
let mut json_map = serde_json::Map::new();
for (key, value) in redis_map {
json_map.insert(key, parse_redis_value(&value));
}
let raw_read = self
.client
.hash_get(Self::message_read_key(uid), message_id.to_string())
.unwrap_or_default();
let read_value = if raw_read.trim().is_empty() {
serde_json::Value::Bool(false)
} else {
parse_redis_value(&raw_read)
};
json_map.insert("read".to_string(), read_value);
serde_json::from_value::<PhoneMessage>(serde_json::Value::Object(json_map))
.map(Some)
.map_err(|error| {
format!(
"Failed to deserialize phone message '{}': {}",
message_id, error
)
})
}
fn save_email_record(&self, email: &PhoneEmail) -> Result<(), String> {
let json_value = serde_json::to_value(email)
.map_err(|error| format!("Failed to serialize phone email: {}", error))?;
let Some(fields) = json_value.as_object() else {
return Err("Failed to convert phone email to object.".to_string());
};
let fields = fields
.iter()
.filter(|(key, _)| key.as_str() != "read")
.map(|(key, value)| (key.clone(), parse_json_value(value)))
.collect();
self.client
.hash_mset(Self::email_record_key(&email.id), fields)
}
fn load_email_record(&self, uid: &str, email_id: &str) -> Result<Option<PhoneEmail>, String> {
let raw_record = self.client.hash_get_all(Self::email_record_key(email_id))?;
if raw_record.trim().is_empty() || raw_record.trim() == "{}" {
return Ok(None);
}
let redis_map: HashMap<String, String> = serde_json::from_str(&raw_record)
.map_err(|error| format!("Failed to parse phone email hash response: {}", error))?;
let mut json_map = serde_json::Map::new();
for (key, value) in redis_map {
json_map.insert(key, parse_redis_value(&value));
}
let raw_read = self
.client
.hash_get(Self::email_read_key(uid), email_id.to_string())
.unwrap_or_default();
let read_value = if raw_read.trim().is_empty() {
serde_json::Value::Bool(false)
} else {
parse_redis_value(&raw_read)
};
json_map.insert("read".to_string(), read_value);
serde_json::from_value::<PhoneEmail>(serde_json::Value::Object(json_map))
.map(Some)
.map_err(|error| {
format!(
"Failed to deserialize phone email '{}': {}",
email_id, error
)
})
}
fn set_message_read(&self, uid: &str, message_id: &str, read: bool) -> Result<(), String> {
self.client.hash_mset(
Self::message_read_key(uid),
vec![(message_id.to_string(), read.to_string())],
)
}
fn set_email_read(&self, uid: &str, email_id: &str, read: bool) -> Result<(), String> {
self.client.hash_mset(
Self::email_read_key(uid),
vec![(email_id.to_string(), read.to_string())],
)
}
}
impl<C: RedisClient> PhoneRepository for RedisPhoneRepository<C> {
fn init(&self, uid: &str) -> Result<(), String> {
let _ = self.list_contacts(uid)?;
let _ = self.list_messages(uid)?;
let _ = self.list_emails(uid)?;
Ok(())
}
fn add_contact(&self, uid: &str, contact_uid: &str) -> Result<bool, String> {
self.client
.set_add(Self::contact_key(uid), contact_uid.to_string())?;
Ok(true)
}
fn remove_contact(&self, uid: &str, contact_uid: &str) -> Result<bool, String> {
self.client
.set_del(Self::contact_key(uid), contact_uid.to_string())?;
Ok(true)
}
fn list_contacts(&self, uid: &str) -> Result<Vec<String>, String> {
let mut contacts = self.client.set_members(Self::contact_key(uid))?;
contacts.sort();
contacts.dedup();
Ok(contacts)
}
fn remove_phone(&self, uid: &str) -> Result<(), String> {
for message in self.list_messages(uid)? {
self.client
.list_del(Self::user_messages_key(uid), 0, message.id.clone())?;
let other_uid = if message.from == uid {
&message.to
} else {
&message.from
};
self.client
.list_del(Self::message_thread_key(uid, other_uid), 0, message.id)?;
}
for email in self.list_emails(uid)? {
self.client
.list_del(Self::user_emails_key(uid), 0, email.id)?;
}
self.client.delete_key(Self::contact_key(uid))?;
self.client.delete_key(Self::user_messages_key(uid))?;
self.client.delete_key(Self::message_read_key(uid))?;
self.client.delete_key(Self::user_emails_key(uid))?;
self.client.delete_key(Self::email_read_key(uid))?;
Ok(())
}
fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), String> {
self.save_message_record(&message)?;
self.client
.list_rpush(Self::user_messages_key(uid), message.id.clone())?;
let other_uid = if message.from == uid {
message.to.as_str()
} else {
message.from.as_str()
};
self.client
.list_rpush(Self::message_thread_key(uid, other_uid), message.id.clone())?;
let read = message.from == uid;
self.set_message_read(uid, &message.id, read)
}
fn list_messages(&self, uid: &str) -> Result<Vec<PhoneMessage>, String> {
let message_ids = self
.client
.list_range(Self::user_messages_key(uid), 0, -1)?;
let mut messages = Vec::with_capacity(message_ids.len());
for message_id in message_ids {
if message_id.trim().is_empty() {
continue;
}
if let Some(message) = self.load_message_record(uid, &message_id)? {
messages.push(message);
}
}
messages.sort_by(|left, right| {
left.timestamp
.partial_cmp(&right.timestamp)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(messages)
}
fn mark_message_read(&self, uid: &str, message_id: &str) -> Result<bool, String> {
let exists = self
.client
.list_range(Self::user_messages_key(uid), 0, -1)?
.iter()
.any(|id| id == message_id);
if !exists {
return Ok(false);
}
self.set_message_read(uid, message_id, true)?;
Ok(true)
}
fn delete_message(&self, uid: &str, message_id: &str) -> Result<bool, String> {
let exists = self
.client
.list_range(Self::user_messages_key(uid), 0, -1)?
.iter()
.any(|id| id == message_id);
if !exists {
return Ok(false);
}
let message = self.load_message_record(uid, message_id)?;
self.client
.list_del(Self::user_messages_key(uid), 0, message_id.to_string())?;
self.client
.hash_del(Self::message_read_key(uid), message_id.to_string())?;
if let Some(message) = message {
let other_uid = if message.from == uid {
&message.to
} else {
&message.from
};
self.client.list_del(
Self::message_thread_key(uid, other_uid),
0,
message_id.to_string(),
)?;
}
Ok(true)
}
fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> {
self.save_email_record(&email)?;
self.client
.list_rpush(Self::user_emails_key(uid), email.id.clone())?;
self.set_email_read(uid, &email.id, false)
}
fn list_emails(&self, uid: &str) -> Result<Vec<PhoneEmail>, String> {
let email_ids = self.client.list_range(Self::user_emails_key(uid), 0, -1)?;
let mut emails = Vec::with_capacity(email_ids.len());
for email_id in email_ids {
if email_id.trim().is_empty() {
continue;
}
if let Some(email) = self.load_email_record(uid, &email_id)? {
emails.push(email);
}
}
emails.sort_by(|left, right| {
right
.timestamp
.partial_cmp(&left.timestamp)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(emails)
}
fn mark_email_read(&self, uid: &str, email_id: &str) -> Result<bool, String> {
let exists = self
.client
.list_range(Self::user_emails_key(uid), 0, -1)?
.iter()
.any(|id| id == email_id);
if !exists {
return Ok(false);
}
self.set_email_read(uid, email_id, true)?;
Ok(true)
}
fn delete_email(&self, uid: &str, email_id: &str) -> Result<bool, String> {
let exists = self
.client
.list_range(Self::user_emails_key(uid), 0, -1)?
.iter()
.any(|id| id == email_id);
if !exists {
return Ok(false);
}
self.client
.list_del(Self::user_emails_key(uid), 0, email_id.to_string())?;
self.client
.hash_del(Self::email_read_key(uid), email_id.to_string())?;
Ok(true)
}
fn next_sequence(&self) -> Result<u64, String> {
let value = self.client.incr_key(Self::sequence_key(), 1)?;
u64::try_from(value).map_err(|_| "Phone sequence overflowed.".to_string())
}
}

View File

@ -1,7 +1,7 @@
//! Virtual garage repository implementation for item data persistence operations.
//!
//! This module provides the data access layer for virtual garage management.
//! Each player's virtual garage is stored as a Redis hash with six fields:
//! Each player's virtual garage is represented by six category fields:
//! - cars: JSON array of car classnames
//! - armor: JSON array of armor classnames
//! - helis: JSON array of helis classnames
@ -10,7 +10,6 @@
//! - other: JSON array of other classnames
use forge_models::VGarage;
use forge_shared::{RedisClient, parse_json_value, parse_redis_value};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
@ -77,113 +76,3 @@ impl VGarageHotRepository for InMemoryVGarageHotRepository {
Ok(())
}
}
/// Redis-based implementation of the VGarageRepository trait.
///
/// Stores each player's virtual garage as a Redis hash with six fields:
/// - cars: JSON array of car classnames
/// - armor: JSON array of armor classnames
/// - helis: JSON array of helis classnames
/// - planes: JSON array of plane classnames
/// - naval: JSON array of naval classnames
/// - other: JSON array of other classnames
///
/// Key format: `vgarage:{uid}`
pub struct RedisVGarageRepository<C: RedisClient> {
client: C,
}
impl<C: RedisClient> RedisVGarageRepository<C> {
pub fn new(client: C) -> Self {
Self { client }
}
}
impl<C: RedisClient> VGarageRepository for RedisVGarageRepository<C> {
fn create(&self, uid: &str, garage: &VGarage) -> Result<(), String> {
let redis_key = format!("owned:garage:{}", uid);
// Serialize locker to JSON string
let garage_json = serde_json::to_string(garage)
.map_err(|e| format!("Failed to serialize garage: {}", e))?;
// Parse JSON string back to Value for field extraction
let json_value: serde_json::Value = serde_json::from_str(&garage_json)
.map_err(|e| format!("Failed to parse garage JSON: {}", e))?;
// Extract fields from JSON object
if let serde_json::Value::Object(garage_map) = json_value {
// Convert each field to Redis-compatible format
let fields: Vec<(String, String)> = garage_map
.into_iter()
.map(|(field, value)| (field, parse_json_value(&value)))
.collect();
// Store all fields atomically using Redis HMSET
self.client.hash_mset(redis_key, fields)
} else {
Err("Failed to convert garage to object".to_string())
}
}
fn update(&self, uid: &str, garage: &VGarage) -> Result<(), String> {
// For a hash, update is the same as create - it's atomic
self.create(uid, garage)
}
fn fetch(&self, uid: &str) -> Result<Option<VGarage>, String> {
let redis_key = format!("owned:garage:{}", uid);
// Retrieve all hash fields from Redis
let garage_string = self.client.hash_get_all(redis_key)?;
// Return None if no data found (garage doesn't exist)
if garage_string.is_empty() {
return Ok(None);
}
let redis_map: std::collections::HashMap<String, String> =
serde_json::from_str(&garage_string)
.map_err(|e| format!("Failed to parse virtual garage hash response: {}", e))?;
let mut json_map = serde_json::Map::new();
for (key, value) in redis_map {
let json_value = parse_redis_value(&value);
json_map.insert(key, json_value);
}
// Reconstruct VLocker from JSON object
let json_obj = serde_json::Value::Object(json_map);
match serde_json::from_value::<VGarage>(json_obj) {
Ok(garage) => Ok(Some(garage)),
// Return None for any deserialization errors (corrupted data)
Err(_) => Ok(None),
}
}
fn get(&self, uid: &str, field: &str) -> Result<Vec<String>, String> {
let redis_key = format!("owned:garage:{}", uid);
// Retrieve the specific field from the hash
let field_json = self.client.hash_get(redis_key, field.to_string())?;
// Return empty vector if field is empty
if field_json.is_empty() {
return Ok(Vec::new());
}
// Deserialize the JSON array
serde_json::from_str::<Vec<String>>(&field_json)
.map_err(|e| format!("Failed to deserialize field '{}': {}", field, e))
}
fn delete(&self, uid: &str) -> Result<(), String> {
let redis_key = format!("owned:garage:{}", uid);
self.client.delete_key(redis_key)
}
fn exists(&self, uid: &str) -> Result<bool, String> {
let redis_key = format!("owned:garage:{}", uid);
self.client.key_exists(redis_key)
}
}

View File

@ -1,14 +1,13 @@
//! Virtual locker repository implementation for item data persistence operations.
//!
//! This module provides the data access layer for virtual locker management.
//! Each player's virtual locker is stored as a Redis hash with four fields:
//! Each player's virtual locker is represented by four category fields:
//! - items: JSON array of item classnames
//! - weapons: JSON array of weapon classnames
//! - magazines: JSON array of magazine classnames
//! - backpacks: JSON array of backpack classnames
use forge_models::VLocker;
use forge_shared::{RedisClient, parse_json_value, parse_redis_value};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
@ -75,111 +74,3 @@ impl VLockerHotRepository for InMemoryVLockerHotRepository {
Ok(())
}
}
/// Redis-based implementation of the VLockerRepository trait.
///
/// Stores each player's virtual locker as a Redis hash with four fields:
/// - items: JSON array of item classnames
/// - weapons: JSON array of weapon classnames
/// - magazines: JSON array of magazine classnames
/// - backpacks: JSON array of backpack classnames
///
/// Key format: `owned:locker:{uid}`
pub struct RedisVLockerRepository<C: RedisClient> {
client: C,
}
impl<C: RedisClient> RedisVLockerRepository<C> {
pub fn new(client: C) -> Self {
Self { client }
}
}
impl<C: RedisClient> VLockerRepository for RedisVLockerRepository<C> {
fn create(&self, uid: &str, locker: &VLocker) -> Result<(), String> {
let redis_key = format!("owned:locker:{}", uid);
// Serialize locker to JSON string
let locker_json = serde_json::to_string(locker)
.map_err(|e| format!("Failed to serialize locker: {}", e))?;
// Parse JSON string back to Value for field extraction
let json_value: serde_json::Value = serde_json::from_str(&locker_json)
.map_err(|e| format!("Failed to parse locker JSON: {}", e))?;
// Extract fields from JSON object
if let serde_json::Value::Object(locker_map) = json_value {
// Convert each field to Redis-compatible format
let fields: Vec<(String, String)> = locker_map
.into_iter()
.map(|(field, value)| (field, parse_json_value(&value)))
.collect();
// Store all fields atomically using Redis HMSET
self.client.hash_mset(redis_key, fields)
} else {
Err("Failed to convert locker to object".to_string())
}
}
fn update(&self, uid: &str, locker: &VLocker) -> Result<(), String> {
// For a hash, update is the same as create - it's atomic
self.create(uid, locker)
}
fn fetch(&self, uid: &str) -> Result<Option<VLocker>, String> {
let redis_key = format!("owned:locker:{}", uid);
// Retrieve all hash fields from Redis
let locker_string = self.client.hash_get_all(redis_key)?;
// Return None if no data found (locker doesn't exist)
if locker_string.is_empty() {
return Ok(None);
}
let redis_map: std::collections::HashMap<String, String> =
serde_json::from_str(&locker_string)
.map_err(|e| format!("Failed to parse virtual locker hash response: {}", e))?;
let mut json_map = serde_json::Map::new();
for (key, value) in redis_map {
let json_value = parse_redis_value(&value);
json_map.insert(key, json_value);
}
// Reconstruct VLocker from JSON object
let json_obj = serde_json::Value::Object(json_map);
match serde_json::from_value::<VLocker>(json_obj) {
Ok(locker) => Ok(Some(locker)),
// Return None for any deserialization errors (corrupted data)
Err(_) => Ok(None),
}
}
fn get(&self, uid: &str, field: &str) -> Result<Vec<String>, String> {
let redis_key = format!("owned:locker:{}", uid);
// Retrieve the specific field from the hash
let field_json = self.client.hash_get(redis_key, field.to_string())?;
// Return empty vector if field is empty
if field_json.is_empty() {
return Ok(Vec::new());
}
// Deserialize the JSON array
serde_json::from_str::<Vec<String>>(&field_json)
.map_err(|e| format!("Failed to deserialize field '{}': {}", field, e))
}
fn delete(&self, uid: &str) -> Result<(), String> {
let redis_key = format!("owned:locker:{}", uid);
self.client.delete_key(redis_key)
}
fn exists(&self, uid: &str) -> Result<bool, String> {
let redis_key = format!("owned:locker:{}", uid);
self.client.key_exists(redis_key)
}
}

View File

@ -1,184 +1,18 @@
# Forge Services
This crate implements the service layer for the Forge application, containing the core business logic and orchestration.
## Architecture
The service layer sits between the API/Extension layer and the Repository layer:
```mermaid
graph TD
Extension[Extension Layer]
Services[Services Layer<br/>#40;This Module#41;]
Repositories[Repositories Layer]
Database[Database]
Extension --> Services
Services --> Repositories
Repositories --> Database
```
This crate owns domain behavior for Forge systems. Services depend on
repository traits, which keeps business logic testable with in-memory stores
and independent from the concrete persistence backend.
## Responsibilities
- **Business Logic:** Enforces game rules and constraints.
- **Validation:** Validates input data before processing.
- **Orchestration:** Coordinates operations across multiple repositories.
- **Error Handling:** Converts technical errors into business-friendly messages.
- **Data Transformation:** Handles JSON parsing and model conversion.
- Validate command inputs.
- Apply domain rules and mutation workflows.
- Return structured results for extension/SQF callers.
- Keep persistence details behind repository traits.
## Operational State Policy
## Test
Most hot-state services in Forge back durable player or organization records and
are expected to flush through the save path. `CAD` and `Task` are the current
exceptions: they are extension-backed operational state services that are
intentionally transient and restart clean with the active server or mission
lifecycle.
## Actor Service
The `ActorService` manages player lifecycle and state.
### Key Features
- **Get-or-Create:** Automatically creates new actors with default values if they don't exist.
- **JSON Integration:** Directly accepts JSON strings for updates and creation.
- **Partial Updates:** Supports updating specific fields without overwriting the entire actor.
- **UID Protection:** Prevents modification of immutable fields like UIDs.
### Usage Example
```rust
use forge_services::ActorService;
use forge_repositories::RedisActorRepository;
// Initialize
let repo = RedisActorRepository::new(client);
let service = ActorService::new(repo);
// 1. Get Actor (creates default if missing)
let actor = service.get_actor("76561198123456789".to_string())?;
// 2. Create/Overwrite Actor
let json_data = r#"{"name": "NewPlayer", "bank": 1000.0}"#;
service.create_actor("76561198123456789".to_string(), json_data.to_string())?;
// 3. Update Actor (Partial)
let update_json = r#"{"bank": 1500.0}"#;
service.update_actor("76561198123456789".to_string(), update_json.to_string())?;
// 4. Check Existence
if service.actor_exists("76561198123456789".to_string())? {
println!("Actor exists");
}
// 5. Delete Actor
// 5. Delete Actor
service.delete_actor("76561198123456789".to_string())?;
```powershell
cargo test -p forge-services
```
## Organization Service
The `OrgService` manages organization (guild/clan) lifecycle and member management.
### Key Features
- **Get-or-Create:** Automatically creates new organizations with default values if they don't exist.
- **Member Management:** Handles adding and removing members with validation.
- **Duplicate Prevention:** Ensures unique organization IDs and member UIDs.
- **Name Validation:** Enforces non-empty organization names.
### Usage Example
```rust
use forge_services::OrgService;
use forge_repositories::RedisOrgRepository;
// Initialize
let repo = RedisOrgRepository::new(client);
let service = OrgService::new(repo);
// 1. Get Organization (creates default if missing)
let org = service.get_org("elite_squad".to_string())?;
// 2. Create/Overwrite Organization
let json_data = r#"{"name": "Elite Squad", "description": "Best players", "leader": "76561198123456789"}"#;
service.create_org("elite_squad".to_string(), json_data.to_string())?;
// 3. Add Member
let member_json = r#"{"uid": "76561198987654321", "rank": "member"}"#;
service.add_member("elite_squad".to_string(), member_json.to_string())?;
// 4. Update Organization
let update_json = r#"{"description": "New description"}"#;
service.update_org("elite_squad".to_string(), update_json.to_string())?;
// 5. Check Existence
if service.org_exists("elite_squad".to_string())? {
println!("Organization exists");
}
```
## Error Handling
The service layer returns `Result<T, String>` where the error string is a descriptive message suitable for logging or displaying to administrators. It wraps lower-level repository errors with additional context.
## Contributing
We welcome contributions to the Forge Service Layer! This guide will help you understand how to add new services and maintain the existing codebase.
### Adding a New Service
To add a new service (e.g., `ItemService`), follow these steps:
1. **Create the Module**: Create a new file in `src/` (e.g., `src/item.rs`).
2. **Define the Struct**: Define your service struct with a generic repository.
```rust
pub struct ItemService<R: ItemRepository> {
repository: R,
}
```
3. **Implement `new`**: Provide a constructor that accepts the repository.
```rust
impl<R: ItemRepository> ItemService<R> {
pub fn new(repository: R) -> Self {
Self { repository }
}
}
```
4. **Implement Business Logic**: Add methods for your business logic (e.g., `create_item`, `transfer_item`).
```rust
impl<R: ItemRepository> ItemService<R> {
pub fn create_item(&self, item_id: String, data: String) -> Result<Item, String> {
// Validation logic...
if self.repository.exists(&item_id)? {
return Err("Item already exists".to_string());
}
// ... logic to create item
let item = Item::new(item_id);
self.repository.create(&item)?;
Ok(item)
}
}
```
5. **Register the Module**: Add your new module to `src/lib.rs` and export the service struct.
```rust
pub mod item;
pub use item::ItemService;
```
### Testing
- **Unit Tests**: Write unit tests for your business logic.
- **Mocking**: Since services use generic repositories, you can easily mock them for testing without a real database.
```rust
// Example Mock
struct MockRepo;
impl ItemRepository for MockRepo { ... }
```
### Best Practices
- **Validation**: Always validate data at the service boundary. Do not rely on the API layer or database constraints alone.
- **Error Messages**: Return user-friendly error messages. Avoid exposing internal database errors directly.
- **Immutability**: Respect immutable fields (like UIDs).
- **Documentation**: Document public methods with doc comments (`///`) explaining their purpose, arguments, and return values.

View File

@ -911,40 +911,39 @@ impl CadViewService {
}
let mut entry = order.fields.clone();
if let Some(target_group_id) = Self::string_field(&entry, "targetGroupId") {
if let Some(target_group) = groups.iter().find_map(|group| {
if let Some(target_group_id) = Self::string_field(&entry, "targetGroupId")
&& let Some(target_group) = groups.iter().find_map(|group| {
let object = Self::as_object_ref(group)?;
(Self::string_field(object, "groupId").unwrap_or_default() == target_group_id)
.then_some(object)
}) {
if let Some(callsign) = Self::string_field(target_group, "callsign") {
entry.insert(
"targetGroupCallsign".to_string(),
Value::String(callsign.clone()),
);
entry.insert(
"title".to_string(),
Value::String(format!("Backup {callsign}")),
);
}
})
{
if let Some(callsign) = Self::string_field(target_group, "callsign") {
entry.insert(
"targetGroupCallsign".to_string(),
Value::String(callsign.clone()),
);
entry.insert(
"title".to_string(),
Value::String(format!("Backup {callsign}")),
);
}
if let Some(position) = target_group.get("position") {
entry.insert("position".to_string(), position.clone());
}
if let Some(position) = target_group.get("position") {
entry.insert("position".to_string(), position.clone());
}
if Self::string_field(&entry, "note")
.unwrap_or_default()
.is_empty()
{
if let Some(callsign) = Self::string_field(&entry, "targetGroupCallsign") {
entry.insert(
"description".to_string(),
Value::String(format!(
"Dispatch order to back up {callsign} at its current position."
)),
);
}
}
if Self::string_field(&entry, "note")
.unwrap_or_default()
.is_empty()
&& let Some(callsign) = Self::string_field(&entry, "targetGroupCallsign")
{
entry.insert(
"description".to_string(),
Value::String(format!(
"Dispatch order to back up {callsign} at its current position."
)),
);
}
}

View File

@ -84,10 +84,10 @@ impl<R: GarageRepository> GarageService<R> {
if let Some(f) = fuel {
vehicle.fuel = f;
}
if let Some(hp_json) = hit_points_json {
if let Ok(hp) = HitPoints::from_json_str(&hp_json) {
vehicle.hit_points = hp;
}
if let Some(hp_json) = hit_points_json
&& let Ok(hp) = HitPoints::from_json_str(&hp_json)
{
vehicle.hit_points = hp;
}
} else {
return Err(format!("Vehicle with plate {} not found", plate));

View File

@ -1164,10 +1164,11 @@ fn can_manage_treasury(
fn resolve_member_uids(org: &HotOrgRecord, requester_uid: Option<&str>) -> Vec<String> {
let mut member_uids = org.members.keys().cloned().collect::<Vec<_>>();
if let Some(uid) = requester_uid {
if !uid.is_empty() && !member_uids.iter().any(|member_uid| member_uid == uid) {
member_uids.push(uid.to_string());
}
if let Some(uid) = requester_uid
&& !uid.is_empty()
&& !member_uids.iter().any(|member_uid| member_uid == uid)
{
member_uids.push(uid.to_string());
}
member_uids
}

View File

@ -521,11 +521,9 @@ where
})();
if let Err(error) = commit_result {
if org_saved {
if let Some(org) = original_org {
let org_id = org.id.clone();
let _ = self.org.override_org(&org_id, org);
}
if org_saved && let Some(org) = original_org {
let org_id = org.id.clone();
let _ = self.org.override_org(&org_id, org);
}
if vgarage_saved {
let _ = self
@ -640,10 +638,11 @@ fn can_manage_treasury(
fn resolve_member_uids(org: &HotOrgRecord, requester_uid: Option<&str>) -> Vec<String> {
let mut member_uids = org.members.keys().cloned().collect::<Vec<_>>();
if let Some(uid) = requester_uid {
if !uid.is_empty() && !member_uids.iter().any(|member_uid| member_uid == uid) {
member_uids.push(uid.to_string());
}
if let Some(uid) = requester_uid
&& !uid.is_empty()
&& !member_uids.iter().any(|member_uid| member_uid == uid)
{
member_uids.push(uid.to_string());
}
member_uids
}

View File

@ -1,7 +1,5 @@
pub mod redis_client;
pub mod validation;
pub use redis_client::{RedisClient, parse_json_value, parse_redis_value};
pub use validation::{
ActorValidationError, BankValidationError, GarageValidationError, LockerValidationError,
OrgValidationError,

View File

@ -1,70 +0,0 @@
/// Redis client abstraction for dependency injection
pub trait RedisClient: Send + Sync {
// Hash operations
fn hash_mset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), String>;
fn hash_get_all(&self, key: String) -> Result<String, String>;
fn hash_get(&self, key: String, field: String) -> Result<String, String>;
fn hash_del(&self, key: String, field: String) -> Result<(), String>;
// List operations
fn list_rpush(&self, key: String, value: String) -> Result<(), String>;
fn list_range(&self, key: String, start: isize, end: isize) -> Result<Vec<String>, String>;
fn list_del(&self, key: String, count: isize, value: String) -> Result<(), String>;
// Set operations
fn set_add(&self, key: String, member: String) -> Result<(), String>;
fn set_members(&self, key: String) -> Result<Vec<String>, String>;
fn set_del(&self, key: String, member: String) -> Result<(), String>;
// Common operations
fn get_key(&self, key: String) -> Result<String, String>;
fn set_key(&self, key: String, value: String) -> Result<(), String>;
fn incr_key(&self, key: String, count: usize) -> Result<i64, String>;
fn key_exists(&self, key: String) -> Result<bool, String>;
fn delete_key(&self, key: String) -> Result<(), String>;
}
/// Converts a JSON value to a Redis-compatible string format.
pub fn parse_json_value(value: &serde_json::Value) -> String {
let wrapped = serde_json::Value::Array(vec![value.clone()]);
wrapped.to_string()
}
/// Converts a Redis string value back to a JSON value with intelligent type detection.
pub fn parse_redis_value(value: &str) -> serde_json::Value {
// Handle empty values
if value.is_empty() {
return serde_json::Value::Null;
}
// Try to parse as JSON first
if let Ok(json_val) = serde_json::from_str(value) {
// Special handling for single-element arrays (unwrap them)
if let serde_json::Value::Array(arr) = &json_val {
if arr.len() == 1 {
return arr[0].clone();
}
}
return json_val;
}
// Try to parse as integer
if let Ok(int_val) = value.parse::<i64>() {
return serde_json::Value::Number(serde_json::Number::from(int_val));
}
// Try to parse as float
if let Ok(float_val) = value.parse::<f64>() {
if let Some(num) = serde_json::Number::from_f64(float_val) {
return serde_json::Value::Number(num);
}
}
// Try to parse as boolean (case-insensitive)
match value.to_lowercase().as_str() {
"true" => serde_json::Value::Bool(true),
"false" => serde_json::Value::Bool(false),
// Default to string if no other type matches
_ => serde_json::Value::String(value.to_string()),
}
}