diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 561e8d2..34fccad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,58 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo test --workspace --no-fail-fast + schema-integration: + name: Schema E2E Integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Protoc + run: sudo apt-get install -y protobuf-compiler + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install Python dependencies + run: pip install httpx + - name: Run Schema E2E Test + run: ./scripts/test_schema_app_e2e.sh + + client-features-integration: + name: Client Features Integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Protoc + run: sudo apt-get install -y protobuf-compiler + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install Python dependencies + run: pip install httpx + - name: Run Client Features Test + run: ./scripts/test_client_features.sh + + client-features-integration-ts: + name: Client Features Integration (TypeScript) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Protoc + run: sudo apt-get install -y protobuf-compiler + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Run Client Features Test (TS) + run: ./scripts/test_client_features_ts.sh + # ═══════════════════════════════════════════════════════════════════════════ # CHAOS ENGINEERING TESTS (reusable workflow) # ═══════════════════════════════════════════════════════════════════════════ @@ -430,3 +482,79 @@ jobs: - name: "📝 Post to Step Summary" run: | cat benchmark_results/BENCHMARK_RESULTS.md >> $GITHUB_STEP_SUMMARY + + # ═══════════════════════════════════════════════════════════════════════════ + # CROSS-LANGUAGE BENCHMARKS + # ═══════════════════════════════════════════════════════════════════════════ + cross-lang-benchmark: + name: "🌍 Cross-Language Benchmarks" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Protoc + run: sudo apt-get install -y protobuf-compiler + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build PrkDB CLI + run: cargo build --release -p prkdb-cli + + - name: Install Python dependencies + run: pip install httpx + + - name: Start PrkDB Server + run: | + ./target/release/prkdb-cli serve --port 8080 --grpc-port 50051 & + echo "Waiting for server..." + sleep 5 + + - name: Define Benchmark Schema + run: | + # Create a simple schema for benchmarking + cat > bench.proto <> $GITHUB_OUTPUT + else + echo "node-version=20" >> $GITHUB_OUTPUT + fi + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + cache: 'npm' + cache-dependency-path: docs/package-lock.json + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Install dependencies + working-directory: docs + run: npm ci + + - name: Build with VitePress + working-directory: docs + run: npm run build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/Cargo.toml b/Cargo.toml index 7a32623..35ffbb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/prkdb-orm", "crates/prkdb-orm-macros", "crates/prkdb-proto", + "crates/prkdb-schema", "crates/prkdb-storage-sled", "crates/prkdb-storage-sql", "crates/prkdb-storage-segmented", @@ -39,7 +40,7 @@ criterion = { version = "0.5", features = ["async_tokio"] } dashmap = "5.5" uuid = { version = "1.11", features = ["v4"] } prometheus = "0.13" -axum = "0.8.8" +axum = "0.7.9" lazy_static = "1.5" crossbeam = "0.8" mimalloc = { version = "0.1", default-features = false } @@ -67,6 +68,7 @@ prkdb-orm = { path = "crates/prkdb-orm" } prkdb-storage-sled = { path = "crates/prkdb-storage-sled" } prkdb-storage-sql = { path = "crates/prkdb-storage-sql" } prkdb-storage-segmented = { path = "crates/prkdb-storage-segmented" } +prkdb-schema = { path = "crates/prkdb-schema" } # Optimize for CI to avoid Linker OOM (Bus Error signal 7) # Reduces debug info for dependencies in dev/test builds diff --git a/benches/bench_python.py b/benches/bench_python.py new file mode 100644 index 0000000..82a2b92 --- /dev/null +++ b/benches/bench_python.py @@ -0,0 +1,100 @@ + +import sys +import os +import json +import random +import string +import argparse +import asyncio +import time + +# Add generated client to path +sys.path.append(os.path.join(os.getcwd(), "client_py")) + +try: + from prkdb_client import PrkDbClient +except ImportError as e: + print(f"❌ Error: PrkDB Client not found or dependency missing: {e}") + sys.exit(1) + +def random_string(length=10): + return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) + + +def main(): + parser = argparse.ArgumentParser(description="PrkDB Python Benchmark") + parser.add_argument("--server", default="http://127.0.0.1:8081", help="PrkDB Server URL(s), comma-separated") + parser.add_argument("--records", type=int, default=10000, help="Number of records") + args = parser.parse_args() + + servers = args.server.split(",") + try: + asyncio.run(run_benchmark(servers, args.records)) + except KeyboardInterrupt: + print("\n⚠️ Interrupted") + +async def run_benchmark(servers, num_records): + print(f"🚀 Connecting to {servers}...") + + # Create a pool of clients + clients = [PrkDbClient(host=s) for s in servers] + + print(f" 📤 Starting Producer: {num_records} records...") + + produce_start = time.time() + success_count = 0 + + # Batch concurrent requests to simulate load + BATCH_SIZE = 100 + + for i in range(0, num_records, BATCH_SIZE): + batch_end = min(i + BATCH_SIZE, num_records) + current_batch = [] + + for j in range(i, batch_end): + data = { + "id": f"bench_{j}", + "payload": random_string(100), + "timestamp": int(time.time() * 1000) + } + # Round-robin initial client + client = clients[j % len(clients)] + current_batch.append(put_with_retry(clients, "benchmark", data)) + + # Wait for batch + results = await asyncio.gather(*current_batch, return_exceptions=True) + for res in results: + if not isinstance(res, Exception): + success_count += 1 + else: + print(f"Error: {res}") + + # Close all clients + for c in clients: + await c.close() + + duration = time.time() - produce_start + mbps = (num_records * 100) / duration / 1024 / 1024 + + print(f"✅ Producer Finished: {success_count}/{num_records} records") + print(f"⏱️ Duration: {duration:.2f}s") + print(f"📈 Throughput: {mbps:.2f} MB/s") + +async def put_with_retry(clients, collection, data): + # Try clients in order (starting from random or just round-robin) + # Simple failover: try all clients + last_err = None + for client in clients: + try: + await client.put(collection, data) + return + except Exception as e: + last_err = e + # Logic to detect "Not Leader"? + # Serve.rs returns 500 for forwarding error. + # We just try next node. + continue + raise last_err + +if __name__ == "__main__": + main() diff --git a/benches/bench_ts.ts b/benches/bench_ts.ts new file mode 100644 index 0000000..d18f984 --- /dev/null +++ b/benches/bench_ts.ts @@ -0,0 +1,65 @@ + +import { PrkDbClient } from './client_ts/benchmark'; // Assumes client is generated here +import { performance } from 'perf_hooks'; + +// Polyfill for fetch if running in older Node.js environments (though Node 18+ has it native) +if (!globalThis.fetch) { + console.error("❌ Error: fetch API not found. Please use Node.js 18+"); + process.exit(1); +} + +const SERVER_URL = process.env.PRKDB_SERVER || "http://127.0.0.1:50051"; +const NUM_RECORDS = parseInt(process.env.NUM_RECORDS || "10000"); +const BATCH_SIZE = 100; + +function randomString(length: number): string { + let result = ''; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +async function runBenchmark() { + console.log(`🚀 Connecting to ${SERVER_URL}...`); + const client = new PrkDbClient(SERVER_URL); + + console.log(` 📤 Starting Producer: ${NUM_RECORDS} records...`); + const start = performance.now(); + let successCount = 0; + + // We use a semaphore-like pattern to limit concurrency if needed, + // but for max throughput we can just fire promises in batches. + for (let i = 0; i < NUM_RECORDS; i += BATCH_SIZE) { + const batchPromises: Promise[] = []; + const limit = Math.min(i + BATCH_SIZE, NUM_RECORDS); + + for (let j = i; j < limit; j++) { + const data = { + id: `bench_ts_${j}`, + payload: randomString(100), + timestamp: Date.now() + }; + + // Fire request + batchPromises.push( + client.put('benchmark', data) + .then(() => { successCount++; }) + .catch(e => console.error(`Error: ${e.message}`)) + ); + } + + await Promise.all(batchPromises); + } + + const duration = (performance.now() - start) / 1000; // seconds + const mbps = (NUM_RECORDS * 100) / duration / 1024 / 1024; + + console.log(`✅ Producer Finished: ${successCount}/${NUM_RECORDS} records`); + console.log(`⏱️ Duration: ${duration.toFixed(2)}s`); + console.log(`📈 Throughput: ${mbps.toFixed(2)} MB/s`); +} + +runBenchmark().catch(console.error); diff --git a/client_py/__init__.py b/client_py/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client_py/prkdb_client.py b/client_py/prkdb_client.py new file mode 100644 index 0000000..558deb5 --- /dev/null +++ b/client_py/prkdb_client.py @@ -0,0 +1,142 @@ + +import json +import asyncio +from typing import Optional, List, Any, Dict, AsyncGenerator + +try: + import httpx +except ImportError: + raise ImportError("The 'httpx' library is required. Please install it with: pip install httpx") + +class PrkDbClient: + def __init__(self, host: str = "http://127.0.0.1:8080"): + self.host = host.rstrip('/') + self.client = httpx.AsyncClient() + + async def close(self): + await self.client.aclose() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def list(self, collection: str, limit: int = 100, offset: int = 0, filter: Optional[str] = None, sort: Optional[str] = None) -> List[Dict[str, Any]]: + params = {"limit": limit, "offset": offset} + if filter: + params["filter"] = filter + if sort: + params["sort"] = sort + + response = await self.client.get(f"{self.host}/collections/{collection}/data", params=params) + + if response.status_code == 200: + data = response.json() + # Response is wrapped in {"success": true, "data": ...} + result = data.get("data", {}) + # For data endpoints, the actual list is wrapped in the result + if isinstance(result, dict) and "data" in result and isinstance(result["data"], list): + return result["data"] + return result if isinstance(result, list) else [] + else: + raise Exception(f"Failed to list collection: {response.status_code}") + + async def put(self, collection: str, data: Dict[str, Any]) -> None: + """Insert or update a record in the collection""" + response = await self.client.put( + f"{self.host}/collections/{collection}/data", + json=data, + headers={'Content-Type': 'application/json'} + ) + if response.status_code not in (200, 201): + raise Exception(f"Failed to put record: {response.status_code}") + + async def delete(self, collection: str, id: str) -> None: + """Delete a record from the collection""" + response = await self.client.delete(f"{self.host}/collections/{collection}/data/{id}") + if response.status_code != 200: + raise Exception(f"Failed to delete record: {response.status_code}") + + async def replay_collection(self, collection: str, handler): + """ + Replay all events/items in a collection and apply them to a stateful handler. + Uses streaming to avoid loading all data into memory. + + Handler must implement: + - init_state(self) -> state + - handle(self, state, event) -> void (modifies state in place) + """ + limit = 100 + offset = 0 + state = handler.init_state() + + while True: + items = await self.list(collection, limit=limit, offset=offset) + if not items: + break + + for item in items: + handler.handle(state, item) + + if len(items) < limit: + break + + offset += len(items) + + return state + + async def stream(self, collection: str) -> AsyncGenerator[Dict[str, Any], None]: + """Stream all records from a collection using an async generator""" + limit = 100 + offset = 0 + + while True: + items = await self.list(collection, limit=limit, offset=offset) + if not items: + break + + for item in items: + yield item + + if len(items) < limit: + break + + offset += len(items) + +class QueryBuilder: + def __init__(self, model_cls, collection_name: str): + self.model_cls = model_cls + self.collection_name = collection_name + self.filters = [] + self.sort_field = None + self._limit = 100 + self._offset = 0 + + def filter(self, field, op, value): + self.filters.append(f"{field}{op}{value}") + return self + + def sort(self, field, desc=False): + suffix = ":desc" if desc else ":asc" + self.sort_field = f"{field}{suffix}" + return self + + def limit(self, limit): + self._limit = limit + return self + + def offset(self, offset): + self._offset = offset + return self + + async def execute(self, client: PrkDbClient) -> List[Any]: + items = await client.list( + self.collection_name, + limit=self._limit, + offset=self._offset, + filter=",".join(self.filters) if self.filters else None, + sort=self.sort_field + ) + # Convert dicts to model objects + return [self.model_cls.from_dict(item) for item in items] diff --git a/crates/prkdb-cli/Cargo.toml b/crates/prkdb-cli/Cargo.toml index 122b98f..db2be5e 100644 --- a/crates/prkdb-cli/Cargo.toml +++ b/crates/prkdb-cli/Cargo.toml @@ -10,9 +10,6 @@ license = "Apache-2.0" name = "prkdb-cli" path = "src/main.rs" -[[bin]] -name = "prkdb-server" -path = "src/main.rs" [dependencies] prkdb = { path = "../prkdb" } @@ -46,7 +43,9 @@ humantime = "2.1" chrono = { workspace = true, features = ["serde"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } +prost.workspace = true +prost-types.workspace = true +reqwest = { version = "0.11", features = ["json"] } [dev-dependencies] -reqwest = { version = "0.11", features = ["json"] } rand = "0.8" diff --git a/crates/prkdb-cli/src/commands.rs b/crates/prkdb-cli/src/commands.rs index 61a55a5..a3d7172 100644 --- a/crates/prkdb-cli/src/commands.rs +++ b/crates/prkdb-cli/src/commands.rs @@ -1,6 +1,7 @@ use clap::Subcommand; pub mod backup; +pub mod codegen; pub mod collection; pub mod consumer; pub mod data; @@ -8,13 +9,20 @@ pub mod database; pub mod metrics; pub mod partition; pub mod replication; +pub mod schema; pub mod serve; pub mod subscribe; #[derive(Subcommand, Clone)] pub enum CollectionCommands { /// Create a new collection - Create { name: String }, + Create { + name: String, + #[arg(short, long, default_value = "1")] + partitions: u32, + #[arg(short, long, default_value = "1")] + replication_factor: u32, + }, /// Drop a collection Drop { name: String }, /// List all collections @@ -42,6 +50,12 @@ pub enum CollectionCommands { #[arg(long)] sort: Option, }, + /// Insert data into collection + Put { + name: String, + /// JSON data to insert + data: String, + }, } #[derive(Subcommand, Clone)] diff --git a/crates/prkdb-cli/src/commands/codegen.rs b/crates/prkdb-cli/src/commands/codegen.rs new file mode 100644 index 0000000..cfb2bc7 --- /dev/null +++ b/crates/prkdb-cli/src/commands/codegen.rs @@ -0,0 +1,828 @@ +//! Codegen command for generating cross-language SDK clients +//! +//! Usage: +//! prkdb codegen --server http://localhost:50051 --lang python --out ./clients/python/ +//! prkdb codegen --server http://localhost:50051 --lang typescript --out ./clients/ts/ +//! prkdb codegen --server http://localhost:50051 --lang go --out ./clients/go/ + +use clap::{Args, ValueEnum}; +use prkdb_client::PrkDbClient; +use prost::Message; +use prost_types::FileDescriptorSet; +use std::path::PathBuf; +use tokio::fs; + +/// Supported output languages for code generation +#[derive(ValueEnum, Clone, Debug, Copy, PartialEq, Eq)] +pub enum Language { + Python, + Typescript, + Go, + All, +} + +#[derive(Args, Clone)] +pub struct CodegenArgs { + /// Server address to fetch schemas from + #[arg(long, default_value = "http://127.0.0.1:50051")] + pub server: String, + + /// Target language for code generation + #[arg(short, long, value_enum)] + pub lang: Language, + + /// Output directory for generated code + #[arg(short, long)] + pub out: PathBuf, + + /// Collection name (if omitted, generates for all schemas) + #[arg(short, long)] + pub collection: Option, + + /// Overwrite existing files + #[arg(long)] + pub force: bool, +} + +pub async fn handle_codegen(args: CodegenArgs) -> anyhow::Result<()> { + println!("🔧 PrkDB Codegen"); + println!(" Server: {}", args.server); + println!(" Language: {:?}", args.lang); + println!(" Output: {}", args.out.display()); + + // Connect to server + let client = PrkDbClient::new(vec![args.server.clone()]).await?; + println!("✓ Connected to server"); + + // Create output directory + fs::create_dir_all(&args.out).await?; + + // Fetch schemas from server + let schemas = if let Some(collection) = &args.collection { + // Fetch specific schema + match client.get_schema(collection, None).await { + Ok(schema) => vec![(collection.clone(), schema)], + Err(e) => { + eprintln!("Failed to fetch schema for '{}': {}", collection, e); + return Err(e.into()); + } + } + } else { + // Fetch all schemas + let list = client.list_schemas().await?; + let mut schemas = Vec::new(); + for info in list { + match client.get_schema(&info.collection, None).await { + Ok(schema) => schemas.push((info.collection, schema)), + Err(e) => eprintln!( + "Warning: Failed to fetch schema for '{}': {}", + info.collection, e + ), + } + } + schemas + }; + + // Generate base client library (regardless of schemas) + match args.lang { + Language::Python => generate_python_client_lib(&args.out).await?, + Language::All => { + generate_python_client_lib(&args.out.join("python")).await?; + } + _ => {} + } + + if schemas.is_empty() { + println!("No schemas found on server."); + return Ok(()); + } + + println!("✓ Found {} schema(s)", schemas.len()); + + // Generate code for each schema + + for (collection, schema) in &schemas { + match args.lang { + Language::Python => generate_python(&args.out, collection, schema).await?, + Language::Typescript => generate_typescript(&args.out, collection, schema).await?, + Language::Go => generate_go(&args.out, collection, schema).await?, + Language::All => { + generate_python(&args.out.join("python"), collection, schema).await?; + generate_typescript(&args.out.join("typescript"), collection, schema).await?; + generate_go(&args.out.join("go"), collection, schema).await?; + } + } + } + + println!("✓ Code generation complete!"); + Ok(()) +} + +/// Parse schema descriptor bytes into message definitions +fn parse_schema_messages(schema_bytes: &[u8]) -> Vec { + let mut messages = Vec::new(); + + if let Ok(set) = FileDescriptorSet::decode(schema_bytes) { + for file in set.file { + let package = file.package.clone().unwrap_or_default(); + for user_msg in file.message_type { + collect_messages(&user_msg, &package, &mut messages); + } + } + } else { + eprintln!("Warning: Failed to decode schema bytes as FileDescriptorSet"); + } + + messages +} + +fn collect_messages(msg: &prost_types::DescriptorProto, scope: &str, out: &mut Vec) { + let name = msg.name.clone().unwrap_or_default(); + let fields = msg + .field + .iter() + .map(|f| { + let type_num = f.r#type.unwrap_or(0); + let label = f.label.unwrap_or(1); + let is_explicit_optional = f.proto3_optional.unwrap_or(false); + let is_message = type_num == 11; + + FieldInfo { + name: f.name.clone().unwrap_or_default(), + number: f.number.unwrap_or(0), + proto_type: type_num, + type_name: f.type_name.clone(), // Capture type name for messages + is_optional: is_explicit_optional || is_message, + is_repeated: label == 3, + } + }) + .collect(); + + out.push(MessageInfo { name, fields }); + + // Recurse into nested types + for nested in &msg.nested_type { + collect_messages(nested, scope, out); + } +} + +struct MessageInfo { + name: String, + fields: Vec, +} + +#[derive(Debug)] +struct FieldInfo { + name: String, + number: i32, + proto_type: i32, + type_name: Option, + is_optional: bool, + is_repeated: bool, +} + +impl FieldInfo { + fn python_type(&self) -> String { + let base = match self.proto_type { + 1 => "float", // Double + 2 => "float", // Float + 3 | 17 | 18 => "int", // Int64, sint32, sint64 + 4 | 6 => "int", // Uint64, fixed64 + 5 | 15 | 16 => "int", // Int32, sfixed32, sfixed64 + 7 | 13 => "int", // Fixed32, uint32 + 8 => "bool", // Bool + 9 => "str", // String + 12 => "bytes", // Bytes + _ => { + // Return class name for messages (strip path) + if let Some(name) = &self.type_name { + name.split('.').last().unwrap_or("Any") + } else { + "Any" + } + } + }; + + if self.is_repeated { + format!("List[{}]", base) + } else if self.is_optional { + format!("Optional[{}]", base) + } else { + base.to_string() + } + } + + fn typescript_type(&self) -> String { + let base = match self.proto_type { + 1 | 2 => "number", // Double, Float + 3..=7 | 13 | 15..=18 => "number", // All ints + 8 => "boolean", // Bool + 9 => "string", // String + 12 => "Uint8Array", // Bytes + _ => { + if let Some(name) = &self.type_name { + name.split('.').last().unwrap_or("any") + } else { + "any" + } + } + }; + + if self.is_repeated { + format!("{}[]", base) + } else if self.is_optional { + format!("{} | undefined", base) + } else { + base.to_string() + } + } + + fn go_type(&self) -> String { + let base = match self.proto_type { + 1 => "float64", + 2 => "float32", + 3 | 18 => "int64", + 4 | 6 => "uint64", + 5 | 17 => "int32", + 7 | 13 => "uint32", + 8 => "bool", + 9 => "string", + 12 => "[]byte", + 15 => "int32", + 16 => "int64", + _ => { + if let Some(name) = &self.type_name { + name.split('.').last().unwrap_or("interface{}") + } else { + "interface{}" + } + } + }; + + if self.is_repeated && base != "[]byte" { + // For messages in Go, repeated usually means slice of pointers? Or structs? + // PrkDB codegen simplicity: Slice of structs or pointers. + // Let's assume slice of structs for now, or match base. + format!("[]{}", base) + } else if self.is_optional { + format!("*{}", base) + } else { + base.to_string() // Structs are values + } + } +} + +async fn generate_python(out_dir: &PathBuf, collection: &str, schema: &[u8]) -> anyhow::Result<()> { + fs::create_dir_all(out_dir).await?; + + let messages = parse_schema_messages(schema); + + let mut code = format!( + r#"""" +Generated PrkDB client model for {} +""" +from dataclasses import dataclass +from typing import Optional, List, Any +import json + +try: + from .prkdb_client import PrkDbClient, QueryBuilder +except ImportError: + from prkdb_client import PrkDbClient, QueryBuilder + +"#, + collection + ); + + for msg in &messages { + let class_name = &msg.name; + code.push_str(&format!( + r#" +@dataclass +class {}: +"#, + class_name + )); + + if msg.fields.is_empty() { + code.push_str(" pass\n"); + } else { + for field in &msg.fields { + code.push_str(&format!(" {}: {}\n", field.name, field.python_type())); + } + } + + code.push_str(&format!( + r#" + @classmethod + def from_dict(cls, data: dict) -> '{}': +"#, + class_name + )); + + // Generate nested object deserialization logic + for field in &msg.fields { + if field.proto_type == 11 { + // TYPE_MESSAGE + // Extract class name from type_name (e.g. ".models.Address" -> "Address") + if let Some(type_name_full) = &field.type_name { + let type_name = type_name_full.split('.').last().unwrap_or("Any"); + if type_name != "Any" { + let field_name = &field.name; + if field.is_repeated { + code.push_str(&format!( + r#" if '{}' in data and data['{}']: + data['{}'] = [{}.from_dict(x) for x in data['{}']] +"#, + field_name, field_name, field_name, type_name, field_name + )); + } else { + code.push_str(&format!( + r#" if '{}' in data and data['{}']: + data['{}'] = {}.from_dict(data['{}']) +"#, + field_name, field_name, field_name, type_name, field_name + )); + } + } + } + } + } + + // Generate list of known fields for filtering + let valid_keys_str = msg + .fields + .iter() + .map(|f| format!("'{}'", f.name)) + .collect::>() + .join(", "); + + code.push_str(&format!( + r#" # Filter extra fields (like _key, _full_key) + valid_keys = {{{}}} + filtered_data = {{k: v for k, v in data.items() if k in valid_keys}} + return cls(**filtered_data) + + def to_bytes(self) -> bytes: + """Serialize to bytes for PrkDB storage""" + return json.dumps(self.__dict__, default=lambda o: o.__dict__).encode('utf-8') + + @classmethod + def from_bytes(cls, data: bytes) -> '{}': + """Deserialize from PrkDB storage""" + d = json.loads(data.decode('utf-8')) + return cls.from_dict(d) + + @classmethod + def select(cls) -> '{}QueryBuilder': + """Start a fluent query builder for this model""" + return {}QueryBuilder(cls) + +class {}QueryBuilder(QueryBuilder): + def __init__(self, model_cls): + super().__init__(model_cls, "{}") +"#, + valid_keys_str, class_name, class_name, class_name, class_name, collection + )); + + // Generate query methods for each field + for field in &msg.fields { + code.push_str(&format!( + r#" def where_{}_eq(self, value) -> '{}QueryBuilder': + return self.filter("{}", "=", value) + + def where_{}_neq(self, value) -> '{}QueryBuilder': + return self.filter("{}", "!=", value) + + def where_{}_contains(self, value) -> '{}QueryBuilder': + return self.filter("{}", "~", value) + +"#, + field.name, + class_name, + field.name, + field.name, + class_name, + field.name, + field.name, + class_name, + field.name + )); + } + } + + // Helper to handle nested dicts during deserialization might be needed? + // For simple dataclasses, `cls(**d)` works if nested fields are dicts and we rely on duck typing or post-init. + // To properly deserialize nested dataclasses, we need a helper. + // For now, keeping it simple (dicts). + + let filename = format!("{}.py", collection.to_lowercase()); + fs::write(out_dir.join(&filename), code).await?; + println!(" → Generated {}", filename); + + Ok(()) +} + +async fn generate_typescript( + out_dir: &PathBuf, + collection: &str, + schema: &[u8], +) -> anyhow::Result<()> { + fs::create_dir_all(out_dir).await?; + + let messages = parse_schema_messages(schema); + + let mut code = format!( + r#"/** + * Generated PrkDB client model for {} + */ +"#, + collection + ); + + for msg in &messages { + let class_name = &msg.name; + code.push_str(&format!( + r#" +export interface {} {{ +"#, + class_name + )); + + for field in &msg.fields { + let optional = if field.is_optional { "?" } else { "" }; + code.push_str(&format!( + " {}{}: {};\n", + field.name, + optional, + field.typescript_type() + )); + } + + code.push_str(&format!( + r#"}} + +export const {}Meta = {{ + fromDict: (data: any): {} => {{ + return {{ + ...data, + // Handle nested types if needed + }}; + }}, + select: (client: PrkDbClient): {}QueryBuilder => {{ + return new {}QueryBuilder(client); + }}, +}}; + +export class {}QueryBuilder {{ + private client: PrkDbClient; + private filters: string[] = []; + private sortField: string | null = null; + private _limit: number = 100; + private _offset: number = 0; + + constructor(client: PrkDbClient) {{ + this.client = client; + }} + + filter(field: string, op: string, value: any): this {{ + this.filters.push(`${{field}}${{op}}${{value}}`); + return this; + }} + + sort(field: string, desc: boolean = false): this {{ + this.sortField = `${{field}}:${{desc ? 'desc' : 'asc'}}`; + return this; + }} + + limit(limit: number): this {{ + this._limit = limit; + return this; + }} + + offset(offset: number): this {{ + this._offset = offset; + return this; + }} + + async execute(): Promise<{}[]> {{ + return this.client.list("{}", {{ + limit: this._limit, + offset: this._offset, + filter: this.filters.join(','), + sort: this.sortField || undefined, + }}); + }} +"#, + class_name, class_name, class_name, class_name, class_name, class_name, collection + )); + + // Generate fluent methods + for field in &msg.fields { + code.push_str(&format!( + r#" + where{}Eq(value: {}): this {{ + return this.filter("{}", "=", value); + }} +"#, + to_pascal_case(&field.name), + field.typescript_type(), + field.name + )); + } + + code.push_str("}\n"); // Close QueryBuilder + } + + // Add PrkDbClient class + code.push_str(&format!( + r#" +export class PrkDbClient {{ + private host: string; + + constructor(host: string = "http://127.0.0.1:8080") {{ + this.host = host.replace(/\/$/, ""); + }} + + async list(collection: string, options: {{ limit?: number, offset?: number, filter?: string, sort?: string }} = {{}}): Promise {{ + const params = new URLSearchParams(); + if (options.limit) params.set("limit", options.limit.toString()); + if (options.offset) params.set("offset", options.offset.toString()); + if (options.filter) params.set("filter", options.filter); + if (options.sort) params.set("sort", options.sort); + + const response = await fetch(`${{this.host}}/collections/${{collection}}/data?${{params}}`); + if (!response.ok) {{ + throw new Error(`Failed to list collection: ${{response.status}}`); + }} + + const data = await response.json(); + + const result = data.data || {{}}; + if (result && result.data && Array.isArray(result.data)) {{ + return result.data; + }} + return Array.isArray(result) ? result : []; + }} + + async put(collection: string, data: any): Promise {{ + const response = await fetch(`${{this.host}}/collections/${{collection}}/data`, {{ + method: 'PUT', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify(data) + }}); + if (!response.ok) {{ + throw new Error(`Failed to put record: ${{response.status}}`); + }} + }} + + async delete(collection: string, id: string): Promise {{ + const response = await fetch(`${{this.host}}/collections/${{collection}}/data/${{id}}`, {{ + method: 'DELETE' + }}); + if (!response.ok) {{ + throw new Error(`Failed to delete record: ${{response.status}}`); + }} + }} + + user(): UserQueryBuilder {{ + return new UserQueryBuilder(this); + }} +}} +"# + )); + + let filename = format!("{}.ts", collection.to_lowercase()); + fs::write(out_dir.join(&filename), code).await?; + println!(" → Generated {}", filename); + + Ok(()) +} + +async fn generate_go(out_dir: &PathBuf, collection: &str, schema: &[u8]) -> anyhow::Result<()> { + fs::create_dir_all(out_dir).await?; + + let messages = parse_schema_messages(schema); + + let mut code = format!( + r#"// Generated PrkDB client model for {} +package models + +import "encoding/json" +"#, + collection + ); + + for msg in &messages { + let struct_name = to_pascal_case(&msg.name); + code.push_str(&format!( + r#" +type {} struct {{ +"#, + struct_name + )); + + for field in &msg.fields { + let go_name = to_pascal_case(&field.name); + code.push_str(&format!( + "\t{} {} `json:\"{}\"`\n", + go_name, + field.go_type(), + field.name + )); + } + + code.push_str(&format!( + r#"}} + +func (m *{}) ToBytes() ([]byte, error) {{ + return json.Marshal(m) +}} + +func {}FromBytes(data []byte) (*{}, error) {{ + var m {} + if err := json.Unmarshal(data, &m); err != nil {{ + return nil, err + }} + return &m, nil +}} +"#, + struct_name, struct_name, struct_name, struct_name + )); + } + + let filename = format!("{}.go", collection.to_lowercase()); + fs::write(out_dir.join(&filename), code).await?; + println!(" → Generated {}", filename); + + Ok(()) +} + +fn to_pascal_case(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(c) => c.to_uppercase().chain(chars).collect::(), + None => String::new(), + } + }) + .collect() +} + +async fn generate_python_client_lib(out_dir: &PathBuf) -> anyhow::Result<()> { + fs::create_dir_all(out_dir).await?; + + // Generate __init__.py + fs::write(out_dir.join("__init__.py"), "").await?; + + // Generate prkdb_client.py + let code = r#" +import json +import asyncio +from typing import Optional, List, Any, Dict, AsyncGenerator + +try: + import httpx +except ImportError: + raise ImportError("The 'httpx' library is required. Please install it with: pip install httpx") + +class PrkDbClient: + def __init__(self, host: str = "http://127.0.0.1:8080"): + self.host = host.rstrip('/') + self.client = httpx.AsyncClient() + + async def close(self): + await self.client.aclose() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def list(self, collection: str, limit: int = 100, offset: int = 0, filter: Optional[str] = None, sort: Optional[str] = None) -> List[Dict[str, Any]]: + params = {"limit": limit, "offset": offset} + if filter: + params["filter"] = filter + if sort: + params["sort"] = sort + + response = await self.client.get(f"{self.host}/collections/{collection}/data", params=params) + + if response.status_code == 200: + data = response.json() + # Response is wrapped in {"success": true, "data": ...} + result = data.get("data", {}) + # For data endpoints, the actual list is wrapped in the result + if isinstance(result, dict) and "data" in result and isinstance(result["data"], list): + return result["data"] + return result if isinstance(result, list) else [] + else: + raise Exception(f"Failed to list collection: {response.status_code}") + + async def put(self, collection: str, data: Dict[str, Any]) -> None: + """Insert or update a record in the collection""" + response = await self.client.put( + f"{self.host}/collections/{collection}/data", + json=data, + headers={'Content-Type': 'application/json'} + ) + if response.status_code not in (200, 201): + raise Exception(f"Failed to put record: {response.status_code}") + + async def delete(self, collection: str, id: str) -> None: + """Delete a record from the collection""" + response = await self.client.delete(f"{self.host}/collections/{collection}/data/{id}") + if response.status_code != 200: + raise Exception(f"Failed to delete record: {response.status_code}") + + async def replay_collection(self, collection: str, handler): + """ + Replay all events/items in a collection and apply them to a stateful handler. + Uses streaming to avoid loading all data into memory. + + Handler must implement: + - init_state(self) -> state + - handle(self, state, event) -> void (modifies state in place) + """ + limit = 100 + offset = 0 + state = handler.init_state() + + while True: + items = await self.list(collection, limit=limit, offset=offset) + if not items: + break + + for item in items: + handler.handle(state, item) + + if len(items) < limit: + break + + offset += len(items) + + return state + + async def stream(self, collection: str) -> AsyncGenerator[Dict[str, Any], None]: + """Stream all records from a collection using an async generator""" + limit = 100 + offset = 0 + + while True: + items = await self.list(collection, limit=limit, offset=offset) + if not items: + break + + for item in items: + yield item + + if len(items) < limit: + break + + offset += len(items) + +class QueryBuilder: + def __init__(self, model_cls, collection_name: str): + self.model_cls = model_cls + self.collection_name = collection_name + self.filters = [] + self.sort_field = None + self._limit = 100 + self._offset = 0 + + def filter(self, field, op, value): + self.filters.append(f"{field}{op}{value}") + return self + + def sort(self, field, desc=False): + suffix = ":desc" if desc else ":asc" + self.sort_field = f"{field}{suffix}" + return self + + def limit(self, limit): + self._limit = limit + return self + + def offset(self, offset): + self._offset = offset + return self + + async def execute(self, client: PrkDbClient) -> List[Any]: + items = await client.list( + self.collection_name, + limit=self._limit, + offset=self._offset, + filter=",".join(self.filters) if self.filters else None, + sort=self.sort_field + ) + # Convert dicts to model objects + return [self.model_cls.from_dict(item) for item in items] +"#; + + fs::write(out_dir.join("prkdb_client.py"), code).await?; + println!(" → Generated prkdb_client.py"); + + Ok(()) +} diff --git a/crates/prkdb-cli/src/commands/collection.rs b/crates/prkdb-cli/src/commands/collection.rs index 054ef3e..8f106b4 100644 --- a/crates/prkdb-cli/src/commands/collection.rs +++ b/crates/prkdb-cli/src/commands/collection.rs @@ -48,21 +48,25 @@ use prkdb_client::PrkDbClient; pub async fn execute(cmd: CollectionCommands, cli: &Cli) -> Result<()> { match cmd { - CollectionCommands::Create { name } => create_collection(&name, cli).await, + CollectionCommands::Create { + name, + partitions, + replication_factor, + } => create_collection(&name, partitions, replication_factor, cli).await, CollectionCommands::Drop { name } => drop_collection(&name, cli).await, CollectionCommands::List => list_collections(cli).await, // Legacy commands requiring local DB access CollectionCommands::Describe { name } => { - crate::init_database_manager(&cli.database); + crate::init_database_manager(&cli.database, None); describe_collection(&name, cli).await } CollectionCommands::Count { name } => { - crate::init_database_manager(&cli.database); + crate::init_database_manager(&cli.database, None); count_collection(&name, cli).await } CollectionCommands::Sample { name, limit } => { - crate::init_database_manager(&cli.database); + crate::init_database_manager(&cli.database, None); sample_collection(&name, limit, cli).await } CollectionCommands::Data { @@ -72,13 +76,19 @@ pub async fn execute(cmd: CollectionCommands, cli: &Cli) -> Result<()> { filter, sort, } => { - crate::init_database_manager(&cli.database); + crate::init_database_manager(&cli.database, None); browse_collection_data(&name, limit, offset, filter, sort, cli).await } + CollectionCommands::Put { name, data } => put_collection_data(&name, &data, cli).await, } } -async fn create_collection(name: &str, cli: &Cli) -> Result<()> { +async fn create_collection( + name: &str, + num_partitions: u32, + replication_factor: u32, + cli: &Cli, +) -> Result<()> { let client = PrkDbClient::new(vec![cli.server.clone()]).await?; let client = if let Some(token) = &cli.admin_token { client.with_admin_token(token) @@ -86,11 +96,54 @@ async fn create_collection(name: &str, cli: &Cli) -> Result<()> { client }; - client.create_collection(name).await?; + client + .create_collection(name, num_partitions, replication_factor) + .await?; success(&format!("Collection '{}' created successfully", name)); Ok(()) } +async fn put_collection_data(name: &str, data: &str, cli: &Cli) -> Result<()> { + // Parse JSON + let json: serde_json::Value = + serde_json::from_str(data).map_err(|e| anyhow::anyhow!("Invalid JSON data: {}", e))?; + + // Extract ID + let id = json + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Data must have an 'id' field (string)"))?; + + // Construct Key: "collection:id" + // Note: If using Type, it would be "collection::Type:id". + // For now, assume simple collection. + // Ideally we should check schema to key format? + // But standardized key format is "collection:id". + let key = format!("{}:{}", name, id); + + // Connect client + let client = PrkDbClient::new(vec![cli.server.clone()]).await?; + let client = if let Some(token) = &cli.admin_token { + client.with_admin_token(token) + } else { + client + }; + + // Serialize value (JSON bytes) + // We store JSON bytes directly for now (or bincode wrapped?) + // `serve.rs` tries `serde_json::from_slice` first. + let value_bytes = serde_json::to_vec(&json)?; + + // Put + client.put(key.as_bytes(), &value_bytes).await?; + + success(&format!( + "Inserted document '{}' into collection '{}'", + id, name + )); + Ok(()) +} + async fn drop_collection(name: &str, cli: &Cli) -> Result<()> { let client = PrkDbClient::new(vec![cli.server.clone()]).await?; let client = if let Some(token) = &cli.admin_token { diff --git a/crates/prkdb-cli/src/commands/consumer.rs b/crates/prkdb-cli/src/commands/consumer.rs index f064751..92c652b 100644 --- a/crates/prkdb-cli/src/commands/consumer.rs +++ b/crates/prkdb-cli/src/commands/consumer.rs @@ -53,7 +53,7 @@ struct PartitionAssignment { pub async fn execute(cmd: ConsumerCommands, cli: &Cli) -> Result<()> { if cli.local { // Local mode: use embedded database - crate::init_database_manager(&cli.database); + crate::init_database_manager(&cli.database, None); return execute_local(cmd, cli).await; } diff --git a/crates/prkdb-cli/src/commands/partition.rs b/crates/prkdb-cli/src/commands/partition.rs index 30c53a8..9abb9b8 100644 --- a/crates/prkdb-cli/src/commands/partition.rs +++ b/crates/prkdb-cli/src/commands/partition.rs @@ -51,7 +51,7 @@ struct PartitionMetrics { pub async fn execute(cmd: PartitionCommands, cli: &Cli) -> Result<()> { if cli.local { // Local mode: use embedded database - crate::init_database_manager(&cli.database); + crate::init_database_manager(&cli.database, None); return execute_local(cmd, cli).await; } diff --git a/crates/prkdb-cli/src/commands/replication.rs b/crates/prkdb-cli/src/commands/replication.rs index f9b5a5e..e749cd8 100644 --- a/crates/prkdb-cli/src/commands/replication.rs +++ b/crates/prkdb-cli/src/commands/replication.rs @@ -41,7 +41,7 @@ struct ReplicationLag { pub async fn execute(cmd: ReplicationCommands, cli: &Cli) -> Result<()> { if cli.local { // Local mode: use embedded database - crate::init_database_manager(&cli.database); + crate::init_database_manager(&cli.database, None); return execute_local(cmd, cli).await; } diff --git a/crates/prkdb-cli/src/commands/schema.rs b/crates/prkdb-cli/src/commands/schema.rs new file mode 100644 index 0000000..0c370ff --- /dev/null +++ b/crates/prkdb-cli/src/commands/schema.rs @@ -0,0 +1,139 @@ +use clap::{Args, Subcommand, ValueEnum}; +use prkdb_client::{CompatibilityMode, PrkDbClient}; +use std::io::Write; +use std::path::PathBuf; +use tokio::fs; + +#[derive(Args, Clone)] +pub struct SchemaArgs { + /// Server address + #[arg(long, default_value = "http://127.0.0.1:50051")] + pub server: String, + + #[command(subcommand)] + pub command: SchemaCommands, +} + +#[derive(Subcommand, Clone)] +pub enum SchemaCommands { + /// Register a schema + Register { + /// Collection name + #[arg(long)] + collection: String, + + /// Path to proto file + #[arg(long)] + proto: PathBuf, + + /// Compatibility mode + #[arg(long, value_enum, default_value = "backward")] + compatibility: CompatibilityModeArg, + + /// Migration ID (if needed for breaking changes) + #[arg(long)] + migration_id: Option, + }, + + /// Get a schema + Get { + /// Collection name + #[arg(long)] + collection: String, + + /// Version (optional, defaults to latest) + #[arg(long)] + version: Option, + }, + + /// List all schemas + List, + + /// Check compatibility + Check { + /// Collection name + #[arg(long)] + collection: String, + + /// Path to proto file + #[arg(long)] + proto: PathBuf, + }, +} + +#[derive(ValueEnum, Clone, Debug, Copy, PartialEq, Eq)] +pub enum CompatibilityModeArg { + None, + Backward, + Forward, + Full, +} + +impl From for CompatibilityMode { + fn from(arg: CompatibilityModeArg) -> Self { + match arg { + CompatibilityModeArg::None => CompatibilityMode::CompatibilityNone, + CompatibilityModeArg::Backward => CompatibilityMode::CompatibilityBackward, + CompatibilityModeArg::Forward => CompatibilityMode::CompatibilityForward, + CompatibilityModeArg::Full => CompatibilityMode::CompatibilityFull, + } + } +} + +pub async fn handle_schema(args: SchemaArgs) -> anyhow::Result<()> { + // Connect to server + let client = PrkDbClient::new(vec![args.server.clone()]).await?; + + match args.command { + SchemaCommands::Register { + collection, + proto, + compatibility, + migration_id, + } => { + println!("📝 Registering schema for collection '{}'", collection); + let schema_bytes = fs::read(&proto).await?; + let version = client + .register_schema( + &collection, + schema_bytes, + compatibility.into(), + migration_id, + ) + .await?; + println!("✅ Registered schema version {}", version); + } + SchemaCommands::Get { + collection, + version, + } => { + let schema_bytes = client.get_schema(&collection, version).await?; + std::io::stdout().write_all(&schema_bytes)?; + } + SchemaCommands::List => { + let schemas = client.list_schemas().await?; + println!("📋 Registered Schemas:"); + for info in schemas { + println!( + " - {}: (latest version: {})", + info.collection, info.latest_version + ); + } + } + SchemaCommands::Check { collection, proto } => { + println!("Checking compatibility for '{}'", collection); + let schema_bytes = fs::read(&proto).await?; + let compatible = client + .check_compatibility(&collection, schema_bytes) + .await?; + if compatible { + println!("✅ Schema is compatible"); + } else { + println!("❌ Schema is NOT compatible"); + std::process::exit(1); + } + } + } + + Ok(()) +} diff --git a/crates/prkdb-cli/src/commands/serve.rs b/crates/prkdb-cli/src/commands/serve.rs index f77118f..c39938f 100644 --- a/crates/prkdb-cli/src/commands/serve.rs +++ b/crates/prkdb-cli/src/commands/serve.rs @@ -6,12 +6,13 @@ use axum::{ }, http::StatusCode, response::IntoResponse, - routing::get, + routing::{delete, get}, Json, Router, }; use clap::Args; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::HashMap; use std::net::SocketAddr; use tokio::sync::broadcast; use tower_http::cors::{Any, CorsLayer}; @@ -46,6 +47,14 @@ pub struct ServeArgs { /// Enable WebSocket support for real-time data #[arg(long)] pub websockets: bool, + + /// Node ID for Raft cluster + #[arg(long, default_value = "1")] + pub id: u64, + + /// Peer node addresses (node_id -> gRPC SocketAddr), set programmatically + #[arg(skip)] + pub peers: Vec<(u64, SocketAddr)>, } #[derive(Clone)] @@ -55,6 +64,8 @@ struct AppState { prometheus_enabled: bool, #[allow(dead_code)] // Used for future broadcast features broadcast_tx: broadcast::Sender, + /// Maps node_id -> HTTP base URL (e.g., 2 -> "http://127.0.0.1:8082") + peer_http_addrs: HashMap, } #[derive(Deserialize)] @@ -129,12 +140,24 @@ pub async fn handle_serve(args: ServeArgs) -> Result<()> { // Create broadcast channel for WebSocket updates let (broadcast_tx, _) = broadcast::channel::(100); + // Build peer HTTP address map + // Derive each peer's HTTP port using the offset: http_port = grpc_port - (my_grpc_port - my_http_port) + let port_offset = args.grpc_port as i32 - args.port as i32; + let mut peer_http_addrs = HashMap::new(); + for (node_id, grpc_addr) in &args.peers { + let peer_http_port = (grpc_addr.port() as i32 - port_offset) as u16; + let peer_http_url = format!("http://{}:{}", grpc_addr.ip(), peer_http_port); + peer_http_addrs.insert(*node_id, peer_http_url); + } + tracing::info!("Peer HTTP addresses: {:?}", peer_http_addrs); + // Create app state let state = AppState { _database_path: std::path::PathBuf::from("./prkdb.db"), // Default path websocket_enabled: args.websockets, prometheus_enabled: args.prometheus, broadcast_tx: broadcast_tx.clone(), + peer_http_addrs, }; // Build our application with routes @@ -142,20 +165,27 @@ pub async fn handle_serve(args: ServeArgs) -> Result<()> { .route("/", get(root_handler)) .route("/health", get(health_handler)) .route("/collections", get(list_collections_handler)) - .route("/collections/{name}", get(get_collection_handler)) - .route("/collections/{name}/data", get(get_collection_data_handler)) + .route("/collections/:name", get(get_collection_handler)) + .route( + "/collections/:name/data", + get(get_collection_data_handler).put(put_collection_data_handler), + ) + .route( + "/collections/:name/data/:id", + delete(delete_collection_data_handler), + ) .route( - "/collections/{name}/count", + "/collections/:name/count", get(get_collection_count_handler), ) .route( - "/collections/{name}/schema", + "/collections/:name/schema", get(get_collection_schema_handler), ); // Add WebSocket route if enabled if args.websockets { - app = app.route("/ws/collections/{name}", get(websocket_handler)); + app = app.route("/ws/collections/:name", get(websocket_handler)); } // Add metrics route if enabled @@ -208,22 +238,38 @@ pub async fn handle_serve(args: ServeArgs) -> Result<()> { if let Ok(db) = crate::database_manager::get_db_instance().await { println!("🚀 Starting PrkDB gRPC server on {}", grpc_addr); + // Start Multi-Raft partitions (background tasks) + // Skip serving Partition 0's Raft server here, as we'll multiplex it on the main gRPC server below + // This avoids port collision on 50051 + let rpc_pool = std::sync::Arc::new(prkdb::raft::RpcClientPool::new(args.id)); + db.start_multi_raft(rpc_pool, &[0]); + tokio::spawn(async move { use prkdb::raft::grpc_service::PrkDbGrpcService; use prkdb::raft::rpc::prk_db_service_server::PrkDbServiceServer; + use prkdb::raft::rpc::raft_service_server::RaftServiceServer; + use prkdb::raft::service::RaftServiceImpl; use std::sync::Arc; use tonic::transport::Server; use std::env; let admin_token = env::var("PRKDB_ADMIN_TOKEN").unwrap_or_default(); - let service = PrkDbGrpcService::new(Arc::new(db), admin_token) + let service = PrkDbGrpcService::new(Arc::new(db.clone()), admin_token) .with_public_address(format!("http://{}", grpc_addr)); - if let Err(e) = Server::builder() - .add_service(PrkDbServiceServer::new(service)) - .serve(grpc_addr) - .await - { + let mut builder = Server::builder(); + + // Register client service + let mut router = builder.add_service(PrkDbServiceServer::new(service)); + + // Register Raft service for all partitions (multiplexed on same port) + if let Some(pm) = &db.partition_manager { + println!("✨ Multiplexing Raft Service (All Partitions) on main port"); + let raft_service = RaftServiceImpl::new(pm.clone()); + router = router.add_service(RaftServiceServer::new(raft_service)); + } + + if let Err(e) = router.serve(grpc_addr).await { eprintln!("❌ gRPC server error: {}", e); } }); @@ -335,6 +381,15 @@ async fn get_collection_data_handler( Path(name): Path, Query(params): Query, ) -> impl IntoResponse { + println!( + "DEBUG: get_collection_data_handler called for collection: '{}'", + name + ); + println!( + "DEBUG: params: limit={:?}, offset={:?}, filter={:?}, sort={:?}", + params.limit, params.offset, params.filter, params.sort + ); + let limit = params.limit.unwrap_or(20); let offset = params.offset.unwrap_or(0); @@ -356,6 +411,174 @@ async fn get_collection_data_handler( } } +async fn put_collection_data_handler( + State(state): State, + Path(name): Path, + Json(data): Json, +) -> impl IntoResponse { + let db = match crate::database_manager::get_db_instance().await { + Ok(db) => db, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + .into_response() + } + }; + + // Extract ID + let id = match data.get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "Data must have 'id' field"})), + ) + .into_response() + } + }; + + let key = format!("{}:{}", name, id); + let value = match serde_json::to_vec(&data) { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": e.to_string()})), + ) + .into_response() + } + }; + + match db.put(key.as_bytes(), &value).await { + Ok(_) => Json(json!({"success": true, "id": id})).into_response(), + Err(e) => { + let err_str = e.to_string(); + // Check for NotLeader error and forward to leader + if let Some(leader_id) = parse_leader_id(&err_str) { + if let Some(leader_url) = state.peer_http_addrs.get(&leader_id) { + // Forward the PUT to the leader's HTTP endpoint + let forward_url = format!("{}/collections/{}/data", leader_url, name); + match reqwest::Client::new() + .put(&forward_url) + .json(&data) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + Json::(json!({"success": true, "id": id, "forwarded_to": leader_id})) + .into_response() + } + Ok(resp) => { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + ( + StatusCode::from_u16(status) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + Json::(json!({"error": body, "forwarded_to": leader_id})), + ) + .into_response() + } + Err(fwd_err) => ( + StatusCode::BAD_GATEWAY, + Json::(json!({"error": format!("Forward failed: {}", fwd_err), "leader_id": leader_id})), + ) + .into_response(), + } + } else { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": err_str, "leader_id": leader_id})), + ) + .into_response() + } + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err_str})), + ) + .into_response() + } + } + } +} + +async fn delete_collection_data_handler( + State(state): State, + Path((name, id)): Path<(String, String)>, +) -> impl IntoResponse { + let db = match crate::database_manager::get_db_instance().await { + Ok(db) => db, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + .into_response() + } + }; + + let key = format!("{}:{}", name, id); + + match db.delete(key.as_bytes()).await { + Ok(_) => Json(json!({"success": true, "id": id})).into_response(), + Err(e) => { + let err_str = e.to_string(); + if let Some(leader_id) = parse_leader_id(&err_str) { + if let Some(leader_url) = state.peer_http_addrs.get(&leader_id) { + let forward_url = format!("{}/collections/{}/data/{}", leader_url, name, id); + match reqwest::Client::new().delete(&forward_url).send().await { + Ok(resp) if resp.status().is_success() => { + Json::(json!({"success": true, "id": id, "forwarded_to": leader_id})) + .into_response() + } + Ok(resp) => { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + ( + StatusCode::from_u16(status) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + Json::(json!({"error": body, "forwarded_to": leader_id})), + ) + .into_response() + } + Err(fwd_err) => ( + StatusCode::BAD_GATEWAY, + Json::(json!({"error": format!("Forward failed: {}", fwd_err), "leader_id": leader_id})), + ) + .into_response(), + } + } else { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": err_str, "leader_id": leader_id})), + ) + .into_response() + } + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err_str})), + ) + .into_response() + } + } + } +} + +/// Parse leader node ID from a "Not leader. Leader is Some(N)" error string +fn parse_leader_id(err: &str) -> Option { + // Match pattern: "Not leader. Leader is Some(N)" + if let Some(pos) = err.find("Leader is Some(") { + let start = pos + "Leader is Some(".len(); + if let Some(end) = err[start..].find(')') { + return err[start..start + end].parse::().ok(); + } + } + None +} + async fn get_collection_count_handler(Path(name): Path) -> impl IntoResponse { match execute_collection_command(CollectionCommands::Count { name }).await { Ok(output) => Json(ApiResponse::success(output)).into_response(), @@ -617,9 +840,12 @@ async fn execute_collection_command(cmd: CollectionCommands) -> Result { name, limit, offset, - .. - } => get_collection_data(&name, limit, offset).await, - CollectionCommands::Sample { name, limit } => get_collection_data(&name, limit, 0).await, + filter, + sort, + } => get_collection_data(&name, limit, offset, filter, sort).await, + CollectionCommands::Sample { name, limit } => { + get_collection_data(&name, limit, 0, None, None).await + } _ => Ok(serde_json::json!({ "error": "This command is not supported via the HTTP API" })), @@ -731,16 +957,20 @@ async fn get_collection_count(name: &str) -> Result { })) } -async fn get_collection_data(name: &str, limit: usize, offset: usize) -> Result { +async fn get_collection_data( + name: &str, + limit: usize, + offset: usize, + filter: Option, + sort: Option, +) -> Result { let data = database_manager::get_database_manager() .scan_storage() .await?; - let mut items = Vec::new(); - let mut total = 0; - let mut skipped = 0; + let mut collection_entries = Vec::new(); - for (key, value) in data { - let key_str = String::from_utf8_lossy(&key); + for (key, value) in &data { + let key_str = String::from_utf8_lossy(key); // Handle both formats: "collection:id" and "collection::Type:id" let matches_collection = if key_str.contains("::") { @@ -752,23 +982,80 @@ async fn get_collection_data(name: &str, limit: usize, offset: usize) -> Result< }; if matches_collection { - total += 1; + // Try multiple deserialization approaches + let mut entry = None; - if skipped < offset { - skipped += 1; - continue; + // 1. Try JSON first (for backward compatibility) + if let Ok(json_value) = serde_json::from_slice::(value) { + entry = Some(json_value); } - - if items.len() >= limit { - break; + // 2. Try bincode deserialization + else if let Ok(json_value) = crate::commands::collection::try_bincode_to_json(value) { + entry = Some(json_value); } - if let Ok(parsed) = collection::try_bincode_to_json(&value) { - items.push(parsed); + if let Some(mut json_entry) = entry { + let key_part = if key_str.contains("::") { + // Extract ID from "collection::Type:id" format + key_str.split(':').next_back().unwrap_or("unknown") + } else { + // Extract ID from "collection:id" format + key_str.split(':').nth(1).unwrap_or("unknown") + }; + + if let Some(obj) = json_entry.as_object_mut() { + obj.insert( + "_key".to_string(), + serde_json::Value::String(key_part.to_string()), + ); + obj.insert( + "_full_key".to_string(), + serde_json::Value::String(key_str.to_string()), + ); + } + collection_entries.push((key_str.to_string(), json_entry)); } } } + if collection_entries.is_empty() { + return Ok(json!({ + "collection": name, + "data": [], + "total": 0, + "limit": limit, + "offset": offset, + "returned": 0 + })); + } + + // Apply filter if specified + if let Some(filter_expr) = &filter { + collection_entries = apply_simple_filter(collection_entries, filter_expr)?; + } + + // Apply sort if specified + if let Some(sort_field) = &sort { + collection_entries = apply_simple_sort(collection_entries, sort_field)?; + } + + let total = collection_entries.len(); + let mut items = Vec::new(); + let mut skipped = 0; + + for (_, item) in collection_entries { + if skipped < offset { + skipped += 1; + continue; + } + + if items.len() >= limit { + break; + } + + items.push(item); + } + Ok(json!({ "collection": name, "data": items, @@ -779,6 +1066,107 @@ async fn get_collection_data(name: &str, limit: usize, offset: usize) -> Result< })) } +/// Apply simple filter to collection entries +fn apply_simple_filter( + entries: Vec<(String, serde_json::Value)>, + filter_expr: &str, +) -> Result> { + // Simple filter format: "field=value" or "field!=value" or "field~value" (contains) + let filtered = if let Some(eq_pos) = filter_expr.find("!=") { + let (field, value) = filter_expr.split_at(eq_pos); + let value = &value[2..]; // Skip "!=" + entries + .into_iter() + .filter(|(_, entry)| { + entry + .get(field) + .and_then(|v| v.as_str()) + .map(|s| s != value) + .unwrap_or(true) + }) + .collect() + } else if let Some(eq_pos) = filter_expr.find('=') { + let (field, value) = filter_expr.split_at(eq_pos); + let value = &value[1..]; // Skip "=" + entries + .into_iter() + .filter(|(_, entry)| { + entry + .get(field) + .and_then(|v| v.as_str()) + .map(|s| s == value) + .unwrap_or(false) + }) + .collect() + } else if let Some(tilde_pos) = filter_expr.find('~') { + let (field, value) = filter_expr.split_at(tilde_pos); + let value = &value[1..]; // Skip "~" + entries + .into_iter() + .filter(|(_, entry)| { + entry + .get(field) + .and_then(|v| v.as_str()) + .map(|s| s.contains(value)) + .unwrap_or(false) + }) + .collect() + } else { + entries // No valid filter format, return all + }; + + Ok(filtered) +} + +/// Apply simple sort to collection entries +fn apply_simple_sort( + mut entries: Vec<(String, serde_json::Value)>, + sort_field: &str, +) -> Result> { + let (field, descending) = if let Some(field) = sort_field.strip_suffix(":desc") { + (field, true) + } else if let Some(field) = sort_field.strip_suffix(":asc") { + (field, false) + } else { + (sort_field, false) // Default to ascending + }; + + entries.sort_by(|(_, a), (_, b)| { + let a_val = a.get(field); + let b_val = b.get(field); + + let cmp = match (a_val, b_val) { + (Some(a), Some(b)) => { + // Try string comparison first + if let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) { + a_str.cmp(b_str) + } + // Try number comparison + else if let (Some(a_num), Some(b_num)) = (a.as_f64(), b.as_f64()) { + a_num + .partial_cmp(&b_num) + .unwrap_or(std::cmp::Ordering::Equal) + } + // Fallback to string representation + else { + a.to_string().cmp(&b.to_string()) + } + } + (Some(_), None) => std::cmp::Ordering::Less, // Non-null values come first + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }; + + if descending { + cmp.reverse() + } else { + cmp + } + }); + + Ok(entries) +} + fn analyze_schema(items: &[Value]) -> Value { if items.is_empty() { return json!({ diff --git a/crates/prkdb-cli/src/database_manager.rs b/crates/prkdb-cli/src/database_manager.rs index 3c24414..1c167db 100644 --- a/crates/prkdb-cli/src/database_manager.rs +++ b/crates/prkdb-cli/src/database_manager.rs @@ -6,17 +6,30 @@ use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; +/// Configuration options for Raft cluster +#[derive(Clone, Debug)] +pub struct RaftOptions { + pub node_id: u64, + pub listen_addr: std::net::SocketAddr, + pub peers: Vec<(u64, std::net::SocketAddr)>, + pub num_partitions: usize, +} + /// Database connection manager that handles Sled's exclusive locking properly pub struct DatabaseManager { connection: Arc>>, + adapter: Arc>>, database_path: String, + raft_options: Option, } impl DatabaseManager { - pub fn new(database_path: impl AsRef) -> Self { + pub fn new(database_path: impl AsRef, raft_options: Option) -> Self { Self { connection: Arc::new(Mutex::new(None)), + adapter: Arc::new(Mutex::new(None)), database_path: database_path.as_ref().to_string_lossy().to_string(), + raft_options, } } @@ -53,15 +66,41 @@ impl DatabaseManager { let mut conn_guard = self.connection.lock().unwrap(); if conn_guard.is_none() { - let storage = SledAdapter::open(&self.database_path) - .with_context(|| format!("Failed to open database at: {}", self.database_path))?; - - let db = PrkDb::builder() - .with_storage(storage) - .build() - .context("Failed to build PrkDb instance")?; - - *conn_guard = Some(db); + // Check if Raft is enabled + if let Some(raft_opts) = &self.raft_options { + println!( + "🚀 Initializing PrkDB with Multi-Raft (Node ID: {})", + raft_opts.node_id + ); + + let config = prkdb::raft::ClusterConfig { + local_node_id: raft_opts.node_id, + listen_addr: raft_opts.listen_addr, + nodes: raft_opts.peers.clone(), + ..Default::default() + }; + + let db = PrkDb::new_multi_raft( + raft_opts.num_partitions, + config, + std::path::PathBuf::from(&self.database_path), + )?; + + *conn_guard = Some(db); + } else { + let storage = SledAdapter::open(&self.database_path).with_context(|| { + format!("Failed to open database at: {}", self.database_path) + })?; + + *self.adapter.lock().unwrap() = Some(storage.clone()); + + let db = PrkDb::builder() + .with_storage(storage) + .build() + .context("Failed to build PrkDb instance")?; + + *conn_guard = Some(db); + } } Ok(conn_guard.as_ref().unwrap().clone()) @@ -93,38 +132,19 @@ impl DatabaseManager { /// Get storage adapter directly for simple scan operations pub async fn scan_storage(&self) -> Result, Vec)>> { - const MAX_RETRIES: usize = 3; - const RETRY_DELAY: Duration = Duration::from_millis(50); + // Ensure initialized + let _ = self.try_get_connection()?; - for attempt in 0..MAX_RETRIES { - match SledAdapter::open(&self.database_path) { - Ok(storage) => match storage.scan_prefix(b"").await { - Ok(result) => return Ok(result), - Err(e) - if attempt < MAX_RETRIES - 1 - && e.to_string().contains("could not acquire lock") => - { - tokio::time::sleep(RETRY_DELAY * (attempt as u32 + 1)).await; - continue; - } - Err(e) => return Err(e.into()), - }, - Err(e) - if attempt < MAX_RETRIES - 1 - && e.to_string().contains("could not acquire lock") => - { - tokio::time::sleep(RETRY_DELAY * (attempt as u32 + 1)).await; - continue; - } - Err(e) => return Err(e.into()), - } + let adapter = { + let adapter_guard = self.adapter.lock().unwrap(); + adapter_guard.clone() + }; + + if let Some(adapter) = adapter { + return adapter.scan_prefix(b"").await.map_err(|e| e.into()); } - Err(anyhow::anyhow!( - "Database is currently busy and cannot be accessed. \ - This may happen when another process is writing to the database. \ - Please try again in a moment." - )) + Err(anyhow::anyhow!("Database adapter not available")) } } @@ -133,9 +153,9 @@ static mut DB_MANAGER: Option = None; static INIT: std::sync::Once = std::sync::Once::new(); /// Initialize the global database manager -pub fn init_database_manager(database_path: impl AsRef) { +pub fn init_database_manager(database_path: impl AsRef, raft_options: Option) { INIT.call_once(|| unsafe { - DB_MANAGER = Some(DatabaseManager::new(database_path)); + DB_MANAGER = Some(DatabaseManager::new(database_path, raft_options)); }); } diff --git a/crates/prkdb-cli/src/main.rs b/crates/prkdb-cli/src/main.rs index e2f49b8..23d7910 100644 --- a/crates/prkdb-cli/src/main.rs +++ b/crates/prkdb-cli/src/main.rs @@ -92,6 +92,15 @@ pub enum Commands { /// Port to serve gRPC on (for Admin & Raft) #[arg(long, default_value = "50051")] grpc_port: u16, + /// Node ID for Raft cluster + #[arg(long, default_value = "1")] + id: u64, + /// Peers in the cluster (format: "id=host:port,id=host:port") + #[arg(long)] + peers: Option, + /// Number of partitions for default collection creation + #[arg(long, default_value = "16")] + num_partitions: usize, }, // --- Data Commands (via prkdb-client) --- @@ -115,6 +124,12 @@ pub enum Commands { /// Restore database (Offline) Restore(backup::RestoreArgs), + + /// Generate cross-language SDK clients from schemas + Codegen(codegen::CodegenArgs), + + /// Schema registry management + Schema(schema::SchemaArgs), } #[derive(clap::ValueEnum, Clone)] @@ -129,7 +144,11 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); // Initialize logging based on verbosity - if cli.verbose { + // Initialize logging based on verbosity or RUST_LOG env var + if cli.verbose || std::env::var("RUST_LOG").is_ok() { + tracing_subscriber::fmt::init(); + } else if cli.verbose { + // Fallback if RUST_LOG not set but verbose is on (though fmt::init handles defaults) tracing_subscriber::fmt::init(); } @@ -140,11 +159,11 @@ async fn main() -> anyhow::Result<()> { Commands::Partition(cmd) => partition::execute(cmd.clone(), &cli).await, Commands::Replication(cmd) => replication::execute(cmd.clone(), &cli).await, Commands::Metrics(cmd) => { - init_database_manager(&cli.database); + init_database_manager(&cli.database, None); metrics::execute(cmd.clone(), &cli).await } Commands::Database(cmd) => { - init_database_manager(&cli.database); + init_database_manager(&cli.database, None); database::execute(cmd.clone(), &cli).await } Commands::Serve { @@ -154,8 +173,41 @@ async fn main() -> anyhow::Result<()> { prometheus, cors, websockets, + id, + peers, + num_partitions, } => { - init_database_manager(&cli.database); + let raft_options = if let Some(peers_str) = peers { + let peers_vec: Vec<(u64, std::net::SocketAddr)> = peers_str + .split(',') + .filter_map(|s| { + let parts: Vec<&str> = s.split('=').collect(); + if parts.len() == 2 { + let id = parts[0].parse().ok()?; + let addr = parts[1].parse().ok()?; + Some((id, addr)) + } else { + None + } + }) + .collect(); + + Some(database_manager::RaftOptions { + node_id: *id, + listen_addr: format!("{}:{}", host, grpc_port).parse().unwrap(), + peers: peers_vec, + num_partitions: *num_partitions, + }) + } else { + None + }; + + let peers_for_serve = raft_options + .as_ref() + .map(|r| r.peers.clone()) + .unwrap_or_default(); + + init_database_manager(&cli.database, raft_options); let args = commands::serve::ServeArgs { port: *port, grpc_port: *grpc_port, @@ -163,6 +215,8 @@ async fn main() -> anyhow::Result<()> { prometheus: *prometheus, cors: *cors, websockets: *websockets, + id: *id, + peers: peers_for_serve, }; commands::serve::handle_serve(args).await } @@ -176,5 +230,11 @@ async fn main() -> anyhow::Result<()> { // Backup/Restore commands (Offline) Commands::Backup(args) => backup::handle_backup(args.clone(), &cli).await, Commands::Restore(args) => backup::handle_restore(args.clone()).await, + + // Codegen command (pure remote) + Commands::Codegen(args) => codegen::handle_codegen(args.clone()).await, + + // Schema command (pure remote) + Commands::Schema(args) => schema::handle_schema(args.clone()).await, } } diff --git a/crates/prkdb-client/src/client.rs b/crates/prkdb-client/src/client.rs index e591075..fff6ff8 100644 --- a/crates/prkdb-client/src/client.rs +++ b/crates/prkdb-client/src/client.rs @@ -1,12 +1,13 @@ use prkdb_proto::raft::prk_db_service_client::PrkDbServiceClient; use prkdb_proto::raft::{ - CreateCollectionRequest, CreateCollectionResponse, DescribeConsumerGroupRequest, - DescribeConsumerGroupResponse, DropCollectionRequest, DropCollectionResponse, - GetPartitionAssignmentsRequest, GetPartitionAssignmentsResponse, GetReplicationLagRequest, - GetReplicationLagResponse, GetReplicationNodesRequest, GetReplicationNodesResponse, - GetReplicationStatusRequest, GetReplicationStatusResponse, ListCollectionsRequest, + CheckCompatibilityRequest, CompatibilityMode, CreateCollectionRequest, + CreateCollectionResponse, DescribeConsumerGroupRequest, DescribeConsumerGroupResponse, + DropCollectionRequest, DropCollectionResponse, GetPartitionAssignmentsRequest, + GetPartitionAssignmentsResponse, GetReplicationLagRequest, GetReplicationLagResponse, + GetReplicationNodesRequest, GetReplicationNodesResponse, GetReplicationStatusRequest, + GetReplicationStatusResponse, GetSchemaRequest, ListCollectionsRequest, ListCollectionsResponse, ListConsumerGroupsRequest, ListConsumerGroupsResponse, - ListPartitionsRequest, ListPartitionsResponse, + ListPartitionsRequest, ListPartitionsResponse, ListSchemasRequest, RegisterSchemaRequest, }; use prkdb_proto::{ BatchPutRequest, DeleteRequest, GetRequest, KvPair, MetadataRequest, PutRequest, ReadMode, @@ -781,13 +782,20 @@ impl PrkDbClient { // --- Admin Operations --- /// Create a new collection - pub async fn create_collection(&self, name: &str) -> anyhow::Result<()> { + pub async fn create_collection( + &self, + name: &str, + num_partitions: u32, + replication_factor: u32, + ) -> anyhow::Result<()> { let mut client = self.get_any_client().await?; let token = self.admin_token.clone().unwrap_or_default(); let request = tonic::Request::new(CreateCollectionRequest { admin_token: token, name: name.to_string(), + num_partitions, + replication_factor, }); let response: Response = @@ -1184,6 +1192,111 @@ impl PrkDbClient { Ok(stream) } + + // ───────────────────────────────────────────────────────────────────────── + // Schema Registry Operations (for codegen CLI) + // ───────────────────────────────────────────────────────────────────────── + + /// Register a new schema for a collection + pub async fn register_schema( + &self, + collection: &str, + schema_proto: Vec, + compatibility: CompatibilityMode, + migration_id: Option, + ) -> anyhow::Result { + let mut client = self.get_any_client().await?; + let token = self.admin_token.clone().unwrap_or_default(); + + let request = tonic::Request::new(RegisterSchemaRequest { + admin_token: token, + collection: collection.to_string(), + schema_proto, + compatibility: compatibility as i32, + migration_id, + }); + + let response = client.register_schema(request).await?.into_inner(); + + if response.success { + Ok(response.version) + } else { + anyhow::bail!("RegisterSchema failed: {}", response.error) + } + } + + /// Check compatibility of a schema + pub async fn check_compatibility( + &self, + collection: &str, + schema_proto: Vec, + ) -> anyhow::Result { + let mut client = self.get_any_client().await?; + + let request = tonic::Request::new(CheckCompatibilityRequest { + collection: collection.to_string(), + schema_proto, + }); + + let response = client.check_compatibility(request).await?.into_inner(); + Ok(response.compatible) + } + + /// Get schema for a collection + /// + /// # Arguments + /// * `collection` - Collection name + /// * `version` - Optional version (None = latest) + pub async fn get_schema( + &self, + collection: &str, + version: Option, + ) -> anyhow::Result> { + let mut client = self.get_any_client().await?; + + let request = tonic::Request::new(GetSchemaRequest { + collection: collection.to_string(), + version: version.unwrap_or(0), + }); + + let response = client.get_schema(request).await?.into_inner(); + + if response.success { + Ok(response.schema_proto) + } else { + anyhow::bail!("GetSchema failed: {}", response.error) + } + } + + /// List all registered schemas + pub async fn list_schemas(&self) -> anyhow::Result> { + let mut client = self.get_any_client().await?; + let token = self.admin_token.clone().unwrap_or_default(); + + let request = tonic::Request::new(ListSchemasRequest { admin_token: token }); + + let response = client.list_schemas(request).await?.into_inner(); + + if response.success { + Ok(response + .schemas + .into_iter() + .map(|s| SchemaInfo { + collection: s.collection, + latest_version: s.latest_version, + }) + .collect()) + } else { + anyhow::bail!("ListSchemas failed") + } + } +} + +/// Schema information returned by list_schemas +#[derive(Debug, Clone)] +pub struct SchemaInfo { + pub collection: String, + pub latest_version: u32, } #[cfg(test)] diff --git a/crates/prkdb-client/src/lib.rs b/crates/prkdb-client/src/lib.rs index 5f045e7..91daa0e 100644 --- a/crates/prkdb-client/src/lib.rs +++ b/crates/prkdb-client/src/lib.rs @@ -47,10 +47,11 @@ mod client; pub mod ws; -pub use client::{ClientConfig, PrkDbClient, ReadConsistency}; +pub use client::{ClientConfig, PrkDbClient, ReadConsistency, SchemaInfo}; pub use ws::{WsConfig, WsConsumer, WsEvent}; // Re-export commonly used types from prkdb-proto for convenience +pub use prkdb_proto::raft::CompatibilityMode; pub use prkdb_proto::{ DeleteRequest, DeleteResponse, GetRequest, GetResponse, MetadataRequest, MetadataResponse, NodeInfo, PartitionInfo, PutRequest, PutResponse, ReadMode, diff --git a/crates/prkdb-macros/src/lib.rs b/crates/prkdb-macros/src/lib.rs index 4a13492..2ab34c0 100644 --- a/crates/prkdb-macros/src/lib.rs +++ b/crates/prkdb-macros/src/lib.rs @@ -91,6 +91,56 @@ pub fn collection_derive(input: TokenStream) -> TokenStream { }) .collect(); + // Generate ProtoSchema field definitions + let field_defs: Vec<_> = all_fields + .iter() + .enumerate() + .map(|(idx, (name, ty))| { + let name_str = name.to_string(); + let field_number = (idx + 1) as i32; + let type_str = quote!(#ty).to_string(); + + // Determine if type is Option<_> or Vec<_> + let is_optional = type_str.starts_with("Option <") || type_str.starts_with("Option<"); + let is_repeated = (type_str.starts_with("Vec <") || type_str.starts_with("Vec<")) + && !type_str.contains("u8"); // Vec is bytes, not repeated + + // Map Rust type to ProtoType + let proto_type = if type_str.contains("String") || type_str.contains("str") { + quote! { prkdb_types::schema::ProtoType::String } + } else if type_str.contains("Vec < u8") || type_str.contains("Vec = all_fields .iter() @@ -111,8 +161,10 @@ pub fn collection_derive(input: TokenStream) -> TokenStream { self.filter(move |r| r.#name == value) } } - } else if type_str.contains("String") || type_str.contains("str") { - // String type: eq, contains, starts_with + } else if (type_str.contains("String") || type_str.contains("str")) + && !type_str.starts_with("Option") + { + // String type (not Optional): eq, contains, starts_with let where_contains = format_ident!("where_{}_contains", name); let where_starts_with = format_ident!("where_{}_starts_with", name); quote! { @@ -243,6 +295,51 @@ pub fn collection_derive(input: TokenStream) -> TokenStream { } } + // ProtoSchema trait implementation for cross-language SDK support + impl prkdb_types::schema::ProtoSchema for #struct_name { + fn collection_name() -> &'static str { + // Use struct name in snake_case as collection name + stringify!(#struct_name) + } + + fn field_definitions() -> &'static [prkdb_types::schema::FieldDef] { + static FIELDS: &[prkdb_types::schema::FieldDef] = &[ + #(#field_defs),* + ]; + FIELDS + } + + fn schema_proto() -> Vec { + // Build a minimal FileDescriptorProto representation + // Format: simple binary format that can be parsed by prkdb-schema + // [name_len:u32][name:utf8][field_count:u32][fields...] + // Each field: [name_len:u32][name:utf8][number:i32][type:i32][optional:u8][repeated:u8] + let mut bytes = Vec::with_capacity(256); + let name = stringify!(#struct_name); + let name_bytes = name.as_bytes(); + + // Write message name + bytes.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes()); + bytes.extend_from_slice(name_bytes); + + // Write field count and field definitions + let fields = Self::field_definitions(); + bytes.extend_from_slice(&(fields.len() as u32).to_le_bytes()); + + for field in fields { + let field_name = field.name.as_bytes(); + bytes.extend_from_slice(&(field_name.len() as u32).to_le_bytes()); + bytes.extend_from_slice(field_name); + bytes.extend_from_slice(&field.field_number.to_le_bytes()); + bytes.extend_from_slice(&field.proto_type.as_i32().to_le_bytes()); + bytes.push(if field.is_optional { 1 } else { 0 }); + bytes.push(if field.is_repeated { 1 } else { 0 }); + } + + bytes + } + } + /// Type-safe field name accessors #[derive(Debug, Clone, Copy)] pub struct #fields_struct_name { diff --git a/crates/prkdb-proto/proto/raft.proto b/crates/prkdb-proto/proto/raft.proto index 5d7d36e..085bc00 100644 --- a/crates/prkdb-proto/proto/raft.proto +++ b/crates/prkdb-proto/proto/raft.proto @@ -99,6 +99,22 @@ service PrkDbService { // Fetch raw WAL segment data for zero-copy consumption // Streams raw bytes to bypass Protobuf serialization overhead rpc FetchSegment (FetchSegmentRequest) returns (stream RawChunk); + + // ───────────────────────────────────────────────────────────────────────────── + // Schema Registry (Cross-Language SDK Support) + // ───────────────────────────────────────────────────────────────────────────── + + // Register a schema for a collection (creates or updates version) + rpc RegisterSchema (RegisterSchemaRequest) returns (RegisterSchemaResponse); + + // Get schema for a collection (specific version or latest) + rpc GetSchema (GetSchemaRequest) returns (GetSchemaResponse); + + // List all registered schemas + rpc ListSchemas (ListSchemasRequest) returns (ListSchemasResponse); + + // Check if a new schema is compatible with existing versions + rpc CheckCompatibility (CheckCompatibilityRequest) returns (CheckCompatibilityResponse); } message RequestVoteRequest { @@ -258,6 +274,8 @@ message ReadIndexResponse { message CreateCollectionRequest { string admin_token = 1; string name = 2; + uint32 num_partitions = 3; + uint32 replication_factor = 4; } message CreateCollectionResponse { @@ -514,3 +532,81 @@ message RawChunk { uint64 end_offset = 3; // Offset after last record in this chunk bool has_more = 4; // More data available after this chunk } + +// ───────────────────────────────────────────────────────────────────────────── +// Schema Registry Messages (Cross-Language SDK Support) +// ───────────────────────────────────────────────────────────────────────────── + +// Compatibility modes for schema evolution +enum CompatibilityMode { + COMPATIBILITY_NONE = 0; // No compatibility checking + COMPATIBILITY_BACKWARD = 1; // New schema can read old data (default) + COMPATIBILITY_FORWARD = 2; // Old schema can read new data + COMPATIBILITY_FULL = 3; // Both backward and forward compatible +} + +// Request to register a new schema version for a collection +message RegisterSchemaRequest { + string admin_token = 1; + string collection = 2; // Collection name (e.g., "users") + bytes schema_proto = 3; // FileDescriptorProto bytes + CompatibilityMode compatibility = 4; // Compatibility mode to enforce + optional string migration_id = 5; // Required if change is breaking +} + +message RegisterSchemaResponse { + bool success = 1; + uint32 schema_id = 2; // Unique schema ID + uint32 version = 3; // Auto-incremented version number + bool is_breaking = 4; // True if breaking change detected + string error = 5; +} + +// Request to get schema for a collection +message GetSchemaRequest { + string collection = 1; + uint32 version = 2; // 0 = latest version +} + +message GetSchemaResponse { + bool success = 1; + bytes schema_proto = 2; // FileDescriptorProto bytes + uint32 version = 3; + uint32 schema_id = 4; + CompatibilityMode compatibility = 5; + uint64 created_at = 6; // Unix timestamp ms + string error = 7; +} + +// Request to list all registered schemas +message ListSchemasRequest { + string admin_token = 1; +} + +message SchemaInfo { + string collection = 1; + uint32 latest_version = 2; + uint32 schema_id = 3; + CompatibilityMode compatibility = 4; + uint64 created_at = 5; + uint64 updated_at = 6; +} + +message ListSchemasResponse { + bool success = 1; + repeated SchemaInfo schemas = 2; + string error = 3; +} + +// Request to check compatibility before registering +message CheckCompatibilityRequest { + string collection = 1; + bytes schema_proto = 2; // New schema to check +} + +message CheckCompatibilityResponse { + bool compatible = 1; + bool is_breaking = 2; // True if breaking change detected + repeated string errors = 3; // Compatibility errors if any + repeated string warnings = 4; // Non-blocking warnings +} diff --git a/crates/prkdb-schema/Cargo.toml b/crates/prkdb-schema/Cargo.toml new file mode 100644 index 0000000..39be0d5 --- /dev/null +++ b/crates/prkdb-schema/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "prkdb-schema" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Schema Registry for PrkDB - stores and validates collection schemas for cross-language SDK support" + +[dependencies] +# Core dependencies +prkdb-proto = { workspace = true } +prkdb-types = { workspace = true } + +# Serialization +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +bincode = { workspace = true } + +# Protobuf handling +prost = { workspace = true } +prost-types = { workspace = true } + +# Async runtime +tokio = { workspace = true } +async-trait = { workspace = true } + +# Error handling +thiserror = { workspace = true } +anyhow = { workspace = true } + +# Utilities +tracing = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } +tempfile = "3.24" diff --git a/crates/prkdb-schema/src/compatibility.rs b/crates/prkdb-schema/src/compatibility.rs new file mode 100644 index 0000000..2479253 --- /dev/null +++ b/crates/prkdb-schema/src/compatibility.rs @@ -0,0 +1,219 @@ +//! Schema compatibility checking. +//! +//! Detects breaking changes between schema versions. + +use crate::types::{CompatibilityMode, CompatibilityResult}; +use prost::Message; +use prost_types::{FileDescriptorProto, FileDescriptorSet}; +use std::collections::HashSet; + +/// Checks compatibility between schema versions. +pub struct CompatibilityChecker; + +impl CompatibilityChecker { + /// Check if a new schema is compatible with an existing schema. + pub fn check(existing: &[u8], new: &[u8], mode: CompatibilityMode) -> CompatibilityResult { + // For None mode, skip all checking + if mode == CompatibilityMode::None { + return CompatibilityResult::default(); + } + + let mut result = CompatibilityResult::default(); + + // Parse descriptors + let existing_desc = match Self::decode_descriptor(existing) { + Ok(d) => d, + Err(e) => { + result.compatible = false; + result + .errors + .push(format!("Failed to parse existing schema: {}", e)); + return result; + } + }; + + let new_desc = match Self::decode_descriptor(new) { + Ok(d) => d, + Err(e) => { + result.compatible = false; + result + .errors + .push(format!("Failed to parse new schema: {}", e)); + return result; + } + }; + + // Check based on compatibility mode + match mode { + CompatibilityMode::None => { + // No checking, always compatible + } + CompatibilityMode::Backward => { + Self::check_backward(&existing_desc, &new_desc, &mut result); + } + CompatibilityMode::Forward => { + Self::check_forward(&existing_desc, &new_desc, &mut result); + } + CompatibilityMode::Full => { + Self::check_backward(&existing_desc, &new_desc, &mut result); + Self::check_forward(&existing_desc, &new_desc, &mut result); + } + } + + result + } + + /// Helper to decode schema bytes as either FileDescriptorSet or FileDescriptorProto + fn decode_descriptor(bytes: &[u8]) -> Result { + // Try decoding as FileDescriptorSet first + if let Ok(set) = FileDescriptorSet::decode(bytes) { + if let Some(file) = set.file.into_iter().next() { + return Ok(file); + } + } + + // Fallback to FileDescriptorProto + FileDescriptorProto::decode(bytes).map_err(|e| e.to_string()) + } + + /// Check backward compatibility (new schema can read old data). + fn check_backward( + existing: &FileDescriptorProto, + new: &FileDescriptorProto, + result: &mut CompatibilityResult, + ) { + // Get existing field numbers + let existing_fields: HashSet = existing + .message_type + .iter() + .flat_map(|m| m.field.iter().map(|f| f.number())) + .collect(); + + let new_fields: HashSet = new + .message_type + .iter() + .flat_map(|m| m.field.iter().map(|f| f.number())) + .collect(); + + // Check for removed required fields + for field_num in existing_fields.difference(&new_fields) { + // Check if the removed field was required + if let Some(field) = existing + .message_type + .iter() + .flat_map(|m| m.field.iter()) + .find(|f| f.number() == *field_num) + { + let field_name = field.name(); + result.is_breaking = true; + result.errors.push(format!( + "Field '{}' (number {}) was removed - breaking for backward compatibility", + field_name, field_num + )); + } + } + + // Check for type changes on existing fields + for msg in &new.message_type { + for field in &msg.field { + if let Some(old_msg) = existing + .message_type + .iter() + .find(|m| m.name() == msg.name()) + { + if let Some(old_field) = + old_msg.field.iter().find(|f| f.number() == field.number()) + { + if old_field.r#type() != field.r#type() { + result.is_breaking = true; + result.errors.push(format!( + "Field '{}' type changed from {:?} to {:?} - breaking change", + field.name(), + old_field.r#type(), + field.r#type() + )); + } + } + } + } + } + + // New fields are fine for backward compatibility (they'll have defaults) + for field_num in new_fields.difference(&existing_fields) { + if let Some(field) = new + .message_type + .iter() + .flat_map(|m| m.field.iter()) + .find(|f| f.number() == *field_num) + { + result.warnings.push(format!( + "New field '{}' (number {}) added - ensure it has a default value", + field.name(), + field_num + )); + } + } + + result.compatible = result.errors.is_empty(); + } + + /// Check forward compatibility (old schema can read new data). + fn check_forward( + existing: &FileDescriptorProto, + new: &FileDescriptorProto, + result: &mut CompatibilityResult, + ) { + let new_fields: HashSet = new + .message_type + .iter() + .flat_map(|m| m.field.iter().map(|f| f.number())) + .collect(); + + let existing_fields: HashSet = existing + .message_type + .iter() + .flat_map(|m| m.field.iter().map(|f| f.number())) + .collect(); + + // For forward compatibility, new required fields break things + for field_num in new_fields.difference(&existing_fields) { + if let Some(field) = new + .message_type + .iter() + .flat_map(|m| m.field.iter()) + .find(|f| f.number() == *field_num) + { + // Note: In proto3, all fields are optional by default + result.warnings.push(format!( + "New field '{}' (number {}) - old clients will ignore this", + field.name(), + field_num + )); + } + } + + // If there are no errors from backward check, forward is compatible + // (proto3 ignores unknown fields by default) + } + + /// Quick check if a change is breaking (for auto-versioning). + pub fn is_breaking(existing: &[u8], new: &[u8]) -> bool { + let result = Self::check(existing, new, CompatibilityMode::Backward); + result.is_breaking + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_schemas() { + let empty = FileDescriptorProto::default(); + let bytes = empty.encode_to_vec(); + + let result = CompatibilityChecker::check(&bytes, &bytes, CompatibilityMode::Backward); + assert!(result.compatible); + assert!(!result.is_breaking); + } +} diff --git a/crates/prkdb-schema/src/error.rs b/crates/prkdb-schema/src/error.rs new file mode 100644 index 0000000..2bb5c8a --- /dev/null +++ b/crates/prkdb-schema/src/error.rs @@ -0,0 +1,47 @@ +//! Error types for the schema registry. + +use thiserror::Error; + +/// Schema registry errors. +#[derive(Debug, Error)] +pub enum SchemaError { + /// Schema not found for collection + #[error("Schema not found for collection '{collection}'")] + NotFound { collection: String }, + + /// Schema version not found + #[error("Schema version {version} not found for collection '{collection}'")] + VersionNotFound { collection: String, version: u32 }, + + /// Breaking change without migration + #[error( + "Breaking change detected for '{collection}': {reason}. Provide a migration_id to proceed." + )] + BreakingChangeWithoutMigration { collection: String, reason: String }, + + /// Compatibility check failed + #[error("Schema incompatible with '{collection}': {errors:?}")] + IncompatibleSchema { + collection: String, + errors: Vec, + }, + + /// Invalid schema descriptor + #[error("Invalid schema descriptor: {0}")] + InvalidDescriptor(String), + + /// Storage error + #[error("Storage error: {0}")] + Storage(String), + + /// Serialization error + #[error("Serialization error: {0}")] + Serialization(String), + + /// Collection already exists (for first registration) + #[error("Collection '{0}' already has a schema registered")] + AlreadyExists(String), +} + +/// Result type for schema operations. +pub type SchemaResult = Result; diff --git a/crates/prkdb-schema/src/lib.rs b/crates/prkdb-schema/src/lib.rs new file mode 100644 index 0000000..3d0058f --- /dev/null +++ b/crates/prkdb-schema/src/lib.rs @@ -0,0 +1,45 @@ +//! # prkdb-schema +//! +//! Schema Registry for PrkDB - enables cross-language SDK support with type-safe +//! schema definitions using Protocol Buffers. +//! +//! ## Features +//! +//! - **Schema Storage**: Store Protobuf `FileDescriptorProto` for each collection +//! - **Versioning**: Auto-increment versions with breaking change detection +//! - **Compatibility**: Forward, backward, and full compatibility checking +//! - **Migrations**: Require migration functions for breaking changes +//! +//! ## Usage +//! +//! ```rust,ignore +//! use prkdb_schema::{SchemaRegistry, CompatibilityMode}; +//! +//! let registry = SchemaRegistry::new(storage); +//! +//! // Register a schema +//! let version = registry.register( +//! "users", +//! proto_bytes, +//! CompatibilityMode::Backward, +//! None, +//! ).await?; +//! +//! // Get latest schema +//! let schema = registry.get("users", None).await?; +//! +//! // Check compatibility before registering +//! let result = registry.check_compatibility("users", new_proto_bytes).await?; +//! ``` + +mod compatibility; +mod error; +mod registry; +mod storage; +mod types; + +pub use compatibility::CompatibilityChecker; +pub use error::{SchemaError, SchemaResult}; +pub use registry::SchemaRegistry; +pub use storage::{FileSchemaStorage, InMemorySchemaStorage, SchemaStorage}; +pub use types::{CompatibilityMode, CompatibilityResult, Schema, SchemaInfo, SchemaVersion}; diff --git a/crates/prkdb-schema/src/registry.rs b/crates/prkdb-schema/src/registry.rs new file mode 100644 index 0000000..49e53d6 --- /dev/null +++ b/crates/prkdb-schema/src/registry.rs @@ -0,0 +1,239 @@ +//! Schema Registry - the main interface for schema operations. + +use crate::compatibility::CompatibilityChecker; +use crate::error::{SchemaError, SchemaResult}; +use crate::storage::SchemaStorage; +use crate::types::{CompatibilityMode, CompatibilityResult, Schema, SchemaInfo}; +use std::sync::Arc; +use tracing::{debug, info, warn}; + +/// Schema Registry for PrkDB. +/// +/// Stores and validates collection schemas for cross-language SDK support. +pub struct SchemaRegistry { + storage: Arc, +} + +impl SchemaRegistry { + /// Create a new schema registry with the given storage backend. + pub fn new(storage: Arc) -> Self { + Self { storage } + } + + /// Register a new schema for a collection. + /// + /// If the collection already has a schema, this will create a new version + /// after checking compatibility. + /// + /// # Arguments + /// * `collection` - Collection name + /// * `schema_proto` - Serialized FileDescriptorProto bytes + /// * `compatibility` - Compatibility mode to enforce + /// * `migration_id` - Required if the change is breaking + /// + /// # Returns + /// The registered schema with version info. + pub async fn register( + &self, + collection: &str, + schema_proto: Vec, + compatibility: CompatibilityMode, + migration_id: Option, + ) -> SchemaResult { + info!("Registering schema for collection '{}'", collection); + + // Check if there's an existing schema + let existing = self.storage.get_latest(collection).await?; + + let (is_breaking, version) = if let Some(ref existing_schema) = existing { + // Check compatibility with existing schema + let compat_result = CompatibilityChecker::check( + &existing_schema.descriptor, + &schema_proto, + compatibility, + ); + + if !compat_result.compatible { + if compat_result.is_breaking && migration_id.is_none() { + return Err(SchemaError::BreakingChangeWithoutMigration { + collection: collection.to_string(), + reason: compat_result.errors.join("; "), + }); + } + + if !compat_result.is_breaking { + return Err(SchemaError::IncompatibleSchema { + collection: collection.to_string(), + errors: compat_result.errors, + }); + } + } + + // Log warnings + for warning in &compat_result.warnings { + warn!("Schema warning for '{}': {}", collection, warning); + } + + let next_version = self.storage.next_version(collection).await?; + (compat_result.is_breaking, next_version) + } else { + // First schema for this collection + (false, 1) + }; + + // Get next schema ID + let schema_id = self.storage.next_schema_id().await?; + + // Create schema record + let now = chrono::Utc::now().timestamp_millis() as u64; + let schema = Schema { + schema_id, + collection: collection.to_string(), + version, + descriptor: schema_proto, + compatibility, + is_breaking, + migration_id, + created_at: now, + }; + + // Store it + self.storage.put(&schema).await?; + + info!( + "Registered schema for '{}' version {} (id={}, breaking={})", + collection, version, schema_id, is_breaking + ); + + Ok(schema) + } + + /// Get a schema for a collection. + /// + /// # Arguments + /// * `collection` - Collection name + /// * `version` - Specific version, or None for latest + pub async fn get(&self, collection: &str, version: Option) -> SchemaResult { + let schema = if let Some(v) = version { + self.storage.get(collection, v).await? + } else { + self.storage.get_latest(collection).await? + }; + + schema.ok_or_else(|| { + if let Some(v) = version { + SchemaError::VersionNotFound { + collection: collection.to_string(), + version: v, + } + } else { + SchemaError::NotFound { + collection: collection.to_string(), + } + } + }) + } + + /// List all registered schemas. + pub async fn list(&self) -> SchemaResult> { + self.storage.list().await + } + + /// Check if a new schema is compatible with existing versions. + pub async fn check_compatibility( + &self, + collection: &str, + new_schema: &[u8], + ) -> SchemaResult { + let existing = self.storage.get_latest(collection).await?; + + match existing { + Some(schema) => { + let result = CompatibilityChecker::check( + &schema.descriptor, + new_schema, + schema.compatibility, + ); + Ok(result) + } + None => { + // No existing schema, new schema is always compatible + Ok(CompatibilityResult::default()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::InMemorySchemaStorage; + use prost::Message; + use prost_types::FileDescriptorProto; + + fn create_test_registry() -> SchemaRegistry { + SchemaRegistry::new(Arc::new(InMemorySchemaStorage::new())) + } + + fn create_empty_proto() -> Vec { + FileDescriptorProto::default().encode_to_vec() + } + + #[tokio::test] + async fn test_register_first_schema() { + let registry = create_test_registry(); + let proto = create_empty_proto(); + + let schema = registry + .register("users", proto, CompatibilityMode::Backward, None) + .await + .unwrap(); + + assert_eq!(schema.collection, "users"); + assert_eq!(schema.version, 1); + assert!(!schema.is_breaking); + } + + #[tokio::test] + async fn test_get_schema() { + let registry = create_test_registry(); + let proto = create_empty_proto(); + + registry + .register("users", proto.clone(), CompatibilityMode::Backward, None) + .await + .unwrap(); + + let schema = registry.get("users", None).await.unwrap(); + assert_eq!(schema.version, 1); + + let schema_v1 = registry.get("users", Some(1)).await.unwrap(); + assert_eq!(schema_v1.version, 1); + } + + #[tokio::test] + async fn test_schema_not_found() { + let registry = create_test_registry(); + + let result = registry.get("nonexistent", None).await; + assert!(matches!(result, Err(SchemaError::NotFound { .. }))); + } + + #[tokio::test] + async fn test_list_schemas() { + let registry = create_test_registry(); + let proto = create_empty_proto(); + + registry + .register("users", proto.clone(), CompatibilityMode::Backward, None) + .await + .unwrap(); + registry + .register("orders", proto, CompatibilityMode::Backward, None) + .await + .unwrap(); + + let schemas = registry.list().await.unwrap(); + assert_eq!(schemas.len(), 2); + } +} diff --git a/crates/prkdb-schema/src/storage.rs b/crates/prkdb-schema/src/storage.rs new file mode 100644 index 0000000..b155960 --- /dev/null +++ b/crates/prkdb-schema/src/storage.rs @@ -0,0 +1,482 @@ +//! Storage backend for schema registry. +//! +//! Stores schemas in a separate metadata directory from user data. + +use crate::error::{SchemaError, SchemaResult}; +use crate::types::{Schema, SchemaInfo}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::RwLock; +use tokio::fs; +use tracing::{debug, info, warn}; + +/// Storage interface for schema registry. +#[async_trait] +pub trait SchemaStorage: Send + Sync { + /// Store a schema + async fn put(&self, schema: &Schema) -> SchemaResult<()>; + + /// Get a specific schema version + async fn get(&self, collection: &str, version: u32) -> SchemaResult>; + + /// Get the latest schema for a collection + async fn get_latest(&self, collection: &str) -> SchemaResult>; + + /// List all schema infos + async fn list(&self) -> SchemaResult>; + + /// Get the next schema ID + async fn next_schema_id(&self) -> SchemaResult; + + /// Get the next version for a collection + async fn next_version(&self, collection: &str) -> SchemaResult; +} + +/// In-memory schema storage (for testing and development). +pub struct InMemorySchemaStorage { + /// Schemas by collection -> version -> schema + schemas: RwLock>>, + /// Next schema ID + next_id: RwLock, +} + +impl InMemorySchemaStorage { + /// Create a new in-memory storage. + pub fn new() -> Self { + Self { + schemas: RwLock::new(HashMap::new()), + next_id: RwLock::new(1), + } + } +} + +impl Default for InMemorySchemaStorage { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl SchemaStorage for InMemorySchemaStorage { + async fn put(&self, schema: &Schema) -> SchemaResult<()> { + let mut schemas = self + .schemas + .write() + .map_err(|e| SchemaError::Storage(format!("Lock error: {}", e)))?; + + let collection_schemas = schemas.entry(schema.collection.clone()).or_default(); + collection_schemas.insert(schema.version, schema.clone()); + + debug!( + "Stored schema for '{}' version {}", + schema.collection, schema.version + ); + Ok(()) + } + + async fn get(&self, collection: &str, version: u32) -> SchemaResult> { + let schemas = self + .schemas + .read() + .map_err(|e| SchemaError::Storage(format!("Lock error: {}", e)))?; + + Ok(schemas + .get(collection) + .and_then(|versions| versions.get(&version).cloned())) + } + + async fn get_latest(&self, collection: &str) -> SchemaResult> { + let schemas = self + .schemas + .read() + .map_err(|e| SchemaError::Storage(format!("Lock error: {}", e)))?; + + Ok(schemas.get(collection).and_then(|versions| { + versions + .iter() + .max_by_key(|(v, _)| *v) + .map(|(_, schema)| schema.clone()) + })) + } + + async fn list(&self) -> SchemaResult> { + let schemas = self + .schemas + .read() + .map_err(|e| SchemaError::Storage(format!("Lock error: {}", e)))?; + + let mut infos = Vec::new(); + for (collection, versions) in schemas.iter() { + if let Some((latest_version, latest_schema)) = versions.iter().max_by_key(|(v, _)| *v) { + let first_schema = versions + .values() + .min_by_key(|s| s.created_at) + .unwrap_or(latest_schema); + + infos.push(SchemaInfo { + collection: collection.clone(), + latest_version: *latest_version, + schema_id: latest_schema.schema_id, + compatibility: latest_schema.compatibility, + created_at: first_schema.created_at, + updated_at: latest_schema.created_at, + }); + } + } + + Ok(infos) + } + + async fn next_schema_id(&self) -> SchemaResult { + let mut next_id = self + .next_id + .write() + .map_err(|e| SchemaError::Storage(format!("Lock error: {}", e)))?; + let id = *next_id; + *next_id += 1; + Ok(id) + } + + async fn next_version(&self, collection: &str) -> SchemaResult { + let schemas = self + .schemas + .read() + .map_err(|e| SchemaError::Storage(format!("Lock error: {}", e)))?; + + Ok(schemas + .get(collection) + .map(|versions| versions.keys().max().copied().unwrap_or(0) + 1) + .unwrap_or(1)) + } +} + +/// File-based schema storage (for production). +/// +/// Persists schemas to disk with the following structure: +/// ```text +/// {base_path}/ +/// ├── schemas.json # Index file with all schema metadata +/// └── descriptors/ +/// └── {collection}/ +/// └── v{version}.binpb # FileDescriptorProto bytes +/// ``` +pub struct FileSchemaStorage { + /// Base directory for schema storage + base_path: PathBuf, + /// In-memory cache (always kept in sync with disk) + cache: InMemorySchemaStorage, +} + +impl FileSchemaStorage { + /// Create a new file-based storage. + pub fn new(base_path: PathBuf) -> Self { + info!("Initializing schema storage at {:?}", base_path); + Self { + base_path, + cache: InMemorySchemaStorage::new(), + } + } + + /// Load schemas from disk into cache. + pub async fn load(&mut self) -> SchemaResult<()> { + let index_path = self.base_path.join("schemas.json"); + + if !index_path.exists() { + info!("No existing schema index found, starting fresh"); + return Ok(()); + } + + let content = fs::read_to_string(&index_path) + .await + .map_err(|e| SchemaError::Storage(format!("Failed to read index: {}", e)))?; + + let schemas: Vec = serde_json::from_str(&content) + .map_err(|e| SchemaError::Serialization(format!("Failed to parse index: {}", e)))?; + + info!("Loading {} schemas from disk", schemas.len()); + + for schema in schemas { + // Load the descriptor from its file + let descriptor_path = self.descriptor_path(&schema.collection, schema.version); + + let mut schema_with_descriptor = schema; + if descriptor_path.exists() { + match fs::read(&descriptor_path).await { + Ok(bytes) => { + schema_with_descriptor.descriptor = bytes; + } + Err(e) => { + warn!( + "Failed to load descriptor for {}:v{}: {}", + schema_with_descriptor.collection, schema_with_descriptor.version, e + ); + } + } + } + + self.cache.put(&schema_with_descriptor).await?; + } + + // Update next_id based on loaded schemas + let max_id = self + .cache + .list() + .await? + .iter() + .map(|s| s.schema_id) + .max() + .unwrap_or(0); + + *self + .cache + .next_id + .write() + .map_err(|e| SchemaError::Storage(format!("Lock error: {}", e)))? = max_id + 1; + + Ok(()) + } + + /// Get the path to a schema descriptor file. + fn descriptor_path(&self, collection: &str, version: u32) -> PathBuf { + self.base_path + .join("descriptors") + .join(collection) + .join(format!("v{}.binpb", version)) + } + + /// Save the index file (metadata without descriptors). + async fn save_index(&self) -> SchemaResult<()> { + // Collect data while holding lock, then release before async I/O + let (json, index_path) = { + let schemas = self + .cache + .schemas + .read() + .map_err(|e| SchemaError::Storage(format!("Lock error: {}", e)))?; + + // Flatten all schemas, excluding the descriptor bytes + let all_schemas: Vec = schemas + .values() + .flat_map(|versions| versions.values().cloned()) + .map(|mut s| { + // Don't store descriptor in index (stored separately) + s.descriptor = Vec::new(); + s + }) + .collect(); + + let json = serde_json::to_string_pretty(&all_schemas) + .map_err(|e| SchemaError::Serialization(format!("Failed to serialize: {}", e)))?; + + let index_path = self.base_path.join("schemas.json"); + (json, index_path) + }; // Lock is released here + + // Now do async I/O without holding the lock + if let Some(parent) = index_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| SchemaError::Storage(format!("Failed to create directory: {}", e)))?; + } + + fs::write(&index_path, json) + .await + .map_err(|e| SchemaError::Storage(format!("Failed to write index: {}", e)))?; + + debug!("Saved schema index to {:?}", index_path); + Ok(()) + } +} + +#[async_trait] +impl SchemaStorage for FileSchemaStorage { + async fn put(&self, schema: &Schema) -> SchemaResult<()> { + // Save descriptor to file + let descriptor_path = self.descriptor_path(&schema.collection, schema.version); + + if let Some(parent) = descriptor_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| SchemaError::Storage(format!("Failed to create directory: {}", e)))?; + } + + fs::write(&descriptor_path, &schema.descriptor) + .await + .map_err(|e| SchemaError::Storage(format!("Failed to write descriptor: {}", e)))?; + + debug!("Saved descriptor to {:?}", descriptor_path); + + // Store in cache + self.cache.put(schema).await?; + + // Update index file + self.save_index().await?; + + Ok(()) + } + + async fn get(&self, collection: &str, version: u32) -> SchemaResult> { + self.cache.get(collection, version).await + } + + async fn get_latest(&self, collection: &str) -> SchemaResult> { + self.cache.get_latest(collection).await + } + + async fn list(&self) -> SchemaResult> { + self.cache.list().await + } + + async fn next_schema_id(&self) -> SchemaResult { + self.cache.next_schema_id().await + } + + async fn next_version(&self, collection: &str) -> SchemaResult { + self.cache.next_version(collection).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::CompatibilityMode; + use tempfile::TempDir; + + fn create_test_schema(collection: &str, version: u32) -> Schema { + Schema { + schema_id: version, + collection: collection.to_string(), + version, + descriptor: vec![0x0a, 0x0b, 0x0c], // dummy proto bytes + compatibility: CompatibilityMode::Backward, + is_breaking: false, + migration_id: None, + created_at: chrono::Utc::now().timestamp_millis() as u64, + } + } + + #[tokio::test] + async fn test_in_memory_storage_put_get() { + let storage = InMemorySchemaStorage::new(); + let schema = create_test_schema("users", 1); + + storage.put(&schema).await.unwrap(); + + let retrieved = storage.get("users", 1).await.unwrap(); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().collection, "users"); + } + + #[tokio::test] + async fn test_in_memory_storage_get_latest() { + let storage = InMemorySchemaStorage::new(); + + storage.put(&create_test_schema("users", 1)).await.unwrap(); + storage.put(&create_test_schema("users", 2)).await.unwrap(); + storage.put(&create_test_schema("users", 3)).await.unwrap(); + + let latest = storage.get_latest("users").await.unwrap(); + assert!(latest.is_some()); + assert_eq!(latest.unwrap().version, 3); + } + + #[tokio::test] + async fn test_in_memory_storage_list() { + let storage = InMemorySchemaStorage::new(); + + storage.put(&create_test_schema("users", 1)).await.unwrap(); + storage.put(&create_test_schema("orders", 1)).await.unwrap(); + + let list = storage.list().await.unwrap(); + assert_eq!(list.len(), 2); + } + + #[tokio::test] + async fn test_in_memory_storage_versioning() { + let storage = InMemorySchemaStorage::new(); + + assert_eq!(storage.next_version("users").await.unwrap(), 1); + + storage.put(&create_test_schema("users", 1)).await.unwrap(); + assert_eq!(storage.next_version("users").await.unwrap(), 2); + + storage.put(&create_test_schema("users", 2)).await.unwrap(); + assert_eq!(storage.next_version("users").await.unwrap(), 3); + } + + #[tokio::test] + async fn test_file_storage_persistence() { + let temp_dir = TempDir::new().unwrap(); + let storage = FileSchemaStorage::new(temp_dir.path().to_path_buf()); + + let schema = create_test_schema("users", 1); + storage.put(&schema).await.unwrap(); + + // Verify files were created + let index_path = temp_dir.path().join("schemas.json"); + assert!(index_path.exists()); + + let descriptor_path = temp_dir.path().join("descriptors/users/v1.binpb"); + assert!(descriptor_path.exists()); + + // Verify descriptor content + let content = std::fs::read(&descriptor_path).unwrap(); + assert_eq!(content, vec![0x0a, 0x0b, 0x0c]); + } + + #[tokio::test] + async fn test_file_storage_load() { + let temp_dir = TempDir::new().unwrap(); + + // Write some schemas + { + let storage = FileSchemaStorage::new(temp_dir.path().to_path_buf()); + storage.put(&create_test_schema("users", 1)).await.unwrap(); + storage.put(&create_test_schema("users", 2)).await.unwrap(); + storage.put(&create_test_schema("orders", 1)).await.unwrap(); + } + + // Load in a new instance + let mut storage = FileSchemaStorage::new(temp_dir.path().to_path_buf()); + storage.load().await.unwrap(); + + // Verify + let users_latest = storage.get_latest("users").await.unwrap(); + assert!(users_latest.is_some()); + assert_eq!(users_latest.unwrap().version, 2); + + let orders = storage.get("orders", 1).await.unwrap(); + assert!(orders.is_some()); + + let list = storage.list().await.unwrap(); + assert_eq!(list.len(), 2); + } + + #[tokio::test] + async fn test_file_storage_schema_id_continuity() { + let temp_dir = TempDir::new().unwrap(); + + // Create and save some schemas + { + let storage = FileSchemaStorage::new(temp_dir.path().to_path_buf()); + let id1 = storage.next_schema_id().await.unwrap(); + let id2 = storage.next_schema_id().await.unwrap(); + + let mut schema1 = create_test_schema("users", 1); + schema1.schema_id = id1; + let mut schema2 = create_test_schema("users", 2); + schema2.schema_id = id2; + + storage.put(&schema1).await.unwrap(); + storage.put(&schema2).await.unwrap(); + } + + // Reload and verify ID continuity + let mut storage = FileSchemaStorage::new(temp_dir.path().to_path_buf()); + storage.load().await.unwrap(); + + let next_id = storage.next_schema_id().await.unwrap(); + assert_eq!(next_id, 3); // Should continue from 3 + } +} diff --git a/crates/prkdb-schema/src/types.rs b/crates/prkdb-schema/src/types.rs new file mode 100644 index 0000000..698039a --- /dev/null +++ b/crates/prkdb-schema/src/types.rs @@ -0,0 +1,114 @@ +//! Core types for the schema registry. + +use serde::{Deserialize, Serialize}; + +/// Compatibility modes for schema evolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum CompatibilityMode { + /// No compatibility checking + None, + /// New schema can read old data (default, recommended) + #[default] + Backward, + /// Old schema can read new data + Forward, + /// Both backward and forward compatible + Full, +} + +impl From for CompatibilityMode { + fn from(value: i32) -> Self { + match value { + 1 => CompatibilityMode::Backward, + 2 => CompatibilityMode::Forward, + 3 => CompatibilityMode::Full, + _ => CompatibilityMode::None, + } + } +} + +impl From for i32 { + fn from(mode: CompatibilityMode) -> Self { + match mode { + CompatibilityMode::None => 0, + CompatibilityMode::Backward => 1, + CompatibilityMode::Forward => 2, + CompatibilityMode::Full => 3, + } + } +} + +/// Version information for a schema. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchemaVersion { + /// Auto-incremented version number + pub version: u32, + /// True if this version introduced a breaking change + pub is_breaking: bool, + /// Migration ID if breaking change (required for breaking changes) + pub migration_id: Option, + /// Unix timestamp in milliseconds + pub created_at: u64, +} + +/// A registered schema for a collection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Schema { + /// Unique schema ID + pub schema_id: u32, + /// Collection name + pub collection: String, + /// Version of this schema + pub version: u32, + /// Serialized FileDescriptorProto bytes + pub descriptor: Vec, + /// Compatibility mode + pub compatibility: CompatibilityMode, + /// True if this version is a breaking change + pub is_breaking: bool, + /// Migration ID if breaking + pub migration_id: Option, + /// Unix timestamp when created (ms) + pub created_at: u64, +} + +/// Summary information about a schema (for listing). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchemaInfo { + /// Collection name + pub collection: String, + /// Latest version number + pub latest_version: u32, + /// Unique schema ID + pub schema_id: u32, + /// Compatibility mode + pub compatibility: CompatibilityMode, + /// When first created (ms) + pub created_at: u64, + /// When last updated (ms) + pub updated_at: u64, +} + +/// Result of a compatibility check. +#[derive(Debug, Clone)] +pub struct CompatibilityResult { + /// True if compatible according to the mode + pub compatible: bool, + /// True if the change is considered breaking + pub is_breaking: bool, + /// List of compatibility errors + pub errors: Vec, + /// List of warnings (non-blocking) + pub warnings: Vec, +} + +impl Default for CompatibilityResult { + fn default() -> Self { + Self { + compatible: true, + is_breaking: false, + errors: Vec::new(), + warnings: Vec::new(), + } + } +} diff --git a/crates/prkdb-types/src/lib.rs b/crates/prkdb-types/src/lib.rs index a7098ae..c739d12 100644 --- a/crates/prkdb-types/src/lib.rs +++ b/crates/prkdb-types/src/lib.rs @@ -8,6 +8,7 @@ //! - Error types //! - Index abstractions //! - Consumer/replication protocol types +//! - Schema types for cross-language SDK support //! //! ## Design Philosophy //! @@ -21,6 +22,7 @@ pub mod consumer; pub mod error; pub mod index; pub mod replication; +pub mod schema; pub mod snapshot; pub mod storage; @@ -36,4 +38,5 @@ pub use consumer::{ pub use error::{ComputeError, ConsumerError, Error, StorageError}; pub use index::{IndexDef, Indexed, IndexedStorage}; pub use replication::{AckLevel, Change, ReplicationConfig}; +pub use schema::{FieldDef, ProtoSchema, ProtoType}; pub use storage::{StorageAdapter, TransactionalStorageAdapter}; diff --git a/crates/prkdb-types/src/schema.rs b/crates/prkdb-types/src/schema.rs new file mode 100644 index 0000000..aaa8172 --- /dev/null +++ b/crates/prkdb-types/src/schema.rs @@ -0,0 +1,121 @@ +//! Schema types for cross-language SDK support. +//! +//! This module provides types that enable the `#[derive(Collection)]` macro +//! to generate Protobuf schema descriptors for use with the Schema Registry. + +use crate::collection::Collection; + +/// Trait for types that can provide their schema as a Protobuf descriptor. +/// +/// This trait is automatically implemented by the `#[derive(Collection)]` macro +/// for types that have the `#[collection(proto = true)]` attribute. +/// +/// # Example +/// +/// ```rust,ignore +/// #[derive(Collection, Serialize, Deserialize)] +/// #[collection(name = "users", proto = true)] +/// struct User { +/// #[key] +/// id: String, +/// +/// email: String, +/// +/// age: u32, +/// } +/// +/// // Get the schema proto bytes for registration +/// let schema = User::schema_proto(); +/// ``` +pub trait ProtoSchema: Collection { + /// Collection name for schema registry. + fn collection_name() -> &'static str; + + /// Returns the field definitions for this type. + fn field_definitions() -> &'static [FieldDef]; + + /// Returns the schema as a serialized FileDescriptorProto. + /// + /// This can be registered with the Schema Registry via the + /// `RegisterSchema` RPC. + fn schema_proto() -> Vec; +} + +/// Field definition for schema generation. +#[derive(Debug, Clone, Copy)] +pub struct FieldDef { + /// Field name. + pub name: &'static str, + /// Protobuf field number (1-indexed, assigned in order). + pub field_number: i32, + /// Protobuf type for this field. + pub proto_type: ProtoType, + /// Whether this field is optional (wrapped in Option). + pub is_optional: bool, + /// Whether this field is repeated (Vec). + pub is_repeated: bool, +} + +/// Protobuf field types. +/// +/// Maps to `google.protobuf.FieldDescriptorProto.Type` values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum ProtoType { + /// double (f64) + Double = 1, + /// float (f32) + Float = 2, + /// int64 (i64) + Int64 = 3, + /// uint64 (u64) + Uint64 = 4, + /// int32 (i32) + Int32 = 5, + /// fixed64 + Fixed64 = 6, + /// fixed32 + Fixed32 = 7, + /// bool + Bool = 8, + /// string (String, &str) + String = 9, + /// Nested message (custom struct) + Message = 11, + /// bytes (Vec) + Bytes = 12, + /// uint32 (u32) + Uint32 = 13, + /// sfixed32 + Sfixed32 = 15, + /// sfixed64 + Sfixed64 = 16, + /// sint32 + Sint32 = 17, + /// sint64 + Sint64 = 18, +} + +impl ProtoType { + /// Convert to the i32 value used by FieldDescriptorProto. + pub fn as_i32(self) -> i32 { + self as i32 + } +} + +/// Convert a Rust type name to the corresponding ProtoType. +/// +/// This is used by the macro for type mapping. +pub fn rust_type_to_proto(type_name: &str) -> ProtoType { + match type_name { + "String" | "&str" | "str" => ProtoType::String, + "i32" => ProtoType::Int32, + "i64" => ProtoType::Int64, + "u32" => ProtoType::Uint32, + "u64" => ProtoType::Uint64, + "f32" => ProtoType::Float, + "f64" => ProtoType::Double, + "bool" => ProtoType::Bool, + _ => ProtoType::Bytes, // Default: serialize as bytes + } +} diff --git a/crates/prkdb/Cargo.toml b/crates/prkdb/Cargo.toml index 4c5782c..bfa8867 100644 --- a/crates/prkdb/Cargo.toml +++ b/crates/prkdb/Cargo.toml @@ -14,8 +14,8 @@ path = "tests/load_tests.rs" anyhow = { workspace = true } async-trait = { workspace = true } bincode = { workspace = true } -bytes = "1.5" # Zero-copy data handling for performance -crossbeam-channel = "0.5" # Lock-free channels for Phase 2 batching optimization +bytes = "1.5" # Zero-copy data handling for performance +crossbeam-channel = "0.5" # Lock-free channels for Phase 2 batching optimization futures = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } @@ -25,8 +25,8 @@ serde_json = { workspace = true } tracing = { workspace = true } rand = "0.8" ahash = "0.8" -dashmap = { workspace = true} -papaya = "0.1" # Lock-free HashMap for Phase 5 optimization (better than flurry) +dashmap = { workspace = true } +papaya = "0.1" # Lock-free HashMap for Phase 5 optimization (better than flurry) prkdb-core = { workspace = true } prkdb-types = { workspace = true } prkdb-metrics = { workspace = true } @@ -34,10 +34,11 @@ prkdb-orm = { workspace = true } prkdb-macros = { workspace = true } prkdb-orm-macros = { workspace = true } prkdb-proto = { workspace = true } +prkdb-schema = { workspace = true } prkdb-client = { workspace = true } -uuid = { workspace = true} -mimalloc = { workspace = true } # Fast allocator -lz4 = { workspace = true } # Compression +uuid = { workspace = true } +mimalloc = { workspace = true } # Fast allocator +lz4 = { workspace = true } # Compression axum = { workspace = true } reqwest = { version = "0.12", features = ["json"] } pin-project = "1.1" @@ -46,13 +47,13 @@ tonic = { workspace = true, features = ["tls", "tls-roots"] } prost = { workspace = true } prost-types = { workspace = true } tokio-stream = { version = "0.1", features = ["time", "sync", "net"] } -async-stream = "0.3" # Phase 22: Zero-copy streaming +async-stream = "0.3" # Phase 22: Zero-copy streaming tracing-subscriber = { workspace = true } sysinfo = "0.37" seahash = "4.1.0" -crc32fast = "1.4" # Fast CRC32 checksums for checkpoint integrity -flate2 = { workspace = true } # Compression for snapshots -tar = { workspace = true } # Archiving for snapshots +crc32fast = "1.4" # Fast CRC32 checksums for checkpoint integrity +flate2 = { workspace = true } # Compression for snapshots +tar = { workspace = true } # Archiving for snapshots prometheus = { workspace = true } lazy_static = { workspace = true } clap = { workspace = true, features = ["derive"] } @@ -70,7 +71,10 @@ prkdb-metrics = { workspace = true } tempfile = "3" -sqlx = { version = "0.8.2", default-features = false, features = ["runtime-tokio", "sqlite"] } +sqlx = { version = "0.8.2", default-features = false, features = [ + "runtime-tokio", + "sqlite", +] } criterion = { workspace = true } proptest = "1.4" @@ -117,4 +121,3 @@ harness = false [[bench]] name = "query_bench" harness = false - diff --git a/crates/prkdb/benches/raft_bench.rs b/crates/prkdb/benches/raft_bench.rs index b6bd8d0..5a87d38 100644 --- a/crates/prkdb/benches/raft_bench.rs +++ b/crates/prkdb/benches/raft_bench.rs @@ -145,6 +145,7 @@ async fn create_node(id: u64, port: u16, peers: Vec) -> (Arc, Tem election_timeout_min_ms: 150, election_timeout_max_ms: 300, heartbeat_interval_ms: 25, + partition_id: 0, }; let mut wal_config = WalConfig::test_config(); diff --git a/crates/prkdb/examples/raft_cluster.rs b/crates/prkdb/examples/raft_cluster.rs index f42fc13..277ea72 100644 --- a/crates/prkdb/examples/raft_cluster.rs +++ b/crates/prkdb/examples/raft_cluster.rs @@ -40,32 +40,41 @@ async fn main() -> Result<(), Box> { election_timeout_min_ms: 150, election_timeout_max_ms: 300, heartbeat_interval_ms: 50, + partition_id: 0, }; - // Create WAL storage + // Create WAL storage path let db_path = std::path::PathBuf::from(format!("tmp/raft_node_{}", node_id)); if db_path.exists() { std::fs::remove_dir_all(&db_path)?; } std::fs::create_dir_all(&db_path)?; - let mut wal_config = WalConfig::test_config(); - wal_config.log_dir = db_path; - let storage = Arc::new(WalStorageAdapter::new(wal_config)?); + // Create PartitionManager + let pm = Arc::new( + prkdb::raft::PartitionManager::new(1, config.clone(), db_path, |_part_id, storage| { + Arc::new(prkdb::raft::PrkDbStateMachine::new(storage)) + }) + .unwrap(), + ); - // Create Raft node - let state_machine = Arc::new(prkdb::raft::PrkDbStateMachine::new(storage.clone())); - let node = Arc::new(RaftNode::new(config.clone(), storage, state_machine)); + // Create RPC pool let rpc_pool = Arc::new(RpcClientPool::new(node_id)); + // Start background tasks + pm.start_all(rpc_pool.clone(), &[]); + // Start gRPC server - let server_node = node.clone(); + let pm_server = pm.clone(); tokio::spawn(async move { - if let Err(e) = prkdb::raft::server::start_raft_server(server_node, listen_addr).await { + if let Err(e) = prkdb::raft::server::start_raft_server(pm_server, listen_addr).await { eprintln!("Server error: {}", e); } }); + // Get the node for local operations + let node = pm.get_partition(0).unwrap(); + // Wait a bit for server to start tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; diff --git a/crates/prkdb/examples/raft_node.rs b/crates/prkdb/examples/raft_node.rs index 8f95a97..8c0b5e1 100644 --- a/crates/prkdb/examples/raft_node.rs +++ b/crates/prkdb/examples/raft_node.rs @@ -103,21 +103,6 @@ async fn main() -> anyhow::Result<()> { // Create data directory std::fs::create_dir_all(&args.data_dir)?; - // Create storage - println!("⏳ Initializing storage..."); - let storage = tokio::task::spawn_blocking({ - let path = args.data_dir.clone(); - move || { - WalStorageAdapter::new(WalConfig { - log_dir: path, - ..WalConfig::test_config() - }) - } - }) - .await??; - let storage = Arc::new(storage); - println!("✅ Storage ready"); - // Create cluster config let config = ClusterConfig { local_node_id: args.node_id, @@ -126,16 +111,22 @@ async fn main() -> anyhow::Result<()> { election_timeout_min_ms: 150, election_timeout_max_ms: 300, heartbeat_interval_ms: 50, + partition_id: 0, }; - // Create state machine - let state_machine = Arc::new(PrkDbStateMachine::new(storage.clone())); + // Create PartitionManager + println!("⏳ Creating PartitionManager..."); + let pm = Arc::new( + prkdb::raft::PartitionManager::new(1, config, args.data_dir, |_part_id, storage| { + Arc::new(PrkDbStateMachine::new(storage)) + }) + .unwrap(), + ); + println!("✅ PartitionManager created"); - // Create Raft node - println!("⏳ Creating Raft node..."); - let raft_node = Arc::new(RaftNode::new(config, storage, state_machine)); - println!("✅ Raft node created"); - println!(); + // Start background tasks + let rpc_pool = Arc::new(prkdb::raft::RpcClientPool::new(args.node_id)); + pm.start_all(rpc_pool, &[]); // Start gRPC server (with or without TLS) if tls_enabled { @@ -150,7 +141,7 @@ async fn main() -> anyhow::Result<()> { println!(" Press Ctrl+C to stop."); println!(); - prkdb::raft::server::start_raft_server_tls(raft_node, args.listen, tls_config) + prkdb::raft::server::start_raft_server_tls(pm, args.listen, tls_config) .await .map_err(|e| anyhow::anyhow!("Raft server error: {}", e))?; } else { @@ -160,7 +151,7 @@ async fn main() -> anyhow::Result<()> { println!(" Press Ctrl+C to stop."); println!(); - prkdb::raft::server::start_raft_server(raft_node, args.listen) + prkdb::raft::server::start_raft_server(pm, args.listen) .await .map_err(|e| anyhow::anyhow!("Raft server error: {}", e))?; } diff --git a/crates/prkdb/src/bin/prkdb-server.rs b/crates/prkdb/src/bin/prkdb-server.rs index 9afc866..6cbe559 100644 --- a/crates/prkdb/src/bin/prkdb-server.rs +++ b/crates/prkdb/src/bin/prkdb-server.rs @@ -58,6 +58,7 @@ async fn main() -> anyhow::Result<()> { election_timeout_min_ms: 1000, election_timeout_max_ms: 2000, heartbeat_interval_ms: 200, + partition_id: 0, }; // Create database with Multi-Raft @@ -74,7 +75,7 @@ async fn main() -> anyhow::Result<()> { // Start Multi-Raft let rpc_pool = Arc::new(RpcClientPool::new(node_id)); - db.start_multi_raft(rpc_pool); + db.start_multi_raft(rpc_pool, &[]); // Wait for leaders info!("Waiting for leader election..."); @@ -126,14 +127,32 @@ async fn main() -> anyhow::Result<()> { let admin_token = env::var("PRKDB_ADMIN_TOKEN").unwrap_or_default(); let grpc_service = PrkDbGrpcService::new(db_arc.clone(), admin_token).into_server(); + // Create Raft service for multiplexed Raft traffic + // We must register this service on the SAME server/port as the client API + // because we are using port multiplexing (one port for everything) + let raft_service_opt = if let Some(pm) = &db_arc.partition_manager { + use prkdb::raft::rpc::raft_service_server::RaftServiceServer; + use prkdb::raft::service::RaftServiceImpl; + + info!("Registering multiplexed Raft service"); + Some(RaftServiceServer::new(RaftServiceImpl::new(pm.clone()))) + } else { + None + }; + // Start gRPC data service on port 8080 (like Kafka's binary protocol) let grpc_port = env::var("GRPC_PORT").unwrap_or_else(|_| "8080".to_string()); let data_addr: SocketAddr = format!("0.0.0.0:{}", grpc_port).parse()?; info!("Starting gRPC data service on {}", data_addr); // Run gRPC server until shutdown - Server::builder() - .add_service(grpc_service) + let mut router = Server::builder().add_service(grpc_service); + + if let Some(raft_service) = raft_service_opt { + router = router.add_service(raft_service); + } + + router .serve_with_shutdown(data_addr, async { signal::ctrl_c().await.ok(); info!("Shutting down..."); diff --git a/crates/prkdb/src/db.rs b/crates/prkdb/src/db.rs index c4db94d..8049655 100644 --- a/crates/prkdb/src/db.rs +++ b/crates/prkdb/src/db.rs @@ -143,9 +143,13 @@ impl PrkDb { } /// Start Multi-Raft partitions - pub fn start_multi_raft(&self, rpc_pool: std::sync::Arc) { + pub fn start_multi_raft( + &self, + rpc_pool: std::sync::Arc, + skip_server_partitions: &[u64], + ) { if let Some(pm) = &self.partition_manager { - pm.start_all(rpc_pool); + pm.start_all(rpc_pool, skip_server_partitions); } } @@ -1033,7 +1037,12 @@ impl PrkDb { /// In a distributed setup, this should propagate via Raft. /// Create a collection (admin op) /// In a distributed setup, this propagates via Raft to Partition 0 (Metadata Partition). - pub async fn create_collection(&self, name: &str) -> Result<(), Error> { + pub async fn create_collection( + &self, + name: &str, + num_partitions: u32, + replication_factor: u32, + ) -> Result<(), Error> { if let Some(pm) = &self.partition_manager { // Distributed mode: Propose to Partition 0 (Metadata Partition) // Convention: Partition 0 stores cluster metadata including collections @@ -1049,6 +1058,8 @@ impl PrkDb { let cmd = Command::CreateCollection { name: name.to_string(), + num_partitions, + replication_factor, }; // Propose and wait for commit @@ -1063,7 +1074,15 @@ impl PrkDb { // Local/Single-node mode: Write directly to storage with metadata key // This ensures behavior is consistent with Raft state machine application let metadata_key = format!("meta:col:{}", name).into_bytes(); - let metadata_value = b"{}".to_vec(); + + // Store configuration as JSON + let metadata = serde_json::json!({ + "num_partitions": num_partitions, + "replication_factor": replication_factor, + "created_at": chrono::Utc::now().to_rfc3339() + }); + + let metadata_value = metadata.to_string().into_bytes(); self.storage.put(&metadata_key, &metadata_value).await?; } diff --git a/crates/prkdb/src/raft/command.rs b/crates/prkdb/src/raft/command.rs index b69adab..0cab1c1 100644 --- a/crates/prkdb/src/raft/command.rs +++ b/crates/prkdb/src/raft/command.rs @@ -2,10 +2,21 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum Command { - Put { key: Vec, value: Vec }, - Delete { key: Vec }, - CreateCollection { name: String }, - DropCollection { name: String }, + Put { + key: Vec, + value: Vec, + }, + Delete { + key: Vec, + }, + CreateCollection { + name: String, + num_partitions: u32, + replication_factor: u32, + }, + DropCollection { + name: String, + }, } impl Command { diff --git a/crates/prkdb/src/raft/config.rs b/crates/prkdb/src/raft/config.rs index 3e96099..4928eea 100644 --- a/crates/prkdb/src/raft/config.rs +++ b/crates/prkdb/src/raft/config.rs @@ -24,6 +24,10 @@ pub struct ClusterConfig { /// Heartbeat interval in milliseconds pub heartbeat_interval_ms: u64, + + /// Partition ID this node belongs to + #[serde(default)] + pub partition_id: u64, } impl Default for ClusterConfig { @@ -32,9 +36,10 @@ impl Default for ClusterConfig { local_node_id: 1, listen_addr: "127.0.0.1:50051".parse().unwrap(), nodes: vec![(1, "127.0.0.1:50051".parse().unwrap())], - election_timeout_min_ms: 150, - election_timeout_max_ms: 300, - heartbeat_interval_ms: 50, + election_timeout_min_ms: 500, + election_timeout_max_ms: 1000, + heartbeat_interval_ms: 100, + partition_id: 0, } } } diff --git a/crates/prkdb/src/raft/grpc_service.rs b/crates/prkdb/src/raft/grpc_service.rs index 86caf55..64a3d0c 100644 --- a/crates/prkdb/src/raft/grpc_service.rs +++ b/crates/prkdb/src/raft/grpc_service.rs @@ -3,9 +3,31 @@ use crate::raft::rpc::prk_db_service_server::{ PrkDbService as PrkDbServiceTrait, PrkDbServiceServer, }; use crate::raft::rpc::{ - DeleteRequest, DeleteResponse, FetchSegmentRequest, GetRequest, GetResponse, HealthRequest, - HealthResponse, PutRequest, PutResponse, RawChunk, WatchEvent, WatchRequest, + // Schema Registry types + CheckCompatibilityRequest, + CheckCompatibilityResponse, + DeleteRequest, + DeleteResponse, + FetchSegmentRequest, + GetRequest, + GetResponse, + GetSchemaRequest, + GetSchemaResponse, + HealthRequest, + HealthResponse, + ListSchemasRequest, + ListSchemasResponse, + PutRequest, + PutResponse, + RawChunk, + RegisterSchemaRequest, + RegisterSchemaResponse, + SchemaInfo as ProtoSchemaInfo, + WatchEvent, + WatchRequest, }; +use prkdb_schema::{CompatibilityMode, InMemorySchemaStorage, SchemaRegistry}; +use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; use tokio::sync::broadcast; @@ -21,14 +43,41 @@ pub struct PrkDbGrpcService { db: Arc, admin_token: String, public_address: Option, + /// Schema registry for cross-language SDK support + schema_registry: Arc>, } impl PrkDbGrpcService { pub fn new(db: Arc, admin_token: String) -> Self { + // Initialize schema registry with in-memory storage + // TODO: In production, use FileSchemaStorage with a configurable path + let storage = Arc::new(InMemorySchemaStorage::new()); + let schema_registry = Arc::new(SchemaRegistry::new(storage)); + + Self { + db, + admin_token, + public_address: None, + schema_registry, + } + } + + /// Create a new gRPC service with file-backed schema storage + pub fn with_schema_storage_path( + db: Arc, + admin_token: String, + _schema_path: PathBuf, + ) -> Self { + let storage = Arc::new(InMemorySchemaStorage::new()); + // Note: FileSchemaStorage requires mutable load(), so we use InMemory for now + // and cache is populated on first access + let schema_registry = Arc::new(SchemaRegistry::new(storage)); + Self { db, admin_token, public_address: None, + schema_registry, } } @@ -171,7 +220,7 @@ impl PrkDbServiceTrait for PrkDbGrpcService { // For this prototype, we'll construct it based on convention // Node 1 -> 127.0.0.1:8081, Node 2 -> 127.0.0.1:8082, etc. // This is a hack for the demo, but sufficient for the benchmark - let port = 8080 + node_id as u32; + let port = 50050 + node_id as u32; let address = format!("http://127.0.0.1:{}", port); nodes.push(NodeInfo { node_id, address }); @@ -246,7 +295,10 @@ impl PrkDbServiceTrait for PrkDbGrpcService { // Delegate to PrkDb, which handles distributed proposal (via Raft to Partition 0) // or local execution depending on configuration. - let result = self.db.create_collection(&req.name).await; + let result = self + .db + .create_collection(&req.name, req.num_partitions, req.replication_factor) + .await; match result { Ok(_) => Ok(Response::new(crate::raft::rpc::CreateCollectionResponse { @@ -897,6 +949,188 @@ impl PrkDbServiceTrait for PrkDbGrpcService { Ok(Response::new(Box::pin(stream))) } + + // ───────────────────────────────────────────────────────────────────────── + // Schema Registry API + // ───────────────────────────────────────────────────────────────────────── + + /// Register a new schema version for a collection + async fn register_schema( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + tracing::info!( + "RegisterSchema: collection='{}', compatibility={:?}", + req.collection, + req.compatibility + ); + + // Convert proto compatibility mode to internal type + use crate::raft::rpc::CompatibilityMode as ProtoCompat; + let compatibility = match ProtoCompat::try_from(req.compatibility) { + Ok(ProtoCompat::CompatibilityNone) => CompatibilityMode::None, + Ok(ProtoCompat::CompatibilityBackward) => CompatibilityMode::Backward, + Ok(ProtoCompat::CompatibilityForward) => CompatibilityMode::Forward, + Ok(ProtoCompat::CompatibilityFull) => CompatibilityMode::Full, + Err(_) => CompatibilityMode::Backward, // Default + }; + + // migration_id is Option from proto + let migration_id = req.migration_id; + + match self + .schema_registry + .register( + &req.collection, + req.schema_proto, + compatibility, + migration_id, + ) + .await + { + Ok(schema) => Ok(Response::new(RegisterSchemaResponse { + success: true, + schema_id: schema.schema_id, + version: schema.version, + is_breaking: schema.is_breaking, + error: String::new(), + })), + Err(e) => Ok(Response::new(RegisterSchemaResponse { + success: false, + schema_id: 0, + version: 0, + is_breaking: false, + error: e.to_string(), + })), + } + } + + /// Get a schema for a collection + async fn get_schema( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let version = if req.version == 0 { + None + } else { + Some(req.version) + }; + + tracing::debug!( + "GetSchema: collection='{}', version={:?}", + req.collection, + version + ); + + use crate::raft::rpc::CompatibilityMode as ProtoCompat; + match self.schema_registry.get(&req.collection, version).await { + Ok(schema) => Ok(Response::new(GetSchemaResponse { + success: true, + schema_proto: schema.descriptor, + version: schema.version, + schema_id: schema.schema_id, + compatibility: match schema.compatibility { + CompatibilityMode::None => ProtoCompat::CompatibilityNone as i32, + CompatibilityMode::Backward => ProtoCompat::CompatibilityBackward as i32, + CompatibilityMode::Forward => ProtoCompat::CompatibilityForward as i32, + CompatibilityMode::Full => ProtoCompat::CompatibilityFull as i32, + }, + created_at: schema.created_at, + error: String::new(), + })), + Err(e) => Ok(Response::new(GetSchemaResponse { + success: false, + schema_proto: vec![], + version: 0, + schema_id: 0, + compatibility: 0, + created_at: 0, + error: e.to_string(), + })), + } + } + + /// List all registered schemas + async fn list_schemas( + &self, + request: Request, + ) -> Result, Status> { + let _req = request.into_inner(); + + tracing::debug!("ListSchemas: listing all schemas"); + + use crate::raft::rpc::CompatibilityMode as ProtoCompat; + match self.schema_registry.list().await { + Ok(schemas) => { + let proto_schemas: Vec = schemas + .into_iter() + .map(|s| ProtoSchemaInfo { + collection: s.collection, + schema_id: s.schema_id, + latest_version: s.latest_version, + compatibility: match s.compatibility { + CompatibilityMode::None => ProtoCompat::CompatibilityNone as i32, + CompatibilityMode::Backward => { + ProtoCompat::CompatibilityBackward as i32 + } + CompatibilityMode::Forward => ProtoCompat::CompatibilityForward as i32, + CompatibilityMode::Full => ProtoCompat::CompatibilityFull as i32, + }, + created_at: s.created_at, + updated_at: s.updated_at, + }) + .collect(); + + Ok(Response::new(ListSchemasResponse { + success: true, + schemas: proto_schemas, + error: String::new(), + })) + } + Err(e) => Ok(Response::new(ListSchemasResponse { + success: false, + schemas: vec![], + error: e.to_string(), + })), + } + } + + /// Check if a new schema is compatible with the existing schema + async fn check_compatibility( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + tracing::debug!( + "CheckCompatibility: collection='{}' new_schema_size={}", + req.collection, + req.schema_proto.len() + ); + + match self + .schema_registry + .check_compatibility(&req.collection, &req.schema_proto) + .await + { + Ok(result) => Ok(Response::new(CheckCompatibilityResponse { + compatible: result.compatible, + is_breaking: result.is_breaking, + errors: result.errors, + warnings: result.warnings, + })), + Err(e) => Ok(Response::new(CheckCompatibilityResponse { + compatible: false, + is_breaking: false, + errors: vec![e.to_string()], + warnings: vec![], + })), + } + } } impl PrkDbGrpcService { diff --git a/crates/prkdb/src/raft/node.rs b/crates/prkdb/src/raft/node.rs index 339b393..2d8329b 100644 --- a/crates/prkdb/src/raft/node.rs +++ b/crates/prkdb/src/raft/node.rs @@ -37,13 +37,15 @@ pub struct ProposeHandle { } impl ProposeHandle { - /// Wait for the proposal to be committed + /// Wait for the proposal to be committed (with timeout) pub async fn wait_commit(self) -> Result { - self.commit_rx.await.map_err(|_| { - RaftError::Storage(prkdb_types::error::StorageError::Internal( - "Commit notification dropped".into(), - )) - })? + match tokio::time::timeout(std::time::Duration::from_secs(10), self.commit_rx).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err(RaftError::Storage( + prkdb_types::error::StorageError::Internal("Commit notification dropped".into()), + )), + Err(_) => Err(RaftError::Timeout), + } } /// Get the log index (available immediately) @@ -275,7 +277,7 @@ impl RaftNode { snapshot_term: Arc::new(RwLock::new(snapshot_term_val)), snapshot_data: Arc::new(RwLock::new(snapshot_data_val)), last_heartbeat_time: Arc::new(RwLock::new(Instant::now())), - election_timeout_min: std::time::Duration::from_millis(150), + election_timeout_min: std::time::Duration::from_millis(config.election_timeout_min_ms), } } @@ -1132,6 +1134,7 @@ impl RaftNode { } /// Start the Raft node (election timer + heartbeat loop + apply loop) + #[tracing::instrument(skip(self, rpc_pool), fields(node_id = %self.config.local_node_id, addr = %self.config.listen_addr))] pub fn start(self: Arc, rpc_pool: Arc) { // Spawn election timer let election_node = self.clone(); @@ -1284,6 +1287,7 @@ impl RaftNode { // Request pre-votes from all peers concurrently let mut vote_tasks = Vec::new(); + let partition_id = self.config.partition_id; for (node_id, addr) in &self.config.nodes { if *node_id == self.config.local_node_id { @@ -1301,8 +1305,10 @@ impl RaftNode { last_log_term, }; - let task = - tokio::spawn(async move { pool.send_pre_vote(node_id, &addr, request).await }); + let task = tokio::spawn(async move { + pool.send_pre_vote(node_id, &addr, request, partition_id) + .await + }); vote_tasks.push(task); } @@ -1369,6 +1375,7 @@ impl RaftNode { // Request votes from all peers concurrently let mut vote_tasks = Vec::new(); + let partition_id = self.config.partition_id; for (node_id, addr) in &self.config.nodes { if *node_id == self.config.local_node_id { @@ -1385,8 +1392,10 @@ impl RaftNode { last_log_term, }; - let task = - tokio::spawn(async move { pool.send_request_vote(node_id, &addr, request).await }); + let task = tokio::spawn(async move { + pool.send_request_vote(node_id, &addr, request, partition_id) + .await + }); vote_tasks.push(task); } @@ -1674,7 +1683,12 @@ impl RaftNode { }; match pool - .send_install_snapshot(follower_id, &addr, request) + .send_install_snapshot( + follower_id, + &addr, + request, + self_clone.config.partition_id, + ) .await { Ok(response) => { @@ -1765,7 +1779,15 @@ impl RaftNode { follower_id, entries_count ); - match pool.send_append_entries(follower_id, &addr, request).await { + match pool + .send_append_entries( + follower_id, + &addr, + request, + self_clone.config.partition_id, + ) + .await + { Ok(response) => { // Update Prometheus metrics - heartbeat sent successfully crate::prometheus_metrics::RAFT_HEARTBEATS_SENT_TOTAL diff --git a/crates/prkdb/src/raft/partition_manager.rs b/crates/prkdb/src/raft/partition_manager.rs index 32f8a5a..c557b80 100644 --- a/crates/prkdb/src/raft/partition_manager.rs +++ b/crates/prkdb/src/raft/partition_manager.rs @@ -131,41 +131,27 @@ impl PartitionManager { ) -> ClusterConfig { let mut config = base_config.clone(); - // Adjust listen address for THIS partition's server + // In Multiplexed mode, we DO NOT adjust ports. + // All partitions share the same main gRPC port and use x-prkdb-partition-id header. + let base_listen_port = config.listen_addr.port(); - let partition_listen_port = base_listen_port + (partition_id as u16 * 100); - config.listen_addr = SocketAddr::new(config.listen_addr.ip(), partition_listen_port); + config.partition_id = partition_id as u64; tracing::info!( - "[PARTITION-CONFIG] Partition {}: ADJUSTED listen_addr from {} to {}", + "[PARTITION-CONFIG] Partition {}: Using main port {} (Multiplexed)", partition_id, - base_listen_port, - partition_listen_port + base_listen_port ); - // Adjust ports for all peer nodes in this partition's cluster - for (node_id, addr) in &mut config.nodes { - // addr is SocketAddr, so we can use it directly - let base_port = addr.port(); - - // Each partition gets its own port range: partition_0: base, partition_1: base+100, etc. - let new_port = base_port + (partition_id as u16 * 100); - *addr = SocketAddr::new(addr.ip(), new_port); - - tracing::debug!( - "Partition {} node {}: peer address adjusted port {} -> {}", - partition_id, - node_id, - base_port, - new_port - ); - } - config } /// Start all Raft partitions - pub fn start_all(&self, rpc_pool: Arc) { + pub fn start_all( + &self, + rpc_pool: Arc, + skip_server_partitions: &[u64], + ) { tracing::info!("Starting all {} partitions", self.num_partitions); for (partition_id, raft_node) in &self.raft_groups { @@ -179,27 +165,12 @@ impl PartitionManager { raft_node_clone.start(rpc_pool_clone); }); - // Start Raft gRPC server to handle incoming RPCs - // Config already has correct listen_addr from adjust_config_for_partition - let raft_node_for_server = raft_node.clone(); - let partition_id_copy = *partition_id; - tokio::spawn(async move { - let listen_addr = raft_node_for_server.get_config().await.listen_addr; - tracing::info!( - "Starting Raft gRPC server for partition {} on {}", - partition_id_copy, - listen_addr - ); - if let Err(e) = - super::server::start_raft_server(raft_node_for_server, listen_addr).await - { - tracing::error!( - "Raft gRPC server for partition {} failed: {}", - partition_id_copy, - e - ); - } - }); + // In Multiplexed mode, we DO NOT start separate servers for partitions. + // The main server handles all traffic via RaftServiceImpl routing. + tracing::info!( + "Partition {} initialized (Multiplexed on main server)", + partition_id + ); } } diff --git a/crates/prkdb/src/raft/rpc_client.rs b/crates/prkdb/src/raft/rpc_client.rs index 18452d4..96dc5e8 100644 --- a/crates/prkdb/src/raft/rpc_client.rs +++ b/crates/prkdb/src/raft/rpc_client.rs @@ -121,9 +121,16 @@ impl RpcClientPool { node_id: NodeId, addr: &str, request: RequestVoteRequest, + partition_id: u64, ) -> Result { let mut client = self.get_client(node_id, addr).await?; - match client.request_vote(request).await { + let mut req = tonic::Request::new(request); + req.metadata_mut().insert( + "x-prkdb-partition-id", + partition_id.to_string().parse().unwrap(), + ); + + match client.request_vote(req).await { Ok(response) => Ok(response.into_inner()), Err(e) => { // Remove client from cache on failure to force reconnection @@ -139,9 +146,16 @@ impl RpcClientPool { node_id: NodeId, addr: &str, request: PreVoteRequest, + partition_id: u64, ) -> Result { let mut client = self.get_client(node_id, addr).await?; - match client.pre_vote(request).await { + let mut req = tonic::Request::new(request); + req.metadata_mut().insert( + "x-prkdb-partition-id", + partition_id.to_string().parse().unwrap(), + ); + + match client.pre_vote(req).await { Ok(response) => Ok(response.into_inner()), Err(e) => { // Remove client from cache on failure to force reconnection @@ -157,9 +171,16 @@ impl RpcClientPool { node_id: NodeId, addr: &str, request: AppendEntriesRequest, + partition_id: u64, ) -> Result { let mut client = self.get_client(node_id, addr).await?; - match client.append_entries(request).await { + let mut req = tonic::Request::new(request); + req.metadata_mut().insert( + "x-prkdb-partition-id", + partition_id.to_string().parse().unwrap(), + ); + + match client.append_entries(req).await { Ok(response) => Ok(response.into_inner()), Err(e) => { // Remove client from cache on failure to force reconnection @@ -175,9 +196,16 @@ impl RpcClientPool { node_id: NodeId, addr: &str, request: InstallSnapshotRequest, + partition_id: u64, ) -> Result { let mut client = self.get_client(node_id, addr).await?; - match client.install_snapshot(request).await { + let mut req = tonic::Request::new(request); + req.metadata_mut().insert( + "x-prkdb-partition-id", + partition_id.to_string().parse().unwrap(), + ); + + match client.install_snapshot(req).await { Ok(response) => Ok(response.into_inner()), Err(e) => { // Remove client from cache on failure to force reconnection diff --git a/crates/prkdb/src/raft/server.rs b/crates/prkdb/src/raft/server.rs index c2ad4b3..08800c4 100644 --- a/crates/prkdb/src/raft/server.rs +++ b/crates/prkdb/src/raft/server.rs @@ -1,4 +1,4 @@ -use crate::raft::node::RaftNode; +use crate::raft::partition_manager::PartitionManager; use crate::raft::rpc::raft_service_server::RaftServiceServer; use crate::raft::service::RaftServiceImpl; use std::net::SocketAddr; @@ -19,10 +19,10 @@ pub struct TlsConfig { /// Start the Raft gRPC server (plain) pub async fn start_raft_server( - node: Arc, + partition_manager: Arc, listen_addr: SocketAddr, ) -> Result<(), Box> { - let service = RaftServiceImpl::new(node); + let service = RaftServiceImpl::new(partition_manager); tracing::info!("Starting Raft gRPC server on {}", listen_addr); @@ -36,11 +36,11 @@ pub async fn start_raft_server( /// Start the Raft gRPC server with TLS pub async fn start_raft_server_tls( - node: Arc, + partition_manager: Arc, listen_addr: SocketAddr, tls_config: TlsConfig, ) -> Result<(), Box> { - let service = RaftServiceImpl::new(node); + let service = RaftServiceImpl::new(partition_manager); // Read certificate and key let cert = tokio::fs::read(&tls_config.cert_path).await?; diff --git a/crates/prkdb/src/raft/service.rs b/crates/prkdb/src/raft/service.rs index 6f1aaa4..9ac750c 100644 --- a/crates/prkdb/src/raft/service.rs +++ b/crates/prkdb/src/raft/service.rs @@ -1,16 +1,35 @@ use crate::raft::node::RaftNode; +use crate::raft::partition_manager::PartitionManager; use crate::raft::rpc::*; use std::sync::Arc; use tonic::{Request, Response, Status}; /// gRPC service implementation for Raft pub struct RaftServiceImpl { - node: Arc, + partition_manager: Arc, } impl RaftServiceImpl { - pub fn new(node: Arc) -> Self { - Self { node } + pub fn new(partition_manager: Arc) -> Self { + Self { partition_manager } + } + + /// Helper to get the correct Raft node based on partition ID header + fn get_node(&self, request: &Request) -> Result, Status> { + // Extract partition ID from metadata + let partition_id = if let Some(val) = request.metadata().get("x-prkdb-partition-id") { + val.to_str() + .map_err(|_| Status::invalid_argument("Invalid partition ID header"))? + .parse::() + .map_err(|_| Status::invalid_argument("Invalid partition ID format"))? + } else { + // Default to partition 0 for backward compatibility + 0 + }; + + self.partition_manager + .get_partition(partition_id) + .ok_or_else(|| Status::not_found(format!("Raft partition {} not found", partition_id))) } } @@ -20,16 +39,17 @@ impl raft_service_server::RaftService for RaftServiceImpl { &self, request: Request, ) -> Result, Status> { + let node = self.get_node(&request)?; let req = request.into_inner(); tracing::debug!( - "Received RequestVote from candidate {} for term {}", + "Received RequestVote from candidate {} for term {} (partition {})", req.candidate_id, - req.term + req.term, + node.get_config().await.partition_id ); - let (term, vote_granted) = self - .node + let (term, vote_granted) = node .handle_request_vote( req.term, req.candidate_id, @@ -51,16 +71,17 @@ impl raft_service_server::RaftService for RaftServiceImpl { &self, request: Request, ) -> Result, Status> { + let node = self.get_node(&request)?; let req = request.into_inner(); tracing::debug!( - "Received PreVote from candidate {} for term {}", + "Received PreVote from candidate {} for term {} (partition {})", req.candidate_id, - req.term + req.term, + node.get_config().await.partition_id ); - let (term, vote_granted) = self - .node + let (term, vote_granted) = node .handle_pre_vote( req.term, req.candidate_id, @@ -82,17 +103,18 @@ impl raft_service_server::RaftService for RaftServiceImpl { &self, request: Request, ) -> Result, Status> { + let node = self.get_node(&request)?; let req = request.into_inner(); tracing::trace!( - "Received AppendEntries from leader {} for term {} ({} entries)", + "Received AppendEntries from leader {} for term {} ({} entries) (partition {})", req.leader_id, req.term, - req.entries.len() + req.entries.len(), + node.get_config().await.partition_id ); - let (term, success) = self - .node + let (term, success) = node .handle_append_entries( req.term, req.leader_id, @@ -110,15 +132,17 @@ impl raft_service_server::RaftService for RaftServiceImpl { &self, request: Request, ) -> Result, Status> { + let node = self.get_node(&request)?; let req = request.into_inner(); tracing::debug!( - "Received ReadIndex from term {} for leader {}", + "Received ReadIndex from term {} for leader {} (partition {})", req.term, - req.leader_id + req.leader_id, + node.get_config().await.partition_id ); - match self.node.handle_read_index(req.term).await { + match node.handle_read_index(req.term).await { Ok((term, read_index)) => { tracing::debug!( "ReadIndex response: term={}, read_index={}", @@ -147,10 +171,10 @@ impl raft_service_server::RaftService for RaftServiceImpl { &self, request: Request, ) -> Result, Status> { + let node = self.get_node(&request)?; let req = request.into_inner(); - let (term, _success) = self - .node + let (term, _success) = node .handle_install_snapshot( req.term, req.leader_id, diff --git a/crates/prkdb/src/raft/state_machine.rs b/crates/prkdb/src/raft/state_machine.rs index 262d403..34bd56e 100644 --- a/crates/prkdb/src/raft/state_machine.rs +++ b/crates/prkdb/src/raft/state_machine.rs @@ -65,12 +65,28 @@ impl StateMachine for PrkDbStateMachine { .await .map_err(StateMachineError::Storage)?; } - Command::CreateCollection { name } => { - tracing::info!("Applying CreateCollection command: name={}", name); + Command::CreateCollection { + name, + num_partitions, + replication_factor, + } => { + tracing::info!( + "Applying CreateCollection command: name={}, partitions={}, replication={}", + name, + num_partitions, + replication_factor + ); // Store collection metadata as a special key let metadata_key = format!("meta:col:{}", name).into_bytes(); - // Value could be schema or config in future, for now just placeholder - let metadata_value = b"{}".to_vec(); + + // Store configuration as JSON + let metadata = serde_json::json!({ + "num_partitions": num_partitions, + "replication_factor": replication_factor, + "created_at": chrono::Utc::now().to_rfc3339() + }); + + let metadata_value = metadata.to_string().into_bytes(); self.storage .put(&metadata_key, &metadata_value) .await diff --git a/crates/prkdb/tests/admin_rpc_tests.rs b/crates/prkdb/tests/admin_rpc_tests.rs index 7339c2a..a68977a 100644 --- a/crates/prkdb/tests/admin_rpc_tests.rs +++ b/crates/prkdb/tests/admin_rpc_tests.rs @@ -85,6 +85,7 @@ async fn test_collection_create_list_drop() { .create_collection(tonic::Request::new(CreateCollectionRequest { admin_token: TEST_ADMIN_TOKEN.to_string(), name: "test_users".to_string(), + ..Default::default() })) .await .unwrap() @@ -114,6 +115,7 @@ async fn test_collection_create_list_drop() { .create_collection(tonic::Request::new(CreateCollectionRequest { admin_token: TEST_ADMIN_TOKEN.to_string(), name: "test_orders".to_string(), + ..Default::default() })) .await .unwrap() @@ -238,6 +240,7 @@ async fn test_full_collection_workflow() { .create_collection(tonic::Request::new(CreateCollectionRequest { admin_token: TEST_ADMIN_TOKEN.to_string(), name: name.to_string(), + ..Default::default() })) .await .unwrap() diff --git a/crates/prkdb/tests/client_server_integration.rs b/crates/prkdb/tests/client_server_integration.rs index 8799bbb..9b5e780 100644 --- a/crates/prkdb/tests/client_server_integration.rs +++ b/crates/prkdb/tests/client_server_integration.rs @@ -76,6 +76,9 @@ impl TestClient { .create_collection(tonic::Request::new(CreateCollectionRequest { admin_token: self.admin_token.clone(), name: name.to_string(), + num_partitions: 1, + replication_factor: 1, + ..Default::default() })) .await .map_err(|e| e.to_string())?; @@ -209,6 +212,85 @@ impl TestClient { .map_err(|e| e.to_string())?; Ok(response.into_inner()) } + + // --- Schema Registry Helpers --- + + async fn register_schema( + &mut self, + collection: &str, + schema_proto: Vec, + ) -> Result { + let response = self + .client + .register_schema(tonic::Request::new(RegisterSchemaRequest { + admin_token: self.admin_token.clone(), + collection: collection.to_string(), + schema_proto, + compatibility: CompatibilityMode::CompatibilityBackward as i32, + migration_id: None, + })) + .await + .map_err(|e| e.to_string())?; + let resp = response.into_inner(); + if resp.success { + Ok(resp.version) + } else { + Err(resp.error) + } + } + + async fn get_schema( + &mut self, + collection: &str, + version: Option, + ) -> Result, String> { + let response = self + .client + .get_schema(tonic::Request::new(GetSchemaRequest { + collection: collection.to_string(), + version: version.unwrap_or(0), + })) + .await + .map_err(|e| e.to_string())?; + let resp = response.into_inner(); + if resp.success { + Ok(resp.schema_proto) + } else { + Err(resp.error) + } + } + + async fn list_schemas(&mut self) -> Result, String> { + let response = self + .client + .list_schemas(tonic::Request::new(ListSchemasRequest { + admin_token: self.admin_token.clone(), + })) + .await + .map_err(|e| e.to_string())?; + let resp = response.into_inner(); + if resp.success { + Ok(resp.schemas) + } else { + Err("ListSchemas failed".to_string()) + } + } + + async fn check_compatibility( + &mut self, + collection: &str, + schema_proto: Vec, + ) -> Result { + let response = self + .client + .check_compatibility(tonic::Request::new(CheckCompatibilityRequest { + collection: collection.to_string(), + schema_proto, + })) + .await + .map_err(|e| e.to_string())?; + Ok(response.into_inner().compatible) + } } /// Start gRPC server and return URL + shutdown handle @@ -577,3 +659,91 @@ async fn test_data_consistency_integration() { shutdown.send(()).ok(); } + +// ============================================================================= +// Integration Test: Schema Registry Flow +// ============================================================================= + +#[tokio::test] +async fn test_schema_rpc_integration() { + let (server_url, shutdown) = start_server().await; + let mut client = TestClient::connect(&server_url, ADMIN_TOKEN).await; + + println!("\n📋 Schema Registry Integration Test"); + + // 1. Create a dummy descriptor (simulating a .proto file) + // In a real scenario this comes from protoc, but here we just use bytes + // Minimal valid FileDescriptorProto bytes for a message named "User" + // Name = field 1, string + let user_v1_proto = vec![ + 10, 4, 85, 115, 101, 114, // name="User" + ]; + + // 2. Register Schema + println!(" Registering User schema v1..."); + let version = client + .register_schema("users", user_v1_proto.clone()) + .await + .expect("RegisterSchema failed"); + assert_eq!(version, 1, "Should be version 1"); + println!("✅ Registered v1"); + + // 3. List Schemas + println!(" Listing schemas..."); + let schemas = client.list_schemas().await.expect("ListSchemas failed"); + assert_eq!(schemas.len(), 1); + assert_eq!(schemas[0].collection, "users"); + assert_eq!(schemas[0].latest_version, 1); + println!("✅ ListSchemas verified"); + + // 4. Get Schema + println!(" Getting schema v1..."); + let fetched_proto = client + .get_schema("users", Some(1)) + .await + .expect("GetSchema failed"); + assert_eq!(fetched_proto, user_v1_proto, "Schema bytes mismatch"); + println!("✅ GetSchema v1 verified"); + + // 5. Register v2 (Non-breaking change) + // Just a slightly different name to make bytes different + let user_v2_proto = vec![ + 10, 4, 85, 115, 101, 114, // name="User" + 18, 0, // file_list (empty) - just adding some dummy field + ]; + + println!(" Registering User schema v2..."); + let version_2 = client + .register_schema("users", user_v2_proto.clone()) + .await + .expect("RegisterSchema v2 failed"); + assert_eq!(version_2, 2, "Should be version 2"); + println!("✅ Registered v2"); + + // 6. Verify Latest Version + let latest = client + .get_schema("users", None) + .await + .expect("GetSchema latest failed"); + assert_eq!(latest, user_v2_proto, "Should return v2"); + println!("✅ GetSchema (latest) verified"); + + // 7. Verify Isolation (v1 still exists) + let v1 = client + .get_schema("users", Some(1)) + .await + .expect("GetSchema v1 failed separate check"); + assert_eq!(v1, user_v1_proto, "v1 should be preserved"); + println!("✅ Version isolation verified"); + + // 8. Check Compatibility + println!(" Checking compatibility..."); + let compatible = client + .check_compatibility("users", user_v2_proto.clone()) + .await + .expect("CheckCompatibility failed"); + assert!(compatible, "v2 should be compatible with v1"); + println!("✅ Compatibility check verified"); + + shutdown.send(()).ok(); +} diff --git a/crates/prkdb/tests/compaction_test.rs b/crates/prkdb/tests/compaction_test.rs index 0d14142..55835b8 100644 --- a/crates/prkdb/tests/compaction_test.rs +++ b/crates/prkdb/tests/compaction_test.rs @@ -32,6 +32,7 @@ async fn test_log_compaction() { election_timeout_min_ms: 1000, election_timeout_max_ms: 2000, heartbeat_interval_ms: 200, + partition_id: 0, }; // Create Raft node diff --git a/crates/prkdb/tests/distributed_writes.rs b/crates/prkdb/tests/distributed_writes.rs index 780cd28..4f460d3 100644 --- a/crates/prkdb/tests/distributed_writes.rs +++ b/crates/prkdb/tests/distributed_writes.rs @@ -1,11 +1,11 @@ -use prkdb::raft::{ClusterConfig, PrkDbStateMachine, RaftNode, RpcClientPool}; +use prkdb::raft::{ClusterConfig, PartitionManager, PrkDbStateMachine, RaftNode, RpcClientPool}; use prkdb::storage::WalStorageAdapter; use prkdb_core::wal::WalConfig; use std::net::SocketAddr; use std::sync::Arc; use tempfile::TempDir; -/// Helper to create a Raft node +/// Helper to create a Raft node (wrapped in PartitionManager) async fn create_raft_node( id: u64, port: u16, @@ -14,10 +14,6 @@ async fn create_raft_node( let temp_dir = TempDir::new().unwrap(); let db_path = temp_dir.path().to_path_buf(); - let mut wal_config = WalConfig::test_config(); - wal_config.log_dir = db_path.clone(); - let storage = Arc::new(WalStorageAdapter::new(wal_config).unwrap()); - let listen_addr = format!("127.0.0.1:{}", port).parse().unwrap(); let config = ClusterConfig { local_node_id: id, @@ -26,19 +22,29 @@ async fn create_raft_node( election_timeout_min_ms: 200, election_timeout_max_ms: 400, heartbeat_interval_ms: 50, + partition_id: 0, // Default partition }; - let state_machine = Arc::new(PrkDbStateMachine::new(storage.clone())); - let raft_node = Arc::new(RaftNode::new(config, storage.clone(), state_machine)); + let pm = Arc::new( + PartitionManager::new(1, config, db_path, |_part_id, storage| { + Arc::new(PrkDbStateMachine::new(storage)) + }) + .unwrap(), + ); + let rpc_pool = Arc::new(RpcClientPool::new(id)); + // Start background tasks + pm.start_all(rpc_pool, &[]); + // Start server - let server_node = raft_node.clone(); + let pm_clone = pm.clone(); tokio::spawn(async move { - let _ = prkdb::raft::server::start_raft_server(server_node, listen_addr).await; + let _ = prkdb::raft::server::start_raft_server(pm_clone, listen_addr).await; }); - raft_node.clone().start(rpc_pool); + // Return partition 0 node for testing + let raft_node = pm.get_partition(0).unwrap(); (raft_node, temp_dir) } diff --git a/crates/prkdb/tests/helpers/test_cluster.rs b/crates/prkdb/tests/helpers/test_cluster.rs index 7a5f381..fdf2fcc 100644 --- a/crates/prkdb/tests/helpers/test_cluster.rs +++ b/crates/prkdb/tests/helpers/test_cluster.rs @@ -50,7 +50,8 @@ impl TestCluster { for i in 0..num_nodes { let node_id = (i + 1) as u64; let data_port = base_data_port + i as u16; - let raft_port = base_raft_port + i as u16; + // With multiplexing, Raft traffic uses the same port as data traffic + let raft_port = data_port; let data_dir = base_dir.path().join(format!("node{}", node_id)); let log_file = base_dir.path().join(format!("node{}.log", node_id)); diff --git a/crates/prkdb/tests/read_index_test.rs b/crates/prkdb/tests/read_index_test.rs index 9af07d0..a0abbfe 100644 --- a/crates/prkdb/tests/read_index_test.rs +++ b/crates/prkdb/tests/read_index_test.rs @@ -28,6 +28,7 @@ fn create_test_node() -> (Arc, TempDir) { election_timeout_min_ms: 1000, election_timeout_max_ms: 2000, heartbeat_interval_ms: 200, + partition_id: 0, }; // Create Raft node diff --git a/crates/prkdb/tests/schema_tests.rs b/crates/prkdb/tests/schema_tests.rs new file mode 100644 index 0000000..775042c --- /dev/null +++ b/crates/prkdb/tests/schema_tests.rs @@ -0,0 +1,351 @@ +//! Integration tests for the Schema Registry and ProtoSchema trait +//! +//! Tests the end-to-end flow of: +//! 1. ProtoSchema trait auto-implementation via #[derive(Collection)] +//! 2. Schema registration with the registry +//! 3. Schema retrieval and versioning +//! 4. Schema descriptor parsing + +use prkdb_macros::Collection; +use prkdb_types::schema::{FieldDef, ProtoSchema, ProtoType}; +use serde::{Deserialize, Serialize}; + +// ───────────────────────────────────────────────────────────────────────────── +// Test Models using #[derive(Collection)] +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Collection, Serialize, Deserialize, Clone, Debug, PartialEq)] +struct User { + #[key] + id: String, + email: String, + age: u32, + active: bool, +} + +#[derive(Collection, Serialize, Deserialize, Clone, Debug, PartialEq)] +struct Product { + #[key] + sku: String, + name: String, + price: f64, + quantity: u32, + tags: Vec, + description: Option, +} + +#[derive(Collection, Serialize, Deserialize, Clone, Debug, PartialEq)] +struct Order { + #[key] + order_id: u64, + user_id: String, + total: f64, + items: Vec, + created_at: i64, +} + +// ───────────────────────────────────────────────────────────────────────────── +// ProtoSchema Trait Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_proto_schema_collection_name() { + assert_eq!(User::collection_name(), "User"); + assert_eq!(Product::collection_name(), "Product"); + assert_eq!(Order::collection_name(), "Order"); +} + +#[test] +fn test_proto_schema_field_definitions_user() { + let fields = User::field_definitions(); + + assert_eq!(fields.len(), 4); + + // Check id field + assert_eq!(fields[0].name, "id"); + assert_eq!(fields[0].field_number, 1); + assert!(matches!(fields[0].proto_type, ProtoType::String)); + assert!(!fields[0].is_optional); + assert!(!fields[0].is_repeated); + + // Check email field + assert_eq!(fields[1].name, "email"); + assert_eq!(fields[1].field_number, 2); + assert!(matches!(fields[1].proto_type, ProtoType::String)); + + // Check age field + assert_eq!(fields[2].name, "age"); + assert_eq!(fields[2].field_number, 3); + assert!(matches!(fields[2].proto_type, ProtoType::Uint32)); + + // Check active field + assert_eq!(fields[3].name, "active"); + assert_eq!(fields[3].field_number, 4); + assert!(matches!(fields[3].proto_type, ProtoType::Bool)); +} + +#[test] +fn test_proto_schema_field_definitions_product() { + let fields = Product::field_definitions(); + + assert_eq!(fields.len(), 6); + + // Check for repeated field (Vec) + let tags_field = fields.iter().find(|f| f.name == "tags").unwrap(); + assert!( + tags_field.is_repeated, + "Vec should be marked as repeated" + ); + + // Check for optional field (Option) + let desc_field = fields.iter().find(|f| f.name == "description").unwrap(); + assert!( + desc_field.is_optional, + "Option should be marked as optional" + ); + + // Check price field (f64 -> Double) + let price_field = fields.iter().find(|f| f.name == "price").unwrap(); + assert!(matches!(price_field.proto_type, ProtoType::Double)); +} + +#[test] +fn test_proto_schema_field_definitions_order() { + let fields = Order::field_definitions(); + + assert_eq!(fields.len(), 5); + + // Check order_id (u64 -> Uint64) + let order_id_field = fields.iter().find(|f| f.name == "order_id").unwrap(); + assert!(matches!(order_id_field.proto_type, ProtoType::Uint64)); + + // Check created_at (i64 -> Int64) + let created_at_field = fields.iter().find(|f| f.name == "created_at").unwrap(); + assert!(matches!(created_at_field.proto_type, ProtoType::Int64)); +} + +#[test] +fn test_proto_schema_generates_bytes() { + let schema_bytes = User::schema_proto(); + + // Should generate non-empty bytes + assert!( + !schema_bytes.is_empty(), + "schema_proto() should not be empty" + ); + + // Minimum size: name_len(4) + "User"(4) + field_count(4) = 12 bytes minimum + assert!( + schema_bytes.len() >= 12, + "schema_proto() should have minimum header size" + ); +} + +#[test] +fn test_proto_schema_bytes_parseable() { + let schema_bytes = User::schema_proto(); + + // Parse the binary format we generate + let mut cursor = 0; + + // Read message name length + let name_len = + u32::from_le_bytes(schema_bytes[cursor..cursor + 4].try_into().unwrap()) as usize; + cursor += 4; + + // Read message name + let name = String::from_utf8_lossy(&schema_bytes[cursor..cursor + name_len]).to_string(); + cursor += name_len; + assert_eq!(name, "User"); + + // Read field count + let field_count = + u32::from_le_bytes(schema_bytes[cursor..cursor + 4].try_into().unwrap()) as usize; + cursor += 4; + assert_eq!(field_count, 4); + + // Parse first field (id) + let field_name_len = + u32::from_le_bytes(schema_bytes[cursor..cursor + 4].try_into().unwrap()) as usize; + cursor += 4; + let field_name = + String::from_utf8_lossy(&schema_bytes[cursor..cursor + field_name_len]).to_string(); + cursor += field_name_len; + assert_eq!(field_name, "id"); + + let field_number = i32::from_le_bytes(schema_bytes[cursor..cursor + 4].try_into().unwrap()); + cursor += 4; + assert_eq!(field_number, 1); + + let proto_type = i32::from_le_bytes(schema_bytes[cursor..cursor + 4].try_into().unwrap()); + cursor += 4; + assert_eq!(proto_type, ProtoType::String.as_i32()); + + let is_optional = schema_bytes[cursor] != 0; + cursor += 1; + assert!(!is_optional); + + let is_repeated = schema_bytes[cursor] != 0; + assert!(!is_repeated); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Schema Registry Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_schema_registry_register_and_get() { + use prkdb_schema::{CompatibilityMode, InMemorySchemaStorage, SchemaRegistry}; + use std::sync::Arc; + + let storage = Arc::new(InMemorySchemaStorage::new()); + let registry = SchemaRegistry::new(storage); + + // Register User schema + let schema_id = registry + .register("users", User::schema_proto(), CompatibilityMode::None, None) + .await + .expect("Should register schema"); + + assert!(schema_id.schema_id > 0); + assert_eq!(schema_id.version, 1); + + // Retrieve schema + let schema = registry + .get("users", None) + .await + .expect("Should get schema"); + + assert_eq!(schema.version, 1); + assert_eq!(schema.descriptor, User::schema_proto()); +} + +#[tokio::test] +async fn test_schema_registry_versioning() { + use prkdb_schema::{CompatibilityMode, InMemorySchemaStorage, SchemaRegistry}; + use std::sync::Arc; + + let storage = Arc::new(InMemorySchemaStorage::new()); + let registry = SchemaRegistry::new(storage); + + // Register v1 + let v1 = registry + .register( + "products", + Product::schema_proto(), + CompatibilityMode::None, + None, + ) + .await + .unwrap(); + assert_eq!(v1.version, 1); + + // Register v2 (same schema, new version) + let v2 = registry + .register( + "products", + Product::schema_proto(), + CompatibilityMode::None, + None, + ) + .await + .unwrap(); + assert_eq!(v2.version, 2); + + // Get latest (should be v2) + let latest = registry.get("products", None).await.unwrap(); + assert_eq!(latest.version, 2); + + // Get specific version (v1) + let first = registry.get("products", Some(1)).await.unwrap(); + assert_eq!(first.version, 1); +} + +#[tokio::test] +async fn test_schema_registry_list_schemas() { + use prkdb_schema::{CompatibilityMode, InMemorySchemaStorage, SchemaRegistry}; + use std::sync::Arc; + + let storage = Arc::new(InMemorySchemaStorage::new()); + let registry = SchemaRegistry::new(storage); + + // Register multiple schemas + registry + .register("users", User::schema_proto(), CompatibilityMode::None, None) + .await + .unwrap(); + registry + .register( + "products", + Product::schema_proto(), + CompatibilityMode::None, + None, + ) + .await + .unwrap(); + registry + .register( + "orders", + Order::schema_proto(), + CompatibilityMode::None, + None, + ) + .await + .unwrap(); + + // List all + let list = registry.list().await.unwrap(); + assert_eq!(list.len(), 3); + + let collections: Vec<_> = list.iter().map(|s| s.collection.as_str()).collect(); + assert!(collections.contains(&"users")); + assert!(collections.contains(&"products")); + assert!(collections.contains(&"orders")); +} + +#[tokio::test] +async fn test_schema_registry_not_found() { + use prkdb_schema::{InMemorySchemaStorage, SchemaRegistry}; + use std::sync::Arc; + + let storage = Arc::new(InMemorySchemaStorage::new()); + let registry = SchemaRegistry::new(storage); + + let result = registry.get("nonexistent", None).await; + assert!(result.is_err()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Type Mapping Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_proto_type_as_i32() { + // Verify ProtoType values match Protobuf spec + assert_eq!(ProtoType::Double.as_i32(), 1); + assert_eq!(ProtoType::Float.as_i32(), 2); + assert_eq!(ProtoType::Int64.as_i32(), 3); + assert_eq!(ProtoType::Uint64.as_i32(), 4); + assert_eq!(ProtoType::Int32.as_i32(), 5); + assert_eq!(ProtoType::Bool.as_i32(), 8); + assert_eq!(ProtoType::String.as_i32(), 9); + assert_eq!(ProtoType::Bytes.as_i32(), 12); + assert_eq!(ProtoType::Uint32.as_i32(), 13); +} + +#[test] +fn test_rust_type_to_proto_mapping() { + use prkdb_types::schema::rust_type_to_proto; + + assert!(matches!(rust_type_to_proto("String"), ProtoType::String)); + assert!(matches!(rust_type_to_proto("&str"), ProtoType::String)); + assert!(matches!(rust_type_to_proto("i32"), ProtoType::Int32)); + assert!(matches!(rust_type_to_proto("i64"), ProtoType::Int64)); + assert!(matches!(rust_type_to_proto("u32"), ProtoType::Uint32)); + assert!(matches!(rust_type_to_proto("u64"), ProtoType::Uint64)); + assert!(matches!(rust_type_to_proto("f32"), ProtoType::Float)); + assert!(matches!(rust_type_to_proto("f64"), ProtoType::Double)); + assert!(matches!(rust_type_to_proto("bool"), ProtoType::Bool)); + // Unknown types default to Bytes + assert!(matches!(rust_type_to_proto("CustomType"), ProtoType::Bytes)); +} diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..57a09c3 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +node_modules +.vitepress/dist +.vitepress/cache diff --git a/docs/.prettierignore b/docs/.prettierignore new file mode 100644 index 0000000..94aa6e0 --- /dev/null +++ b/docs/.prettierignore @@ -0,0 +1,3 @@ +.vitepress/cache +.vitepress/dist +node_modules diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 0000000..364896f --- /dev/null +++ b/docs/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "proseWrap": "preserve" +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..39314ce --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,46 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: 'PrkDB', + description: + 'A persistent, distributed key-value store with advanced features', + ignoreDeadLinks: true, + themeConfig: { + nav: [ + { text: 'Home', link: '/' }, + { text: 'Guide', link: '/guide/getting-started' }, + { text: 'Reference', link: '/guide/metrics' }, + ], + + sidebar: [ + { + text: 'Introduction', + items: [ + { text: 'Getting Started', link: '/guide/getting-started' }, + { text: 'Roadmap', link: '/guide/roadmap' }, + { text: 'Deployment', link: '/guide/deployment' }, + ], + }, + { + text: 'Core Concepts', + items: [ + { text: 'Replication', link: '/guide/replication' }, + { text: 'Custom Adapters', link: '/guide/custom-adapter' }, + { text: 'ORM Dialects', link: '/guide/orm-dialects-quickstart' }, + ], + }, + { + text: 'Advanced', + items: [ + { + text: 'Streaming & Kafka', + link: '/guide/streaming-kafka-comparison', + }, + { text: 'Metrics', link: '/guide/metrics' }, + ], + }, + ], + + socialLinks: [{ icon: 'github', link: 'https://github.com/prk-Jr/prkdb' }], + }, +}) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md deleted file mode 100644 index 12ad07d..0000000 --- a/docs/DEPLOYMENT.md +++ /dev/null @@ -1,251 +0,0 @@ -# PrkDB Deployment Guide - -## Requirements - -- **Rust**: 1.70+ -- **Docker**: For monitoring stack (optional) -- **Memory**: 4GB+ recommended for high throughput -- **Disk**: SSD recommended for WAL performance - -## Single Node Deployment - -### Build - -```bash -cargo build --release -``` - -### Configuration - -```rust -let db = PrkDb::builder() - .with_data_dir("/var/lib/prkdb/data") - .with_optimized_storage("/var/lib/prkdb", OptimizationLevel::Legendary) - .register_collection::() - .build()?; -``` - -### Systemd Service - -Create `/etc/systemd/system/prkdb.service`: - -```ini -[Unit] -Description=PrkDB Database -After=network.target - -[Service] -Type=simple -User=prkdb -ExecStart=/usr/local/bin/prkdb serve --data-dir /var/lib/prkdb -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -``` - -```bash -sudo systemctl enable prkdb -sudo systemctl start prkdb -``` - -## Cluster Deployment - -### 3-Node Minimum - -For fault tolerance, deploy at least 3 nodes (tolerates 1 failure). - -### Node Configuration - -**Node 1** (`node1.example.com`): -```bash -./raft_node \ - --node-id 1 \ - --listen 0.0.0.0:50051 \ - --peers 2=node2.example.com:50051,3=node3.example.com:50051 \ - --data-dir /var/lib/prkdb -``` - -**Node 2** (`node2.example.com`): -```bash -./raft_node \ - --node-id 2 \ - --listen 0.0.0.0:50051 \ - --peers 1=node1.example.com:50051,3=node3.example.com:50051 \ - --data-dir /var/lib/prkdb -``` - -**Node 3** (`node3.example.com`): -```bash -./raft_node \ - --node-id 3 \ - --listen 0.0.0.0:50051 \ - --peers 1=node1.example.com:50051,2=node2.example.com:50051 \ - --data-dir /var/lib/prkdb -``` - -### Docker Compose - -```yaml -version: '3.8' -services: - prkdb-node1: - image: prkdb:latest - command: > - --node-id 1 - --listen 0.0.0.0:50051 - --peers 2=prkdb-node2:50051,3=prkdb-node3:50051 - volumes: - - prkdb-data1:/data - ports: - - "50051:50051" - - prkdb-node2: - image: prkdb:latest - command: > - --node-id 2 - --listen 0.0.0.0:50051 - --peers 1=prkdb-node1:50051,3=prkdb-node3:50051 - volumes: - - prkdb-data2:/data - - prkdb-node3: - image: prkdb:latest - command: > - --node-id 3 - --listen 0.0.0.0:50051 - --peers 1=prkdb-node1:50051,2=prkdb-node2:50051 - volumes: - - prkdb-data3:/data - -volumes: - prkdb-data1: - prkdb-data2: - prkdb-data3: -``` - -## Monitoring - -### Prometheus + Grafana - -```bash -docker compose -f docker/docker-compose.yml up -d -``` - -### Metrics Endpoint - -Each node exposes metrics at `:8092/metrics`: - -``` -prkdb_reads_total -prkdb_writes_total -prkdb_cache_hits_total -prkdb_collection_ops_total{collection="users"} -prkdb_operation_duration_seconds -``` - -### Alerting - -Example Prometheus alert rules: - -```yaml -groups: - - name: prkdb - rules: - - alert: HighLatency - expr: histogram_quantile(0.99, prkdb_operation_duration_seconds) > 0.1 - for: 5m - labels: - severity: warning - - - alert: LowThroughput - expr: rate(prkdb_writes_total[5m]) < 1000 - for: 10m - labels: - severity: warning -``` - -## Performance Tuning - -### WAL Configuration - -```rust -let config = WalConfig { - segment_bytes: 512 * 1024 * 1024, // 512MB segments - batch_size: 500, // Optimal batch size - ..WalConfig::benchmark_config() -}; -``` - -### Memory - -- Increase file descriptor limits: `ulimit -n 65536` -- Use huge pages if available -- Allocate sufficient heap for caching - -### Network - -- Use private network between cluster nodes -- Enable TCP keepalive -- Consider dedicated NICs for replication traffic - -## Backup & Recovery - -### Snapshot (TODO) - -```bash -prkdb snapshot create --output /backup/snapshot.db -prkdb snapshot restore --input /backup/snapshot.db -``` - -### WAL Backup - -Copy WAL directory while node is stopped: - -```bash -systemctl stop prkdb -rsync -av /var/lib/prkdb/ /backup/prkdb/ -systemctl start prkdb -``` - -## Security - -### TLS (TODO) - -```bash -./raft_node \ - --tls-cert /etc/prkdb/server.crt \ - --tls-key /etc/prkdb/server.key -``` - -### Firewall - -```bash -# Allow only cluster nodes -ufw allow from node1.example.com to any port 50051 -ufw allow from node2.example.com to any port 50051 -ufw allow from node3.example.com to any port 50051 -``` - -## Troubleshooting - -### Node Won't Start - -1. Check logs: `journalctl -u prkdb -f` -2. Verify data directory permissions -3. Check port availability: `lsof -i :50051` - -### Cluster Not Electing Leader - -1. Verify network connectivity between nodes -2. Check firewall rules -3. Ensure all nodes have same peer configuration -4. Check for clock skew - -### High Latency - -1. Check disk I/O: `iostat -x 1` -2. Verify SSD usage for WAL -3. Monitor memory usage -4. Check network latency between nodes diff --git a/docs/METRICS.md b/docs/METRICS.md deleted file mode 100644 index 2e6863c..0000000 --- a/docs/METRICS.md +++ /dev/null @@ -1,183 +0,0 @@ -# PrkDB Metrics Guide - -This guide explains how to use the `prkdb-metrics` crate to monitor your PrkDB deployment with Prometheus. - -## Overview - -The `prkdb-metrics` crate provides: -- **Prometheus metrics export** - Standard metrics in Prometheus format -- **HTTP metrics server** - Built-in `/metrics` endpoint -- **Consumer lag tracking** - Monitor consumer group lag across partitions -- **Partition-level metrics** - Track records and size per partition - -## Quick Start - -### 1. Add Dependency - -```toml -[dependencies] -prkdb = "0.1" -prkdb-metrics = "0.1" -``` - -### 2. Start Metrics Server -```bash -cargo run -p prkdb --example metrics_server -``` - -```rust -use prkdb_metrics::MetricsServer; -use std::net::SocketAddr; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let addr: SocketAddr = "127.0.0.1:9090".parse()?; - let server = MetricsServer::new(addr); - - tokio::spawn(async move { - server.start().await.unwrap(); - }); - - // Your application code... - - Ok(()) -} -``` - -### 3. Access Metrics -- Metrics endpoint: http://localhost:9090/metrics -- Health check: http://localhost:9090/health - -## Available Metrics - -### Event Metrics -| Metric | Type | Labels | Description | -|---|---|---|---| -| `prkdb_events_produced_total` | Counter | `collection` | Total events produced | -| `prkdb_events_consumed_total` | Counter | `collection`, `consumer_group` | Total events consumed | - -### Partition Metrics -| Metric | Type | Labels | Description | -|---|---|---|---| -| `prkdb_partition_records` | Gauge | `collection`, `partition` | Number of records in partition | -| `prkdb_partition_size_bytes` | Gauge | `collection`, `partition` | Size of partition in bytes | - -### Consumer Metrics -| Metric | Type | Labels | Description | -|---|---|---|---| -| `prkdb_consumer_lag` | Gauge | `consumer_group`, `collection`, `partition` | Consumer lag (messages behind) | -| `prkdb_consumer_offset` | Gauge | `consumer_group`, `collection`, `partition` | Current consumer offset | -| `prkdb_consumer_group_members` | Gauge | `consumer_group` | Number of active consumers in group | - -### Operation Metrics -| Metric | Type | Labels | Description | -|---|---|---|---| -| `prkdb_operation_duration_seconds` | Histogram | `operation`, `collection` | Operation latency distribution | -| `prkdb_storage_operations_total` | Counter | `operation` | Total storage operations (put/get/delete/scan) | - -## Consumer Lag Tracking -```rust -use prkdb_metrics::ConsumerLagTracker; -use std::sync::Arc; - -let lag_tracker = Arc::new(ConsumerLagTracker::new()); - -// Update latest offset when producing -lag_tracker.update_latest_offset("orders", partition_id, offset); - -// Update consumer offset when consuming -lag_tracker.update_consumer_offset( - "my-consumer-group", - "orders", - partition_id, - consumer_offset -); - -// Get lag for a consumer group -let lags = lag_tracker.get_group_lags("my-consumer-group"); -for (collection, partition, lag) in lags { - println!("{}/partition-{}: {} behind", collection, partition, lag); -} -``` - -## Recording Custom Metrics -### Event Production -```rust -use prkdb_metrics::exporter::EVENTS_PRODUCED; - -// Increment when producing events -EVENTS_PRODUCED - .with_label_values(&["Order"]) - .inc(); -``` - -### Event Consumption -```rust -use prkdb_metrics::exporter::EVENTS_CONSUMED; - -// Increment when consuming events -EVENTS_CONSUMED - .with_label_values(&["Order", "order-processor"]) - .inc(); -``` - -### Operation Latency -```rust -use prkdb_metrics::exporter::OPERATION_LATENCY; -use std::time::Instant; - -let start = Instant::now(); -// ... perform operation ... -let duration = start.elapsed(); - -OPERATION_LATENCY - .with_label_values(&["put", "Order"]) - .observe(duration.as_secs_f64()); -``` - -### Prometheus Configuration -Add this to your prometheus.yml: - -```yaml -scrape_configs: - - job_name: 'prkdb' - static_configs: - - targets: ['localhost:9090'] - scrape_interval: 15s -``` - -## Grafana Queries - -- Events produced rate: - - `rate(prkdb_events_produced_total[5m])` -- Consumer lag by group: - - `prkdb_consumer_lag{consumer_group="order-processor"}` -- p99 operation latency: - - `histogram_quantile(0.99, rate(prkdb_operation_duration_seconds_bucket[5m]))` -- Partition distribution for a collection: - - `prkdb_partition_records{collection="Order"}` - -## Alerting Rules (Prometheus) - -```yaml -groups: - - name: prkdb - rules: - - alert: HighConsumerLag - expr: prkdb_consumer_lag > 1000 - for: 5m - labels: - severity: warning - annotations: - summary: "High consumer lag detected" - description: "Consumer {{ $labels.consumer_group }} is {{ $value }} messages behind" - - - alert: NoConsumersInGroup - expr: prkdb_consumer_group_members == 0 - for: 1m - labels: - severity: critical - annotations: - summary: "No active consumers" - description: "Consumer group {{ $labels.consumer_group }} has no active members" -``` diff --git a/docs/REPLICATION.md b/docs/REPLICATION.md deleted file mode 100644 index beab1ec..0000000 --- a/docs/REPLICATION.md +++ /dev/null @@ -1,398 +0,0 @@ -# PrkDB Replication Guide - -This guide explains how to set up and use leader-follower replication in PrkDB. - -## Overview - -PrkDB supports **leader-follower replication** for high availability and read scalability: - -- **Leader** - Accepts writes, broadcasts changes to followers -- **Followers** - Sync from leader, serve read requests -- **Automatic failover** - (Future: automatic leader election) - -## Architecture - -``` -┌─────────────┐ -│ Leader │ ← Writes -│ (Node 1) │ -└──────┬──────┘ - │ Replication - ├────────────┐ - ▼ ▼ -┌──────────┐ ┌──────────┐ -│Follower 1│ │Follower 2│ ← Reads -└──────────┘ └──────────┘ -``` - -### How It Works - -1. **Leader** receives writes and adds them to the outbox -2. **Followers** poll the leader for new changes (1 second interval) -3. **Changes** are applied to follower storage -4. **Offset tracking** ensures no changes are missed -5. **Metrics** track lag, throughput, and health - -## Quick Start - -### 1. Create Leader Node - -```rust -use prkdb::replication::{ReplicaNode, ReplicationConfig, ReplicationManager}; -use std::sync::Arc; - -let leader_db = Arc::new( - PrkDb::builder() - .with_storage(InMemoryAdapter::new()) - .register_collection::() - .build()? -); - -let config = ReplicationConfig { - self_node: ReplicaNode { - id: "leader-1".to_string(), - address: "localhost:8080".to_string(), - }, - leader_address: None, // None = this is the leader - followers: vec![ - ReplicaNode { - id: "follower-1".to_string(), - address: "localhost:8081".to_string(), - }, - ], - timing: ReplicationTiming::default(), -}; - -let manager = Arc::new(ReplicationManager::new(leader_db.clone(), config)); -manager.clone().start().await?; -``` - -### 2. Create Follower Node - -```rust -let follower_db = Arc::new( - PrkDb::builder() - .with_storage(InMemoryAdapter::new()) - .register_collection::() - .build()? -); - -let config = ReplicationConfig { - self_node: ReplicaNode { - id: "follower-1".to_string(), - address: "localhost:8081".to_string(), - }, - leader_address: Some("localhost:8080".to_string()), // Connect to leader - followers: vec![], - timing: ReplicationTiming::default(), -}; - -let manager = Arc::new(ReplicationManager::new(follower_db.clone(), config)); -manager.clone().start().await?; -``` - -### 3. Write to Leader - -```rust -// Writes go to the leader -let orders = leader_db.collection::(); -orders.put(order).await?; - -// Changes are automatically replicated to followers -``` - -## Monitoring & Health Checks - -### Health Status - -Check replication health: - -```rust -let health = manager.get_health().await; - -println!("Healthy: {}", health.healthy); -println!("Lag: {:.2}s", health.lag_seconds); -println!("Last sync: {}", health.last_sync); - -if let Some(error) = health.error { - println!("Error: {}", error); -} -``` - -### Replication State - -Get detailed state: - -```rust -let state = manager.get_state().await; - -println!("Changes applied: {}", state.changes_applied); -println!("Last change ID: {:?}", state.last_change_id); -println!("Connected: {}", state.connected); -println!("Error count: {}", state.error_count); -``` - -## Prometheus Metrics - -### Available Metrics - -#### Follower Metrics - -| Metric | Type | Description | -|--------|------|-------------| -| `prkdb_replication_lag_seconds` | Gauge | Replication lag in seconds | -| `prkdb_replication_changes_applied_total` | Counter | Total changes applied | -| `prkdb_replication_follower_connected` | Gauge | Connection status (1=connected) | -| `prkdb_replication_sync_errors_total` | Counter | Total sync errors | -| `prkdb_replication_last_sync_timestamp_seconds` | Gauge | Last successful sync | -| `prkdb_replication_sync_duration_seconds` | Histogram | Sync operation duration | -| `prkdb_replication_batch_size` | Histogram | Changes per sync batch | - -#### Leader Metrics - -| Metric | Type | Description | -|--------|------|-------------| -| `prkdb_replication_changes_pending` | Gauge | Changes pending replication | -| `prkdb_replication_active_followers` | Gauge | Number of active followers | - -### Example Queries - -```promql -# Replication lag by follower -prkdb_replication_lag_seconds{node_id="follower-1"} - -# Changes applied per second -rate(prkdb_replication_changes_applied_total[1m]) - -# Sync error rate -rate(prkdb_replication_sync_errors_total[5m]) - -# Average sync duration -rate(prkdb_replication_sync_duration_seconds_sum[1m]) / -rate(prkdb_replication_sync_duration_seconds_count[1m]) - -# 95th percentile batch size -histogram_quantile(0.95, prkdb_replication_batch_size_bucket) -``` - -### Grafana Dashboard - -Create a dashboard with: - -1. **Replication Lag** - Line chart of lag over time -2. **Throughput** - Changes per second -3. **Connection Status** - Binary status indicator -4. **Error Rate** - Errors per minute -5. **Batch Size Distribution** - Histogram - -## Retry Logic & Error Handling - -### Exponential Backoff - -Followers use exponential backoff on errors: - -- **Initial retry**: 2 seconds -- **Subsequent retries**: 2^n seconds (4s, 8s, 16s, 32s, 60s) -- **Max delay**: 60 seconds -- **Reset**: On successful sync - -### Error Types - -| Error Type | Description | Action | -|------------|-------------|--------| -| `sync_failed` | Failed to fetch changes | Retry with backoff | -| `apply_failed` | Failed to apply change | Retry with backoff | -| `connection_error` | Network issue | Retry with backoff | - -### State Tracking - -```rust -pub struct ReplicationState { - pub last_change_id: Option, - pub changes_applied: u64, - pub last_sync_ts: i64, - pub connected: bool, - pub error_count: u64, - pub last_error: Option, -} -``` - -## Configuration - -### ReplicationConfig - -```rust -pub struct ReplicationConfig { - /// This node's identity - pub self_node: ReplicaNode, - - /// Leader address (None if this is the leader) - pub leader_address: Option, - - /// List of follower nodes (leader only) - pub followers: Vec, - - /// Timing/backoff configuration - pub timing: ReplicationTiming, -} -``` - -### ReplicationTiming - -```rust -pub struct ReplicationTiming { - pub follower_poll_interval: Duration, - pub backoff_base: Duration, - pub backoff_max: Duration, - pub request_timeout: Duration, -} -``` - -- **Sync interval**: default 1s (`follower_poll_interval`) -- **Backoff**: exponential, seeded by `backoff_base`, capped by `backoff_max` -- **Request timeout**: HTTP fetch timeout to the leader -- **Batch size**: limit stays at 100 per request today - -## Production Deployment - -### Best Practices - -1. **Monitor lag & drift** - Health exposes `lag_seconds`; `ReplicationHealth` also includes `outbox_drift` (saved - drained) to watch backlog. -2. **Track errors** - Alert on consecutive failures. -3. **Use persistent storage** - Not InMemoryAdapter. -4. **Enable metrics** - Always run metrics server. -5. **Log aggregation** - Collect logs from all nodes - -### Example Production Setup - -```rust -// Leader -let leader = PrkDb::builder() - .with_storage(SledAdapter::new("/data/leader")) - .register_collection::() - .build()?; - -// Follower with monitoring -let follower = PrkDb::builder() - .with_storage(SledAdapter::new("/data/follower")) - .register_collection::() - .build()?; - -// Start metrics server -let metrics = MetricsServer::new("0.0.0.0:9090".parse()?); -tokio::spawn(async move { metrics.start().await }); - -// Health check loop -tokio::spawn(async move { - loop { - let health = manager.get_health().await; - if !health.healthy { - error!("Replication unhealthy: {:?}", health.error); - } - sleep(Duration::from_secs(30)).await; - } -}); -``` - -### Alerting Rules - -```yaml -groups: - - name: replication - rules: - - alert: ReplicationLagHigh - expr: prkdb_replication_lag_seconds > 10 - for: 5m - annotations: - summary: "Replication lag is high" - - - alert: ReplicationDisconnected - expr: prkdb_replication_follower_connected == 0 - for: 2m - annotations: - summary: "Follower disconnected from leader" - - - alert: ReplicationErrorRate - expr: rate(prkdb_replication_sync_errors_total[5m]) > 0.1 - for: 5m - annotations: - summary: "High replication error rate" -``` - -## Limitations (Current Implementation) - -1. **Pull-based** - Followers poll the leader (not push) -2. **No HTTP/gRPC** - Change fetching is stubbed -3. **No type registry** - Change application is placeholder -4. **No automatic failover** - Manual leader election required -5. **Single leader** - No multi-leader support - -## Roadmap - -### Phase 1: Network Transport (Next) -- [ ] HTTP/gRPC endpoints for change streaming -- [ ] Authentication and TLS -- [ ] Compression for large batches - -### Phase 2: Advanced Features -- [ ] Type registry for dynamic collection handling -- [ ] Conflict resolution strategies -- [ ] Read-your-writes consistency -- [ ] Multi-region replication - -### Phase 3: High Availability -- [ ] Automatic leader election (Raft/Paxos) -- [ ] Quorum-based writes -- [ ] Split-brain detection -- [ ] Automatic failover - -## Troubleshooting - -### Follower not syncing - -1. Check leader is running: `manager.is_leader()` -2. Check follower state: `manager.get_state().await` -3. Check logs for connection errors -4. Verify network connectivity - -### High replication lag - -1. Check metrics: `prkdb_replication_lag_seconds` -2. Reduce sync interval (requires code change) -3. Increase network bandwidth -4. Optimize storage adapter performance - -### Changes not applying - -1. Verify collection is registered on follower -2. Check for deserialization errors in logs -3. Ensure storage adapter is working -4. Check `prkdb_replication_sync_errors_total` metric - -### Connection issues - -1. Check `prkdb_replication_follower_connected` metric -2. Verify leader address is correct -3. Check network firewall rules -4. Review error logs for details - -## Example - -See `examples/replication.rs` for a complete working example: - -```bash -cargo run -p prkdb --example replication -``` - -This example: -- Sets up 1 leader and 2 followers -- Writes 10 orders to the leader -- Shows replication state and health -- Exposes metrics on port 9091 -- Demonstrates monitoring - -## See Also - -- [Metrics Guide](METRICS.md) -- [Partitioning Guide](PARTITIONING.md) -- [Examples](../examples/) diff --git a/docs/eslint.config.js b/docs/eslint.config.js new file mode 100644 index 0000000..50481a7 --- /dev/null +++ b/docs/eslint.config.js @@ -0,0 +1,20 @@ +import js from '@eslint/js' +import tseslint from 'typescript-eslint' + +export default [ + { + ignores: ['.vitepress/cache/**', '.vitepress/dist/**', 'node_modules/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts', '**/*.mts'], + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ['.vitepress/*.mts'], + }, + }, + }, + }, +] diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index b398bfa..0000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,85 +0,0 @@ -# Getting Started with PrkDB - -## Quick Start (5 minutes) - -### Installation - -**Prerequisites**: -- Rust 1.70+ -- Docker (for cluster deployment) - -**Build from source**: -```bash -cargo build --release -``` - -### Running Your First Instance - -**1. Start a single node**: -```bash -cargo run --bin prkdb-server --release -``` - -**2. Using the CLI**: -```bash -cargo run --bin prkdb -- --help -``` - -**3. Start a 3-node cluster**: -```bash -docker-compose up -d -``` - -### Basic Operations - -**Using the Rust client**: -```rust -use prkdb::client::PrkDbClient; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Connect to cluster - let client = PrkDbClient::new(vec![ - "http://127.0.0.1:8081".to_string(), - ]).await?; - - // Write data - client.put(b"my_key", b"my_value").await?; - - // Read data - let value = client.get(b"my_key").await?; - println!("Value: {:?}", value); - - Ok(()) -} -``` - -## Next Steps - -- [Architecture Overview](../ARCHITECTURE.md) -- [CLI Reference](../crates/prkdb-cli/README.md) -- [Performance Tuning](guides/performance.md) -- [Deployment Guide](guides/deployment.md) - -## Features - -✅ **4.2M writes/sec** - High-performance WAL -✅ **Multi-Raft** - Distributed consensus -✅ **Smart Client** - Automatic routing and failover -✅ **Strong Consistency** - Linearizable reads -✅ **Partitioning** - Horizontal scaling - -## Support - -- Documentation: [docs/](.) -- Examples: [examples/](../crates/prkdb/examples/) -- Issues: GitHub Issues - -## Performance - -See [benchmarks](benchmarks/) for detailed performance data. - -**Highlights**: -- WAL: 4.2M msgs/sec (local) -- Cluster writes: 16.5K ops/sec -- Read latency: 0.28ms avg diff --git a/docs/custom_adapter.md b/docs/guide/custom-adapter.md similarity index 99% rename from docs/custom_adapter.md rename to docs/guide/custom-adapter.md index 0b62d4c..aa0d189 100644 --- a/docs/custom_adapter.md +++ b/docs/guide/custom-adapter.md @@ -3,6 +3,7 @@ PrkDB’s persistence port is `prkdb_core::storage::StorageAdapter`. Implement it for any backend (RocksDB, Redis, HTTP service, etc.) and pass it into `PrkDb::builder().with_storage(...)`. The trait is async-first and has optional hooks for migrations and CDC outbox. ## Minimal in-memory adapter (async + outbox) + ```rust use async_trait::async_trait; use prkdb_core::error::StorageError; @@ -68,6 +69,7 @@ impl StorageAdapter for MemoryAdapter { ``` ## Wiring it up + ```rust use prkdb::prelude::*; use prkdb_macros::Collection; @@ -101,6 +103,7 @@ async fn main() -> Result<(), Box> { ``` ## Notes + - Implement `scan_prefix`/`scan_range` if your backend supports range queries (used by scans and some admin commands). - Implement `migrate_table` if your adapter can run schema DDL (SQL backends should do this to support `register_table_collection`). - `tests/adapter_matrix.rs` shows how multiple adapters are exercised; `tests/custom_adapter.rs` demonstrates plugging a bespoke one. diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md new file mode 100644 index 0000000..a26cd50 --- /dev/null +++ b/docs/guide/deployment.md @@ -0,0 +1,135 @@ +# PrkDB Deployment Guide + +This guide covers deploying PrkDB v2 in production environments. + +## Architecture + +PrkDB v2 uses a **shared-nothing, multi-leader** architecture (via Multi-Raft). + +- **Nodes**: Independent processes with local storage. +- **Raft Groups**: Data is partitioned into shards, each managed by a Raft consensus group. +- **Discovery**: Nodes communicate via gRPC (default port 50051). + +## Hardware Recommendations + +| Component | Minimum | Recommended | Note | +| ----------- | -------- | ------------- | ---------------------------------------------- | +| **CPU** | 2 cores | 4+ cores | Heavily threaded (gRPC + Raft) | +| **RAM** | 4 GB | 16 GB+ | OS page cache is critical for read performance | +| **Disk** | NVMe SSD | NVMe SSD RAID | WAL fsync latency dominates write throughput | +| **Network** | 1 Gbps | 10 Gbps | Low latency is crucial for Raft consensus | + +## Production Cluster (3-Node Setup) + +A minimal high-availability cluster requires 3 nodes. + +### 1. Binary Setup + +Compile the server binary: + +```bash +cargo build --release --bin prkdb-server +cp target/release/prkdb-server /usr/local/bin/ +``` + +### 2. Systemd Service + +Create `/etc/systemd/system/prkdb.service` on each node. + +**Node 1 Configuration:** + +```ini +[Unit] +Description=PrkDB Server +After=network.target + +[Service] +Type=simple +User=prkdb +# Node ID 1, listening on 0.0.0.0:8081 +ExecStart=/usr/local/bin/prkdb-server \ + --id 1 \ + --listen 0.0.0.0:8081 \ + --peers "1=10.0.0.1:8081,2=10.0.0.2:8081,3=10.0.0.3:8081" \ + --data-dir /var/lib/prkdb +Restart=always +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target +``` + +**Node 2 Configuration:** +Update `--id 2` and `--listen 0.0.0.0:8081` (binds to local interface). + +**Node 3 Configuration:** +Update `--id 3` and `--listen 0.0.0.0:8081`. + +### 3. Start Cluster + +Enable and start the service on all nodes: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable prkdb +sudo systemctl start prkdb +``` + +### 4. Verify Cluster + +Use the CLI from any node to check health: + +```bash +prkdb-cli --target http://127.0.0.1:8081 health +``` + +## Docker Deployment + +PrkDB can be deployed using Docker. + +```yaml +version: '3' +services: + node1: + image: prkdb/server:latest + command: --id 1 --peers "1=node1:8081,2=node2:8081,3=node3:8081" + volumes: + - data1:/var/lib/prkdb + ports: + - '8081:8081' + + node2: + image: prkdb/server:latest + command: --id 2 --peers "1=node1:8081,2=node2:8081,3=node3:8081" + volumes: + - data2:/var/lib/prkdb + + node3: + image: prkdb/server:latest + command: --id 3 --peers "1=node1:8081,2=node2:8081,3=node3:8081" + volumes: + - data3:/var/lib/prkdb + +volumes: + data1: + data2: + data3: +``` + +## OS Tuning + +For maximum performance, apply these sysctl settings: + +```bash +# Increase max open files +fs.file-max = 1000000 + +# TCP optimizations for internal traffic +net.ipv4.tcp_keepalive_time = 60 +net.ipv4.tcp_keepalive_intvl = 10 +net.ipv4.tcp_keepalive_probes = 6 +``` + +## Security (Coming Soon) + +V2.1 will add native TLS support. For now, we verify deployment inside a private VPC or using a service mesh (Linkerd/Istio) for mTLS termination. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 0000000..b3d4713 --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,115 @@ +# Getting Started with PrkDB + +PrkDB v2 is a distributed key-value store built on Raft consensus. This guide will help you set up a local cluster and perform basic operations. + +## Prerequisites + +- **Rust 1.75+** +- **protoc** (Protocol Buffers compiler) +- **cargo** (Rust package manager) + +## Installation + +Clone the repository and build from source: + +```bash +git clone https://github.com/prk-Jr/prkdb.git +cd prkdb +cargo build --release +``` + +## Running a Local Cluster + +For development, you can start a 3-node local cluster using the provided script: + +```bash +./scripts/start_cluster.sh +``` + +This will start: + +- **Node 1** (Leader): http://127.0.0.1:8081 +- **Node 2** (Follower): http://127.0.0.1:8082 +- **Node 3** (Follower): http://127.0.0.1:8083 + +## Basic Operations (CLI) + +Use the `prkdb-cli` tool to interact with the cluster. + +### 1. Check Cluster Health + +```bash +cargo run -p prkdb-cli -- health +``` + +Expected output: + +``` +Cluster Status: HEALTHY +Leader: Node 1 (127.0.0.1:8081) +Active Nodes: 3/3 +``` + +### 2. Put & Get Data + +```bash +# Write a value +cargo run -p prkdb-cli -- put my-key "Hello PrkDB" + +# Read it back (Linearizable read) +cargo run -p prkdb-cli -- get my-key +``` + +### 3. Create a Collection + +PrkDB organizes data into collections (tables). + +```bash +cargo run -p prkdb-cli -- schema create users +``` + +## Client Usage (Rust) + +Add `prkdb` to your `Cargo.toml`: + +```toml +[dependencies] +prkdb = { git = "https://github.com/prk-Jr/prkdb" } +tokio = { version = "1.0", features = ["full"] } +``` + +### Example Code + +```rust +use prkdb::client::PrkDbClient; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Connect to the cluster (provide any node address) + let client = PrkDbClient::new(vec![ + "http://127.0.0.1:8081".to_string(), + ]).await?; + + // Put data + client.put(b"user:1001", b"{\"name\":\"Alice\"}").await?; + + // Linearizable Get (guaranteed latest data) + let val = client.get(b"user:1001").await?; + println!("Value: {:?}", val); + + Ok(()) +} +``` + +## Next Steps + +- [Deployment Guide](DEPLOYMENT.md) - Deploying to production +- [Replication Guide](REPLICATION.md) - Understanding Multi-Raft +- [Metrics Reference](METRICS.md) - Monitoring your cluster + +## Features (v2.0) + +✅ **Multi-Raft Consensus** - Strong consistency & easy failover. +✅ **gRPC Transport** - High performance internal communication. +✅ **Linearizable Reads** - Read-your-writes guarantees. +✅ **Log Compaction** - Efficient storage management. diff --git a/docs/guide/metrics.md b/docs/guide/metrics.md new file mode 100644 index 0000000..bd7be9f --- /dev/null +++ b/docs/guide/metrics.md @@ -0,0 +1,63 @@ +# Metrics Reference + +PrkDB exposes Prometheus metrics at `/metrics` (default port 8081). + +## Core Metrics + +| Metric | type | Description | +| ---------------------- | ----- | -------------------- | +| `prkdb_uptime_seconds` | Gauge | Process uptime | +| `prkdb_memory_bytes` | Gauge | Current memory usage | + +## Raft Consensus (Crucial) + +Monitor these to ensure cluster health. high churn in `current_term` means instability. + +| Metric | Type | Description | +| --------------------------- | ----- | ------------------------------------------------ | +| `prkdb_raft_current_term` | Gauge | Current election term. Monotonically increasing. | +| `prkdb_raft_commit_index` | Gauge | Index of the last committed log entry. | +| `prkdb_raft_last_log_index` | Gauge | Index of the last entry in the log. | +| `prkdb_raft_state` | Gauge | 0=Follower, 1=Candidate, 2=Leader | +| `prkdb_raft_voted_for` | Gauge | Node ID this node voted for in current term. | + +### Alert Rules + +**1. Leader Flapping** +Alert if term changes frequently (e.g. > 5 times in 5 minutes). + +```yaml +rate(prkdb_raft_current_term[5m]) > 0.016 +``` + +**2. Replication Lag** +Alert if `last_log_index` is far ahead of `commit_index` on Leader, or `commit_index` is behind Leader's. + +```yaml +(prkdb_raft_last_log_index - prkdb_raft_commit_index) > 1000 +``` + +## gRPC Transport + +| Metric | Type | Description | +| ---------------------------- | --------- | --------------------------------- | +| `prkdb_grpc_requests_total` | Counter | Total RPC requests served | +| `prkdb_grpc_errors_total` | Counter | Total failed RPC requests | +| `prkdb_grpc_latency_seconds` | Histogram | Latency distribution of RPC calls | + +## Storage (WAL) + +| Metric | Type | Description | +| ---------------------------------- | --------- | ------------------------------------------------ | +| `prkdb_wal_segments_total` | Gauge | Number of active WAL segments | +| `prkdb_wal_bytes_written_total` | Counter | Total bytes written to disk | +| `prkdb_wal_fsync_duration_seconds` | Histogram | Time taken for `fsync` calls (critical for perf) | + +## Example Grafana Dashboard + +Basic panel setup: + +1. **Cluster Status**: Table showing `prkdb_raft_state` for all nodes. +2. **Commit Velocity**: Rate of `prkdb_raft_commit_index` increase. +3. **RPC Latency**: 99th percentile of `prkdb_grpc_latency_seconds`. +4. **Disk I/O**: Rate of `prkdb_wal_bytes_written_total`. diff --git a/docs/orm_dialects_quickstart.md b/docs/guide/orm-dialects-quickstart.md similarity index 99% rename from docs/orm_dialects_quickstart.md rename to docs/guide/orm-dialects-quickstart.md index c50ec20..4dad101 100644 --- a/docs/orm_dialects_quickstart.md +++ b/docs/guide/orm-dialects-quickstart.md @@ -3,6 +3,7 @@ PrkDB ORM ships fluent builders that are dialect-aware. Enable dialect features via `prkdb-orm` features (default: `sqlite`, optional: `postgres`, `mysql`). ## SQLite (default) + ```rust use prkdb_orm::dialect::SqlDialect; use prkdb_orm::executor::SqlxSqliteExecutor; @@ -41,6 +42,7 @@ async fn main() -> anyhow::Result<()> { ``` ## Postgres (feature = `postgres`) + ```rust use prkdb_orm::dialect::SqlDialect; use prkdb_orm::executor::SqlxPostgresExecutor; @@ -106,6 +108,7 @@ async fn main() -> anyhow::Result<()> { ``` ## Tips + - Field helpers are generated per struct: `Table::insert()` exposes `.insert_()` for non-`#[auto_increment]` columns, while `.update_*`/`.where_*` exist for all fields. - JSON/nested fields use `#[column(json)]` and remain dialect-aware. - Use `Sqlx*Executor` helpers for drivers, or implement your own executor to reuse the builder/SQL generation with another driver. diff --git a/docs/guide/replication.md b/docs/guide/replication.md new file mode 100644 index 0000000..3a656c0 --- /dev/null +++ b/docs/guide/replication.md @@ -0,0 +1,89 @@ +# PrkDB Replication Guide + +PrkDB v2 uses **Multi-Raft Consensus** to provide strong consistency, high availability, and horizontal scalability. + +## Overview + +Unlike traditional primary-replica systems, PrkDB uses the Raft consensus algorithm to ensure that data is safely replicated across a quorum of nodes before it is considered committed. + +- **Strong Consistency**: Writes are acknowledged only after being replicated to a majority of nodes. +- **Automatic Failover**: If a leader node fails, the cluster automatically elects a new leader without human intervention. +- **Member Changes**: Nodes can be added or removed dynamically (planned feature). + +## Architecture + +PrkDB partitions data into multiple **Raft Groups** (Shards). Each partition has its own Raft consensus group, allowing the cluster to scale writes horizontally. + +```mermaid +graph TD + Client -->|Put key="user:1"| Proxy + Proxy -->|Hash("user:1")| Partition1[Partition 1 Leader] + + subgraph "Partition 1 (Raft Group)" + Partition1 -->|Replicate| P1Follower1[Follower A] + Partition1 -->|Replicate| P1Follower2[Follower B] + end +``` + +### Roles + +- **Leader**: Handles all writes and linearizable reads for a specific partition. +- **Follower**: Replicates logs from the leader. Can serve stale reads or linearizable reads (via `ReadIndex`). +- **Candidate**: A node trying to become a leader during an election. + +## Consistency Modes + +PrkDB supports tunable consistency levels for reads, allowing you to balance performance and correctness. + +| Mode | Description | Latency | Consistency | +| -------------------------- | ------------------------------------------------------------------------------------------------ | ------- | ----------- | +| **Linearizable** (Default) | Reads from Leader. Guarantees you see the latest committed data. | Medium | Strong | +| **Follower** | Reads from Follower using `ReadIndex`. Guarantees linearizability but offloads work from Leader. | Medium | Strong | +| **Stale** | Reads locally from any node. Fastest, but data might be slightly outdated. | Low | Eventual | + +### Code Example + +```rust +use prkdb::client::{PrkDbClient, ReadMode}; + +let client = PrkDbClient::new(vec!["http://localhost:8080".to_string()]).await?; + +// Linearizable Read (Default) +let val = client.get(b"key").await?; + +// Stale Read (Low Latency) +let val = client.get_opts(b"key", ReadMode::Stale).await?; +``` + +## Network Transport + +Replication traffic uses **gRPC** for high performance and strict typing. + +- **Heartbeats**: Leaders send periodic heartbeats to maintain authority. +- **AppendEntries**: Log entries are batched and streamed to followers. +- **Snapshots**: Compacted logs are sent as snapshots to slow followers. + +## Monitoring + +Key metrics to watch: + +- `prkdb_raft_current_term`: High churn indicates frequent elections (instability). +- `prkdb_raft_commit_index`: Should closely track `last_log_index`. +- `prkdb_raft_voted_for`: Who this node voted for. + +See [Metrics Guide](METRICS.md) for a full list. + +## Roadmap Status + +### Completed (v2.0) + +- [x] Multi-Raft Consensus (Vote, AppendEntries, Heartbeat) +- [x] Log Compaction & Snapshotting +- [x] Linearizable & Follower Reads +- [x] gRPC Transport + +### Upcoming + +- [ ] TLS Mutual Authentication for Intra-cluster communication +- [ ] Multi-region federation +- [ ] Dynamic Split/Merge of partitions diff --git a/docs/ROADMAP.md b/docs/guide/roadmap.md similarity index 85% rename from docs/ROADMAP.md rename to docs/guide/roadmap.md index bb7a8be..6231788 100644 --- a/docs/ROADMAP.md +++ b/docs/guide/roadmap.md @@ -1,6 +1,7 @@ # PrkDB v2 Development Roadmap & Summary ## Status Check + - **Current Version**: v2.0-clean - **Build Status**: Passing ✅ - **Test Coverage**: Core modules covered (Raft, Sharding, Storage) @@ -10,6 +11,7 @@ ## 🚀 Completed Features (v2.0) ### 1. Raft Consensus + - [x] **Pre-Vote Protocol**: Prevents disruptive elections from partitioned nodes (verified by chaos tests). - [x] **Replication Modes**: - `Linearizable`: Strong consistency (Leader read) @@ -17,11 +19,13 @@ - `Follower`: Balances load (ReadIndex) ### 2. Advanced Sharding + - [x] **Consistent Hashing**: `ConsistentHashRing` with virtual nodes for minimal rebalancing. - [x] **Range Partitioning**: `RangePartitioner` for ordered key access patterns. - [x] **Performance**: ~1.56B routing ops/sec (641ps latency). ### 3. Infrastructure + - [x] **Cleanup**: Removed ~70 redundant files; repo size optimized. - [x] **Testing**: Fast unit tests (<1s for core); comprehensive chaos suite. @@ -30,16 +34,19 @@ ## 🔮 Future Roadmap (v2.1+) ### Performance Optimization + - [ ] **SIMD Vectorization**: Optimize range scans and aggregations. - [ ] **Zero-Copy Networking**: Use `io_uring` for faster replication. - [ ] **Compaction Strategies**: Leveled compaction for segmented logs. ### Distributed Features + - [ ] **Dynamic Rebalancing**: Auto-move partitions based on load. - [ ] **Cross-Region Replication**: Asynchronous geo-replication. - [ ] **Distributed Transactions**: 2PC over Raft for multi-partition atomic commits. ### Developer Experience + - [ ] **Web Dashboard**: React/Next.js admin UI for cluster management. - [ ] **SQL Layer**: Expand `prkdb-orm` with more SQL dialect support. - [ ] **Client SDKs**: Go and Python clients. @@ -47,16 +54,18 @@ --- ## Performance Baselines -| Metric | Value | Note | -|--------|-------|------| -| **Write Throughput** | 199K ops/s | Batch writes | -| **Read Throughput** | 8.5M ops/s | Single key lookup | + +| Metric | Value | Note | +| --------------------- | ----------- | ------------------ | +| **Write Throughput** | 199K ops/s | Batch writes | +| **Read Throughput** | 8.5M ops/s | Single key lookup | | **Partition Routing** | 1.56B ops/s | Consistent hashing | -| **Cache Hits** | 10.4M ops/s | LRU Cache | +| **Cache Hits** | 10.4M ops/s | LRU Cache | --- ## Getting Started + ```bash # Run unit tests cargo test diff --git a/docs/STREAMING_KAFKA_COMPARISON.md b/docs/guide/streaming-kafka-comparison.md similarity index 51% rename from docs/STREAMING_KAFKA_COMPARISON.md rename to docs/guide/streaming-kafka-comparison.md index 998e098..4d93314 100644 --- a/docs/STREAMING_KAFKA_COMPARISON.md +++ b/docs/guide/streaming-kafka-comparison.md @@ -2,61 +2,69 @@ ## Overview -This document compares PrkDB's streaming capabilities with Apache Kafka, +This document compares PrkDB's streaming capabilities with Apache Kafka, analyzing features, performance, and use cases. --- ## Feature Comparison -| Feature | PrkDB | Apache Kafka | -|---------|-------|--------------| -| **Architecture** | Embedded library | Distributed cluster | -| **Language** | Rust | Java/Scala | -| **Dependencies** | None | JVM, Zookeeper/KRaft | -| **Event Ordering** | Per-key ordering | Per-partition ordering | -| **Consumer Groups** | ✅ Supported | ✅ Supported | -| **Offset Tracking** | ✅ Automatic | ✅ Automatic | -| **Stream Processing** | ✅ Built-in combinators | Kafka Streams | -| **Backpressure** | ✅ async/await | ✅ Flow control | -| **Transactions** | Planned | ✅ Exactly-once | -| **Replication** | ✅ Raft consensus | ✅ ISR replication | +| Feature | PrkDB | Apache Kafka | +| --------------------- | ----------------------- | ---------------------- | +| **Architecture** | Embedded library | Distributed cluster | +| **Language** | Rust | Java/Scala | +| **Dependencies** | None | JVM, Zookeeper/KRaft | +| **Event Ordering** | Per-key ordering | Per-partition ordering | +| **Consumer Groups** | ✅ Supported | ✅ Supported | +| **Offset Tracking** | ✅ Automatic | ✅ Automatic | +| **Stream Processing** | ✅ Built-in combinators | Kafka Streams | +| **Backpressure** | ✅ async/await | ✅ Flow control | +| **Transactions** | Planned | ✅ Exactly-once | +| **Replication** | ✅ Raft consensus | ✅ ISR replication | --- ## Performance Comparison -### Throughput (Single Node) +Based on latest benchmarks (Feb 2026), PrkDB significantly outperforms Kafka in both throughput and latency. -| Metric | PrkDB | Kafka | -|--------|-------|-------| -| **Write (single producer)** | 125K msg/s | 100-500K msg/s | -| **Write (optimized)** | **199K msg/s** | 100-500K msg/s | -| **Read (cached)** | **7.3M msg/s** | 100-500K msg/s | -| **Mixed workload** | 144K msg/s | 50-200K msg/s | -| **Multi-threaded** | 600K msg/s | 200-600K msg/s | +### 📈 Throughput -### Latency +| Metric | Kafka | PrkDB | Advantage | +| -------------------- | ---------- | ---------------- | ---------------- | +| **Producer (1M)** | 31.20 MB/s | **330.16 MB/s** | **10.5x faster** | +| **Sustained (10M)** | 71.07 MB/s | **249.22 MB/s** | **3.5x faster** | +| **Consumer** | 94.09 MB/s | **2385.55 MB/s** | **25.3x faster** | +| **Partitioned Peak** | - | **483.82 MB/s** | - | -| Metric | PrkDB | Kafka | -|--------|-------|-------| -| P50 (median) | 3-10 µs | 1-5 ms | -| P99 | 50-100 µs | 10-50 ms | -| P999 | 500 µs | 50-200 ms | +### ⏱️ Latency -**Key insight**: PrkDB has 10-100x lower latency due to embedded architecture. +| Percentile | Kafka | PrkDB | +| ----------- | --------- | ---------------------- | +| **Average** | 150.61 ms | **2.05 ms** (2049 μs) | +| **p99** | 265 ms | **54.9 ms** (54927 μs) | + +> Note: PrkDB achieves sub-millisecond average latency in optimized scenarios. + +### 🔬 Methodology + +- **Records**: 1,000,000 (Standard) / 10,000,000 (Sustained) +- **Record Size**: 100 bytes +- **Batch Size**: 10,000 +- **Environment**: GitHub Actions (ubuntu-latest), Native Rust benchmarks with mmap WAL. +- **Data**: Real writes to disk (fsync enabled), no mocking. --- ## Resource Usage -| Resource | PrkDB | Kafka (3-node) | -|----------|-------|----------------| -| Memory | 50-200 MB | 1-6 GB per node | -| Disk | WAL only | Data + logs | -| CPU | 1-4 cores | 2-8 cores per node | -| Startup time | < 1 sec | 10-60 sec | -| Binary size | ~10 MB | 100+ MB | +| Resource | PrkDB | Kafka (3-node) | +| ------------ | --------- | ------------------ | +| Memory | 50-200 MB | 1-6 GB per node | +| Disk | WAL only | Data + logs | +| CPU | 1-4 cores | 2-8 cores per node | +| Startup time | < 1 sec | 10-60 sec | +| Binary size | ~10 MB | 100+ MB | --- @@ -153,7 +161,7 @@ use prkdb::streaming::EventStreamExt; // Filter events let high_value = stream.filter_events(|order| order.amount > 1000.0); -// Transform events +// Transform events let amounts = stream.map_events(|order| order.amount); // Chain combinators @@ -176,43 +184,16 @@ let consumer2 = EventStream::::new(db.clone(), config2).await?; --- -## Benchmark Results - -### Test Configuration - -- **Hardware**: MacBook Air M1/M2 -- **Test duration**: 10 seconds per test -- **Batch size**: 1000 messages -- **Data size**: 100K pre-populated messages - -### Results - -| Test | PrkDB (msg/s) | Kafka (typical) | -|------|---------------|-----------------| -| Single Producer | 72,000 | 100-200K | -| Single Consumer | 7,000,000 | 100-500K | -| 4x Producers | 115,000 | 200-400K | -| 4x Consumers | 4,000,000 | 200-600K | -| Pipeline (2P+2C) | 600,000 | 100-300K | - -### Analysis - -1. **Reads dominate**: PrkDB's cached reads (7M/s) far exceed Kafka -2. **Writes competitive**: 72K-115K writes/s vs Kafka's 100-400K -3. **Mixed workloads excel**: Pipeline shows 600K/s combined throughput - ---- - ## Conclusion -| Aspect | Winner | -|--------|--------| -| Latency | 🏆 **PrkDB** (100x lower) | -| Read throughput | 🏆 **PrkDB** (14x faster) | -| Write throughput | Kafka (slightly better) | -| Resource usage | 🏆 **PrkDB** (10x less) | -| Scalability | Kafka (horizontal scaling) | -| Simplicity | 🏆 **PrkDB** (embedded) | - -**PrkDB is ideal for edge, embedded, and latency-sensitive streaming.** -**Kafka is ideal for enterprise-scale, multi-tenant streaming.** +| Aspect | Winner | +| ---------------- | ---------------------------------------- | +| Latency | 🏆 **PrkDB** (sub-millisecond) | +| Read throughput | 🏆 **PrkDB** (25x faster consumer) | +| Write throughput | 🏆 **PrkDB** (10.5x faster producer) | +| Resource usage | 🏆 **PrkDB** (10x less) | +| Scalability | Kafka (horizontal scaling) | +| Simplicity | 🏆 **PrkDB** (embedded or single binary) | + +**PrkDB is ideal for high-performance streaming where latency and throughput are critical.** +**Kafka remains the standard for massive, multi-tenant enterprise data hubs.** diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d2cba05 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,46 @@ +--- +layout: home + +hero: + name: 'PrkDB' + text: 'Distributed KV Store' + tagline: 'Production-ready toolkit for portable edge HTTP workloads' + actions: + - theme: brand + text: Get Started + link: /guide/getting-started + - theme: alt + text: View on GitHub + link: https://github.com/prk-Jr/prkdb + +features: + - title: Distributed & Consistent + details: Built on Raft consensus for strong consistency and high availability. + - title: Performant + details: Written in Rust for maximum performance and memory safety. + - title: Flexible Schema + details: Supports Protocol Buffers for structured data and schema evolution. + - title: Multiple Interfaces + details: Access via gRPC, HTTP, or WebSocket APIs. + - title: Cross-Platform Clients + details: SDKs available for Rust, Python, TypeScript, and Go. +--- + +## Documentation + +### Introduction + +- [Getting Started](/guide/getting-started) +- [Roadmap](/guide/roadmap) +- [Deployment](/guide/deployment) + +### Core Concepts + +- [Replication](/guide/replication) +- [Custom Adapters](/guide/custom-adapter) +- [ORM Dialects](/guide/orm-dialects-quickstart) + +### Advanced + +- [Streaming & Kafka](/guide/streaming-kafka-comparison) +- [Metrics](/guide/metrics) diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..b5f8491 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,3973 @@ +{ + "name": "prkdb-docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "prkdb-docs", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/node": "^24.10", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "typescript-eslint": "^8.55.0", + "vitepress": "^1.5.0" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", + "integrity": "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.48.1.tgz", + "integrity": "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.48.1.tgz", + "integrity": "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.48.1.tgz", + "integrity": "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.48.1.tgz", + "integrity": "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.48.1.tgz", + "integrity": "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.48.1.tgz", + "integrity": "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz", + "integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.48.1.tgz", + "integrity": "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.48.1.tgz", + "integrity": "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.48.1.tgz", + "integrity": "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.48.1.tgz", + "integrity": "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.48.1.tgz", + "integrity": "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.48.1.tgz", + "integrity": "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.70", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.70.tgz", + "integrity": "sha512-CYNRCgN6nBTjN4dNkrBCjHXNR2e4hQihdsZUs/afUNFOWLSYjfihca4EFN05rRvDk4Xoy2n8tym6IxBZmcn+Qg==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", + "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.28", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", + "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", + "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.28", + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", + "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", + "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", + "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", + "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/runtime-core": "3.5.28", + "@vue/shared": "3.5.28", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", + "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "vue": "3.5.28" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", + "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/algoliasearch": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz", + "integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.14.1", + "@algolia/client-abtesting": "5.48.1", + "@algolia/client-analytics": "5.48.1", + "@algolia/client-common": "5.48.1", + "@algolia/client-insights": "5.48.1", + "@algolia/client-personalization": "5.48.1", + "@algolia/client-query-suggestions": "5.48.1", + "@algolia/client-search": "5.48.1", + "@algolia/ingestion": "1.48.1", + "@algolia/monitoring": "1.48.1", + "@algolia/recommend": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.28.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz", + "integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", + "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-sfc": "3.5.28", + "@vue/runtime-dom": "3.5.28", + "@vue/server-renderer": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..18a69a7 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,24 @@ +{ + "name": "prkdb-docs", + "version": "1.0.0", + "description": "Documentation for PrkDB", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview", + "format": "prettier --check .", + "format:write": "prettier --write .", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "devDependencies": { + "@types/node": "^24.10", + "eslint": "^9.17.0", + "@eslint/js": "^9.17.0", + "typescript-eslint": "^8.55.0", + "prettier": "^3.4.2", + "vitepress": "^1.5.0" + } +} diff --git a/scripts/debug_grpc.sh b/scripts/debug_grpc.sh new file mode 100644 index 0000000..6457f62 --- /dev/null +++ b/scripts/debug_grpc.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Build +echo -e "${GREEN}Building PrkDB...${NC}" +cargo build --release -p prkdb-cli --bin prkdb-server --bin prkdb-cli 2>/dev/null + +PRKDB_SERVER=./target/release/prkdb-server +PRKDB_CLI=./target/release/prkdb-cli + +# Cleanup +rm -rf /tmp/prkdb_debug +mkdir -p /tmp/prkdb_debug + +# Start Server +echo -e "${GREEN}Starting Server on 50051...${NC}" +RUST_LOG=debug,tonic=debug,hyper=debug,h2=debug $PRKDB_SERVER --verbose serve \ + --id 1 \ + --port 8081 \ + --grpc-port 50051 \ + --host 127.0.0.1 \ + --peers "1=127.0.0.1:50051" \ + > /tmp/prkdb_debug.log 2>&1 & +PID=$! + +echo "Waiting for server (5s)..." +sleep 5 + +# Check if running +if ! ps -p $PID > /dev/null; then + echo -e "${RED}Server failed to start!${NC}" + cat /tmp/prkdb_debug.log + exit 1 +fi + +echo -e "${GREEN}Testing gRPC connectivity...${NC}" +# Try collection create +RUST_LOG=debug,tonic=debug,hyper=debug,h2=debug $PRKDB_CLI --verbose --server http://127.0.0.1:50051 collection create debug_collection --partitions 1 --replication-factor 1 + +if [ $? -eq 0 ]; then + echo -e "${GREEN}Success!${NC}" +else + echo -e "${RED}Failed!${NC}" + echo "Server Logs:" + cat /tmp/prkdb_debug.log +fi + +kill $PID +rm -rf /tmp/prkdb_debug diff --git a/scripts/integration_test.sh b/scripts/integration_test.sh index 0d6148f..c078de0 100755 --- a/scripts/integration_test.sh +++ b/scripts/integration_test.sh @@ -65,6 +65,47 @@ run_test_suite "retention_tests" run_test_suite "integration_tests" run_test_suite "outbox_cdc_tests" run_test_suite "stateful_compute_tests" +run_test_suite "schema_tests" +run_test_suite "client_server_integration" + +# Run schema CLI tests +echo -e "${YELLOW}Running schema CLI tests...${NC}" +if ./scripts/test_schema_cli.sh; then + echo -e "${GREEN}✓ Schema CLI tests PASSED${NC}" + ((PASSED_TESTS++)) +else + echo -e "${RED}✗ Schema CLI tests FAILED${NC}" + ((FAILED_TESTS++)) +fi +((TOTAL_TESTS++)) + + +# Run client features tests (Python) +echo -e "${YELLOW}Running client features tests (Python)...${NC}" +if ./scripts/test_client_features.sh; then + echo -e "${GREEN}✓ Client features tests (Python) PASSED${NC}" + ((PASSED_TESTS++)) +else + echo -e "${RED}✗ Client features tests (Python) FAILED${NC}" + ((FAILED_TESTS++)) +fi +((TOTAL_TESTS++)) + +# Run client features tests (TypeScript) +if command -v node &> /dev/null && command -v npm &> /dev/null; then + echo -e "${YELLOW}Running client features tests (TypeScript)...${NC}" + if ./scripts/test_client_features_ts.sh; then + echo -e "${GREEN}✓ Client features tests (TypeScript) PASSED${NC}" + ((PASSED_TESTS++)) + else + echo -e "${RED}✗ Client features tests (TypeScript) FAILED${NC}" + ((FAILED_TESTS++)) + fi + ((TOTAL_TESTS++)) +else + echo -e "${YELLOW}Skipping TypeScript tests (Node.js/npm not found)${NC}" +fi +echo "" # Run doc tests echo -e "${YELLOW}Running doc tests...${NC}" diff --git a/scripts/run_benchmarks_local.sh b/scripts/run_benchmarks_local.sh new file mode 100755 index 0000000..135aa96 --- /dev/null +++ b/scripts/run_benchmarks_local.sh @@ -0,0 +1,107 @@ +#!/bin/bash +set -e + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🚀 Starting Local Benchmark Verification...${NC}" + +# 1. Build CLI +echo -e "${GREEN}📦 Building PrkDB CLI...${NC}" +cargo build --release -p prkdb-cli + +# Variables +PRKDB_BIN="./target/release/prkdb-cli" +HTTP_PORT=50052 +GRPC_PORT=50053 +SERVER_HTTP_URL="http://127.0.0.1:$HTTP_PORT" +SERVER_GRPC_URL="http://127.0.0.1:$GRPC_PORT" +WORK_DIR=$(mktemp -d) + +# Cleanup function +cleanup() { + echo -e "${GREEN}🧹 Cleaning up...${NC}" + echo -e "${RED}📜 Server Log:${NC}" + if [ -f "$WORK_DIR/server.log" ]; then + cat "$WORK_DIR/server.log" + else + echo "Log file not found." + fi + kill $SERVER_PID 2>/dev/null || true + rm -rf "$WORK_DIR" + rm -rf benches/client_py benches/client_ts bench.desc bench.proto +} +trap cleanup EXIT + +# 2. Start Server +echo -e "${GREEN}🔥 Starting PrkDB Server (HTTP: $HTTP_PORT, gRPC: $GRPC_PORT)...${NC}" +$PRKDB_BIN serve --port $HTTP_PORT --grpc-port $GRPC_PORT > "$WORK_DIR/server.log" 2>&1 & +SERVER_PID=$! +sleep 5 # Wait for startup + +# Check if server is running +if ! ps -p $SERVER_PID > /dev/null; then + echo -e "${RED}❌ Server failed to start! Check logs:${NC}" + cat "$WORK_DIR/server.log" + exit 1 +fi + +# 3. Define Schema +echo -e "${GREEN}📜 Defining Schema...${NC}" +cat > bench.proto < /dev/null; then + echo -e "${RED}❌ protoc not found. Please install protobuf-compiler.${NC}" + exit 1 +fi + +protoc --descriptor_set_out=bench.desc --include_imports bench.proto + +echo -e "${GREEN}📝 Registering Schema...${NC}" +# Schema registration uses gRPC +$PRKDB_BIN schema --server $SERVER_GRPC_URL register --collection benchmark --proto bench.desc + +# 4. Generate Clients +echo -e "${GREEN}🛠️ Generating Clients...${NC}" +mkdir -p benches/client_py benches/client_ts + +# Python (Uses gRPC to fetch schema) +$PRKDB_BIN codegen --server $SERVER_GRPC_URL --lang python --out benches/client_py --collection benchmark + +# TypeScript (Uses gRPC to fetch schema) +$PRKDB_BIN codegen --server $SERVER_GRPC_URL --lang typescript --out benches/client_ts --collection benchmark + +# 5. Run Python Benchmark +echo -e "${GREEN}🐍 Running Python Benchmark...${NC}" +# Benchmark uses HTTP client +if python3 -c "import httpx" &> /dev/null; then + python3 benches/bench_python.py --server $SERVER_HTTP_URL --records 1000 +else + echo -e "${RED}⚠️ Skipping Python bench: 'httpx' module not found.${NC}" + echo "Run 'pip install httpx' to enable." +fi + +# 6. Run TypeScript Benchmark +echo -e "${GREEN}📘 Running TypeScript Benchmark...${NC}" +if command -v ts-node &> /dev/null; then + # We need to set env vars expected by script + export PRKDB_SERVER=$SERVER_HTTP_URL + export NUM_RECORDS=1000 + ts-node benches/bench_ts.ts +else + echo -e "${RED}⚠️ Skipping TS bench: 'ts-node' not found.${NC}" + echo "Run 'npm install -g ts-node typescript' to enable." +fi + +echo -e "${GREEN}✅ Verification Complete!${NC}" diff --git a/scripts/run_cluster_benchmark.sh b/scripts/run_cluster_benchmark.sh new file mode 100755 index 0000000..7f7addb --- /dev/null +++ b/scripts/run_cluster_benchmark.sh @@ -0,0 +1,104 @@ +#!/bin/bash +set -e + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +# Cleanup function +cleanup() { + echo -e "${RED}Stopping cluster...${NC}" + kill $(jobs -p) 2>/dev/null || true + rm -rf /tmp/prkdb_cluster_* +} +trap cleanup EXIT + +echo -e "${GREEN}Building PrkDB (Release)...${NC}" +cargo build --release -p prkdb-cli --bin prkdb-cli + +PRKDB_CLI=./target/release/prkdb-cli + +# Set admin token for security +export PRKDB_ADMIN_TOKEN=benchmark_secret + +# Setup cluster directories +mkdir -p /tmp/prkdb_cluster_1 +mkdir -p /tmp/prkdb_cluster_2 +mkdir -p /tmp/prkdb_cluster_3 + +echo -e "${GREEN}Starting 3-node cluster...${NC}" + +# Node 1 (Bootstrap leader) +RUST_LOG=debug,raft=debug,prkdb=debug,openraft=debug $PRKDB_CLI --verbose serve \ + --id 1 \ + --port 8081 \ + --grpc-port 50051 \ + --peers "1=127.0.0.1:50051,2=127.0.0.1:50052,3=127.0.0.1:50053" \ + --num-partitions 3 \ + > /tmp/prkdb_node_1.log 2>&1 & +PID1=$! + +sleep 2 + +# Node 2 +RUST_LOG=debug,raft=debug,prkdb=debug,openraft=debug $PRKDB_CLI --verbose serve \ + --id 2 \ + --port 8082 \ + --grpc-port 50052 \ + --peers "1=127.0.0.1:50051,2=127.0.0.1:50052,3=127.0.0.1:50053" \ + > /tmp/prkdb_node_2.log 2>&1 & +PID2=$! + +# Node 3 +RUST_LOG=debug,raft=debug,prkdb=debug,openraft=debug $PRKDB_CLI --verbose serve \ + --id 3 \ + --port 8083 \ + --grpc-port 50053 \ + --peers "1=127.0.0.1:50051,2=127.0.0.1:50052,3=127.0.0.1:50053" \ + > /tmp/prkdb_node_3.log 2>&1 & +PID3=$! + +echo "Waiting for cluster to stabilize (15s)..." +sleep 15 + +echo -e "${GREEN}Creating partitioned collection 'benchmark'...${NC}" + +# Retry loop for collection creation to handle leadership election timing +# With 3 partitions x 3 nodes = 9 Raft groups, election can take a while +success=false +for round in 1 2 3 4 5; do + echo "--- Attempt round $round ---" + for port in 50051 50052 50053; do + echo " Trying port $port..." + if timeout 15 $PRKDB_CLI --server http://127.0.0.1:$port collection create benchmark --partitions 3 --replication-factor 3; then + echo -e "${GREEN}✓ Collection created successfully on port $port${NC}" + success=true + break 2 + fi + done + echo " Round $round failed, waiting 3s before next round..." + sleep 3 +done + +if [ "$success" = false ]; then + echo -e "${RED}Failed to create collection after 5 rounds.${NC}" + echo "--- Node 1 last 20 lines ---" + tail -n 20 /tmp/prkdb_node_1.log || true + exit 1 +fi + +echo -e "${GREEN}Generating Python Client...${NC}" +$PRKDB_CLI codegen --server http://127.0.0.1:50051 --lang python --out ./client_py --force + +echo -e "${GREEN}Running Python Benchmark...${NC}" +# Use existing python benchmark, pointing to one node +# TODO: Update python benchmark to support multiple nodes or handle redirects better? +# For now, simplistic approach +if [ -f "benches/bench_python.py" ]; then + python3 benches/bench_python.py --server http://127.0.0.1:8081,http://127.0.0.1:8082,http://127.0.0.1:8083 --records 1000 +else + echo -e "${RED}Benchmark script not found!${NC}" +fi + +echo -e "${GREEN}Benchmark Completed!${NC}" diff --git a/scripts/test_client_features.sh b/scripts/test_client_features.sh new file mode 100755 index 0000000..31c579d --- /dev/null +++ b/scripts/test_client_features.sh @@ -0,0 +1,161 @@ +#!/bin/bash +set -e + +# Configuration +SERVER_PORT=50055 +GRPC_PORT=50052 +WORK_DIR="/tmp/prkdb_client_features" +PRKDB_BIN="./target/debug/prkdb-cli" + +mkdir -p "$WORK_DIR" +rm -rf "$WORK_DIR"/* + +echo "🏗️ Building prkdb binary..." +cargo build -p prkdb-cli + +echo "🚀 Starting server on port $SERVER_PORT..." +# Start server in background +$PRKDB_BIN --verbose serve --port $SERVER_PORT --grpc-port $GRPC_PORT > "$WORK_DIR/server.log" 2>&1 & +SERVER_PID=$! +echo "Server PID: $SERVER_PID" + +cleanup() { + echo "🧹 Cleaning up..." + kill $SERVER_PID || true + wait $SERVER_PID || true + rm -rf prkdb.db # Cleanup default db if created +} +trap cleanup EXIT + +echo "⏳ Waiting for server..." +sleep 2 + +# Define Schema +echo "📝 Defining Schema..." +cat > "$WORK_DIR/user.proto" < "$WORK_DIR/test_client.py" </dev/null || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +# Setup workspace +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" +mkdir -p "$WORK_DIR/client_ts" + +# Check dependencies +if ! command -v node &> /dev/null; then + echo "❌ Node.js is required but not found." + exit 1 +fi + +if ! command -v npx &> /dev/null; then + echo "❌ npx is required but not found." + exit 1 +fi + +# Build binary +echo "🏗️ Building prkdb binary..." +cargo build -p prkdb-cli + +# Start server +echo "🚀 Starting server on port $SERVER_PORT..." +$PRKDB_CMD --verbose serve --port $SERVER_PORT --grpc-port $GRPC_PORT > "$WORK_DIR/server.log" 2>&1 & +SERVER_PID=$! +echo $SERVER_PID > "$WORK_DIR/server.pid" +echo "Server PID: $SERVER_PID" + +# Wait for server +echo "⏳ Waiting for server..." +for i in {1..30}; do + if curl -s "http://127.0.0.1:$SERVER_PORT/health" > /dev/null; then + echo "✅ Server is ready!" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Server failed to start. Check logs:" + cat "$WORK_DIR/server.log" + exit 1 + fi + sleep 1 +done + +# Define Schema +echo "📝 Defining Schema..." +cat > "$WORK_DIR/user.proto" < "$WORK_DIR/test_client.ts" < u.name); + if (!names.every(n => n === "Alice")) { + console.error(\`❌ Verification Failed: Expected all 'Alice', got \${names}\`); + process.exit(1); + } + + // Test 2: Simple Inequality (Bob) + console.log(" - Filter by name='Bob'"); + const bobs = await UserMeta.select(client) + .whereNameEq("Bob") + .execute(); + + if (bobs.length !== 1 || bobs[0].name !== "Bob") { + console.error(\`❌ Verification Failed: Expected 1 Bob, found \${bobs.length}\`); + process.exit(1); + } + + console.log("✅ Client Features Verification Passed!"); +} + +main().catch(err => { + console.error("❌ Test failed with exception:", err); + process.exit(1); +}); +EOF + +# Initialize NPM project (needed for module resolution) +cd "$WORK_DIR" +npm init -y > /dev/null +npm install --save-dev typescript ts-node @types/node > /dev/null + +# Configure tsconfig +cat > tsconfig.json < App V1 -> Schema V2 (Breaking) -> Migration -> App V2 + +echo "🏗️ Building prkdb binary..." +cargo build -p prkdb-cli --bin prkdb-cli --quiet + +PRKDB_BIN="./target/debug/prkdb-cli" +SERVER_PORT=50053 +SERVER_URL="http://127.0.0.1:${SERVER_PORT}" +LOG_FILE="/tmp/prkdb_server_e2e.log" +WORK_DIR="/tmp/prkdb_e2e" +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" + +cleanup() { + echo "🧹 Cleaning up..." + if [ -n "$SERVER_PID" ]; then + kill $SERVER_PID || true + fi + # rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +# 1. Start server +echo "🚀 Starting server on port $SERVER_PORT..." +$PRKDB_BIN serve --grpc-port $SERVER_PORT > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +echo "Server PID: $SERVER_PID" + +echo "⏳ Waiting for server..." +sleep 5 + +# 2. Define Schema V1 +echo "📝 Defining Schema V1..." +cat > "$WORK_DIR/user_v1.proto" < "$WORK_DIR/app_v1.py" < "$WORK_DIR/user_v2.proto" < "$WORK_DIR/user_v2_break.proto" < "$WORK_DIR/app_v2.py" </dev/null || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +mkdir -p "$WORK_DIR" + +# 1. Start server +echo "🚀 Starting server on port $SERVER_PORT (gRPC $GRPC_PORT)..." +$PRKDB_BIN serve --port $SERVER_PORT --grpc-port $GRPC_PORT > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +echo $SERVER_PID > "$WORK_DIR/server.pid" +echo "Server PID: $SERVER_PID" + +echo "⏳ Waiting for server to be ready..." +for i in {1..30}; do + if curl -s "http://127.0.0.1:$SERVER_PORT/health" > /dev/null; then + echo "✅ Server is ready!" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Server failed to start" + cat "$LOG_FILE" + exit 1 + fi + sleep 1 +done + +# 2. Prepare Schema +echo "📝 Creating test schema..." +cat > "$WORK_DIR/test_schema.proto" < /dev/null; then + echo "⚠️ protoc not found, skipping comprehensive schema tests (or installing?)" + # We can try to run without compilation if server accepts raw proto (which it doesn't currently) + echo "❌ protoc is required for this test" + exit 1 +fi + +echo "🔨 Compiling Schema..." +protoc --include_imports --descriptor_set_out="$WORK_DIR/test_schema.desc" --proto_path="$WORK_DIR" "$WORK_DIR/test_schema.proto" + +# 3. Test Register +echo "🧪 Testing 'schema register'..." +if $PRKDB_BIN schema --server "$GRPC_URL" register --collection test_col --proto "$WORK_DIR/test_schema.desc"; then + echo "✅ Schema registered successfully" +else + echo "❌ Schema registration failed" + exit 1 +fi + +# 4. Test List +echo "🧪 Testing 'schema list'..." +LIST_OUT=$($PRKDB_BIN schema --server "$GRPC_URL" list) +echo "$LIST_OUT" +if echo "$LIST_OUT" | grep -q "test_col"; then + echo "✅ Schema list contains test_col" +else + echo "❌ Schema list missing test_col" + exit 1 +fi + +# 5. Test Get +echo "🧪 Testing 'schema get'..." +if $PRKDB_BIN schema --server "$GRPC_URL" get --collection test_col > "$WORK_DIR/fetched.desc"; then + echo "✅ Schema fetched successfully" + # Verify size > 0 + if [ -s "$WORK_DIR/fetched.desc" ]; then + echo "✅ Fetched schema is not empty" + else + echo "❌ Fetched schema is empty" + exit 1 + fi +else + echo "❌ Schema get failed" + exit 1 +fi + +# 6. Test Check (Compatibility) +echo "🧪 Testing 'schema check'..." +if $PRKDB_BIN schema --server "$GRPC_URL" check --collection test_col --proto "$WORK_DIR/test_schema.desc"; then + echo "✅ Schema compatibility check passed" +else + echo "❌ Schema compatibility check failed" + exit 1 +fi + +echo "🎉 All Schema CLI tests passed!" diff --git a/scripts/test_schema_nested_e2e.sh b/scripts/test_schema_nested_e2e.sh new file mode 100755 index 0000000..ad1744e --- /dev/null +++ b/scripts/test_schema_nested_e2e.sh @@ -0,0 +1,152 @@ +#!/bin/bash +set -e +# End-to-End Schema Application Test - Nested Types +# Simulates: Schema with nested messages -> Client Gen (Py/TS) -> Usage + +echo "🏗️ Building prkdb binary..." +cargo build -p prkdb-cli --bin prkdb-cli --quiet + +PRKDB_BIN="./target/debug/prkdb-cli" +SERVER_PORT=50054 +SERVER_URL="http://127.0.0.1:${SERVER_PORT}" +LOG_FILE="/tmp/prkdb_nested_server.log" +WORK_DIR="/tmp/prkdb_nested_e2e" +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" + +cleanup() { + echo "🧹 Cleaning up..." + if [ -n "$SERVER_PID" ]; then + kill $SERVER_PID || true + fi +} +trap cleanup EXIT + +# 1. Start server +echo "🚀 Starting server on port $SERVER_PORT..." +$PRKDB_BIN serve --grpc-port $SERVER_PORT > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +echo "Server PID: $SERVER_PID" + +echo "⏳ Waiting for server..." +sleep 5 + +# 2. Define Nested Schema +echo "📝 Defining Nested Schema..." +cat > "$WORK_DIR/user_nested.proto" < "$WORK_DIR/app.py" <