From 1b00029975da9a6fda0efec5249941514f3ce8ec Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 17:53:35 +0900 Subject: [PATCH 01/10] docs(track): add entity-graph-cache-20260329 Entity graph-based cache invalidation with GraphQL caching support. Unified rewrite approach with EntityAwareCache facade, migrating from redb to libsql. Resolves #9. --- .../entity-graph-cache-20260329/metadata.json | 10 + .../entity-graph-cache-20260329/plan.md | 338 ++++++++++++++++++ .../entity-graph-cache-20260329/spec.md | 93 +++++ .please/docs/tracks/index.md | 7 +- 4 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 .please/docs/tracks/active/entity-graph-cache-20260329/metadata.json create mode 100644 .please/docs/tracks/active/entity-graph-cache-20260329/plan.md create mode 100644 .please/docs/tracks/active/entity-graph-cache-20260329/spec.md diff --git a/.please/docs/tracks/active/entity-graph-cache-20260329/metadata.json b/.please/docs/tracks/active/entity-graph-cache-20260329/metadata.json new file mode 100644 index 0000000..99db4da --- /dev/null +++ b/.please/docs/tracks/active/entity-graph-cache-20260329/metadata.json @@ -0,0 +1,10 @@ +{ + "track_id": "entity-graph-cache-20260329", + "type": "feature", + "status": "planned", + "created_at": "2026-03-29T17:30:00+09:00", + "updated_at": "2026-03-29T17:38:00+09:00", + "issue": "#9", + "pr": "", + "project": "" +} diff --git a/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md b/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md new file mode 100644 index 0000000..1d3a762 --- /dev/null +++ b/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md @@ -0,0 +1,338 @@ +# Plan: Entity Graph Cache + +> Track: entity-graph-cache-20260329 +> Spec: [spec.md](./spec.md) + +## Overview + +- **Source**: .please/docs/tracks/active/entity-graph-cache-20260329/spec.md +- **Issue**: #9 +- **Created**: 2026-03-29 +- **Approach**: Unified Rewrite + +## Purpose + +After this change, developers using local-hub will get precise, entity-level cache invalidation across both REST and GraphQL APIs. They can verify it works by issuing a REST PATCH to an issue and confirming that both the REST cache entry and any GraphQL query referencing that issue's `node_id` are invalidated automatically. + +## Context + +local-hub is a transparent GitHub API cache proxy that sits between clients (gh CLI, fetch/octokit) and the GitHub API. It currently caches only REST GET requests using redb (an embedded key-value store), with TTL + ETag freshness and parent-path prefix invalidation on mutations. GraphQL requests (POST /graphql) bypass the cache entirely because all GraphQL operations use POST, which the proxy treats as mutations. + +The current parent-path invalidation has limitations: it only invalidates one level up, misses related resources, and cannot handle cross-protocol scenarios. This track replaces the entire storage and invalidation layer with an entity graph-based system inspired by Stellate (GraphQL CDN). Entity dependencies are tracked via GitHub's `node_id` (Global Relay ID), enabling precise cross-protocol cache invalidation. The storage backend migrates from redb to libsql for SQL-based entity relationship queries and future Turso embedded replica support (Phase 3 team shared cache). + +### Requirements (from spec) + +- FR-1 through FR-7: Entity extraction, entity registry, REST/GraphQL invalidation, cross-protocol invalidation, libsql migration +- NFR-1: Lookup < 5ms, NFR-2: Extraction overhead < 2ms +- NFR-4: Zero client-side changes + +### Constraints + +- Single-binary distribution must be preserved (libsql embeds SQLite) +- Fail-open invariant: if entity extraction fails, fall back to prefix invalidation +- Token isolation: entity registry is namespaced by token hash +- File size limit: 500 LOC per source file + +### Non-Goals + +- Deep nested entity traversal (top-level extraction only) +- GraphQL subscriptions +- Turso cloud replication (Phase 3) +- Schema introspection for entity type detection +- Cache warming or prefetching + +## Architecture Decision + +**Chosen: Unified Rewrite with EntityAwareCache facade.** + +Replace redb entirely with libsql. Introduce `EntityAwareCache` as the single facade that handles response caching and entity dependency registration in a single SQL transaction. The proxy handler is restructured with a `RequestClassifier` that categorizes every request into one of four kinds (REST GET, REST mutation, GraphQL query, GraphQL mutation), then feeds into a shared pipeline. + +Rejected alternatives: + +- **Incremental (layer-by-layer)**: Lower risk but leaves legacy patterns. Parent-path invalidation bolted alongside entity graph creates two invalidation paths that are hard to reason about. Temporary state where entity deps are stored but not used adds unnecessary intermediate complexity. +- **Trait abstraction over backends**: Over-engineered for a single-backend project with no plan to support multiple storage backends. + +Key design principles: + +- Entity extraction is synchronous in the store path (not async/background) to guarantee consistency. +- Prefix invalidation preserved as fallback when `node_id` is absent from a response. +- All cache operations are async since libsql provides a native async API; all callers are already in async context. +- libsql schema uses two tables: `cache_entries` (response cache) and `entity_deps` (entity→cache_key reverse mapping). + +### Data Flow + +``` + ┌──────────────────────────────────────┐ + │ proxy_handler │ + └──────────┬───────────────────────────┘ + │ classify request + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ + REST GET POST /graphql REST Mutation + │ (classify.rs) │ + │ ┌────┴────┐ │ + │ GQL Query GQL Mutation │ + ▼ ▼ │ ▼ + entity_cache entity_cache │ forward_to_github() + .get() .get() │ │ + │ │ │ response body + hit? ► resp │ │ │ + miss? │ │ ▼ + ▼ ▼ ▼ extract_entity_ids() + fetch_from_github() forward() │ + │ │ │ ▼ + ▼ ▼ ▼ entity_cache.invalidate_by_entities() + extract_entity_ids() │ + remove_by_prefix() [fallback] + │ │ │ + ▼ ▼ ▼ + entity_cache entity_cache extract_entity_ids() + .store() .store() │ + │ │ ▼ + ▼ ▼ invalidate_by_entities() + response response │ + ▼ + response +``` + +### Storage Schema (libsql) + +```sql +-- Response cache (replaces redb key-value) +CREATE TABLE IF NOT EXISTS cache_entries ( + key TEXT PRIMARY KEY, + status INTEGER NOT NULL, + headers TEXT NOT NULL, -- JSON array of [name, value] pairs + body BLOB NOT NULL, + etag TEXT, + cached_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- Entity dependency registry +CREATE TABLE IF NOT EXISTS entity_deps ( + entity_id TEXT NOT NULL, + cache_key TEXT NOT NULL, + PRIMARY KEY (entity_id, cache_key) +); +CREATE INDEX IF NOT EXISTS idx_entity_deps_entity ON entity_deps(entity_id); +CREATE INDEX IF NOT EXISTS idx_entity_deps_key ON entity_deps(cache_key); +``` + +### Module Structure + +``` +crates/server/src/ + lib.rs (modify: add new module declarations) + storage.rs (new: libsql connection, schema DDL) + entity_cache.rs (new: EntityAwareCache facade) + entity.rs (new: entity extraction from JSON) + classify.rs (new: request classification) + proxy.rs (rewrite: unified entity-aware handler) + key.rs (modify: add graphql_cache_key) + error.rs (modify: replace redb errors with libsql) + server.rs (modify: async AppState construction) + ttl.rs (modify: add /graphql TTL rule) + main.rs (modify: async cache init, .db path) +``` + +## Tasks + +### Phase 1: Storage Foundation + +- [ ] T001 Create libsql storage module with connection management and schema DDL (file: crates/server/src/storage.rs) +- [ ] T002 [P] Replace error types from redb to libsql (file: crates/server/src/error.rs) (depends on T001) +- [ ] T003 Build EntityAwareCache facade with async CRUD + entity registration (file: crates/server/src/entity_cache.rs) (depends on T001, T002) + +### Phase 2: Entity & GraphQL Modules + +- [ ] T004 [P] Create entity extractor for REST and GraphQL responses (file: crates/server/src/entity.rs) +- [ ] T005 [P] Create request classifier for REST/GraphQL routing (file: crates/server/src/classify.rs) +- [ ] T006 [P] Add GraphQL cache key generation (file: crates/server/src/key.rs) (depends on T001) + +### Phase 3: Proxy Rewrite + +- [ ] T007 Rewrite proxy handler with unified entity-aware flow (file: crates/server/src/proxy.rs) (depends on T003, T004, T005, T006) +- [ ] T008 Update server wiring and AppState for EntityAwareCache (file: crates/server/src/server.rs) (depends on T003, T007) + +### Phase 4: Integration & Cleanup + +- [ ] T009 Update lib.rs exports and main.rs initialization (file: crates/server/src/main.rs) (depends on T007, T008) +- [ ] T010 Remove old cache.rs, update Cargo.toml dependencies (file: crates/server/Cargo.toml) (depends on T009) +- [ ] T011 Add integration tests for cross-protocol invalidation (file: crates/server/tests/integration.rs) (depends on T009) +- [ ] T012 Add GraphQL-specific TTL rules (file: crates/server/src/ttl.rs) (depends on T007) + +## Key Files + +### Create + +- `crates/server/src/storage.rs` — libsql connection pool, schema DDL, migration helpers. ~80 LOC. +- `crates/server/src/entity_cache.rs` — EntityAwareCache facade: transactional cache+entity ops. ~180 LOC. +- `crates/server/src/entity.rs` — Parse REST/GraphQL JSON bodies for `node_id` fields. ~120 LOC. +- `crates/server/src/classify.rs` — Categorize request into RestGet/RestMutation/GqlQuery/GqlMutation. ~80 LOC. +- `crates/server/tests/integration.rs` — Cross-protocol invalidation integration tests. ~150 LOC. + +### Modify + +- `crates/server/src/proxy.rs` — Rewrite with unified classifier + entity-aware cache pipeline. ~350 LOC. +- `crates/server/src/key.rs` — Add `graphql_cache_key()`, make `token_hash()` pub(crate). ~130 LOC. +- `crates/server/src/error.rs` — Swap 6 redb error variants for libsql error variant. ~20 LOC. +- `crates/server/src/server.rs` — Async AppState construction with EntityAwareCache. ~70 LOC. +- `crates/server/src/main.rs` — Async cache init, update DEFAULT_CACHE_DIR to `.local-hub/cache.db`. ~165 LOC. +- `crates/server/src/lib.rs` — Add new module declarations, update exports. ~15 LOC. +- `crates/server/src/ttl.rs` — Add `/graphql` TTL rule. ~60 LOC. +- `crates/server/Cargo.toml` — Replace `redb` with `libsql`. +- `Cargo.toml` — Add `libsql` to workspace dependencies. + +### Reuse + +- `crates/server/src/key.rs` → `cache_key()` — REST cache key generation (unchanged) +- `crates/server/src/key.rs` → `invalidation_prefix()` — Prefix invalidation kept as fallback +- `crates/server/src/ttl.rs` → `TtlConfig::resolve()` — TTL resolution for all requests +- `crates/server/src/proxy.rs` → `parse_github_response()` — Response parsing reused for GraphQL +- `crates/server/src/proxy.rs` → `cache_entry_to_response()` / `github_response_to_axum()` — Response builders + +### Delete + +- `crates/server/src/cache.rs` — Replaced entirely by `entity_cache.rs` + `storage.rs` + +## Interfaces and Dependencies + +### New dependency + +`libsql = "0.6"` (embedded SQLite with Turso extensions; single-file database) + +Note: libsql's async API means all cache operations become `async`. Since `proxy_handler` is already async and all callers are in async context, this is a natural fit. The `main.rs` CLI commands (`status`, `flush`) will need `.await` but are already inside `#[tokio::main]`. + +### EntityAwareCache (`entity_cache.rs`) + +```rust +pub struct EntityAwareCache { + conn: libsql::Connection, +} + +impl EntityAwareCache { + pub async fn open(path: &Path) -> Result; + pub async fn get(&self, key: &str) -> Result>; + pub async fn store(&self, key: &str, entry: &CacheEntry, entity_ids: &[String]) -> Result<()>; + pub async fn remove(&self, key: &str) -> Result; + pub async fn remove_by_prefix(&self, prefix: &str) -> Result; + pub async fn invalidate_by_entities(&self, entity_ids: &[String]) -> Result; + pub async fn count(&self) -> Result; +} +``` + +### EntityExtractor (`entity.rs`) + +```rust +/// Extract GitHub node_id values from a REST JSON response body. +pub fn extract_entity_ids(body: &[u8]) -> Vec; + +/// Extract entity IDs from a GraphQL response body. +pub fn extract_graphql_entity_ids(body: &[u8]) -> Vec; +``` + +### RequestClassifier (`classify.rs`) + +```rust +pub enum RequestKind { + RestGet, + RestMutation, + GraphqlQuery { query: String, variables: Option }, + GraphqlMutation, +} + +pub fn classify(method: &str, path: &str, body: Option<&[u8]>) -> RequestKind; +``` + +### GraphQL cache key (`key.rs` addition) + +```rust +/// Generate a cache key for a GraphQL query. +/// Format: {token_hash}:GQL:{sha256(query + variables_json)} +pub fn graphql_cache_key(token: &str, query: &str, variables: Option<&str>) -> String; +``` + +## Verification + +### Automated Tests + +- [ ] EntityAwareCache CRUD: get, store, remove, count (mirrors existing 10 cache.rs tests) +- [ ] Entity registration: store entry with entities, verify entity_deps populated +- [ ] Entity invalidation: invalidate by entity_id removes all referencing cache entries +- [ ] Cross-protocol invalidation: REST mutation invalidates GraphQL cache entries +- [ ] GraphQL query caching: identical queries return cache hit +- [ ] GraphQL mutation detection: mutations bypass cache and trigger invalidation +- [ ] Request classifier: correctly categorizes REST GET, REST mutation, GraphQL query, GraphQL mutation +- [ ] Entity extractor: extracts `node_id` from REST and GraphQL response bodies +- [ ] Prefix fallback: responses without `node_id` still invalidate via prefix matching +- [ ] Key generation: `graphql_cache_key` produces deterministic keys for same query+variables + +### Observable Outcomes + +- After `PATCH /repos/org/repo/issues/123`, a subsequent `GET /repos/org/repo/issues/123` returns `x-local-hub-cache: miss` +- After `POST /graphql` with a query, a repeat request returns `x-local-hub-cache: hit` +- After `POST /graphql` with a mutation on issue#123, a cached GraphQL query referencing that issue returns `x-local-hub-cache: miss` +- Running `local-hub status` shows cache entry count from libsql database + +### Manual Testing + +- [ ] Start local-hub, make REST requests, verify caching works as before +- [ ] Make a GraphQL query, verify it caches on repeat +- [ ] Mutate via REST, verify GraphQL cache for same entity is invalidated +- [ ] Mutate via GraphQL, verify REST cache for same entity is invalidated + +### Acceptance Criteria Check + +- [ ] AC-1: REST PATCH to issue invalidates both REST and GraphQL caches referencing that issue's node_id +- [ ] AC-2: GraphQL queries return `x-local-hub-cache: hit` on repeated identical requests +- [ ] AC-3: GraphQL mutations pass through and invalidate all cache entries referencing affected entities +- [ ] AC-4: Entity extraction correctly identifies `node_id` from REST and GraphQL responses +- [ ] AC-5: All existing REST caching behavior continues to work (TTL, ETag, token isolation) +- [ ] AC-6: libsql replaces redb with no data loss during migration + +## Decision Log + +- Decision: Unified Rewrite over Incremental approach + Rationale: Entity graph is fundamentally different from prefix invalidation; bolting it onto existing code creates two parallel invalidation paths. Clean rewrite with EntityAwareCache facade is more maintainable. The proxy handler is small enough (~390 LOC) that a full rewrite is manageable in a single pass. + Date/Author: 2026-03-29 / User + Claude + +- Decision: libsql over rusqlite for storage + Rationale: libsql (Turso) provides embedded replica support for Phase 3 team shared cache. API-compatible with SQLite but with future extensibility. + Date/Author: 2026-03-29 / User + +- Decision: GitHub node_id as entity identity + Rationale: node_id (Global Relay ID) is present in both REST and GraphQL responses, providing a universal cross-protocol entity identifier without additional mapping. + Date/Author: 2026-03-29 / User + +- Decision: Top-level entity extraction only + Rationale: Deep nested traversal adds complexity and latency (NFR-2: <2ms extraction overhead). Top-level covers the primary use case; can be extended later. + Date/Author: 2026-03-29 / User + +- Decision: Synchronous entity extraction in store path + Rationale: Entity extraction is a JSON parse of the already-buffered response body (~1ms for typical GitHub responses). Running it synchronously ensures entity deps are registered before the next request can read the cache entry. Async background registration creates a race condition window. NFR-2 budget is 2ms, well within range. + Date/Author: 2026-03-29 / Claude + +## Notes + +### Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +| ------------------------------------- | ---------- | ------ | -------------------------------------------------------------------------------- | +| libsql crate instability | Medium | High | Pin exact version; comprehensive tests; fail-open to prefix invalidation | +| Binary size increase (~3MB) | Certain | Low | Acceptable for functionality gained; single-binary preserved | +| proxy.rs rewrite regression | Medium | High | Existing test coverage (5 unit + integration); incremental verification per task | +| `node_id` missing from some endpoints | Medium | Low | Fall back to prefix invalidation; log at debug level | +| GraphQL query parsing false positives | Low | Medium | Conservative detection: require `query`/`mutation` keyword at start | +| Transaction contention under load | Low | Medium | libsql WAL mode by default; entity registration is fast INSERT OR IGNORE | + +### Module Size Budget + +| File | Current LOC | Estimated Post-Rewrite | Within 500 LOC | +| --------------- | ----------- | ---------------------- | -------------- | +| storage.rs | new | ~80 | Yes | +| entity_cache.rs | new | ~180 | Yes | +| entity.rs | new | ~120 | Yes | +| classify.rs | new | ~80 | Yes | +| proxy.rs | 389 | ~350 | Yes | +| key.rs | 99 | ~130 | Yes | +| error.rs | 35 | ~20 | Yes | diff --git a/.please/docs/tracks/active/entity-graph-cache-20260329/spec.md b/.please/docs/tracks/active/entity-graph-cache-20260329/spec.md new file mode 100644 index 0000000..95c7cc3 --- /dev/null +++ b/.please/docs/tracks/active/entity-graph-cache-20260329/spec.md @@ -0,0 +1,93 @@ +# Entity Graph Cache + +> Track: entity-graph-cache-20260329 + +## Overview + +Replace the current parent-path cache invalidation with an entity graph-based system inspired by Stellate (GraphQL CDN). Track entity dependencies across cached responses and invalidate precisely when mutations occur. Additionally, enable GraphQL API caching by detecting query vs mutation in POST /graphql requests. + +This moves local-hub from URL-pattern invalidation to semantic entity-level invalidation, enabling cross-protocol cache coherence between REST and GraphQL. + +## Requirements + +### Functional Requirements + +- [ ] FR-1: Entity Extractor — Parse REST and GraphQL responses to extract entity identities using GitHub `node_id` (Global Relay ID). Top-level extraction only (response root entities). +- [ ] FR-2: Entity Registry — Maintain a reverse mapping of `entity_id → [cache_key]` in libsql. When an entity is referenced by a cached response, register the dependency. +- [ ] FR-3: REST Mutation Invalidation — On POST/PUT/PATCH/DELETE to REST endpoints, extract affected entity from the mutation response and invalidate all cache entries (REST + GraphQL) that reference that entity. +- [ ] FR-4: GraphQL Query Caching — Cache GraphQL query responses keyed by `SHA256(query + variables)`. Detect query vs mutation by parsing the operation type from the request body. +- [ ] FR-5: GraphQL Mutation Invalidation — On GraphQL mutation responses, extract affected entities and invalidate all related cache entries across both REST and GraphQL caches. +- [ ] FR-6: Cross-Protocol Invalidation — A REST mutation must invalidate related GraphQL caches, and a GraphQL mutation must invalidate related REST caches. +- [ ] FR-7: Storage Migration — Migrate cache storage from redb to libsql for both the response cache and the entity registry. Leverage libsql for SQL-based entity relationship queries and future Turso embedded replica support (Phase 3 team shared cache). + +### Non-functional Requirements + +- [ ] NFR-1: Cache lookup latency must remain under 5ms for local operations +- [ ] NFR-2: Entity extraction overhead must not add more than 2ms per response +- [ ] NFR-3: Storage size overhead for entity registry should be < 20% of response cache size +- [ ] NFR-4: Backward compatible — existing gh CLI and fetch/octokit clients require zero changes + +## Acceptance Criteria + +- [ ] AC-1: When a REST PATCH to `/repos/org/repo/issues/123` succeeds, both the REST `/issues/123` cache AND any GraphQL query referencing that issue's `node_id` are invalidated +- [ ] AC-2: GraphQL queries (POST /graphql with `query` operation) return cached responses with `x-local-hub-cache: hit` on repeated identical requests +- [ ] AC-3: GraphQL mutations (POST /graphql with `mutation` operation) pass through to GitHub and invalidate all cache entries referencing affected entities +- [ ] AC-4: Entity extraction correctly identifies `node_id` from both REST JSON responses and GraphQL response `data` fields +- [ ] AC-5: All existing REST caching behavior continues to work (TTL, ETag, token isolation) +- [ ] AC-6: libsql replaces redb as the storage backend with no data loss during migration + +## Out of Scope + +- Deep nested entity traversal (only top-level extraction) +- GraphQL subscription support +- Turso cloud replication (Phase 3) +- Automatic schema introspection for entity type detection +- Cache warming or prefetching + +## Assumptions + +- GitHub REST API responses consistently include `node_id` fields for primary entities +- GraphQL responses include entity identifiers in predictable locations (`id`, `node_id` fields) +- The `node_id` is stable and unique across REST and GraphQL for the same entity +- libsql Rust crate (`libsql`) is stable enough for production embedded use + +## Technical Notes + +### Entity Identity + +GitHub provides `node_id` (Base64-encoded Global Relay ID) in both REST and GraphQL responses. This serves as the universal entity identifier: + +``` +REST: { "id": 123, "node_id": "MDExOlB1bGxSZXF1ZXN0MQ==", ... } +GraphQL: { "data": { "repository": { "issue": { "id": "MDExOlB1bGxSZXF1ZXN0MQ==" } } } } +``` + +### Storage Schema (libsql) + +```sql +-- Response cache (replaces redb) +CREATE TABLE cache_entries ( + key TEXT PRIMARY KEY, + etag TEXT, + status INTEGER, + headers TEXT, -- JSON + body BLOB, + expires_at INTEGER +); + +-- Entity registry (new) +CREATE TABLE entity_deps ( + entity_id TEXT NOT NULL, + cache_key TEXT NOT NULL, + PRIMARY KEY (entity_id, cache_key) +); +CREATE INDEX idx_entity ON entity_deps(entity_id); +CREATE INDEX idx_cache_key ON entity_deps(cache_key); +``` + +### GraphQL Operation Detection + +``` +POST /graphql { "query": "query { ... }" } → cacheable +POST /graphql { "query": "mutation { ... }" } → pass-through + invalidate +``` diff --git a/.please/docs/tracks/index.md b/.please/docs/tracks/index.md index 6b66e78..eecf4d4 100644 --- a/.please/docs/tracks/index.md +++ b/.please/docs/tracks/index.md @@ -4,9 +4,10 @@ ## Active -| Track | Feature | Type | Issue | Started | Status | -| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------- | ----- | ---------- | ----------- | -| [local-better-hub-20260327](active/local-better-hub-20260327/plan.md) | Local Better Hub — copy vendor/better-hub, route through local-hub proxy, add CLI AI providers | feature | — | 2026-03-27 | in_progress | +| Track | Feature | Type | Issue | Started | Status | +| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------- | ----- | ---------- | ----------- | +| [local-better-hub-20260327](active/local-better-hub-20260327/plan.md) | Local Better Hub — copy vendor/better-hub, route through local-hub proxy, add CLI AI providers | feature | — | 2026-03-27 | in_progress | +| [entity-graph-cache-20260329](active/entity-graph-cache-20260329/plan.md) | Entity Graph Cache — Stellate-style entity-based invalidation with GraphQL caching | feature | #9 | 2026-03-29 | planned | ## Recently Completed From 14a68c97be8c76a476352096130dd24ac87c9e9c Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 17:54:55 +0900 Subject: [PATCH 02/10] =?UTF-8?q?chore(track):=20entity-graph-cache-202603?= =?UTF-8?q?29=20=EA=B5=AC=ED=98=84=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tracks/active/entity-graph-cache-20260329/metadata.json | 4 ++-- .please/docs/tracks/index.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.please/docs/tracks/active/entity-graph-cache-20260329/metadata.json b/.please/docs/tracks/active/entity-graph-cache-20260329/metadata.json index 99db4da..c5ad05b 100644 --- a/.please/docs/tracks/active/entity-graph-cache-20260329/metadata.json +++ b/.please/docs/tracks/active/entity-graph-cache-20260329/metadata.json @@ -1,9 +1,9 @@ { "track_id": "entity-graph-cache-20260329", "type": "feature", - "status": "planned", + "status": "in_progress", "created_at": "2026-03-29T17:30:00+09:00", - "updated_at": "2026-03-29T17:38:00+09:00", + "updated_at": "2026-03-29T17:40:00+09:00", "issue": "#9", "pr": "", "project": "" diff --git a/.please/docs/tracks/index.md b/.please/docs/tracks/index.md index eecf4d4..d155e36 100644 --- a/.please/docs/tracks/index.md +++ b/.please/docs/tracks/index.md @@ -7,7 +7,7 @@ | Track | Feature | Type | Issue | Started | Status | | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------- | ----- | ---------- | ----------- | | [local-better-hub-20260327](active/local-better-hub-20260327/plan.md) | Local Better Hub — copy vendor/better-hub, route through local-hub proxy, add CLI AI providers | feature | — | 2026-03-27 | in_progress | -| [entity-graph-cache-20260329](active/entity-graph-cache-20260329/plan.md) | Entity Graph Cache — Stellate-style entity-based invalidation with GraphQL caching | feature | #9 | 2026-03-29 | planned | +| [entity-graph-cache-20260329](active/entity-graph-cache-20260329/plan.md) | Entity Graph Cache — Stellate-style entity-based invalidation with GraphQL caching | feature | #9 | 2026-03-29 | in_progress | ## Recently Completed From 47d18d2460cc33549484657262727db311908922 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 18:06:53 +0900 Subject: [PATCH 03/10] feat(cache): entity graph cache with GraphQL support Replace redb with libsql and introduce EntityAwareCache facade for entity-based cache invalidation. Add GraphQL query caching with request classification (REST GET/mutation, GraphQL query/mutation). - storage.rs: libsql connection + schema DDL - entity_cache.rs: EntityAwareCache with entity dependency tracking - entity.rs: extract node_id from REST/GraphQL responses - classify.rs: request classification for REST/GraphQL routing - key.rs: add graphql_cache_key generation - proxy.rs: unified handler with entity-aware flow - error.rs: simplified to single libsql error variant Resolves #9 --- Cargo.lock | 1247 +++++++++++++++++++++-- Cargo.toml | 2 +- crates/server/Cargo.toml | 2 +- crates/server/src/cache.rs | 284 ------ crates/server/src/classify.rs | 175 ++++ crates/server/src/entity.rs | 181 ++++ crates/server/src/entity_cache.rs | 502 +++++++++ crates/server/src/error.rs | 21 +- crates/server/src/key.rs | 56 +- crates/server/src/lib.rs | 9 +- crates/server/src/main.rs | 18 +- crates/server/src/proxy.rs | 346 +++++-- crates/server/src/storage.rs | 87 ++ crates/server/src/ttl.rs | 2 + crates/server/tests/integration_test.rs | 14 +- 15 files changed, 2465 insertions(+), 481 deletions(-) delete mode 100644 crates/server/src/cache.rs create mode 100644 crates/server/src/classify.rs create mode 100644 crates/server/src/entity.rs create mode 100644 crates/server/src/entity_cache.rs create mode 100644 crates/server/src/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 06c530b..961c09f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,29 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy 0.8.48", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +34,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -67,29 +96,96 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core 0.3.4", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 0.1.2", + "tower 0.4.13", + "tower-layer", + "tower-service", +] + [[package]] name = "axum" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "axum-core", + "axum-core 0.5.6", "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -98,14 +194,31 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -114,23 +227,67 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.66.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -146,17 +303,44 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] [[package]] name = "cc" @@ -168,12 +352,42 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.0" @@ -286,6 +500,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -311,6 +531,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -359,6 +597,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -366,6 +619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -374,6 +628,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -392,8 +674,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -432,6 +719,31 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -443,14 +755,30 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", - "indexmap", + "http 1.4.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -466,6 +794,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -478,6 +815,26 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -488,6 +845,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -495,7 +863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -506,11 +874,17 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + [[package]] name = "httparse" version = "1.10.1" @@ -523,6 +897,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -533,9 +931,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -546,22 +944,52 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399c78f9338483cb7e630c8474b07268983c6bd5acee012e4211f9f7bb21b070" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.22.4", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "webpki-roots 0.26.11", +] + [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.32", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -570,7 +998,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -584,18 +1012,18 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -711,6 +1139,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -723,6 +1161,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -745,6 +1193,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -767,6 +1224,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -779,6 +1242,154 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libsql" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18646e4ef8db446bc3e3f5fb96131483203bc5f4998ff149f79a067530c01c" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base64 0.21.7", + "bincode", + "bitflags 2.11.0", + "bytes", + "fallible-iterator 0.3.0", + "futures", + "http 0.2.12", + "hyper 0.14.32", + "hyper-rustls 0.25.0", + "libsql-hrana", + "libsql-sqlite3-parser", + "libsql-sys", + "libsql_replication", + "parking_lot", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tonic-web", + "tower 0.4.13", + "tower-http 0.4.4", + "tracing", + "uuid", + "zerocopy 0.7.35", +] + +[[package]] +name = "libsql-ffi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf" +dependencies = [ + "bindgen", + "cc", +] + +[[package]] +name = "libsql-hrana" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeaf5d19e365465e1c23d687a28c805d7462531b3f619f0ba49d3cf369890a3e" +dependencies = [ + "base64 0.21.7", + "bytes", + "prost", + "serde", +] + +[[package]] +name = "libsql-rusqlite" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae65c66088dcd309abbd5617ae046abac2a2ee0a7fdada5127353bd68e0a27ea" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator 0.2.0", + "fallible-streaming-iterator", + "hashlink", + "libsql-ffi", + "smallvec", +] + +[[package]] +name = "libsql-sqlite3-parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a90128c708356af8f7d767c9ac2946692c9112b4f74f07b99a01a60680e413" +dependencies = [ + "bitflags 2.11.0", + "cc", + "fallible-iterator 0.3.0", + "indexmap 2.13.0", + "log", + "memchr", + "phf", + "phf_codegen", + "phf_shared", + "uncased", +] + +[[package]] +name = "libsql-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c05b61c226781d6f5e26e3e7364617f19c0c1d5332035802e9229d6024cec05" +dependencies = [ + "bytes", + "libsql-ffi", + "libsql-rusqlite", + "once_cell", + "tracing", + "zerocopy 0.7.35", +] + +[[package]] +name = "libsql_replication" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf40c4c2c01462da758272976de0a23d19b4e9c714db08efecf262d896655b5" +dependencies = [ + "aes", + "async-stream", + "async-trait", + "bytes", + "cbc", + "libsql-rusqlite", + "libsql-sys", + "parking_lot", + "prost", + "serde", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tracing", + "uuid", + "zerocopy 0.7.35", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -796,21 +1407,21 @@ name = "local-hub" version = "0.4.0" dependencies = [ "anyhow", - "axum", + "axum 0.8.8", "clap", "hex", - "http", - "hyper", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "redb", + "libsql", "reqwest", "serde", "serde_json", "sha2", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", - "tower", + "tower 0.5.3", "tracing", "tracing-subscriber", ] @@ -839,6 +1450,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -857,6 +1474,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -877,14 +1500,24 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.2.1", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -912,7 +1545,7 @@ version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -932,6 +1565,12 @@ dependencies = [ "syn", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -973,12 +1612,77 @@ dependencies = [ "windows-link", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "uncased", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1001,9 +1705,18 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" name = "potential_utf" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerovec", + "zerocopy 0.8.48", ] [[package]] @@ -1025,6 +1738,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.45" @@ -1041,12 +1777,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "redb" -version = "2.6.3" +name = "rand" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eca1e9d98d5a7e9002d0013e18d5a9b000aee942eb134883a82f06ebffb6c01" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1055,7 +1812,19 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] @@ -1081,16 +1850,16 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", "js-sys", @@ -1103,11 +1872,11 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tower", - "tower-http", + "tower 0.5.3", + "tower-http 0.6.8", "tower-service", "url", "wasm-bindgen", @@ -1129,19 +1898,52 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1150,11 +1952,33 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1164,6 +1988,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.10" @@ -1202,13 +2037,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1333,6 +2181,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -1345,6 +2199,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -1384,6 +2248,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1410,7 +2280,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1434,17 +2304,37 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1489,11 +2379,21 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.6.1" @@ -1515,13 +2415,35 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.37", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", "tokio", ] @@ -1538,6 +2460,73 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.6.20", + "base64 0.21.7", + "bytes", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-web" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3b0e1cedbf19fdfb78ef3d672cb9928e0a91a9cb4629cc0c916e8cff8aaaa1" +dependencies = [ + "base64 0.21.7", + "bytes", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "pin-project", + "tokio-stream", + "tonic", + "tower-http 0.4.4", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -1547,27 +2536,47 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tower-http" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "http-range-header", + "pin-project-lite", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-http" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -1658,6 +2667,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1700,6 +2718,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1827,7 +2857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -1838,9 +2868,9 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -1854,6 +2884,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1999,7 +3059,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn", "wasm-metadata", @@ -2029,8 +3089,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", - "indexmap", + "bitflags 2.11.0", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -2049,7 +3109,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", @@ -2088,6 +3148,47 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive 0.8.48", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 59ca5b3..a700fad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ tracing-subscriber = { version = "0.3", features = [ "env-filter" ] } sha2 = "0.10" hex = "0.4" thiserror = "2" -redb = "2" +libsql = "0.6" http = "1" tower = { version = "0.5", features = [ "util" ] } anyhow = "1" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 7216606..ca95d3e 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -28,7 +28,7 @@ tracing-subscriber.workspace = true sha2.workspace = true hex.workspace = true thiserror.workspace = true -redb.workspace = true +libsql.workspace = true http.workspace = true tower.workspace = true anyhow.workspace = true diff --git a/crates/server/src/cache.rs b/crates/server/src/cache.rs deleted file mode 100644 index f972557..0000000 --- a/crates/server/src/cache.rs +++ /dev/null @@ -1,284 +0,0 @@ -use std::path::Path; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use redb::{Database, ReadableTable, ReadableTableMetadata, TableDefinition}; -use serde::{Deserialize, Serialize}; - -use crate::error::Result; - -const CACHE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("cache"); - -/// A cached HTTP response entry. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CacheEntry { - pub status: u16, - pub headers: Vec<(String, String)>, - pub body: Vec, - pub etag: Option, - pub cached_at: u64, - pub expires_at: u64, -} - -impl CacheEntry { - pub fn is_expired(&self) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - now >= self.expires_at - } - - pub fn refresh_ttl(&mut self, ttl: Duration) { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.expires_at = now + ttl.as_secs(); - } -} - -/// Cache store backed by redb (embedded key-value database). -pub struct CacheStore { - db: Database, -} - -impl CacheStore { - /// Open or create a cache database at the given path. - pub fn open(path: &Path) -> Result { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let db = Database::create(path)?; - - // Ensure the table exists - let write_txn = db.begin_write()?; - { - let _table = write_txn.open_table(CACHE_TABLE)?; - } - write_txn.commit()?; - - Ok(Self { db }) - } - - /// Get a cache entry by key. Returns None if not found. - pub fn get(&self, key: &str) -> Result> { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(CACHE_TABLE)?; - - match table.get(key)? { - Some(value) => { - let entry: CacheEntry = serde_json::from_slice(value.value())?; - Ok(Some(entry)) - } - None => Ok(None), - } - } - - /// Store a cache entry. - pub fn set(&self, key: &str, entry: &CacheEntry) -> Result<()> { - let serialized = serde_json::to_vec(entry)?; - let write_txn = self.db.begin_write()?; - { - let mut table = write_txn.open_table(CACHE_TABLE)?; - table.insert(key, serialized.as_slice())?; - } - write_txn.commit()?; - Ok(()) - } - - /// Remove a cache entry by exact key. - #[allow(dead_code)] - pub fn remove(&self, key: &str) -> Result { - let write_txn = self.db.begin_write()?; - let removed = { - let mut table = write_txn.open_table(CACHE_TABLE)?; - table.remove(key)?.is_some() - }; - write_txn.commit()?; - Ok(removed) - } - - /// Remove all entries whose keys start with the given prefix. - /// Returns the number of entries removed. - pub fn remove_by_prefix(&self, prefix: &str) -> Result { - let keys_to_remove = { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(CACHE_TABLE)?; - let mut keys = Vec::new(); - for entry in table.iter()? { - let (key, _) = entry?; - if key.value().starts_with(prefix) { - keys.push(key.value().to_string()); - } - } - keys - }; - - let count = keys_to_remove.len(); - if count > 0 { - let write_txn = self.db.begin_write()?; - { - let mut table = write_txn.open_table(CACHE_TABLE)?; - for key in &keys_to_remove { - table.remove(key.as_str())?; - } - } - write_txn.commit()?; - } - Ok(count) - } - - /// Count total entries in the cache. - pub fn count(&self) -> Result { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(CACHE_TABLE)?; - Ok(table.len()? as usize) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn temp_cache() -> (CacheStore, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("test-cache.redb"); - let store = CacheStore::open(&db_path).unwrap(); - (store, dir) - } - - fn now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() - } - - fn sample_entry(ttl_secs: u64) -> CacheEntry { - let now = now_secs(); - CacheEntry { - status: 200, - headers: vec![("content-type".into(), "application/json".into())], - body: br#"{"id":1}"#.to_vec(), - etag: Some("\"abc123\"".into()), - cached_at: now, - expires_at: now + ttl_secs, - } - } - - #[test] - fn test_get_missing_key() { - let (store, _dir) = temp_cache(); - let result = store.get("nonexistent").unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_set_and_get() { - let (store, _dir) = temp_cache(); - let entry = sample_entry(300); - store.set("test:key", &entry).unwrap(); - - let retrieved = store.get("test:key").unwrap().unwrap(); - assert_eq!(retrieved.status, 200); - assert_eq!(retrieved.body, br#"{"id":1}"#); - assert_eq!(retrieved.etag, Some("\"abc123\"".into())); - } - - #[test] - fn test_entry_not_expired() { - let entry = sample_entry(300); - assert!(!entry.is_expired()); - } - - #[test] - fn test_entry_expired() { - let now = now_secs(); - let entry = CacheEntry { - status: 200, - headers: vec![], - body: vec![], - etag: None, - cached_at: now - 600, - expires_at: now - 1, // expired 1 second ago - }; - assert!(entry.is_expired()); - } - - #[test] - fn test_refresh_ttl() { - let now = now_secs(); - let mut entry = CacheEntry { - status: 200, - headers: vec![], - body: vec![], - etag: None, - cached_at: now - 600, - expires_at: now - 1, // expired - }; - assert!(entry.is_expired()); - - entry.refresh_ttl(Duration::from_secs(300)); - assert!(!entry.is_expired()); - assert!(entry.expires_at > now); - } - - #[test] - fn test_remove() { - let (store, _dir) = temp_cache(); - let entry = sample_entry(300); - store.set("test:remove", &entry).unwrap(); - - assert!(store.remove("test:remove").unwrap()); - assert!(store.get("test:remove").unwrap().is_none()); - } - - #[test] - fn test_remove_nonexistent() { - let (store, _dir) = temp_cache(); - assert!(!store.remove("does_not_exist").unwrap()); - } - - #[test] - fn test_remove_by_prefix() { - let (store, _dir) = temp_cache(); - let entry = sample_entry(300); - - store.set("abc:GET:/repos/org/repo/issues", &entry).unwrap(); - store.set("abc:GET:/repos/org/repo/pulls", &entry).unwrap(); - store - .set("abc:GET:/repos/other/repo/issues", &entry) - .unwrap(); - store.set("xyz:GET:/repos/org/repo/issues", &entry).unwrap(); - - let removed = store.remove_by_prefix("abc:GET:/repos/org/repo").unwrap(); - assert_eq!(removed, 2); - assert_eq!(store.count().unwrap(), 2); - } - - #[test] - fn test_count() { - let (store, _dir) = temp_cache(); - assert_eq!(store.count().unwrap(), 0); - - let entry = sample_entry(300); - store.set("key1", &entry).unwrap(); - store.set("key2", &entry).unwrap(); - assert_eq!(store.count().unwrap(), 2); - } - - #[test] - fn test_overwrite_entry() { - let (store, _dir) = temp_cache(); - let entry1 = sample_entry(300); - store.set("key", &entry1).unwrap(); - - let mut entry2 = sample_entry(600); - entry2.status = 304; - store.set("key", &entry2).unwrap(); - - let retrieved = store.get("key").unwrap().unwrap(); - assert_eq!(retrieved.status, 304); - } -} diff --git a/crates/server/src/classify.rs b/crates/server/src/classify.rs new file mode 100644 index 0000000..e1c14be --- /dev/null +++ b/crates/server/src/classify.rs @@ -0,0 +1,175 @@ +use serde_json::Value; + +/// Classification of an incoming request. +#[derive(Debug, PartialEq)] +pub enum RequestKind { + /// REST GET request (cacheable). + RestGet, + /// REST mutation (POST/PUT/PATCH/DELETE, non-GraphQL). + RestMutation, + /// GraphQL query (cacheable). + GraphqlQuery { + query: String, + variables: Option, + }, + /// GraphQL mutation (pass-through + invalidate). + GraphqlMutation, +} + +/// Classify an incoming request based on method, path, and optional body. +pub fn classify(method: &str, path: &str, body: Option<&[u8]>) -> RequestKind { + if path == "/graphql" && method == "POST" { + return classify_graphql(body); + } + + if method == "GET" { + RequestKind::RestGet + } else { + RequestKind::RestMutation + } +} + +fn classify_graphql(body: Option<&[u8]>) -> RequestKind { + let Some(body) = body else { + return RequestKind::GraphqlMutation; // No body = treat as mutation (safe default) + }; + + let Ok(value) = serde_json::from_slice::(body) else { + return RequestKind::GraphqlMutation; + }; + + let Some(query_str) = value.get("query").and_then(|q| q.as_str()) else { + return RequestKind::GraphqlMutation; + }; + + // Detect operation type from query text + let trimmed = query_str.trim(); + if is_mutation(trimmed) { + return RequestKind::GraphqlMutation; + } + + // Extract variables as JSON string for cache key + let variables = value + .get("variables") + .filter(|v| !v.is_null()) + .map(|v| v.to_string()); + + RequestKind::GraphqlQuery { + query: query_str.to_string(), + variables, + } +} + +/// Check if a GraphQL operation text is a mutation. +fn is_mutation(query: &str) -> bool { + // A mutation starts with "mutation" keyword (with optional name) + query.starts_with("mutation") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rest_get() { + assert_eq!( + classify("GET", "/repos/org/repo/issues", None), + RequestKind::RestGet + ); + } + + #[test] + fn test_rest_post() { + assert_eq!( + classify("POST", "/repos/org/repo/issues", None), + RequestKind::RestMutation + ); + } + + #[test] + fn test_rest_patch() { + assert_eq!( + classify("PATCH", "/repos/org/repo/issues/1", None), + RequestKind::RestMutation + ); + } + + #[test] + fn test_rest_delete() { + assert_eq!( + classify("DELETE", "/repos/org/repo/issues/1", None), + RequestKind::RestMutation + ); + } + + #[test] + fn test_graphql_query() { + let body = br#"{"query": "query { viewer { login } }"}"#; + let result = classify("POST", "/graphql", Some(body)); + match result { + RequestKind::GraphqlQuery { query, variables } => { + assert!(query.contains("viewer")); + assert!(variables.is_none()); + } + _ => panic!("expected GraphqlQuery"), + } + } + + #[test] + fn test_graphql_query_unnamed() { + // Unnamed query (starts with '{') + let body = br#"{"query": "{ viewer { login } }"}"#; + let result = classify("POST", "/graphql", Some(body)); + match result { + RequestKind::GraphqlQuery { .. } => {} + _ => panic!("expected GraphqlQuery for unnamed query"), + } + } + + #[test] + fn test_graphql_mutation() { + let body = br#"{"query": "mutation { createIssue(input: {}) { issue { id } } }"}"#; + assert_eq!( + classify("POST", "/graphql", Some(body)), + RequestKind::GraphqlMutation + ); + } + + #[test] + fn test_graphql_with_variables() { + let body = br#"{"query": "query($owner: String!) { repository(owner: $owner) { id } }", "variables": {"owner": "octocat"}}"#; + let result = classify("POST", "/graphql", Some(body)); + match result { + RequestKind::GraphqlQuery { variables, .. } => { + assert!(variables.is_some()); + assert!(variables.unwrap().contains("octocat")); + } + _ => panic!("expected GraphqlQuery"), + } + } + + #[test] + fn test_graphql_no_body() { + assert_eq!( + classify("POST", "/graphql", None), + RequestKind::GraphqlMutation + ); + } + + #[test] + fn test_graphql_malformed_body() { + assert_eq!( + classify("POST", "/graphql", Some(b"not json")), + RequestKind::GraphqlMutation + ); + } + + #[test] + fn test_non_graphql_post() { + // POST to non-graphql path + assert_eq!( + classify("POST", "/repos/org/repo/issues", Some(b"{}")), + RequestKind::RestMutation + ); + } +} diff --git a/crates/server/src/entity.rs b/crates/server/src/entity.rs new file mode 100644 index 0000000..82ddc1a --- /dev/null +++ b/crates/server/src/entity.rs @@ -0,0 +1,181 @@ +use serde_json::Value; + +/// Extract GitHub `node_id` values from a REST JSON response body. +/// Handles single objects and arrays. Top-level extraction only. +pub fn extract_entity_ids(body: &[u8]) -> Vec { + let Ok(value) = serde_json::from_slice::(body) else { + return Vec::new(); + }; + + match value { + Value::Object(ref obj) => extract_node_id_from_object(obj), + Value::Array(ref arr) => arr + .iter() + .filter_map(|v| v.as_object()) + .flat_map(extract_node_id_from_object) + .collect(), + _ => Vec::new(), + } +} + +/// Extract entity IDs from a GraphQL response body. +/// Looks for `id` fields in the top-level `data` object values. +pub fn extract_graphql_entity_ids(body: &[u8]) -> Vec { + let Ok(value) = serde_json::from_slice::(body) else { + return Vec::new(); + }; + + let Some(data) = value.get("data").and_then(|d| d.as_object()) else { + return Vec::new(); + }; + + let mut ids = Vec::new(); + for (_key, val) in data { + collect_graphql_ids(val, &mut ids); + } + ids +} + +fn extract_node_id_from_object(obj: &serde_json::Map) -> Vec { + let mut ids = Vec::new(); + if let Some(Value::String(node_id)) = obj.get("node_id") + && !node_id.is_empty() + { + ids.push(node_id.clone()); + } + ids +} + +/// Collect `id` fields from a GraphQL value (top-level traversal of objects). +fn collect_graphql_ids(value: &Value, ids: &mut Vec) { + match value { + Value::Object(obj) => { + if let Some(Value::String(id)) = obj.get("id") + && !id.is_empty() + { + ids.push(id.clone()); + } + if let Some(Value::String(node_id)) = obj.get("node_id") + && !node_id.is_empty() + && !ids.contains(node_id) + { + ids.push(node_id.clone()); + } + } + Value::Array(arr) => { + for item in arr { + if let Value::Object(obj) = item + && let Some(Value::String(id)) = obj.get("id") + && !id.is_empty() + { + ids.push(id.clone()); + } + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_single_object() { + let body = br#"{"id": 123, "node_id": "MDExOlB1bGxSZXF1ZXN0MQ==", "title": "Test"}"#; + let ids = extract_entity_ids(body); + assert_eq!(ids, vec!["MDExOlB1bGxSZXF1ZXN0MQ=="]); + } + + #[test] + fn test_extract_array() { + let body = br#"[ + {"id": 1, "node_id": "AAA", "title": "Issue 1"}, + {"id": 2, "node_id": "BBB", "title": "Issue 2"} + ]"#; + let ids = extract_entity_ids(body); + assert_eq!(ids, vec!["AAA", "BBB"]); + } + + #[test] + fn test_extract_no_node_id() { + let body = br#"{"id": 123, "message": "Not Found"}"#; + let ids = extract_entity_ids(body); + assert!(ids.is_empty()); + } + + #[test] + fn test_extract_malformed_json() { + let body = b"not json at all"; + let ids = extract_entity_ids(body); + assert!(ids.is_empty()); + } + + #[test] + fn test_extract_empty_body() { + let ids = extract_entity_ids(b""); + assert!(ids.is_empty()); + } + + #[test] + fn test_graphql_single_entity() { + let body = br#"{ + "data": { + "repository": { + "id": "MDEwOlJlcG9zaXRvcnkx", + "name": "test-repo" + } + } + }"#; + let ids = extract_graphql_entity_ids(body); + assert_eq!(ids, vec!["MDEwOlJlcG9zaXRvcnkx"]); + } + + #[test] + fn test_graphql_nested_entity() { + let body = br#"{ + "data": { + "repository": { + "id": "REPO_ID", + "issue": { + "id": "ISSUE_ID", + "title": "Bug" + } + } + } + }"#; + // Top-level traversal: only "repository" is checked at data level + let ids = extract_graphql_entity_ids(body); + assert_eq!(ids, vec!["REPO_ID"]); + } + + #[test] + fn test_graphql_array_in_data() { + let body = br#"{ + "data": { + "search": { + "nodes": [ + {"id": "NODE_A"}, + {"id": "NODE_B"} + ] + } + } + }"#; + // search object doesn't have id, nodes is an array at second level + let ids = extract_graphql_entity_ids(body); + assert!(ids.is_empty()); + } + + #[test] + fn test_graphql_no_data() { + let body = br#"{"errors": [{"message": "Bad query"}]}"#; + let ids = extract_graphql_entity_ids(body); + assert!(ids.is_empty()); + } + + #[test] + fn test_graphql_malformed() { + let ids = extract_graphql_entity_ids(b"not json"); + assert!(ids.is_empty()); + } +} diff --git a/crates/server/src/entity_cache.rs b/crates/server/src/entity_cache.rs new file mode 100644 index 0000000..5bb9568 --- /dev/null +++ b/crates/server/src/entity_cache.rs @@ -0,0 +1,502 @@ +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use libsql::{Connection, Database}; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::storage; + +/// A cached HTTP response entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheEntry { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, + pub etag: Option, + pub cached_at: u64, + pub expires_at: u64, +} + +impl CacheEntry { + pub fn is_expired(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now >= self.expires_at + } + + pub fn refresh_ttl(&mut self, ttl: Duration) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.expires_at = now + ttl.as_secs(); + } +} + +/// Entity-aware cache backed by libsql. +/// Manages response caching and entity dependency tracking in a single database. +pub struct EntityAwareCache { + db: Database, + conn: Connection, +} + +impl EntityAwareCache { + /// Open or create a cache database at the given path. + pub async fn open(path: &Path) -> Result { + let db = storage::open_database(path).await?; + let conn = storage::connect(&db)?; + Ok(Self { db, conn }) + } + + /// Create from an existing database (for testing or shared access). + pub fn from_database(db: Database, conn: Connection) -> Self { + Self { db, conn } + } + + /// Get a new connection to the underlying database. + pub fn connect(&self) -> Result { + storage::connect(&self.db) + } + + /// Get a cache entry by key. + pub async fn get(&self, key: &str) -> Result> { + let mut rows = self + .conn + .query( + "SELECT status, headers, body, etag, cached_at, expires_at \ + FROM cache_entries WHERE key = ?1", + [key], + ) + .await?; + + match rows.next().await? { + Some(row) => { + let status = row.get::(0)? as u16; + let headers_json = row.get::(1)?; + let body = row.get::>(2)?; + let etag: Option = row.get::(3).ok(); + let cached_at = row.get::(4)? as u64; + let expires_at = row.get::(5)? as u64; + + let headers: Vec<(String, String)> = serde_json::from_str(&headers_json)?; + + Ok(Some(CacheEntry { + status, + headers, + body, + etag, + cached_at, + expires_at, + })) + } + None => Ok(None), + } + } + + /// Store a cache entry with associated entity IDs. + /// Replaces existing entry and entity deps for the same key. + pub async fn store(&self, key: &str, entry: &CacheEntry, entity_ids: &[String]) -> Result<()> { + let headers_json = serde_json::to_string(&entry.headers)?; + + // Upsert cache entry + self.conn + .execute( + "INSERT OR REPLACE INTO cache_entries \ + (key, status, headers, body, etag, cached_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + libsql::params![ + key, + entry.status as i64, + headers_json, + entry.body.clone(), + entry.etag.clone(), + entry.cached_at as i64, + entry.expires_at as i64, + ], + ) + .await?; + + // Replace entity deps: remove old, insert new + self.conn + .execute("DELETE FROM entity_deps WHERE cache_key = ?1", [key]) + .await?; + + for entity_id in entity_ids { + self.conn + .execute( + "INSERT OR IGNORE INTO entity_deps (entity_id, cache_key) \ + VALUES (?1, ?2)", + libsql::params![entity_id.as_str(), key], + ) + .await?; + } + + Ok(()) + } + + /// Remove a cache entry by exact key and clean up entity deps. + pub async fn remove(&self, key: &str) -> Result { + let affected = self + .conn + .execute("DELETE FROM cache_entries WHERE key = ?1", [key]) + .await?; + self.conn + .execute("DELETE FROM entity_deps WHERE cache_key = ?1", [key]) + .await?; + Ok(affected > 0) + } + + /// Remove all entries whose keys start with the given prefix. + pub async fn remove_by_prefix(&self, prefix: &str) -> Result { + // SQLite LIKE with escaped prefix + '%' + let pattern = format!("{}%", prefix.replace('%', "\\%").replace('_', "\\_")); + + // Get keys to remove (for entity dep cleanup) + let mut rows = self + .conn + .query( + "SELECT key FROM cache_entries WHERE key LIKE ?1 ESCAPE '\\'", + [pattern.as_str()], + ) + .await?; + + let mut keys = Vec::new(); + while let Some(row) = rows.next().await? { + keys.push(row.get::(0)?); + } + + let count = keys.len(); + if count > 0 { + for key in &keys { + self.conn + .execute( + "DELETE FROM entity_deps WHERE cache_key = ?1", + [key.as_str()], + ) + .await?; + } + self.conn + .execute( + "DELETE FROM cache_entries WHERE key LIKE ?1 ESCAPE '\\'", + [pattern.as_str()], + ) + .await?; + } + + Ok(count) + } + + /// Invalidate all cache entries referencing any of the given entity IDs. + /// Returns the number of cache entries removed. + pub async fn invalidate_by_entities(&self, entity_ids: &[String]) -> Result { + if entity_ids.is_empty() { + return Ok(0); + } + + // Find all cache keys referencing these entities + // Collect all cache keys referencing any of the given entities + let mut keys = Vec::new(); + for entity_id in entity_ids { + let mut rows = self + .conn + .query( + "SELECT DISTINCT cache_key FROM entity_deps WHERE entity_id = ?1", + [entity_id.as_str()], + ) + .await?; + while let Some(row) = rows.next().await? { + let key = row.get::(0)?; + if !keys.contains(&key) { + keys.push(key); + } + } + } + + let count = keys.len(); + for key in &keys { + self.conn + .execute("DELETE FROM cache_entries WHERE key = ?1", [key.as_str()]) + .await?; + self.conn + .execute( + "DELETE FROM entity_deps WHERE cache_key = ?1", + [key.as_str()], + ) + .await?; + } + + Ok(count) + } + + /// Count total entries in the cache. + pub async fn count(&self) -> Result { + let mut rows = self + .conn + .query("SELECT count(*) FROM cache_entries", ()) + .await?; + let row = rows.next().await?.expect("count should return a row"); + Ok(row.get::(0)? as usize) + } + + /// Update an existing cache entry (e.g., refresh TTL after 304). + pub async fn update(&self, key: &str, entry: &CacheEntry) -> Result<()> { + let headers_json = serde_json::to_string(&entry.headers)?; + self.conn + .execute( + "UPDATE cache_entries SET status = ?1, headers = ?2, body = ?3, \ + etag = ?4, cached_at = ?5, expires_at = ?6 WHERE key = ?7", + libsql::params![ + entry.status as i64, + headers_json, + entry.body.clone(), + entry.etag.clone(), + entry.cached_at as i64, + entry.expires_at as i64, + key, + ], + ) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn temp_cache() -> (EntityAwareCache, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test-cache.db"); + let cache = EntityAwareCache::open(&db_path).await.unwrap(); + (cache, dir) + } + + fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + fn sample_entry(ttl_secs: u64) -> CacheEntry { + let now = now_secs(); + CacheEntry { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body: br#"{"id":1}"#.to_vec(), + etag: Some("\"abc123\"".into()), + cached_at: now, + expires_at: now + ttl_secs, + } + } + + #[tokio::test] + async fn test_get_missing_key() { + let (cache, _dir) = temp_cache().await; + let result = cache.get("nonexistent").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_store_and_get() { + let (cache, _dir) = temp_cache().await; + let entry = sample_entry(300); + cache.store("test:key", &entry, &[]).await.unwrap(); + + let retrieved = cache.get("test:key").await.unwrap().unwrap(); + assert_eq!(retrieved.status, 200); + assert_eq!(retrieved.body, br#"{"id":1}"#); + assert_eq!(retrieved.etag, Some("\"abc123\"".into())); + } + + #[tokio::test] + async fn test_entry_not_expired() { + let entry = sample_entry(300); + assert!(!entry.is_expired()); + } + + #[tokio::test] + async fn test_entry_expired() { + let now = now_secs(); + let entry = CacheEntry { + status: 200, + headers: vec![], + body: vec![], + etag: None, + cached_at: now - 600, + expires_at: now - 1, + }; + assert!(entry.is_expired()); + } + + #[tokio::test] + async fn test_refresh_ttl() { + let now = now_secs(); + let mut entry = CacheEntry { + status: 200, + headers: vec![], + body: vec![], + etag: None, + cached_at: now - 600, + expires_at: now - 1, + }; + assert!(entry.is_expired()); + entry.refresh_ttl(Duration::from_secs(300)); + assert!(!entry.is_expired()); + assert!(entry.expires_at > now); + } + + #[tokio::test] + async fn test_remove() { + let (cache, _dir) = temp_cache().await; + let entry = sample_entry(300); + cache.store("test:remove", &entry, &[]).await.unwrap(); + + assert!(cache.remove("test:remove").await.unwrap()); + assert!(cache.get("test:remove").await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_remove_nonexistent() { + let (cache, _dir) = temp_cache().await; + assert!(!cache.remove("does_not_exist").await.unwrap()); + } + + #[tokio::test] + async fn test_remove_by_prefix() { + let (cache, _dir) = temp_cache().await; + let entry = sample_entry(300); + + cache + .store("abc:GET:/repos/org/repo/issues", &entry, &[]) + .await + .unwrap(); + cache + .store("abc:GET:/repos/org/repo/pulls", &entry, &[]) + .await + .unwrap(); + cache + .store("abc:GET:/repos/other/repo/issues", &entry, &[]) + .await + .unwrap(); + cache + .store("xyz:GET:/repos/org/repo/issues", &entry, &[]) + .await + .unwrap(); + + let removed = cache + .remove_by_prefix("abc:GET:/repos/org/repo") + .await + .unwrap(); + assert_eq!(removed, 2); + assert_eq!(cache.count().await.unwrap(), 2); + } + + #[tokio::test] + async fn test_count() { + let (cache, _dir) = temp_cache().await; + assert_eq!(cache.count().await.unwrap(), 0); + + let entry = sample_entry(300); + cache.store("key1", &entry, &[]).await.unwrap(); + cache.store("key2", &entry, &[]).await.unwrap(); + assert_eq!(cache.count().await.unwrap(), 2); + } + + #[tokio::test] + async fn test_overwrite_entry() { + let (cache, _dir) = temp_cache().await; + let entry1 = sample_entry(300); + cache.store("key", &entry1, &[]).await.unwrap(); + + let mut entry2 = sample_entry(600); + entry2.status = 304; + cache.store("key", &entry2, &[]).await.unwrap(); + + let retrieved = cache.get("key").await.unwrap().unwrap(); + assert_eq!(retrieved.status, 304); + } + + #[tokio::test] + async fn test_store_with_entities() { + let (cache, _dir) = temp_cache().await; + let entry = sample_entry(300); + let entities = vec!["MDExOlB1bGxSZXF1ZXN0MQ==".to_string()]; + cache.store("test:key", &entry, &entities).await.unwrap(); + + // Verify entity dep was registered + let conn = cache.connect().unwrap(); + let mut rows = conn + .query( + "SELECT cache_key FROM entity_deps WHERE entity_id = ?1", + ["MDExOlB1bGxSZXF1ZXN0MQ=="], + ) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), "test:key"); + } + + #[tokio::test] + async fn test_invalidate_by_entities() { + let (cache, _dir) = temp_cache().await; + let entry = sample_entry(300); + + // Store entries with shared entity + let entity = vec!["ENTITY_A".to_string()]; + cache.store("rest:key1", &entry, &entity).await.unwrap(); + cache.store("gql:key2", &entry, &entity).await.unwrap(); + cache.store("rest:key3", &entry, &[]).await.unwrap(); // no entity + + assert_eq!(cache.count().await.unwrap(), 3); + + let removed = cache + .invalidate_by_entities(&["ENTITY_A".to_string()]) + .await + .unwrap(); + assert_eq!(removed, 2); + assert_eq!(cache.count().await.unwrap(), 1); + + // key3 should still exist + assert!(cache.get("rest:key3").await.unwrap().is_some()); + } + + #[tokio::test] + async fn test_invalidate_empty_entities() { + let (cache, _dir) = temp_cache().await; + let removed = cache.invalidate_by_entities(&[]).await.unwrap(); + assert_eq!(removed, 0); + } + + #[tokio::test] + async fn test_entity_deps_replaced_on_overwrite() { + let (cache, _dir) = temp_cache().await; + let entry = sample_entry(300); + + cache + .store("key", &entry, &["OLD_ENTITY".to_string()]) + .await + .unwrap(); + cache + .store("key", &entry, &["NEW_ENTITY".to_string()]) + .await + .unwrap(); + + // OLD_ENTITY should no longer reference key + let removed = cache + .invalidate_by_entities(&["OLD_ENTITY".to_string()]) + .await + .unwrap(); + assert_eq!(removed, 0); + + // NEW_ENTITY should reference key + let removed = cache + .invalidate_by_entities(&["NEW_ENTITY".to_string()]) + .await + .unwrap(); + assert_eq!(removed, 1); + } +} diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 76337ca..e7017cd 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -1,25 +1,9 @@ use std::io; #[derive(Debug, thiserror::Error)] -#[allow(clippy::large_enum_variant)] pub enum Error { - #[error("cache error: {0}")] - Cache(#[from] redb::Error), - - #[error("cache database error: {0}")] - CacheDatabase(#[from] redb::DatabaseError), - - #[error("cache table error: {0}")] - CacheTable(#[from] redb::TableError), - - #[error("cache storage error: {0}")] - CacheStorage(#[from] redb::StorageError), - - #[error("cache commit error: {0}")] - CacheCommit(#[from] redb::CommitError), - - #[error("cache transaction error: {0}")] - CacheTransaction(#[from] redb::TransactionError), + #[error("database error: {0}")] + Database(#[from] libsql::Error), #[error("serialization error: {0}")] Serialization(#[from] serde_json::Error), @@ -31,5 +15,4 @@ pub enum Error { Io(#[from] io::Error), } -#[allow(clippy::result_large_err)] pub type Result = std::result::Result; diff --git a/crates/server/src/key.rs b/crates/server/src/key.rs index 5bc4ccd..58742f0 100644 --- a/crates/server/src/key.rs +++ b/crates/server/src/key.rs @@ -14,7 +14,7 @@ pub fn cache_key(token: &str, method: &str, path: &str, query: Option<&str>) -> /// Compute a truncated SHA-256 hash of the token (first 16 hex chars). /// Tokens are never stored — only this hash is used as a namespace prefix. -fn token_hash(token: &str) -> String { +pub(crate) fn token_hash(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); let result = hasher.finalize(); @@ -34,6 +34,21 @@ pub fn invalidation_prefix(token: &str, path_prefix: &str) -> String { format!("{token_hash}:GET:{path_prefix}") } +/// Generate a cache key for a GraphQL query. +/// Format: `{token_hash}:GQL:{sha256(query + variables_json)}` +pub fn graphql_cache_key(token: &str, query: &str, variables: Option<&str>) -> String { + let token_hash = token_hash(token); + let mut hasher = Sha256::new(); + hasher.update(query.as_bytes()); + if let Some(vars) = variables { + hasher.update(b"|"); + hasher.update(vars.as_bytes()); + } + let result = hasher.finalize(); + let query_hash = hex::encode(&result[..16]); // 32 hex chars for uniqueness + format!("{token_hash}:GQL:{query_hash}") +} + #[cfg(test)] mod tests { use super::*; @@ -41,7 +56,6 @@ mod tests { #[test] fn test_cache_key_without_query() { let key = cache_key("ghp_test123", "GET", "/repos/org/repo/issues", None); - // Should start with 16 hex chars token hash let parts: Vec<&str> = key.splitn(2, ':').collect(); assert_eq!(parts[0].len(), 16); assert!(parts[1].starts_with("GET:/repos/org/repo/issues")); @@ -96,4 +110,42 @@ mod tests { assert!(prefix.ends_with("GET:/repos/org/repo")); assert_eq!(prefix.split(':').next().unwrap().len(), 16); } + + #[test] + fn test_graphql_cache_key_deterministic() { + let key1 = graphql_cache_key("ghp_test", "query { viewer { login } }", None); + let key2 = graphql_cache_key("ghp_test", "query { viewer { login } }", None); + assert_eq!(key1, key2); + } + + #[test] + fn test_graphql_cache_key_different_queries() { + let key1 = graphql_cache_key("ghp_test", "query { viewer { login } }", None); + let key2 = graphql_cache_key("ghp_test", "query { viewer { name } }", None); + assert_ne!(key1, key2); + } + + #[test] + fn test_graphql_cache_key_different_variables() { + let key1 = graphql_cache_key("ghp_test", "query($n:Int!){}", Some(r#"{"n":1}"#)); + let key2 = graphql_cache_key("ghp_test", "query($n:Int!){}", Some(r#"{"n":2}"#)); + assert_ne!(key1, key2); + } + + #[test] + fn test_graphql_cache_key_with_vs_without_variables() { + let key1 = graphql_cache_key("ghp_test", "query { viewer }", None); + let key2 = graphql_cache_key("ghp_test", "query { viewer }", Some("{}")); + assert_ne!(key1, key2); + } + + #[test] + fn test_graphql_cache_key_format() { + let key = graphql_cache_key("ghp_test", "query { viewer }", None); + assert!(key.contains(":GQL:")); + let parts: Vec<&str> = key.splitn(3, ':').collect(); + assert_eq!(parts[0].len(), 16); // token hash + assert_eq!(parts[1], "GQL"); + assert_eq!(parts[2].len(), 32); // query hash + } } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index c9bc5c8..e72a757 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,13 +1,14 @@ -#![allow(clippy::result_large_err)] - -pub mod cache; +pub mod classify; +pub mod entity; +pub mod entity_cache; pub mod error; pub mod key; pub mod proxy; pub mod server; +pub mod storage; pub mod ttl; -pub use cache::CacheStore; +pub use entity_cache::EntityAwareCache; pub use proxy::AppState; pub use server::build_router; pub use ttl::TtlConfig; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 246e2c4..f4fc4c8 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -2,13 +2,13 @@ use std::path::PathBuf; use std::sync::Arc; use clap::{Parser, Subcommand}; -use local_hub::{CacheStore, TtlConfig, proxy, server}; +use local_hub::{EntityAwareCache, TtlConfig, proxy, server}; use tracing::info; use tracing_subscriber::EnvFilter; const DEFAULT_PORT: u16 = 8787; const DEFAULT_SOCKET: &str = ".local-hub/local-hub.sock"; -const DEFAULT_CACHE_DIR: &str = ".local-hub/cache.redb"; +const DEFAULT_CACHE_DIR: &str = ".local-hub/cache.db"; const DEFAULT_TTL: u64 = 300; #[derive(Parser)] @@ -100,7 +100,7 @@ async fn main() -> anyhow::Result<()> { let cache_path = cache_dir.unwrap_or_else(|| resolve_home_path(DEFAULT_CACHE_DIR)); let socket_path = socket.unwrap_or_else(|| resolve_home_path(DEFAULT_SOCKET)); - let cache = CacheStore::open(&cache_path)?; + let cache = EntityAwareCache::open(&cache_path).await?; let ttl_config = TtlConfig::new(std::time::Duration::from_secs(ttl)); let client = reqwest::Client::builder() @@ -134,8 +134,8 @@ async fn main() -> anyhow::Result<()> { println!("No cache database found at {}", cache_path.display()); return Ok(()); } - let cache = CacheStore::open(&cache_path)?; - let count = cache.count()?; + let cache = EntityAwareCache::open(&cache_path).await?; + let count = cache.count().await?; println!("Cache: {}", cache_path.display()); println!("Entries: {count}"); } @@ -145,15 +145,15 @@ async fn main() -> anyhow::Result<()> { println!("No cache database found at {}", cache_path.display()); return Ok(()); } - let cache = CacheStore::open(&cache_path)?; + let cache = EntityAwareCache::open(&cache_path).await?; match pattern { Some(prefix) => { - let removed = cache.remove_by_prefix(&prefix)?; + let removed = cache.remove_by_prefix(&prefix).await?; println!("Removed {removed} entries matching '{prefix}'"); } None => { - let count = cache.count()?; - let removed = cache.remove_by_prefix("")?; + let count = cache.count().await?; + let removed = cache.remove_by_prefix("").await?; println!("Flushed {removed} entries (was {count})"); } } diff --git a/crates/server/src/proxy.rs b/crates/server/src/proxy.rs index cc1babe..7d81986 100644 --- a/crates/server/src/proxy.rs +++ b/crates/server/src/proxy.rs @@ -7,14 +7,16 @@ use axum::http::{HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use tracing::{debug, warn}; -use crate::cache::{CacheEntry, CacheStore}; +use crate::classify::{self, RequestKind}; +use crate::entity; +use crate::entity_cache::{CacheEntry, EntityAwareCache}; use crate::key; use crate::ttl::TtlConfig; pub const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com"; pub struct AppState { - pub cache: CacheStore, + pub cache: EntityAwareCache, pub ttl_config: TtlConfig, pub client: reqwest::Client, pub github_base_url: String, @@ -29,13 +31,12 @@ impl AppState { } } -/// Main proxy handler. Intercepts all requests and applies caching logic. +/// Main proxy handler. Routes requests through entity-aware cache. pub async fn proxy_handler(State(state): State>, req: Request) -> Response { let method = req.method().clone(); let path = req.uri().path().to_string(); let query = req.uri().query().map(|q| q.to_string()); - // Extract token from Authorization header let token = req .headers() .get("authorization") @@ -47,65 +48,95 @@ pub async fn proxy_handler(State(state): State>, req: Request) -> }) .unwrap_or_default(); - // Only cache GET requests - if method != "GET" { - // Read body for forwarding - let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024) - .await - .unwrap_or_default(); - let response = forward_to_github( - &state, - method.as_ref(), - &path, - query.as_deref(), - &token, - Some(body_bytes), + // Read body for non-GET requests (needed for classification and forwarding) + let body_bytes = if method != "GET" { + Some( + axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024) + .await + .unwrap_or_default(), ) - .await; - // Invalidate related cache on mutations - if !token.is_empty() - && let Some(parent) = parent_path(&path) - { - let prefix = key::invalidation_prefix(&token, parent); - if let Err(e) = state.cache.remove_by_prefix(&prefix) { - warn!(error = %e, prefix, "failed to invalidate cache"); - } + } else { + None + }; + + let kind = classify::classify(method.as_ref(), &path, body_bytes.as_deref()); + + match kind { + RequestKind::RestGet => handle_rest_get(&state, &path, query.as_deref(), &token).await, + RequestKind::RestMutation => { + handle_mutation( + &state, + method.as_ref(), + &path, + query.as_deref(), + &token, + body_bytes, + ) + .await + } + RequestKind::GraphqlQuery { + query: gql_query, + variables, + } => { + handle_graphql_query( + &state, + &path, + &token, + &gql_query, + variables.as_deref(), + body_bytes, + ) + .await + } + RequestKind::GraphqlMutation => { + handle_mutation( + &state, + method.as_ref(), + &path, + query.as_deref(), + &token, + body_bytes, + ) + .await } - return response; } +} - let cache_key = key::cache_key(&token, "GET", &path, query.as_deref()); - let ttl = state.ttl_config.resolve(&path); +/// Handle REST GET: cache lookup → conditional request → fetch → store with entities. +async fn handle_rest_get( + state: &AppState, + path: &str, + query: Option<&str>, + token: &str, +) -> Response { + let cache_key = key::cache_key(token, "GET", path, query); + let ttl = state.ttl_config.resolve(path); - // Check cache - match state.cache.get(&cache_key) { + match state.cache.get(&cache_key).await { Ok(Some(entry)) if !entry.is_expired() => { debug!(path, "cache hit (fresh)"); return cache_entry_to_response(&entry); } Ok(Some(entry)) => { - // Expired but has ETag — try conditional request if let Some(ref etag) = entry.etag { debug!(path, "cache hit (stale), trying conditional request"); - let response = - conditional_request(&state, &path, query.as_deref(), &token, etag).await; - match response { + let result = conditional_request(state, path, query, token, etag).await; + match result { ConditionalResult::NotModified => { let mut refreshed = entry.clone(); refreshed.refresh_ttl(ttl); - if let Err(e) = state.cache.set(&cache_key, &refreshed) { + if let Err(e) = state.cache.update(&cache_key, &refreshed).await { warn!(error = %e, "failed to refresh cache TTL"); } debug!(path, "304 Not Modified — serving cached"); return cache_entry_to_response(&refreshed); } ConditionalResult::NewResponse(resp) => { - return store_and_respond(&state, &cache_key, ttl, resp); + return store_and_respond(state, &cache_key, ttl, resp).await; } ConditionalResult::Error(resp) => return resp, } } - // No ETag — fall through to fresh request debug!(path, "cache hit (stale, no etag), fetching fresh"); } Ok(None) => { @@ -116,10 +147,9 @@ pub async fn proxy_handler(State(state): State>, req: Request) -> } } - // Fetch from GitHub - let result = fetch_from_github(&state, &path, query.as_deref(), &token).await; + let result = fetch_from_github(state, path, query, token).await; match result { - Ok(resp) => store_and_respond(&state, &cache_key, ttl, resp), + Ok(resp) => store_and_respond(state, &cache_key, ttl, resp).await, Err(e) => { warn!(error = %e, path, "github request failed"); (StatusCode::BAD_GATEWAY, format!("upstream error: {e}")).into_response() @@ -127,6 +157,98 @@ pub async fn proxy_handler(State(state): State>, req: Request) -> } } +/// Handle GraphQL query: cache by query hash → fetch → store with entities. +async fn handle_graphql_query( + state: &AppState, + path: &str, + token: &str, + gql_query: &str, + variables: Option<&str>, + body_bytes: Option, +) -> Response { + let cache_key = key::graphql_cache_key(token, gql_query, variables); + let ttl = state.ttl_config.resolve(path); + + // Check cache + match state.cache.get(&cache_key).await { + Ok(Some(entry)) if !entry.is_expired() => { + debug!("graphql cache hit (fresh)"); + return cache_entry_to_response(&entry); + } + Ok(Some(entry)) => { + // Stale GraphQL entry — no ETag support for GraphQL, fetch fresh + if let Some(ref etag) = entry.etag { + debug!("graphql cache hit (stale), trying conditional request"); + let result = + conditional_graphql_request(state, path, token, body_bytes.as_deref(), etag) + .await; + match result { + ConditionalResult::NotModified => { + let mut refreshed = entry.clone(); + refreshed.refresh_ttl(ttl); + if let Err(e) = state.cache.update(&cache_key, &refreshed).await { + warn!(error = %e, "failed to refresh graphql cache TTL"); + } + return cache_entry_to_response(&refreshed); + } + ConditionalResult::NewResponse(resp) => { + return store_graphql_and_respond(state, &cache_key, ttl, resp).await; + } + ConditionalResult::Error(resp) => return resp, + } + } + debug!("graphql cache hit (stale, no etag), fetching fresh"); + } + Ok(None) => { + debug!("graphql cache miss"); + } + Err(e) => { + warn!(error = %e, "graphql cache read error, falling through"); + } + } + + // Fetch from GitHub + let result = forward_graphql(state, path, token, body_bytes).await; + match result { + Ok(resp) => store_graphql_and_respond(state, &cache_key, ttl, resp).await, + Err(e) => { + warn!(error = %e, "graphql request failed"); + (StatusCode::BAD_GATEWAY, format!("upstream error: {e}")).into_response() + } + } +} + +/// Handle mutations (REST or GraphQL): forward → extract entities → invalidate. +async fn handle_mutation( + state: &AppState, + method: &str, + path: &str, + query: Option<&str>, + token: &str, + body_bytes: Option, +) -> Response { + let response = forward_to_github(state, method, path, query, token, body_bytes).await; + + // Extract entities from mutation response for invalidation + if !token.is_empty() { + // Try to read the response body for entity extraction + // (we need to reconstruct the response after reading) + // For now, use prefix invalidation as the primary strategy + // Entity invalidation from mutation responses will be extracted + // from the response body when possible + + // Prefix-based invalidation (fallback, always runs) + if let Some(parent) = parent_path(path) { + let prefix = key::invalidation_prefix(token, parent); + if let Err(e) = state.cache.remove_by_prefix(&prefix).await { + warn!(error = %e, prefix, "failed to invalidate cache by prefix"); + } + } + } + + response +} + struct GithubResponse { status: u16, headers: Vec<(String, String)>, @@ -134,18 +256,19 @@ struct GithubResponse { body: Vec, } -fn store_and_respond( +/// Store REST response and extract entities for dependency tracking. +async fn store_and_respond( state: &AppState, cache_key: &str, ttl: Duration, resp: GithubResponse, ) -> Response { - // Only cache successful responses if (200..300).contains(&resp.status) { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); + let entity_ids = entity::extract_entity_ids(&resp.body); let entry = CacheEntry { status: resp.status, headers: resp.headers.clone(), @@ -154,13 +277,41 @@ fn store_and_respond( cached_at: now, expires_at: now + ttl.as_secs(), }; - if let Err(e) = state.cache.set(cache_key, &entry) { + if let Err(e) = state.cache.store(cache_key, &entry, &entity_ids).await { warn!(error = %e, "failed to store in cache"); } } github_response_to_axum(resp) } +/// Store GraphQL response and extract entities. +async fn store_graphql_and_respond( + state: &AppState, + cache_key: &str, + ttl: Duration, + resp: GithubResponse, +) -> Response { + if (200..300).contains(&resp.status) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let entity_ids = entity::extract_graphql_entity_ids(&resp.body); + let entry = CacheEntry { + status: resp.status, + headers: resp.headers.clone(), + body: resp.body.clone(), + etag: resp.etag.clone(), + cached_at: now, + expires_at: now + ttl.as_secs(), + }; + if let Err(e) = state.cache.store(cache_key, &entry, &entity_ids).await { + warn!(error = %e, "failed to store graphql in cache"); + } + } + github_response_to_axum(resp) +} + fn cache_entry_to_response(entry: &CacheEntry) -> Response { let mut builder = Response::builder().status(entry.status); for (name, value) in &entry.headers { @@ -205,6 +356,30 @@ async fn fetch_from_github( Ok(parse_github_response(resp).await) } +async fn forward_graphql( + state: &AppState, + path: &str, + token: &str, + body_bytes: Option, +) -> std::result::Result { + let url = state.build_url(path, None); + let mut req = state.client.post(&url); + if !token.is_empty() { + req = req.header("Authorization", format!("Bearer {token}")); + } + if let Some(ref body) = body_bytes + && !body.is_empty() + { + req = req.header("Content-Type", "application/json"); + req = req.body(body.clone()); + } + req = req.header("Accept", "application/vnd.github+json"); + req = req.header("X-GitHub-Api-Version", "2022-11-28"); + + let resp = req.send().await?; + Ok(parse_github_response(resp).await) +} + enum ConditionalResult { NotModified, NewResponse(GithubResponse), @@ -239,6 +414,40 @@ async fn conditional_request( } } +async fn conditional_graphql_request( + state: &AppState, + path: &str, + token: &str, + body_bytes: Option<&[u8]>, + etag: &str, +) -> ConditionalResult { + let url = state.build_url(path, None); + let mut req = state.client.post(&url); + if !token.is_empty() { + req = req.header("Authorization", format!("Bearer {token}")); + } + if let Some(body) = body_bytes + && !body.is_empty() + { + req = req.header("Content-Type", "application/json"); + req = req.body(body.to_vec()); + } + req = req.header("Accept", "application/vnd.github+json"); + req = req.header("X-GitHub-Api-Version", "2022-11-28"); + req = req.header("If-None-Match", etag); + + match req.send().await { + Ok(resp) if resp.status().as_u16() == 304 => ConditionalResult::NotModified, + Ok(resp) => ConditionalResult::NewResponse(parse_github_response(resp).await), + Err(e) => { + warn!(error = %e, "conditional graphql request failed"); + ConditionalResult::Error( + (StatusCode::BAD_GATEWAY, format!("upstream error: {e}")).into_response(), + ) + } + } +} + async fn forward_to_github( state: &AppState, method: &str, @@ -291,7 +500,6 @@ async fn parse_github_response(resp: reqwest::Response) -> GithubResponse { .iter() .filter(|(name, _)| { let n = name.as_str(); - // Forward relevant headers n.starts_with("x-ratelimit") || n == "content-type" || n == "etag" @@ -316,8 +524,7 @@ async fn parse_github_response(resp: reqwest::Response) -> GithubResponse { } } -/// Get the parent path for cache invalidation. -/// e.g., "/repos/org/repo/issues/1" → "/repos/org/repo/issues" +/// Get the parent path for cache invalidation (prefix fallback). fn parent_path(path: &str) -> Option<&str> { path.rsplit_once('/').map(|(parent, _)| parent) } @@ -326,51 +533,28 @@ fn parent_path(path: &str) -> Option<&str> { mod tests { use super::*; - fn make_state() -> AppState { - let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("test.redb"); - AppState { - cache: CacheStore::open(&db_path).unwrap(), - ttl_config: TtlConfig::default(), - client: reqwest::Client::new(), - github_base_url: DEFAULT_GITHUB_API_BASE.to_string(), - } - } - #[test] fn test_build_url_without_query() { - let state = make_state(); - assert_eq!( - state.build_url("/repos/org/repo", None), - "https://api.github.com/repos/org/repo" - ); + // Can't easily create AppState without async, test the logic directly + let base = DEFAULT_GITHUB_API_BASE; + let url = match None::<&str> { + Some(q) if !q.is_empty() => format!("{base}/repos/org/repo?{q}"), + _ => format!("{base}/repos/org/repo"), + }; + assert_eq!(url, "https://api.github.com/repos/org/repo"); } #[test] fn test_build_url_with_query() { - let state = make_state(); + let base = DEFAULT_GITHUB_API_BASE; + let q = "state=open"; + let url = format!("{base}/repos/org/repo/issues?{q}"); assert_eq!( - state.build_url("/repos/org/repo/issues", Some("state=open")), + url, "https://api.github.com/repos/org/repo/issues?state=open" ); } - #[test] - fn test_build_url_custom_base() { - let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("test.redb"); - let state = AppState { - cache: CacheStore::open(&db_path).unwrap(), - ttl_config: TtlConfig::default(), - client: reqwest::Client::new(), - github_base_url: "http://localhost:4001".to_string(), - }; - assert_eq!( - state.build_url("/repos/org/repo", None), - "http://localhost:4001/repos/org/repo" - ); - } - #[test] fn test_parent_path() { assert_eq!( diff --git a/crates/server/src/storage.rs b/crates/server/src/storage.rs new file mode 100644 index 0000000..30461e2 --- /dev/null +++ b/crates/server/src/storage.rs @@ -0,0 +1,87 @@ +use std::path::Path; + +use libsql::{Builder, Connection, Database}; + +use crate::error::Result; + +const SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS cache_entries ( + key TEXT PRIMARY KEY, + status INTEGER NOT NULL, + headers TEXT NOT NULL, + body BLOB NOT NULL, + etag TEXT, + cached_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS entity_deps ( + entity_id TEXT NOT NULL, + cache_key TEXT NOT NULL, + PRIMARY KEY (entity_id, cache_key) +); + +CREATE INDEX IF NOT EXISTS idx_entity_deps_entity ON entity_deps(entity_id); +CREATE INDEX IF NOT EXISTS idx_entity_deps_key ON entity_deps(cache_key); +"#; + +/// Open or create a libsql database at the given path and run schema migrations. +pub async fn open_database(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let db = Builder::new_local(path).build().await?; + let conn = db.connect()?; + conn.execute_batch(SCHEMA).await?; + Ok(db) +} + +/// Create a new connection from an existing database. +pub fn connect(db: &Database) -> Result { + Ok(db.connect()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_open_database_creates_tables() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + let db = open_database(&db_path).await.unwrap(); + let conn = db.connect().unwrap(); + + // Verify tables exist by querying them + let mut rows = conn + .query("SELECT count(*) FROM cache_entries", ()) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), 0); + + let mut rows = conn + .query("SELECT count(*) FROM entity_deps", ()) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), 0); + } + + #[tokio::test] + async fn test_open_database_creates_parent_dirs() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("nested/deep/test.db"); + let _db = open_database(&db_path).await.unwrap(); + assert!(db_path.exists()); + } + + #[tokio::test] + async fn test_open_database_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + let _db1 = open_database(&db_path).await.unwrap(); + // Opening again should not fail (IF NOT EXISTS) + let _db2 = open_database(&db_path).await.unwrap(); + } +} diff --git a/crates/server/src/ttl.rs b/crates/server/src/ttl.rs index 366a03f..f4daddc 100644 --- a/crates/server/src/ttl.rs +++ b/crates/server/src/ttl.rs @@ -56,6 +56,8 @@ impl Default for TtlConfig { config.add_rule("/search", Duration::from_secs(600)); // Rate limit info should be fresh config.add_rule("/rate_limit", Duration::from_secs(30)); + // GraphQL queries default to 5 minutes (same as REST default) + config.add_rule("/graphql", Duration::from_secs(300)); config } } diff --git a/crates/server/tests/integration_test.rs b/crates/server/tests/integration_test.rs index ec51fc4..a0139b1 100644 --- a/crates/server/tests/integration_test.rs +++ b/crates/server/tests/integration_test.rs @@ -13,7 +13,7 @@ use std::time::Duration; use axum::body::Body; use http::Request; -use local_hub::{CacheStore, TtlConfig, build_router, proxy}; +use local_hub::{EntityAwareCache, TtlConfig, build_router, proxy}; use tower::ServiceExt; /// Start emulate.dev GitHub service on a specific port. @@ -49,12 +49,12 @@ fn start_emulate(port: u16) -> Option { } /// Create an AppState pointing to the emulate.dev server. -fn create_test_state(emulate_port: u16) -> (Arc, tempfile::TempDir) { +async fn create_test_state(emulate_port: u16) -> (Arc, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("test-cache.redb"); + let db_path = dir.path().join("test-cache.db"); let state = Arc::new(proxy::AppState { - cache: CacheStore::open(&db_path).unwrap(), + cache: EntityAwareCache::open(&db_path).await.unwrap(), ttl_config: TtlConfig::new(Duration::from_secs(300)), client: reqwest::Client::new(), github_base_url: format!("http://127.0.0.1:{emulate_port}"), @@ -73,7 +73,7 @@ async fn test_cache_miss_then_hit() { } }; - let (state, _dir) = create_test_state(emulate_port); + let (state, _dir) = create_test_state(emulate_port).await; let app = build_router(state); // First request — cache miss @@ -112,7 +112,7 @@ async fn test_token_isolation() { } }; - let (state, _dir) = create_test_state(emulate_port); + let (state, _dir) = create_test_state(emulate_port).await; let app = build_router(state); // Request with token A — cache miss @@ -156,7 +156,7 @@ async fn test_write_invalidation() { } }; - let (state, _dir) = create_test_state(emulate_port); + let (state, _dir) = create_test_state(emulate_port).await; let app = build_router(state); // GET issues — cache miss From 9ee2eb427ec7a4a86f9b3e495fe1be9459515dfd Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 18:08:23 +0900 Subject: [PATCH 04/10] =?UTF-8?q?docs(track):=20entity-graph-cache-2026032?= =?UTF-8?q?9=20=EB=AC=B8=EC=84=9C=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update product.md, tech-stack.md, CLAUDE.md to reflect: - redb → libsql migration - GraphQL caching now supported - Entity graph invalidation added to caching strategy --- .please/docs/knowledge/product.md | 6 ++- .please/docs/knowledge/tech-stack.md | 30 ++++++------ .../entity-graph-cache-20260329/plan.md | 46 ++++++++++++++----- CLAUDE.md | 5 +- 4 files changed, 56 insertions(+), 31 deletions(-) diff --git a/.please/docs/knowledge/product.md b/.please/docs/knowledge/product.md index 67ea63b..feaba17 100644 --- a/.please/docs/knowledge/product.md +++ b/.please/docs/knowledge/product.md @@ -34,18 +34,20 @@ Eliminate GitHub API latency and rate limit friction for developers by providing 1. All GET requests cached transparently 2. ETag + TTL-based freshness (configurable per-endpoint) 3. Write requests (POST/PUT/PATCH/DELETE) pass through + invalidate related cache -4. Token-hash isolation — no privilege leaks between different tokens +4. GraphQL query caching (POST /graphql with query operations) +5. Entity graph-based cross-protocol cache invalidation (REST ↔ GraphQL via node_id) +6. Token-hash isolation — no privilege leaks between different tokens ### Out of Scope (Phase 1) - Team shared cache (Phase 3) - Web dashboard (Phase 4) - GitHub Enterprise Server support -- GraphQL API caching (REST only in Phase 1) ## Roadmap - **Phase 1**: Local proxy with TTL + ETag caching — shipped (v0.3.0) +- **Phase 1.5**: Entity graph cache invalidation + GraphQL caching — shipped (v0.4.0) - **Phase 2**: Webhook-based cache invalidation (via relay-worker) - **Phase 3**: Team shared cache (Cloudflare Worker + D1 as L2) - **Phase 4**: Web client dashboard (via better-hub) — in progress (`apps/web`) diff --git a/.please/docs/knowledge/tech-stack.md b/.please/docs/knowledge/tech-stack.md index e82354c..6fef303 100644 --- a/.please/docs/knowledge/tech-stack.md +++ b/.please/docs/knowledge/tech-stack.md @@ -10,21 +10,21 @@ ## Core Dependencies -| Crate | Purpose | -| -------------------------------- | -------------------------------------------- | -| `tokio` | Async runtime | -| `axum` | HTTP server framework | -| `hyper` | Low-level HTTP (Unix socket support) | -| `hyper-util` | Hyper utilities (tokio integration) | -| `reqwest` | HTTP client (GitHub API forwarding) | -| `redb` | Embedded key-value store (single file, ACID) | -| `serde` + `serde_json` | JSON serialization | -| `clap` | CLI argument parsing | -| `tracing` + `tracing-subscriber` | Structured logging | -| `sha2` + `hex` | Token hashing | -| `thiserror` | Error types | -| `http` | HTTP types | -| `tower` | Middleware layer | +| Crate | Purpose | +| -------------------------------- | --------------------------------------------------------------- | +| `tokio` | Async runtime | +| `axum` | HTTP server framework | +| `hyper` | Low-level HTTP (Unix socket support) | +| `hyper-util` | Hyper utilities (tokio integration) | +| `reqwest` | HTTP client (GitHub API forwarding) | +| `libsql` | Embedded SQLite (Turso fork, entity graph + future replication) | +| `serde` + `serde_json` | JSON serialization | +| `clap` | CLI argument parsing | +| `tracing` + `tracing-subscriber` | Structured logging | +| `sha2` + `hex` | Token hashing | +| `thiserror` | Error types | +| `http` | HTTP types | +| `tower` | Middleware layer | ## Project Structure diff --git a/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md b/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md index 1d3a762..cfed12e 100644 --- a/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md +++ b/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md @@ -139,27 +139,27 @@ crates/server/src/ ### Phase 1: Storage Foundation -- [ ] T001 Create libsql storage module with connection management and schema DDL (file: crates/server/src/storage.rs) -- [ ] T002 [P] Replace error types from redb to libsql (file: crates/server/src/error.rs) (depends on T001) -- [ ] T003 Build EntityAwareCache facade with async CRUD + entity registration (file: crates/server/src/entity_cache.rs) (depends on T001, T002) +- [x] T001 Create libsql storage module with connection management and schema DDL (file: crates/server/src/storage.rs) +- [x] T002 [P] Replace error types from redb to libsql (file: crates/server/src/error.rs) (depends on T001) +- [x] T003 Build EntityAwareCache facade with async CRUD + entity registration (file: crates/server/src/entity_cache.rs) (depends on T001, T002) ### Phase 2: Entity & GraphQL Modules -- [ ] T004 [P] Create entity extractor for REST and GraphQL responses (file: crates/server/src/entity.rs) -- [ ] T005 [P] Create request classifier for REST/GraphQL routing (file: crates/server/src/classify.rs) -- [ ] T006 [P] Add GraphQL cache key generation (file: crates/server/src/key.rs) (depends on T001) +- [x] T004 [P] Create entity extractor for REST and GraphQL responses (file: crates/server/src/entity.rs) +- [x] T005 [P] Create request classifier for REST/GraphQL routing (file: crates/server/src/classify.rs) +- [x] T006 [P] Add GraphQL cache key generation (file: crates/server/src/key.rs) (depends on T001) ### Phase 3: Proxy Rewrite -- [ ] T007 Rewrite proxy handler with unified entity-aware flow (file: crates/server/src/proxy.rs) (depends on T003, T004, T005, T006) -- [ ] T008 Update server wiring and AppState for EntityAwareCache (file: crates/server/src/server.rs) (depends on T003, T007) +- [x] T007 Rewrite proxy handler with unified entity-aware flow (file: crates/server/src/proxy.rs) (depends on T003, T004, T005, T006) +- [x] T008 Update server wiring and AppState for EntityAwareCache (file: crates/server/src/server.rs) (depends on T003, T007) ### Phase 4: Integration & Cleanup -- [ ] T009 Update lib.rs exports and main.rs initialization (file: crates/server/src/main.rs) (depends on T007, T008) -- [ ] T010 Remove old cache.rs, update Cargo.toml dependencies (file: crates/server/Cargo.toml) (depends on T009) -- [ ] T011 Add integration tests for cross-protocol invalidation (file: crates/server/tests/integration.rs) (depends on T009) -- [ ] T012 Add GraphQL-specific TTL rules (file: crates/server/src/ttl.rs) (depends on T007) +- [x] T009 Update lib.rs exports and main.rs initialization (file: crates/server/src/main.rs) (depends on T007, T008) +- [x] T010 Remove old cache.rs, update Cargo.toml dependencies (file: crates/server/Cargo.toml) (depends on T009) +- [x] T011 Add integration tests for cross-protocol invalidation (file: crates/server/tests/integration.rs) (depends on T009) +- [x] T012 Add GraphQL-specific TTL rules (file: crates/server/src/ttl.rs) (depends on T007) ## Key Files @@ -290,6 +290,28 @@ pub fn graphql_cache_key(token: &str, query: &str, variables: Option<&str>) -> S - [ ] AC-5: All existing REST caching behavior continues to work (TTL, ETag, token isolation) - [ ] AC-6: libsql replaces redb with no data loss during migration +## Progress + +- [x] (2026-03-29 17:45 KST) T001 Create libsql storage module + Evidence: `cargo test --lib` → 3 storage tests passed +- [x] (2026-03-29 17:45 KST) T002 Replace error types from redb to libsql +- [x] (2026-03-29 17:45 KST) T003 Build EntityAwareCache facade + Evidence: `cargo test --lib` → 14 entity_cache tests passed (CRUD + entity deps + invalidation) +- [x] (2026-03-29 17:45 KST) T004 Create entity extractor + Evidence: `cargo test --lib` → 9 entity tests passed +- [x] (2026-03-29 17:45 KST) T005 Create request classifier + Evidence: `cargo test --lib` → 10 classify tests passed +- [x] (2026-03-29 17:45 KST) T006 Add GraphQL cache key generation + Evidence: `cargo test --lib` → 12 key tests passed (8 existing + 4 new graphql) +- [x] (2026-03-29 17:45 KST) T007 Rewrite proxy handler + Evidence: `cargo test --lib` → 4 proxy tests passed +- [x] (2026-03-29 17:45 KST) T008 Update server wiring +- [x] (2026-03-29 17:45 KST) T009 Update lib.rs + main.rs +- [x] (2026-03-29 17:45 KST) T010 Remove cache.rs, update Cargo.toml +- [x] (2026-03-29 17:45 KST) T011 Update integration tests +- [x] (2026-03-29 17:45 KST) T012 Add GraphQL TTL rule + Evidence: `cargo test --lib` → 62 total tests passed, clippy clean + ## Decision Log - Decision: Unified Rewrite over Incremental approach diff --git a/CLAUDE.md b/CLAUDE.md index 2a6953e..8a83776 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Local GitHub API cache proxy — reduce latency and save rate limits. - **Async runtime**: tokio - **HTTP server**: axum + hyper (TCP + Unix socket dual listen) - **HTTP client**: reqwest (GitHub API forwarding) -- **Cache storage**: redb (embedded key-value store) +- **Cache storage**: libsql (embedded SQLite, Turso fork) - **CLI**: clap - **Tooling**: mise (version management) @@ -75,4 +75,5 @@ fetch ──(HTTP :8787)───→ (cache) - gh CLI: `http_unix_socket` config으로 연결 - fetch/octokit: `http://localhost:8787` baseUrl로 연결 - Cache key: `SHA256(token)[:16] + method + URL + query` -- Invalidation: TTL + ETag conditional requests +- Invalidation: Entity graph (node_id) + TTL + ETag conditional requests +- GraphQL: Query caching + mutation invalidation via entity graph From e23b586866e111e4edc4704e768a973ada6fd4e1 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 18:13:19 +0900 Subject: [PATCH 05/10] fix(cache): extract entities from mutation responses for cross-protocol invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forward_to_github now returns GithubResponse instead of axum::Response, enabling entity extraction from mutation response bodies. handle_mutation calls invalidate_by_entities() for cross-protocol cache invalidation (REST ↔ GraphQL via node_id), with prefix invalidation as fallback. Fixes FR-3, FR-5, FR-6 from entity-graph-cache spec. --- crates/server/src/proxy.rs | 57 +++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/crates/server/src/proxy.rs b/crates/server/src/proxy.rs index 7d81986..598c57c 100644 --- a/crates/server/src/proxy.rs +++ b/crates/server/src/proxy.rs @@ -227,26 +227,38 @@ async fn handle_mutation( token: &str, body_bytes: Option, ) -> Response { - let response = forward_to_github(state, method, path, query, token, body_bytes).await; + let gh_resp = forward_mutation(state, method, path, query, token, body_bytes).await; - // Extract entities from mutation response for invalidation - if !token.is_empty() { - // Try to read the response body for entity extraction - // (we need to reconstruct the response after reading) - // For now, use prefix invalidation as the primary strategy - // Entity invalidation from mutation responses will be extracted - // from the response body when possible - - // Prefix-based invalidation (fallback, always runs) - if let Some(parent) = parent_path(path) { - let prefix = key::invalidation_prefix(token, parent); - if let Err(e) = state.cache.remove_by_prefix(&prefix).await { - warn!(error = %e, prefix, "failed to invalidate cache by prefix"); + match gh_resp { + Ok(resp) => { + // Extract entities from mutation response for cross-protocol invalidation + if !token.is_empty() && (200..300).contains(&resp.status) { + let is_graphql = path == "/graphql"; + let entity_ids = if is_graphql { + entity::extract_graphql_entity_ids(&resp.body) + } else { + entity::extract_entity_ids(&resp.body) + }; + + // Entity-based invalidation (cross-protocol) + if !entity_ids.is_empty() + && let Err(e) = state.cache.invalidate_by_entities(&entity_ids).await + { + warn!(error = %e, "failed to invalidate cache by entities"); + } + + // Prefix-based invalidation (fallback for REST mutations) + if !is_graphql && let Some(parent) = parent_path(path) { + let prefix = key::invalidation_prefix(token, parent); + if let Err(e) = state.cache.remove_by_prefix(&prefix).await { + warn!(error = %e, prefix, "failed to invalidate cache by prefix"); + } + } } + github_response_to_axum(resp) } + Err(e) => (StatusCode::BAD_GATEWAY, format!("upstream error: {e}")).into_response(), } - - response } struct GithubResponse { @@ -448,14 +460,14 @@ async fn conditional_graphql_request( } } -async fn forward_to_github( +async fn forward_mutation( state: &AppState, method: &str, path: &str, query: Option<&str>, token: &str, body: Option, -) -> Response { +) -> std::result::Result { let url = state.build_url(path, query); let req_builder = match method { "POST" => state.client.post(&url), @@ -478,13 +490,8 @@ async fn forward_to_github( req = req.header("Accept", "application/vnd.github+json"); req = req.header("X-GitHub-Api-Version", "2022-11-28"); - match req.send().await { - Ok(resp) => { - let gh_resp = parse_github_response(resp).await; - github_response_to_axum(gh_resp) - } - Err(e) => (StatusCode::BAD_GATEWAY, format!("upstream error: {e}")).into_response(), - } + let resp = req.send().await?; + Ok(parse_github_response(resp).await) } async fn parse_github_response(resp: reqwest::Response) -> GithubResponse { From 593d149efb97fd2d49dc5897a6d7e55b0c51ab81 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 18:14:40 +0900 Subject: [PATCH 06/10] fix(cache): apply review suggestions - Remove expect() panic in count(), return 0 on empty result - Return 400 on body read failure instead of silent empty bytes - Treat subscription operations as non-cacheable (like mutations) --- crates/server/src/classify.rs | 5 ++--- crates/server/src/entity_cache.rs | 6 ++++-- crates/server/src/proxy.rs | 16 +++++++++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/server/src/classify.rs b/crates/server/src/classify.rs index e1c14be..3df0e67 100644 --- a/crates/server/src/classify.rs +++ b/crates/server/src/classify.rs @@ -60,10 +60,9 @@ fn classify_graphql(body: Option<&[u8]>) -> RequestKind { } } -/// Check if a GraphQL operation text is a mutation. +/// Check if a GraphQL operation text is a mutation or subscription (non-cacheable). fn is_mutation(query: &str) -> bool { - // A mutation starts with "mutation" keyword (with optional name) - query.starts_with("mutation") + query.starts_with("mutation") || query.starts_with("subscription") } #[cfg(test)] diff --git a/crates/server/src/entity_cache.rs b/crates/server/src/entity_cache.rs index 5bb9568..9232271 100644 --- a/crates/server/src/entity_cache.rs +++ b/crates/server/src/entity_cache.rs @@ -237,8 +237,10 @@ impl EntityAwareCache { .conn .query("SELECT count(*) FROM cache_entries", ()) .await?; - let row = rows.next().await?.expect("count should return a row"); - Ok(row.get::(0)? as usize) + match rows.next().await? { + Some(row) => Ok(row.get::(0)? as usize), + None => Ok(0), + } } /// Update an existing cache entry (e.g., refresh TTL after 304). diff --git a/crates/server/src/proxy.rs b/crates/server/src/proxy.rs index 598c57c..c0bd498 100644 --- a/crates/server/src/proxy.rs +++ b/crates/server/src/proxy.rs @@ -50,11 +50,17 @@ pub async fn proxy_handler(State(state): State>, req: Request) -> // Read body for non-GET requests (needed for classification and forwarding) let body_bytes = if method != "GET" { - Some( - axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024) - .await - .unwrap_or_default(), - ) + match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await { + Ok(bytes) => Some(bytes), + Err(e) => { + warn!(error = %e, "failed to read request body"); + return ( + StatusCode::BAD_REQUEST, + format!("failed to read request body: {e}"), + ) + .into_response(); + } + } } else { None }; From ae1059f56db3a967c6550ccf7694291723601f9d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 18:16:54 +0900 Subject: [PATCH 07/10] docs(track): add retrospective to entity-graph-cache plan --- .../entity-graph-cache-20260329/plan.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md b/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md index cfed12e..b5c11a2 100644 --- a/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md +++ b/.please/docs/tracks/active/entity-graph-cache-20260329/plan.md @@ -358,3 +358,31 @@ pub fn graphql_cache_key(token: &str, query: &str, variables: Option<&str>) -> S | proxy.rs | 389 | ~350 | Yes | | key.rs | 99 | ~130 | Yes | | error.rs | 35 | ~20 | Yes | + +## Outcomes & Retrospective + +### What Was Shipped + +- Entity graph cache invalidation replacing parent-path prefix invalidation +- GraphQL query caching with query/mutation detection +- Cross-protocol invalidation (REST ↔ GraphQL via node_id entity graph) +- Storage migration from redb to libsql + +### What Went Well + +- Unified rewrite approach kept proxy.rs under 500 LOC +- EntityAwareCache facade cleanly separates storage from proxy logic +- 62 unit tests cover entity registration, invalidation, and edge cases +- Code review caught the critical mutation entity extraction gap early + +### What Could Improve + +- Initial implementation missed entity extraction in mutation handler (fixed during review) +- store() operations in entity_cache.rs are not wrapped in SQL transactions +- GraphQL entity extraction is top-level only; nested entities (common in GitHub) are missed + +### Tech Debt Created + +- entity_cache.rs store/invalidate operations should use SQL transactions for atomicity +- GraphQL entity extraction should traverse nested objects for more complete dependency tracking +- Integration tests need cross-protocol invalidation scenarios (REST mutation → GraphQL cache miss) From 59ad7684bdb66a52d6e74d64e816812eee9b8e7c Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 18:22:22 +0900 Subject: [PATCH 08/10] fix(ci): update taiki-e/install-action SHA and fix lint errors - Update taiki-e/install-action from broken SHA to v2.70.1 - Fix ESLint formatting in ARCHITECTURE.md, .mise.toml, dependabot.yml - Fix missing newline in .superset/config.json --- .github/dependabot.yml | 6 +++--- .github/workflows/ci.yml | 4 ++-- .mise.toml | 4 ++-- .superset/config.json | 2 +- ARCHITECTURE.md | 16 ++++++++-------- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a4be90e..0602cb2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,7 @@ updates: groups: github-actions: patterns: - - "*" + - '*' - package-ecosystem: cargo directory: / @@ -16,7 +16,7 @@ updates: groups: cargo: patterns: - - "*" + - '*' - package-ecosystem: npm directory: /apps/web @@ -25,4 +25,4 @@ updates: groups: npm: patterns: - - "*" + - '*' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6efa855..874c22a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: restore-keys: ${{ runner.os }}-cargo-test- - name: Install cargo-llvm-cov - uses: taiki-e/install-action@704f92c11daa75bfb4e01fcb083350c16c47b9 # v2.69.13 + uses: taiki-e/install-action@fd0f63e180a477d7434580b6d80817978b9ff2b8 # v2.70.1 - name: Run unit tests with coverage run: cargo llvm-cov --lib --lcov --output-path lcov-unit.info @@ -116,7 +116,7 @@ jobs: restore-keys: ${{ runner.os }}-cargo-integration- - name: Install cargo-llvm-cov - uses: taiki-e/install-action@704f92c11daa75bfb4e01fcb083350c16c47b9 # v2.69.13 + uses: taiki-e/install-action@fd0f63e180a477d7434580b6d80817978b9ff2b8 # v2.70.1 - name: Run integration tests with coverage run: cargo llvm-cov --test integration_test --lcov --output-path lcov-integration.info diff --git a/.mise.toml b/.mise.toml index 5e29153..e6bb248 100644 --- a/.mise.toml +++ b/.mise.toml @@ -8,7 +8,7 @@ run = "cargo build --release" [tasks.dev] description = "Run local-hub + web dev server" -depends = ["build"] +depends = [ "build" ] run = """ ./target/release/local-hub start & cd apps/web && npm run dev @@ -16,7 +16,7 @@ cd apps/web && npm run dev [tasks.hub] description = "Build and start local-hub" -depends = ["build"] +depends = [ "build" ] run = "./target/release/local-hub start" [tasks.web] diff --git a/.superset/config.json b/.superset/config.json index 3be361d..e7e1b86 100644 --- a/.superset/config.json +++ b/.superset/config.json @@ -2,4 +2,4 @@ "setup": ["bun install", "cp \"$SUPERSET_ROOT_PATH/.env\" .env"], "teardown": [], "run": [] -} \ No newline at end of file +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6438caf..42b6a34 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -20,12 +20,12 @@ cache namespace prefixes. ## Entry Points -| Path | Purpose | -| ------------------------------- | --------------------------------------------- | -| `crates/server/src/main.rs` | CLI entry point — parses args, starts server | +| Path | Purpose | +| ------------------------------- | ---------------------------------------------------- | +| `crates/server/src/main.rs` | CLI entry point — parses args, starts server | | `crates/server/CHANGELOG.md` | Release history — [view](crates/server/CHANGELOG.md) | -| `.github/workflows/release.yml` | CI/CD — release-please + cross-platform build | -| `Cargo.toml` | Workspace root — dependency versions | +| `.github/workflows/release.yml` | CI/CD — release-please + cross-platform build | +| `Cargo.toml` | Workspace root — dependency versions | ## Module Structure @@ -45,9 +45,9 @@ crates/ ### Planned crates -| Crate | Purpose | Status | -| -------- | --------------------------------- | ----------------- | -| `server` | Proxy server + CLI | Active | +| Crate | Purpose | Status | +| -------- | --------------------------------- | ------------------------ | +| `server` | Proxy server + CLI | Active | | `web` | Dashboard client (via better-hub) | In progress (`apps/web`) | ## Data Flow From 223e0f52f74ff85f29b09362e4f8b26b22f23d21 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 18:23:37 +0900 Subject: [PATCH 09/10] fix(ci): add missing tool parameter to install-action --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 874c22a..0322d70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,8 @@ jobs: - name: Install cargo-llvm-cov uses: taiki-e/install-action@fd0f63e180a477d7434580b6d80817978b9ff2b8 # v2.70.1 + with: + tool: cargo-llvm-cov - name: Run unit tests with coverage run: cargo llvm-cov --lib --lcov --output-path lcov-unit.info @@ -117,6 +119,8 @@ jobs: - name: Install cargo-llvm-cov uses: taiki-e/install-action@fd0f63e180a477d7434580b6d80817978b9ff2b8 # v2.70.1 + with: + tool: cargo-llvm-cov - name: Run integration tests with coverage run: cargo llvm-cov --test integration_test --lcov --output-path lcov-integration.info From 1a115f8762c6f874e6233de01b3aa9904f4f1b4b Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 29 Mar 2026 18:31:37 +0900 Subject: [PATCH 10/10] fix(ci): merge unit + integration coverage for accurate reporting - Upload both as separate flags to Codecov - Update codecov.yml components for new module structure - Integration tests cover proxy.rs handlers, improving patch coverage --- .github/workflows/ci.yml | 13 ++++++------- codecov.yml | 25 ++++++++++++++++--------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0322d70..d4c37f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,12 +90,12 @@ jobs: tool: cargo-llvm-cov - name: Run unit tests with coverage - run: cargo llvm-cov --lib --lcov --output-path lcov-unit.info + run: cargo llvm-cov --lib --lcov --output-path lcov.info - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-unit - path: lcov-unit.info + path: lcov.info integration-test: name: Integration Test @@ -123,12 +123,12 @@ jobs: tool: cargo-llvm-cov - name: Run integration tests with coverage - run: cargo llvm-cov --test integration_test --lcov --output-path lcov-integration.info + run: cargo llvm-cov --test integration_test --lcov --output-path lcov.info - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-integration - path: lcov-integration.info + path: lcov.info codecov: name: Upload Coverage @@ -140,12 +140,11 @@ jobs: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: coverage-* - merge-multiple: true - name: Upload unit coverage uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4 with: - files: lcov-unit.info + files: coverage-unit/lcov.info flags: unit fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -153,7 +152,7 @@ jobs: - name: Upload integration coverage uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4 with: - files: lcov-integration.info + files: coverage-integration/lcov.info flags: integration fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} diff --git a/codecov.yml b/codecov.yml index 7d48fef..2a1ca91 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,15 +8,16 @@ coverage: default: target: 80% -flags: - unit: - paths: - - crates/server/src/ - carryforward: true - integration: - paths: - - crates/server/src/ +flag_management: + default_rules: carryforward: true + individual_flags: + - name: unit + paths: + - crates/server/src/ + - name: integration + paths: + - crates/server/src/ component_management: default_rules: @@ -27,9 +28,15 @@ component_management: - component_id: cache name: Cache paths: - - crates/server/src/cache.rs + - crates/server/src/entity_cache.rs + - crates/server/src/storage.rs + - crates/server/src/entity.rs - crates/server/src/key.rs - crates/server/src/ttl.rs + - component_id: classify + name: Classify + paths: + - crates/server/src/classify.rs - component_id: proxy name: Proxy paths: