diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d6b7ac3 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Connection string for PostgreSQL. The indexer and API both read from this. +DATABASE_URL=postgresql://trident:password@localhost:5432/trident + +# Connection string for Redis. Used by the indexer to publish events and by +# the API to consume them for WebSocket delivery. +REDIS_URL=redis://localhost:6379 + +# Soroban RPC endpoint. For testnet use https://soroban-testnet.stellar.org +# For mainnet use https://horizon.stellar.org/soroban/rpc or a private node. +STELLAR_RPC_URL=https://soroban-testnet.stellar.org + +# Network identifier passed through to the indexer for validation and labelling. +# One of: mainnet | testnet | futurenet +NETWORK=testnet + +# How often the indexer polls the RPC node for new ledgers, in milliseconds. +POLL_INTERVAL_MS=5000 + +# Set to true to store diagnostic events (emitted only in debug mode by Soroban). +# Leave false for production — diagnostic events are high-volume and rarely useful. +INDEX_DIAGNOSTIC=false + +# Log verbosity level. One of: error | warn | info | debug | trace +LOG_LEVEL=info + +# Port the Go API server listens on. +PORT=3000 + +# Random secret used to salt API key hashes. Must be changed before deployment. +# Generate with: openssl rand -hex 32 +API_KEY_SALT=change-this-to-a-random-string + +# PostgreSQL credentials (used by docker-compose.yml in production). +POSTGRES_USER=trident +POSTGRES_PASSWORD=password +POSTGRES_DB=trident diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d0c2d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +jobs: + # --------------------------------------------------------------------------- + # Rust — format, lint, test + # --------------------------------------------------------------------------- + rust: + name: Rust + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Rust build artefacts + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy (deny warnings) + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Tests + run: cargo test --all + + # --------------------------------------------------------------------------- + # Go — vet and lint + # --------------------------------------------------------------------------- + go: + name: Go + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/api + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache-dependency-path: services/api/go.sum + + - name: go vet + run: go vet ./... + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + working-directory: services/api + + # --------------------------------------------------------------------------- + # TypeScript — type check + # --------------------------------------------------------------------------- + typescript: + name: TypeScript + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/typescript + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: sdk/typescript/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run lint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85546bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Rust +/target +**/target/ +Cargo.lock +**/*.rs.bk + +# Go +services/api/bin/ +*.exe +*.out + +# Node / TypeScript +node_modules/ +sdk/typescript/dist/ +sdk/typescript/.turbo/ +*.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Misc +*.log +dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..250298d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,111 @@ +# Contributing to Trident + +Trident is infrastructure — the kind of system other developers will build products on top of without thinking about it. That means the bar for what gets merged is higher than it would be for an application. Code here needs to be correct, readable, and maintainable by someone who didn't write it. Those three things, in that order. + +--- + +## Before the Codebase Is Public + +The most useful thing you can do right now is engage with the design while it can still change. Read the full specification at [`docs/SPECIFICATION.md`](./docs/SPECIFICATION.md) and open an issue if something looks wrong, underspecified, or like a decision that hasn't been thought through. Open a [Discussion](https://github.com/trident-build/trident/discussions) and describe what you're building on Stellar and what you'd need from an indexer. If you've built event pipelines or indexing infrastructure before, your architectural critique matters more right now than it will once the code exists. + +--- + +## Getting Set Up + +You'll need Rust via [rustup](https://rustup.rs), Node.js 20 LTS for the TypeScript SDK, and Docker with Compose v2. Once those are in place, getting a local environment running should take under ten minutes. + +```bash +git clone https://github.com/trident-build/trident.git +cd trident +cp .env.example .env +docker compose -f docker/docker-compose.dev.yml up -d +cargo build +``` + +The dev Compose file starts PostgreSQL and Redis only. You run the indexer and API locally so changes reflect immediately without rebuilding containers. To work with real data, set `STELLAR_RPC_URL` and `NETWORK=testnet` in your `.env`. Full setup details will live in [`docs/development.md`](./docs/development.md) once the repo is scaffolded. + +--- + +## How the Repo Is Structured + +``` +trident/ +├── crates/ +│ ├── indexer/ # Rust core — streamer, XDR parser, cursor management +│ ├── api/ # Rust gRPC server +│ └── common/ # Shared types, errors, config +├── services/ +│ └── api/ # Go front office — REST, GraphQL, WebSocket, Redis consumer +├── sdk/ +│ └── typescript/ # TypeScript SDK (@trident-indexer/sdk) +├── database/ +│ ├── schema.sql # Canonical PostgreSQL schema +│ └── migrations/ # Versioned, append-only migration files +├── docker/ +└── docs/ +``` + +The Rust crates own everything from the chain to storage. The Go service owns everything from storage to the developer. The TypeScript SDK is a pure HTTP and WebSocket client with no knowledge of the database and no direct connection to any internal service. + +--- + +## Workflow + +Before writing code for anything non-trivial, open an issue first and comment to claim it. This prevents duplicated effort and makes sure your approach aligns with where the project is heading before you invest time in it. + +All branches come off `dev` — `main` is for tagged releases only. Name branches as `type/issue-number-short-description`, for example `feature/42-graphql-subscriptions` or `fix/87-cursor-recovery`. + +Commit messages follow [Conventional Commits](https://www.conventionalcommits.org) with type, scope, and an imperative description: + +``` +feat(indexer): add cursor recovery on restart +fix(api): return 404 when event id not found +docs(sdk): add subscribeToContract example +chore(deps): update tokio to 1.35 +``` + +Valid types are `feat`, `fix`, `docs`, `test`, `refactor`, `perf`, and `chore`. Valid scopes are `indexer`, `api`, `sdk`, `db`, `docker`, and `docs`. Reference the issue in the commit footer with `Closes #NNN`. + +--- + +## Pull Requests + +A PR that moves through review quickly explains *why* the change exists, not just what it does. It has tests covering the new behaviour — for bug fixes specifically, a regression test that would have caught the original bug. CI is green before review is requested. It changes one thing. + +A PR that comes back for revision is one that mixes concerns, doesn't explain the reasoning, or changes a public interface without prior discussion. One concern per PR is a firm rule regardless of how small the changes are. + +--- + +## Code Standards + +On the Rust side, `cargo clippy` must pass clean and `cargo fmt` must produce no diff — both enforced in CI with no exceptions. `.unwrap()` is not allowed in non-test code because a panic in the streaming loop means missed events, and missed events are the one thing Trident cannot tolerate. New error variants go in `crates/common`. Public functions and types get doc comments. + +On the Go side, `golangci-lint` must pass. Errors are returned and never ignored. Every function doing I/O takes a `context.Context` as its first argument. Error messages returned to developers need to be genuinely useful — not "internal server error" but something that tells them exactly what was wrong and how to fix it. + +On the TypeScript side, strict mode is on and `any` is not allowed. The SDK's public interface deserves particular care because renaming anything after v1 is a breaking change requiring a major version bump and migration docs. ESLint and Prettier must be clean. + +SQL migrations are append-only — a migration committed to `main` is never edited, only superseded by a new numbered one. Index names follow `idx__`. Production queries always name columns explicitly. + +--- + +## Good First Issues + +Once development starts, we'll tag approachable issues with `good first issue`. These tend to be things like adding a missing filter parameter to an endpoint, writing tests for an existing parser function, improving an error message with more context, or documenting an undocumented function. They're scoped to help you understand the system without needing to know all of it upfront. + +For more substantial work — changes to the indexer core, query performance improvements, new API capabilities — open a Discussion with a concrete use case before writing any code. The indexer's streaming and cursor logic in particular warrants a conversation before anyone touches it. + +--- + +## Security + +Security issues must not be filed as public GitHub issues. Send them to `security@trident.build` and expect a response within 48 hours. + +--- + +## Getting Help + +[GitHub Discussions](https://github.com/trident-build/trident/discussions) is the right place for questions, ideas, and design conversations before an issue is opened. [GitHub Issues](https://github.com/trident-build/trident/issues) is for confirmed bugs and concrete feature requests. For anything that shouldn't be public, reach out at `contributors@trident.build`. + +--- + +*Trident is infrastructure for the whole Stellar ecosystem. Getting it right matters. Thanks for helping.* diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..60d9e22 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +members = [ + "crates/indexer", + "crates/api", + "crates/common", +] +resolver = "2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e8b09ba --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Depo-dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 026bd1a..9c7d4a3 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,39 @@ Full historical event storage with no enforced retention limit, so a query again - [x] Architecture defined - [x] Full specification written — [`docs/SPECIFICATION.md`](./docs/SPECIFICATION.md) -- [ ] Repository scaffolding +- [x] Repository scaffolded +- [x] CI pipeline active - [ ] Phase 1 development begins --- +## Contributing + +All branches come off `dev`. Before opening a pull request, make sure all three CI checks pass locally — the pipeline will block merge if any of them fail. + +**Rust** +```bash +cargo fmt --all # format — CI runs --check, so this must be clean +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all +``` + +**Go** (`services/api`) +```bash +go vet ./... +golangci-lint run # install: https://golangci-lint.run/usage/install/ +``` + +**TypeScript** (`sdk/typescript`) +```bash +npm ci +npm run lint # runs tsc --noEmit in strict mode +``` + +Running these before pushing means CI passes on the first try. See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for branching conventions, commit message format, and code standards. + +--- +
*Build on Stellar. Query everything.* diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml new file mode 100644 index 0000000..e3d7bf4 --- /dev/null +++ b/crates/api/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "trident-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +trident-common = { path = "../common" } +tokio = { version = "1", features = ["full"] } +tonic = "0.11" +prost = "0.12" +tokio-stream = "0.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[build-dependencies] +tonic-build = "0.11" +protoc-bin-vendored = "3" diff --git a/crates/api/build.rs b/crates/api/build.rs new file mode 100644 index 0000000..47fae43 --- /dev/null +++ b/crates/api/build.rs @@ -0,0 +1,12 @@ +fn main() -> Result<(), Box> { + // Use the vendored protoc binary so neither CI nor local dev need a system install. + let protoc_path = protoc_bin_vendored::protoc_bin_path()?; + std::env::set_var("PROTOC", protoc_path); + + tonic_build::configure() + .build_server(true) + .build_client(false) + .compile(&["../../proto/trident.proto"], &["../../proto"])?; + + Ok(()) +} diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs new file mode 100644 index 0000000..9064a5e --- /dev/null +++ b/crates/api/src/main.rs @@ -0,0 +1,30 @@ +use std::net::SocketAddr; +use tracing_subscriber::EnvFilter; + +pub mod trident { + tonic::include_proto!("trident"); +} + +mod services; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + let addr: SocketAddr = std::env::var("GRPC_ADDR") + .unwrap_or_else(|_| "0.0.0.0:50051".into()) + .parse()?; + + tracing::info!(%addr, "Trident gRPC server listening"); + + let events_service = services::events::EventsServiceImpl::new(); + + tonic::transport::Server::builder() + .add_service(trident::events_server::EventsServer::new(events_service)) + .serve(addr) + .await?; + + Ok(()) +} diff --git a/crates/api/src/services/events.rs b/crates/api/src/services/events.rs new file mode 100644 index 0000000..8619779 --- /dev/null +++ b/crates/api/src/services/events.rs @@ -0,0 +1,59 @@ +use tonic::{Request, Response, Status}; + +use crate::trident::{ + events_server::Events, Event, GetEventRequest, ListEventsRequest, ListEventsResponse, + StreamEventsRequest, +}; + +pub struct EventsServiceImpl { + // TODO: db: sqlx::PgPool + // TODO: redis: redis::aio::MultiplexedConnection (for StreamEvents) +} + +impl EventsServiceImpl { + pub fn new() -> Self { + Self {} + } +} + +#[tonic::async_trait] +impl Events for EventsServiceImpl { + /// Return a paginated list of historical events matching the filter. + async fn list_events( + &self, + request: Request, + ) -> Result, Status> { + let _req = request.into_inner(); + // TODO: build WHERE clause from filter fields + // TODO: execute paginated query against soroban_events + // TODO: serialise rows to proto Event messages + // TODO: compute next_cursor from the last row's id + Err(Status::unimplemented("list_events not yet implemented")) + } + + /// Return a single event by UUID. + async fn get_event( + &self, + request: Request, + ) -> Result, Status> { + let _req = request.into_inner(); + // TODO: SELECT * FROM soroban_events WHERE id = $1 + // TODO: return Status::not_found if no row + Err(Status::unimplemented("get_event not yet implemented")) + } + + type StreamEventsStream = tokio_stream::wrappers::ReceiverStream>; + + /// Stream real-time events for a contract from Redis Streams. + async fn stream_events( + &self, + request: Request, + ) -> Result, Status> { + let _req = request.into_inner(); + // TODO: create a tokio mpsc channel + // TODO: spawn a task that reads from the Redis stream via XREAD BLOCK + // and sends matching events down the channel + // TODO: return ReceiverStream wrapping the receiver + Err(Status::unimplemented("stream_events not yet implemented")) + } +} diff --git a/crates/api/src/services/mod.rs b/crates/api/src/services/mod.rs new file mode 100644 index 0000000..a9970c2 --- /dev/null +++ b/crates/api/src/services/mod.rs @@ -0,0 +1 @@ +pub mod events; diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml new file mode 100644 index 0000000..95b5496 --- /dev/null +++ b/crates/common/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "trident-common" +version = "0.1.0" +edition = "2021" + +[dependencies] +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/common/src/errors.rs b/crates/common/src/errors.rs new file mode 100644 index 0000000..114e221 --- /dev/null +++ b/crates/common/src/errors.rs @@ -0,0 +1,20 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TridentError { + /// Failure communicating with or parsing a response from Stellar RPC. + #[error("RPC error: {0}")] + RpcError(String), + + /// Failure decoding or normalising raw XDR event data. + #[error("Parse error: {0}")] + ParseError(String), + + /// Failure reading from or writing to PostgreSQL or Redis. + #[error("Storage error: {0}")] + StorageError(String), + + /// Missing or invalid configuration value. + #[error("Config error: {0}")] + ConfigError(String), +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs new file mode 100644 index 0000000..d1bd738 --- /dev/null +++ b/crates/common/src/lib.rs @@ -0,0 +1,5 @@ +pub mod errors; +pub mod types; + +pub use errors::TridentError; +pub use types::{EventType, SorobanEvent}; diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs new file mode 100644 index 0000000..2974719 --- /dev/null +++ b/crates/common/src/types.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +/// Distinguishes the three event categories emitted by the Soroban runtime. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EventType { + /// Emitted explicitly by contract code via `env.events().publish(...)`. + Contract, + /// Emitted by the Soroban host itself (e.g. fee events). + System, + /// Emitted only when diagnostic mode is enabled; never stored by default. + Diagnostic, +} + +/// Normalised representation of a single Soroban event as stored in PostgreSQL +/// and published onto Redis Streams. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SorobanEvent { + /// Strkey-encoded contract address (C...). + pub contract_id: String, + /// Ordered list of topic values, XDR-decoded to their string representations. + pub topics: Vec, + /// Decoded event body. Scalar XDR types are coerced to JSON primitives; + /// map/vec types become JSON objects/arrays. + pub data: serde_json::Value, + /// Ledger sequence number in which this event was emitted. + pub ledger_sequence: u64, + /// ISO 8601 UTC timestamp of the ledger close. + pub ledger_timestamp: String, + /// Hash of the transaction that emitted this event. + pub transaction_hash: String, + /// Zero-based index of this event within its transaction. + pub event_index: u32, + /// Category of event as reported by the Soroban host. + pub event_type: EventType, +} diff --git a/crates/indexer/Cargo.toml b/crates/indexer/Cargo.toml new file mode 100644 index 0000000..599090d --- /dev/null +++ b/crates/indexer/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "trident-indexer" +version = "0.1.0" +edition = "2021" + +[dependencies] +trident-common = { path = "../common" } + +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# HTTP client for Stellar RPC JSON-RPC calls +reqwest = { version = "0.11", features = ["json"] } + +# Database +sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono", "json"] } + +# Redis +redis = { version = "0.24", features = ["tokio-comp"] } + +# Stellar XDR decoding — pin to a specific protocol version via the "curr" feature +# If the network upgrades to a new protocol, update this version accordingly. +stellar-xdr = { version = "26.0.1", features = ["curr"] } + +# Strkey encoding for contract and account addresses +stellar-strkey = "0.0.16" + +# Base64 decoding for XDR payloads +base64 = "0.22" + +# Serialisation +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# UUIDs for event primary keys +uuid = { version = "1", features = ["v4", "serde"] } + +# Timestamps +chrono = { version = "0.4", features = ["serde"] } + +# Hex encoding for bytes values +hex = "0.4" + +# Retry logic with exponential backoff +tokio-retry = "0.3" diff --git a/crates/indexer/src/config.rs b/crates/indexer/src/config.rs new file mode 100644 index 0000000..901e93e --- /dev/null +++ b/crates/indexer/src/config.rs @@ -0,0 +1,36 @@ +use std::time::Duration; +use trident_common::TridentError; + +pub struct Config { + pub database_url: String, + pub redis_url: String, + pub stellar_rpc_url: String, + pub network: String, + pub poll_interval: Duration, + pub index_diagnostic: bool, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Self { + database_url: require_env("DATABASE_URL")?, + redis_url: require_env("REDIS_URL")?, + stellar_rpc_url: require_env("STELLAR_RPC_URL")?, + network: std::env::var("NETWORK").unwrap_or_else(|_| "testnet".into()), + poll_interval: Duration::from_millis( + std::env::var("POLL_INTERVAL_MS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(5000), + ), + index_diagnostic: std::env::var("INDEX_DIAGNOSTIC") + .map(|v| v.eq_ignore_ascii_case("true")) + .unwrap_or(false), + }) + } +} + +fn require_env(key: &str) -> Result { + std::env::var(key) + .map_err(|_| TridentError::ConfigError(format!("{key} is required but not set"))) +} diff --git a/crates/indexer/src/db/mod.rs b/crates/indexer/src/db/mod.rs new file mode 100644 index 0000000..13a3057 --- /dev/null +++ b/crates/indexer/src/db/mod.rs @@ -0,0 +1,102 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use trident_common::{EventType, SorobanEvent, TridentError}; +use uuid::Uuid; + +/// Insert a normalised event. Silently ignores duplicates (same tx_hash + event_index) +/// because the streamer may replay events during cursor recovery. +pub async fn insert_event(pool: &PgPool, event: &SorobanEvent) -> Result<(), TridentError> { + let id = Uuid::new_v4(); + let event_type = match event.event_type { + EventType::Contract => "contract", + EventType::System => "system", + EventType::Diagnostic => "diagnostic", + }; + let topics = serde_json::to_value(&event.topics) + .map_err(|e| TridentError::StorageError(format!("topics serialise: {e}")))?; + let ledger_ts: DateTime = event + .ledger_timestamp + .parse() + .map_err(|e| TridentError::StorageError(format!("ledger timestamp parse: {e}")))?; + + sqlx::query( + r#" + INSERT INTO soroban_events + (id, contract_id, ledger_sequence, ledger_timestamp, transaction_hash, + event_index, event_type, topics, data) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (transaction_hash, event_index) DO NOTHING + "#, + ) + .bind(id) + .bind(&event.contract_id) + .bind(event.ledger_sequence as i64) + .bind(ledger_ts) + .bind(&event.transaction_hash) + .bind(event.event_index as i32) + .bind(event_type) + .bind(&topics) + .bind(&event.data) + .execute(pool) + .await + .map_err(|e| TridentError::StorageError(format!("insert_event: {e}")))?; + + Ok(()) +} + +/// Read the latest processed ledger cursor from system_state. +pub async fn get_cursor(pool: &PgPool) -> Result { + let row: (String,) = + sqlx::query_as("SELECT value FROM system_state WHERE key = 'latest_ledger_cursor'") + .fetch_one(pool) + .await + .map_err(|e| TridentError::StorageError(format!("get_cursor: {e}")))?; + + row.0 + .parse::() + .map_err(|e| TridentError::StorageError(format!("cursor parse: {e}"))) +} + +/// Persist the latest processed ledger sequence so the streamer can resume +/// from the correct position after a restart. +pub async fn set_cursor(pool: &PgPool, ledger: u64) -> Result<(), TridentError> { + sqlx::query( + "UPDATE system_state SET value = $1, updated_at = NOW() WHERE key = 'latest_ledger_cursor'", + ) + .bind(ledger.to_string()) + .execute(pool) + .await + .map_err(|e| TridentError::StorageError(format!("set_cursor: {e}")))?; + + Ok(()) +} + +/// Record a processed ledger in ledger_metadata for gap detection. +pub async fn insert_ledger_metadata( + pool: &PgPool, + ledger_sequence: u64, + ledger_hash: &str, + ledger_timestamp: &str, + event_count: i32, +) -> Result<(), TridentError> { + let ts: DateTime = ledger_timestamp + .parse() + .map_err(|e| TridentError::StorageError(format!("ledger timestamp parse: {e}")))?; + + sqlx::query( + r#" + INSERT INTO ledger_metadata (ledger_sequence, ledger_hash, ledger_timestamp, event_count) + VALUES ($1, $2, $3, $4) + ON CONFLICT (ledger_sequence) DO NOTHING + "#, + ) + .bind(ledger_sequence as i64) + .bind(ledger_hash) + .bind(ts) + .bind(event_count) + .execute(pool) + .await + .map_err(|e| TridentError::StorageError(format!("insert_ledger_metadata: {e}")))?; + + Ok(()) +} diff --git a/crates/indexer/src/main.rs b/crates/indexer/src/main.rs new file mode 100644 index 0000000..3f34838 --- /dev/null +++ b/crates/indexer/src/main.rs @@ -0,0 +1,31 @@ +use tracing_subscriber::EnvFilter; + +mod config; +mod db; +mod parser; +mod redis_stream; +mod rpc; +mod streamer; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + tracing::info!("Trident indexer starting"); + + let cfg = config::Config::from_env()?; + + let db_pool = sqlx::PgPool::connect(&cfg.database_url).await?; + tracing::info!("Database connected"); + + let redis_client = redis::Client::open(cfg.redis_url.as_str())?; + let redis_conn = redis_client.get_multiplexed_async_connection().await?; + tracing::info!("Redis connected"); + + let mut s = streamer::Streamer::new(cfg, db_pool, redis_conn); + s.run().await?; + + Ok(()) +} diff --git a/crates/indexer/src/parser/mod.rs b/crates/indexer/src/parser/mod.rs new file mode 100644 index 0000000..45d6b05 --- /dev/null +++ b/crates/indexer/src/parser/mod.rs @@ -0,0 +1,203 @@ +//! # Parser +//! +//! Owns XDR decoding and event normalisation. Responsibilities: +//! +//! - Decoding raw base64-encoded XDR strings as returned by the Soroban RPC +//! `getEvents` method into typed Rust values via the `stellar-xdr` crate. +//! - Normalising decoded `ScVal` topics into human-readable string representations +//! and the event body into a `serde_json::Value` for storage and forwarding. +//! - Type coercion: Symbol/String → plain string, Address → strkey, I128/U128 → +//! decimal string, Bool → "true"/"false", Bytes → hex, Map/Vec → JSON object/array. +//! - Returning `TridentError::ParseError` for any input that cannot be decoded so +//! the caller (Streamer) can decide whether to skip or halt. + +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde_json::Value as Json; +use stellar_strkey::{ed25519, Contract}; +use stellar_xdr::curr::{ + AccountId, ContractId, Limited, Limits, PublicKey, ReadXdr, ScAddress, ScVal, +}; +use trident_common::{EventType, SorobanEvent, TridentError}; + +use crate::rpc::RawEvent; + +pub struct Parser { + pub index_diagnostic: bool, +} + +impl Parser { + pub fn new(index_diagnostic: bool) -> Self { + Self { index_diagnostic } + } + + /// Decode a raw RPC event into a normalised `SorobanEvent`. + /// + /// Returns `None` if the event type is `diagnostic` and `index_diagnostic` + /// is false — the caller should silently skip `None` returns. + pub fn parse_event(&self, raw: &RawEvent) -> Result, TridentError> { + let event_type = parse_event_type(&raw.event_type)?; + + if event_type == EventType::Diagnostic && !self.index_diagnostic { + return Ok(None); + } + + // Skip events emitted by failed contract calls — they have no observable effect. + if !raw.in_successful_contract_call { + return Ok(None); + } + + let contract_id = raw.contract_id.clone().unwrap_or_default(); + + let topics: Vec = raw + .topic + .iter() + .map(|xdr| decode_scval(xdr).map(|v| scval_to_string(&v))) + .collect::>()?; + + let data = if raw.value.is_empty() { + Json::Null + } else { + decode_scval(&raw.value).map(|v| scval_to_json(&v))? + }; + + let ledger_sequence: u64 = raw + .ledger + .parse() + .map_err(|_| TridentError::ParseError(format!("invalid ledger: {}", raw.ledger)))?; + + // event_index is the second component of the opaque id string: "{encoded}-{index}" + let event_index: u32 = raw + .id + .split('-') + .next_back() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + Ok(Some(SorobanEvent { + contract_id, + topics, + data, + ledger_sequence, + ledger_timestamp: raw.ledger_closed_at.clone(), + transaction_hash: raw.tx_hash.clone(), + event_index, + event_type, + })) + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn parse_event_type(raw: &str) -> Result { + match raw { + "contract" => Ok(EventType::Contract), + "system" => Ok(EventType::System), + "diagnostic" => Ok(EventType::Diagnostic), + other => Err(TridentError::ParseError(format!( + "unknown event type: {other}" + ))), + } +} + +fn decode_scval(b64: &str) -> Result { + let bytes = STANDARD + .decode(b64) + .map_err(|e| TridentError::ParseError(format!("base64 decode: {e}")))?; + let mut cursor = std::io::Cursor::new(bytes); + ScVal::read_xdr(&mut Limited::new(&mut cursor, Limits::none())) + .map_err(|e| TridentError::ParseError(format!("XDR decode ScVal: {e}"))) +} + +/// Convert a topic `ScVal` to a compact string representation. +pub fn scval_to_string(val: &ScVal) -> String { + match val { + ScVal::Symbol(s) => s.to_utf8_string_lossy(), + ScVal::String(s) => s.to_utf8_string_lossy(), + ScVal::Bool(b) => b.to_string(), + ScVal::Void => "void".into(), + ScVal::U32(n) => n.to_string(), + ScVal::I32(n) => n.to_string(), + ScVal::U64(n) => n.to_string(), + ScVal::I64(n) => n.to_string(), + ScVal::U128(parts) => { + let val = ((parts.hi as u128) << 64) | (parts.lo as u128); + val.to_string() + } + ScVal::I128(parts) => { + let val = ((parts.hi as i128) << 64) | (parts.lo as i128); + val.to_string() + } + ScVal::U256(parts) => format!( + "u256({:x}{:x}{:x}{:x})", + parts.hi_hi, parts.hi_lo, parts.lo_hi, parts.lo_lo + ), + ScVal::I256(parts) => format!( + "i256({:x}{:x}{:x}{:x})", + parts.hi_hi, parts.hi_lo, parts.lo_hi, parts.lo_lo + ), + ScVal::Bytes(b) => hex::encode(b.as_slice()), + ScVal::Address(addr) => scaddress_to_string(addr), + // For complex types in topic position, fall back to debug representation + other => format!("{other:?}"), + } +} + +/// Recursively convert a `ScVal` to a `serde_json::Value` for the event body. +pub fn scval_to_json(val: &ScVal) -> Json { + match val { + ScVal::Void => Json::Null, + ScVal::Bool(b) => Json::Bool(*b), + ScVal::Symbol(s) => Json::String(s.to_utf8_string_lossy()), + ScVal::String(s) => Json::String(s.to_utf8_string_lossy()), + ScVal::U32(n) => Json::from(*n), + ScVal::I32(n) => Json::from(*n), + ScVal::U64(n) => Json::from(*n), + ScVal::I64(n) => Json::from(*n), + ScVal::U128(parts) => { + let v = ((parts.hi as u128) << 64) | (parts.lo as u128); + // Use string for values that overflow JSON's safe integer range + if v <= u64::MAX as u128 { + Json::from(v as u64) + } else { + Json::String(v.to_string()) + } + } + ScVal::I128(parts) => { + let v = ((parts.hi as i128) << 64) | (parts.lo as i128); + if v >= i64::MIN as i128 && v <= i64::MAX as i128 { + Json::from(v as i64) + } else { + Json::String(v.to_string()) + } + } + ScVal::Bytes(b) => Json::String(hex::encode(b.as_slice())), + ScVal::Address(addr) => Json::String(scaddress_to_string(addr)), + ScVal::Vec(Some(items)) => Json::Array(items.iter().map(scval_to_json).collect()), + ScVal::Vec(None) => Json::Array(vec![]), + ScVal::Map(Some(entries)) => { + let obj: serde_json::Map = entries + .iter() + .map(|e| (scval_to_string(&e.key), scval_to_json(&e.val))) + .collect(); + Json::Object(obj) + } + ScVal::Map(None) => Json::Object(serde_json::Map::new()), + other => Json::String(format!("{other:?}")), + } +} + +fn scaddress_to_string(addr: &ScAddress) -> String { + match addr { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(bytes))) => { + // stellar-strkey 0.0.16+ returns heapless::String — convert to std::String + ed25519::PublicKey(bytes.0).to_string().as_str().to_owned() + } + // stellar-xdr 26.x wraps the hash in ContractId; the inner Hash holds [u8; 32] + ScAddress::Contract(ContractId(hash)) => Contract(hash.0).to_string().as_str().to_owned(), + // stellar-xdr 26.x added MuxedAccount, ClaimableBalance, LiquidityPool variants; + // these do not appear in Soroban contract events but the match must be exhaustive. + other => format!("{other:?}"), + } +} diff --git a/crates/indexer/src/redis_stream/mod.rs b/crates/indexer/src/redis_stream/mod.rs new file mode 100644 index 0000000..c6a17d8 --- /dev/null +++ b/crates/indexer/src/redis_stream/mod.rs @@ -0,0 +1,39 @@ +use redis::AsyncCommands; +use trident_common::{SorobanEvent, TridentError}; + +const STREAM_KEY: &str = "trident:events"; + +/// Publish a normalised event onto the Redis Stream. The Go API layer +/// consumes this stream to fan out to WebSocket subscribers. +/// +/// Each entry is a flat hash of event fields. `topics` and `data` are +/// JSON-serialised strings so all values are plain Redis strings. +pub async fn publish_event( + conn: &mut redis::aio::MultiplexedConnection, + event: &SorobanEvent, +) -> Result<(), TridentError> { + let topics = serde_json::to_string(&event.topics) + .map_err(|e| TridentError::StorageError(format!("topics serialise: {e}")))?; + let data = event.data.to_string(); + let event_type = format!("{:?}", event.event_type).to_lowercase(); + + let _: String = conn + .xadd( + STREAM_KEY, + "*", + &[ + ("contract_id", event.contract_id.as_str()), + ("ledger_sequence", &event.ledger_sequence.to_string()), + ("ledger_timestamp", event.ledger_timestamp.as_str()), + ("transaction_hash", event.transaction_hash.as_str()), + ("event_index", &event.event_index.to_string()), + ("event_type", &event_type), + ("topics", &topics), + ("data", &data), + ], + ) + .await + .map_err(|e| TridentError::StorageError(format!("redis xadd: {e}")))?; + + Ok(()) +} diff --git a/crates/indexer/src/rpc/mod.rs b/crates/indexer/src/rpc/mod.rs new file mode 100644 index 0000000..830c4c5 --- /dev/null +++ b/crates/indexer/src/rpc/mod.rs @@ -0,0 +1,151 @@ +use serde::{Deserialize, Serialize}; +use trident_common::TridentError; + +/// A single raw event as returned by the Stellar RPC `getEvents` method. +/// Topics and data are base64-encoded XDR strings; the parser decodes them. +#[derive(Debug, Deserialize)] +pub struct RawEvent { + #[serde(rename = "type")] + pub event_type: String, + /// Ledger sequence number as a numeric string. + pub ledger: String, + #[serde(rename = "ledgerClosedAt")] + pub ledger_closed_at: String, + #[serde(rename = "contractId")] + pub contract_id: Option, + pub id: String, + #[serde(rename = "pagingToken")] + pub paging_token: String, + #[serde(rename = "txHash")] + pub tx_hash: String, + /// Ordered list of base64 XDR-encoded ScVal topic values. + pub topic: Vec, + /// Base64 XDR-encoded ScVal event body. + pub value: String, + #[serde(rename = "inSuccessfulContractCall")] + pub in_successful_contract_call: bool, +} + +#[derive(Debug)] +pub struct EventsPage { + pub events: Vec, + pub latest_ledger: u64, +} + +// --------------------------------------------------------------------------- +// JSON-RPC wire types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct JsonRpcRequest<'a, P: Serialize> { + jsonrpc: &'static str, + id: u64, + method: &'a str, + params: P, +} + +#[derive(Deserialize)] +struct JsonRpcResponse { + result: Option, + error: Option, +} + +#[derive(Deserialize)] +struct JsonRpcError { + code: i64, + message: String, +} + +#[derive(Serialize)] +struct GetEventsParams { + #[serde(rename = "startLedger", skip_serializing_if = "Option::is_none")] + start_ledger: Option, + filters: Vec, + pagination: Pagination, +} + +#[derive(Serialize)] +struct Pagination { + limit: u32, + #[serde(skip_serializing_if = "Option::is_none")] + cursor: Option, +} + +#[derive(Deserialize)] +struct GetEventsResult { + events: Vec, + #[serde(rename = "latestLedger")] + latest_ledger: u64, +} + +// --------------------------------------------------------------------------- +// RPC client +// --------------------------------------------------------------------------- + +pub struct RpcClient { + http: reqwest::Client, + url: String, +} + +impl RpcClient { + pub fn new(url: String) -> Self { + Self { + http: reqwest::Client::new(), + url, + } + } + + /// Fetch a page of events from the Stellar RPC node. + /// + /// Pass `start_ledger` on the first call to anchor the scan position. + /// On subsequent calls pass `cursor` (the `paging_token` from the last + /// event received) to continue pagination. Only one of the two should be + /// set at a time — the RPC rejects requests that supply both. + pub async fn get_events( + &self, + start_ledger: Option, + cursor: Option, + ) -> Result { + let params = GetEventsParams { + start_ledger, + filters: vec![], + pagination: Pagination { limit: 200, cursor }, + }; + + let req = JsonRpcRequest { + jsonrpc: "2.0", + id: 1, + method: "getEvents", + params, + }; + + let resp = self + .http + .post(&self.url) + .json(&req) + .send() + .await + .map_err(|e| TridentError::RpcError(format!("HTTP request failed: {e}")))?; + + let body: JsonRpcResponse = resp + .json() + .await + .map_err(|e| TridentError::RpcError(format!("Failed to decode RPC response: {e}")))?; + + if let Some(err) = body.error { + return Err(TridentError::RpcError(format!( + "RPC error {}: {}", + err.code, err.message + ))); + } + + let result = body + .result + .ok_or_else(|| TridentError::RpcError("Empty result in RPC response".into()))?; + + Ok(EventsPage { + events: result.events, + latest_ledger: result.latest_ledger, + }) + } +} diff --git a/crates/indexer/src/streamer/mod.rs b/crates/indexer/src/streamer/mod.rs new file mode 100644 index 0000000..f6bcf5f --- /dev/null +++ b/crates/indexer/src/streamer/mod.rs @@ -0,0 +1,167 @@ +//! # Streamer +//! +//! Owns the RPC polling loop. Responsibilities: +//! +//! - Maintaining the ledger cursor: reading the last processed sequence from +//! `system_state` on startup, advancing it after each successful batch, and +//! persisting it atomically with the events it covers. +//! - Calling `getEvents` on the Stellar Soroban RPC node on a configurable +//! interval (`POLL_INTERVAL_MS`), following the `pagingToken` cursor field +//! to paginate across large ledger ranges within a single poll cycle. +//! - Fault tolerance and retry logic: transient RPC failures are retried with +//! exponential backoff; persistent failures are logged without crashing the +//! process or losing cursor position so the next poll cycle can recover. +//! - Handing each raw event to the `Parser` and forwarding normalised +//! `SorobanEvent` values to both PostgreSQL (via `db`) and Redis Streams +//! (via `redis_stream`). + +use std::time::Duration; + +use sqlx::PgPool; +use tokio_retry::{strategy::ExponentialBackoff, Retry}; +use trident_common::TridentError; + +use crate::{config::Config, db, parser::Parser, redis_stream, rpc::RpcClient}; + +pub struct Streamer { + config: Config, + db: PgPool, + redis: redis::aio::MultiplexedConnection, + rpc: RpcClient, + parser: Parser, +} + +impl Streamer { + pub fn new(config: Config, db: PgPool, redis: redis::aio::MultiplexedConnection) -> Self { + let rpc = RpcClient::new(config.stellar_rpc_url.clone()); + let parser = Parser::new(config.index_diagnostic); + Self { + config, + db, + redis, + rpc, + parser, + } + } + + /// Start the polling loop. Runs indefinitely — spawn with `tokio::spawn` + /// or drive directly from `main`. Never returns `Ok(())` in normal operation. + pub async fn run(&mut self) -> Result<(), TridentError> { + tracing::info!( + network = %self.config.network, + poll_interval_ms = %self.config.poll_interval.as_millis(), + "Streamer started" + ); + + let mut cursor = db::get_cursor(&self.db).await?; + tracing::info!(cursor, "Resuming from ledger cursor"); + + loop { + match self.poll_once(&mut cursor).await { + Ok(events_processed) => { + if events_processed > 0 { + tracing::info!(events_processed, cursor, "Batch processed"); + } else { + tracing::debug!(cursor, "No new events"); + } + } + Err(e) => { + // Log but do not crash — the cursor is safe, next poll will retry. + tracing::error!(error = %e, "Poll cycle failed, will retry next interval"); + } + } + + tokio::time::sleep(self.config.poll_interval).await; + } + } + + /// Execute a single poll cycle. Fetches all available pages from the RPC + /// starting at `cursor`, persists each event, and advances the cursor. + /// Returns the total number of events processed in this cycle. + async fn poll_once(&mut self, cursor: &mut u64) -> Result { + let retry_strategy = ExponentialBackoff::from_millis(200) + .max_delay(Duration::from_secs(30)) + .take(5); + + // First-ever run: anchor to ledger 1 via start_ledger. + // All subsequent calls use paging_token cursors so the RPC can resume + // exactly where we left off without re-scanning from genesis. + let (start_ledger, initial_cursor) = if *cursor == 0 { + (Some(1u64), None) + } else { + (None, Some(cursor.to_string())) + }; + + let mut page_cursor = initial_cursor; + let mut total = 0; + + loop { + let pc = page_cursor.clone(); + let sl = start_ledger; + let page = Retry::spawn(retry_strategy.clone(), || async { + self.rpc.get_events(sl, pc.clone()).await + }) + .await?; + + tracing::debug!( + latest_ledger = page.latest_ledger, + cursor = *cursor, + "RPC page received" + ); + + if page.events.is_empty() { + break; + } + + let last_paging_token = page.events.last().map(|e| e.paging_token.clone()); + + let mut events_in_page: i32 = 0; + for raw in &page.events { + match self.parser.parse_event(raw) { + Ok(Some(event)) => { + db::insert_event(&self.db, &event).await?; + redis_stream::publish_event(&mut self.redis, &event).await?; + total += 1; + events_in_page += 1; + } + Ok(None) => {} // diagnostic or failed-call event — intentionally skipped + Err(e) => { + tracing::warn!( + tx_hash = %raw.tx_hash, + error = %e, + "Skipping unparseable event" + ); + } + } + } + + // Advance the persistent cursor and record ledger metadata. + if let Some(last) = page.events.last() { + let seq: u64 = last.ledger.parse().unwrap_or(*cursor); + if seq > *cursor { + *cursor = seq; + db::set_cursor(&self.db, *cursor).await?; + // Ledger hash is not available from getEvents; record what we have. + // TODO: enrich with ledger hash via getLedger RPC when needed. + db::insert_ledger_metadata( + &self.db, + seq, + "", + &last.ledger_closed_at, + events_in_page, + ) + .await?; + } + } + + // An incomplete page means we have caught up to the chain tip. + if page.events.len() < 200 { + break; + } + + page_cursor = last_paging_token; + } + + Ok(total) + } +} diff --git a/database/migrations/0001_init.sql b/database/migrations/0001_init.sql new file mode 100644 index 0000000..a3d28bc --- /dev/null +++ b/database/migrations/0001_init.sql @@ -0,0 +1,91 @@ +-- Trident PostgreSQL Schema +-- Canonical definition. Migrations in ./migrations/ mirror this file incrementally. + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- --------------------------------------------------------------------------- +-- soroban_events +-- Primary store for every indexed Soroban contract event. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS soroban_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id TEXT NOT NULL, + ledger_sequence BIGINT NOT NULL, + ledger_timestamp TIMESTAMPTZ NOT NULL, + transaction_hash TEXT NOT NULL, + event_index INTEGER NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('contract', 'system', 'diagnostic')), + topics JSONB NOT NULL DEFAULT '[]', + topic_0 TEXT GENERATED ALWAYS AS (topics ->> 0) STORED, + topic_1 TEXT GENERATED ALWAYS AS (topics ->> 1) STORED, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Single-column indexes +CREATE INDEX IF NOT EXISTS idx_soroban_events_contract_id ON soroban_events (contract_id); +CREATE INDEX IF NOT EXISTS idx_soroban_events_ledger_sequence ON soroban_events (ledger_sequence); +CREATE INDEX IF NOT EXISTS idx_soroban_events_ledger_timestamp ON soroban_events (ledger_timestamp); +CREATE INDEX IF NOT EXISTS idx_soroban_events_topic_0 ON soroban_events (topic_0); +CREATE INDEX IF NOT EXISTS idx_soroban_events_topic_1 ON soroban_events (topic_1); + +-- Composite index covering the most common query pattern: events for a +-- contract filtered by primary topic (e.g. all "transfer" events for token X) +CREATE INDEX IF NOT EXISTS idx_soroban_events_contract_topic_0 ON soroban_events (contract_id, topic_0); + +-- GIN index for arbitrary topic containment queries +CREATE INDEX IF NOT EXISTS idx_soroban_events_topics_gin ON soroban_events USING GIN (topics); + +-- Unique constraint: a given event position within a transaction is immutable +ALTER TABLE soroban_events + ADD CONSTRAINT uq_soroban_events_tx_index + UNIQUE (transaction_hash, event_index); + +-- --------------------------------------------------------------------------- +-- system_state +-- Persistent cursor tracking so the indexer can resume after restart without +-- re-scanning from genesis. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS system_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed the cursor row so the indexer can always do an UPDATE rather than +-- an upsert on the hot path. +INSERT INTO system_state (key, value) +VALUES ('latest_ledger_cursor', '0') +ON CONFLICT (key) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- indexed_contracts +-- Registry of contracts whose events Trident is actively indexing. +-- A NULL network means "all networks". +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS indexed_contracts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id TEXT NOT NULL, + network TEXT, + label TEXT, + index_from BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_indexed_contracts_id_network UNIQUE (contract_id, network) +); + +CREATE INDEX IF NOT EXISTS idx_indexed_contracts_contract_id ON indexed_contracts (contract_id); + +-- --------------------------------------------------------------------------- +-- ledger_metadata +-- Lightweight record of every processed ledger for gap detection and +-- provenance tracking. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS ledger_metadata ( + ledger_sequence BIGINT PRIMARY KEY, + ledger_hash TEXT NOT NULL, + ledger_timestamp TIMESTAMPTZ NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ledger_metadata_timestamp ON ledger_metadata (ledger_timestamp); diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..a3d28bc --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,91 @@ +-- Trident PostgreSQL Schema +-- Canonical definition. Migrations in ./migrations/ mirror this file incrementally. + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- --------------------------------------------------------------------------- +-- soroban_events +-- Primary store for every indexed Soroban contract event. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS soroban_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id TEXT NOT NULL, + ledger_sequence BIGINT NOT NULL, + ledger_timestamp TIMESTAMPTZ NOT NULL, + transaction_hash TEXT NOT NULL, + event_index INTEGER NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('contract', 'system', 'diagnostic')), + topics JSONB NOT NULL DEFAULT '[]', + topic_0 TEXT GENERATED ALWAYS AS (topics ->> 0) STORED, + topic_1 TEXT GENERATED ALWAYS AS (topics ->> 1) STORED, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Single-column indexes +CREATE INDEX IF NOT EXISTS idx_soroban_events_contract_id ON soroban_events (contract_id); +CREATE INDEX IF NOT EXISTS idx_soroban_events_ledger_sequence ON soroban_events (ledger_sequence); +CREATE INDEX IF NOT EXISTS idx_soroban_events_ledger_timestamp ON soroban_events (ledger_timestamp); +CREATE INDEX IF NOT EXISTS idx_soroban_events_topic_0 ON soroban_events (topic_0); +CREATE INDEX IF NOT EXISTS idx_soroban_events_topic_1 ON soroban_events (topic_1); + +-- Composite index covering the most common query pattern: events for a +-- contract filtered by primary topic (e.g. all "transfer" events for token X) +CREATE INDEX IF NOT EXISTS idx_soroban_events_contract_topic_0 ON soroban_events (contract_id, topic_0); + +-- GIN index for arbitrary topic containment queries +CREATE INDEX IF NOT EXISTS idx_soroban_events_topics_gin ON soroban_events USING GIN (topics); + +-- Unique constraint: a given event position within a transaction is immutable +ALTER TABLE soroban_events + ADD CONSTRAINT uq_soroban_events_tx_index + UNIQUE (transaction_hash, event_index); + +-- --------------------------------------------------------------------------- +-- system_state +-- Persistent cursor tracking so the indexer can resume after restart without +-- re-scanning from genesis. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS system_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed the cursor row so the indexer can always do an UPDATE rather than +-- an upsert on the hot path. +INSERT INTO system_state (key, value) +VALUES ('latest_ledger_cursor', '0') +ON CONFLICT (key) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- indexed_contracts +-- Registry of contracts whose events Trident is actively indexing. +-- A NULL network means "all networks". +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS indexed_contracts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id TEXT NOT NULL, + network TEXT, + label TEXT, + index_from BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_indexed_contracts_id_network UNIQUE (contract_id, network) +); + +CREATE INDEX IF NOT EXISTS idx_indexed_contracts_contract_id ON indexed_contracts (contract_id); + +-- --------------------------------------------------------------------------- +-- ledger_metadata +-- Lightweight record of every processed ledger for gap detection and +-- provenance tracking. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS ledger_metadata ( + ledger_sequence BIGINT PRIMARY KEY, + ledger_hash TEXT NOT NULL, + ledger_timestamp TIMESTAMPTZ NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ledger_metadata_timestamp ON ledger_metadata (ledger_timestamp); diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 0000000..af05c0b --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -0,0 +1,36 @@ +version: "3.9" + +# Development compose — runs only the infrastructure dependencies. +# The indexer and API service are run locally (cargo run / go run) so that +# code changes reflect immediately without rebuilding containers. + +services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: trident + POSTGRES_PASSWORD: password + POSTGRES_DB: trident + ports: + - "5432:5432" + volumes: + - postgres_dev_data:/var/lib/postgresql/data + - ../database/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U trident -d trident"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_dev_data:/data + command: redis-server --appendonly yes + +volumes: + postgres_dev_data: + redis_dev_data: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..34df560 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,72 @@ +version: "3.9" + +# Production compose — runs all four services. +# All secrets are passed via environment variables; never hardcode values here. + +services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 15s + restart: unless-stopped + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + command: redis-server --appendonly yes + restart: unless-stopped + + indexer: + build: + context: ../crates/indexer + dockerfile: Dockerfile + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + STELLAR_RPC_URL: ${STELLAR_RPC_URL} + NETWORK: ${NETWORK} + POLL_INTERVAL_MS: ${POLL_INTERVAL_MS} + INDEX_DIAGNOSTIC: ${INDEX_DIAGNOSTIC} + LOG_LEVEL: ${LOG_LEVEL} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + + api: + build: + context: ../services/api + dockerfile: Dockerfile + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + PORT: ${PORT} + API_KEY_SALT: ${API_KEY_SALT} + LOG_LEVEL: ${LOG_LEVEL} + ports: + - "${PORT}:${PORT}" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + indexer: + condition: service_started + restart: unless-stopped + +volumes: + postgres_data: + redis_data: diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md new file mode 100644 index 0000000..b706cc8 --- /dev/null +++ b/docs/SPECIFICATION.md @@ -0,0 +1,3 @@ +# Trident — Project Specification + +> The full specification is being drafted and will be added here. It will cover the complete data model, RPC polling protocol, XDR decoding rules, cursor management semantics, PostgreSQL schema rationale, Redis Streams message format, REST and GraphQL API contracts, WebSocket subscription protocol, and SDK design decisions. diff --git a/proto/trident.proto b/proto/trident.proto new file mode 100644 index 0000000..cb44e2c --- /dev/null +++ b/proto/trident.proto @@ -0,0 +1,118 @@ +syntax = "proto3"; + +package trident; + +// --------------------------------------------------------------------------- +// EventsService +// +// The gRPC interface between the Rust core and the Go front office. +// The Go API layer calls these RPCs to serve developer-facing REST, GraphQL, +// and WebSocket endpoints. Direct developer access to this service is not +// intended — it is an internal transport boundary. +// --------------------------------------------------------------------------- +service Events { + // Return a paginated list of historical events matching the filter. + rpc ListEvents(ListEventsRequest) returns (ListEventsResponse); + + // Return a single event by its UUID primary key. + rpc GetEvent(GetEventRequest) returns (Event); + + // Server-side streaming RPC: subscribe to real-time events for a contract. + // The server pushes each new matching event as it is indexed. + rpc StreamEvents(StreamEventsRequest) returns (stream Event); +} + +// --------------------------------------------------------------------------- +// Core message types +// --------------------------------------------------------------------------- + +message Event { + // UUID primary key as assigned by PostgreSQL. + string id = 1; + + // Strkey-encoded contract address (C...). + string contract_id = 2; + + // Ledger sequence number in which this event was emitted. + uint64 ledger_sequence = 3; + + // ISO 8601 UTC timestamp of the ledger close. + string ledger_timestamp = 4; + + // Hash of the transaction that emitted this event. + string transaction_hash = 5; + + // Zero-based index of this event within its transaction. + uint32 event_index = 6; + + // "contract" | "system" | "diagnostic" + string event_type = 7; + + // Decoded topic values in order. Serialised from ScVal to string. + repeated string topics = 8; + + // Decoded event body as a JSON string. Complex types are serialised + // recursively; i128/u128 values outside i64 range are JSON strings. + string data = 9; + + // Row insertion timestamp (server time, not ledger time). + string created_at = 10; +} + +// --------------------------------------------------------------------------- +// ListEvents +// --------------------------------------------------------------------------- + +message ListEventsRequest { + // Filter by contract address. Empty string means all contracts. + string contract_id = 1; + + // Filter by the value of the first topic (e.g. "transfer"). + string topic_0 = 2; + + // Filter by the value of the second topic. + string topic_1 = 3; + + // Inclusive lower bound on ledger sequence. + uint64 ledger_from = 4; + + // Inclusive upper bound on ledger sequence. 0 means no upper bound. + uint64 ledger_to = 5; + + // Opaque cursor from the previous response. Empty string on first call. + string cursor = 6; + + // Maximum number of events to return. Capped server-side at 200. + uint32 limit = 7; +} + +message ListEventsResponse { + repeated Event events = 1; + + // Pass this as `cursor` on the next call to fetch the next page. + // Empty string when there are no more results. + string next_cursor = 2; + + bool has_more = 3; +} + +// --------------------------------------------------------------------------- +// GetEvent +// --------------------------------------------------------------------------- + +message GetEventRequest { + // UUID of the event to fetch. + string id = 1; +} + +// --------------------------------------------------------------------------- +// StreamEvents +// --------------------------------------------------------------------------- + +message StreamEventsRequest { + // Contract address to subscribe to. Required. + string contract_id = 1; + + // Optional topic_0 filter — only events whose first topic matches are pushed. + string topic_0 = 2; +} diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 0000000..8946dc8 --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,1491 @@ +{ + "name": "@trident-indexer/sdk", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@trident-indexer/sdk", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "zod": "^3.22.4" + }, + "devDependencies": { + "tsup": "^8.0.2", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "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/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "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/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "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/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "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/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "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.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "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/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000..c2e8fd4 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,32 @@ +{ + "name": "@trident-indexer/sdk", + "version": "0.0.1", + "description": "TypeScript client SDK for the Trident Soroban event indexer", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "dev": "tsup src/index.ts --format cjs,esm --dts --watch", + "lint": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.22.4" + }, + "devDependencies": { + "tsup": "^8.0.2", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + }, + "license": "MIT" +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 0000000..39f7072 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +export type Network = "mainnet" | "testnet" | "futurenet"; + +export interface TridentClientConfig { + apiUrl: string; + apiKey: string; + network: Network; +} + +// --------------------------------------------------------------------------- +// Domain types (mirrors SorobanEvent on the server side) +// --------------------------------------------------------------------------- + +export const EventTypeSchema = z.enum(["contract", "system", "diagnostic"]); +export type EventType = z.infer; + +export const SorobanEventSchema = z.object({ + id: z.string().uuid(), + contractId: z.string(), + ledgerSequence: z.number().int().nonnegative(), + ledgerTimestamp: z.string().datetime(), + transactionHash: z.string(), + eventIndex: z.number().int().nonnegative(), + eventType: EventTypeSchema, + topics: z.array(z.string()), + data: z.unknown(), + createdAt: z.string().datetime(), +}); +export type SorobanEvent = z.infer; + +// --------------------------------------------------------------------------- +// Query parameter types +// --------------------------------------------------------------------------- + +export interface QueryEventsParams { + contractId?: string; + topic0?: string; + topic1?: string; + ledgerFrom?: number; + ledgerTo?: number; + after?: string; + limit?: number; +} + +export interface GetEventByIdParams { + id: string; +} + +export interface SubscribeToContractParams { + contractId: string; + topic0?: string; + onEvent: (event: SorobanEvent) => void; + onError?: (error: Error) => void; +} + +export interface Subscription { + unsubscribe: () => void; +} + +export interface PaginatedEvents { + events: SorobanEvent[]; + cursor: string | null; + hasMore: boolean; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +export class TridentClient { + private readonly config: TridentClientConfig; + + constructor(config: TridentClientConfig) { + this.config = config; + } + + /** + * Query historical Soroban events with optional filtering. + * + * Supports filtering by contract address, topic values, and ledger range. + * Results are cursor-paginated — pass the returned `cursor` as `after` on + * the next call to fetch the next page. Returns events in ascending ledger + * order. + */ + async queryEvents(params: QueryEventsParams): Promise { + void params; + throw new Error("not yet implemented"); + } + + /** + * Fetch a single event by its UUID. + * + * Returns the full `SorobanEvent` record, or throws if no event with the + * given id exists. + */ + async getEventById(params: GetEventByIdParams): Promise { + void params; + throw new Error("not yet implemented"); + } + + /** + * Open a real-time WebSocket subscription to events emitted by a contract. + * + * Calls `onEvent` for every new event that matches the subscription criteria + * as it lands on-chain. Calls `onError` on connection failure and attempts + * to reconnect automatically. Returns a `Subscription` handle whose + * `unsubscribe()` method tears down the WebSocket cleanly. + */ + subscribeToContract(params: SubscribeToContractParams): Subscription { + void params; + throw new Error("not yet implemented"); + } +} diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 0000000..59243fa --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/services/api/go.mod b/services/api/go.mod new file mode 100644 index 0000000..1521f16 --- /dev/null +++ b/services/api/go.mod @@ -0,0 +1,3 @@ +module github.com/Depo-dev/trident/services/api + +go 1.22 diff --git a/services/api/go.sum b/services/api/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/services/api/main.go b/services/api/main.go new file mode 100644 index 0000000..ee5ec51 --- /dev/null +++ b/services/api/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "3000" + } + + mux := http.NewServeMux() + + // --------------------------------------------------------------------------- + // REST router + // Wire in the REST handler here. Each resource group gets its own handler + // func registered under /v1/. Example: + // mux.HandleFunc("GET /v1/events", handlers.ListEvents) + // mux.HandleFunc("GET /v1/events/{id}", handlers.GetEvent) + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // GraphQL handler + // Mount the GraphQL endpoint here, e.g. using gqlgen: + // srv := handler.NewDefaultServer(generated.NewExecutableSchema(cfg)) + // mux.Handle("/graphql", srv) + // mux.Handle("/playground", playground.Handler("Trident", "/graphql")) + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // WebSocket handler + // Mount the WebSocket subscription endpoint here. Clients subscribe to a + // contract address and receive a stream of SorobanEvent JSON objects in + // real time as they land on-chain. + // mux.HandleFunc("/ws", ws.Handler(redisClient)) + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // Redis Streams consumer + // Start the background consumer here. It reads from the Redis Stream written + // by the Rust indexer and fans out to connected WebSocket clients. + // go consumer.Start(ctx, redisClient, hub) + // --------------------------------------------------------------------------- + + server := &http.Server{ + Addr: fmt.Sprintf(":%s", port), + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + slog.Info("Trident API server listening", "port", port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + slog.Info("shutting down") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + slog.Error("graceful shutdown failed", "err", err) + } +}