From 5c8f1f3dddcd6b17bc0a46caf39aaeb597fcb9d0 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 14 May 2026 01:45:35 +0000 Subject: [PATCH 001/108] Initial graphql-orm backup crate --- .../skills/graphql-orm-macros/SKILL.md | 90 +++ .../.agents/skills/rust-skills/SKILL.md | 335 +++++++++ crates/graphql-orm-backup/.gitignore | 2 + crates/graphql-orm-backup/AGENTS.md | 19 + crates/graphql-orm-backup/Cargo.lock | 703 ++++++++++++++++++ crates/graphql-orm-backup/Cargo.toml | 29 + crates/graphql-orm-backup/README.md | 105 +++ .../graphql-orm-backup/docs/architecture.md | 53 ++ .../docs/graphql-orm-agent-brief.md | 100 +++ crates/graphql-orm-backup/docs/plan.md | 59 ++ .../docs/provider-roadmap.md | 46 ++ .../docs/restore-semantics.md | 46 ++ .../docs/snapshot-format.md | 51 ++ crates/graphql-orm-backup/docs/usage.md | 146 ++++ crates/graphql-orm-backup/src/backup.rs | 191 +++++ crates/graphql-orm-backup/src/database.rs | 66 ++ crates/graphql-orm-backup/src/error.rs | 48 ++ crates/graphql-orm-backup/src/lib.rs | 39 + .../src/local_repository.rs | 147 ++++ crates/graphql-orm-backup/src/manifest.rs | 96 +++ crates/graphql-orm-backup/src/object_index.rs | 26 + crates/graphql-orm-backup/src/planner.rs | 26 + crates/graphql-orm-backup/src/repository.rs | 17 + crates/graphql-orm-backup/src/restore.rs | 45 ++ crates/graphql-orm-backup/src/verify.rs | 43 ++ .../tests/full_backup_creation.rs | 368 +++++++++ .../tests/local_repository_round_trip.rs | 142 ++++ .../tests/manifest_round_trip.rs | 213 ++++++ 28 files changed, 3251 insertions(+) create mode 100644 crates/graphql-orm-backup/.agents/skills/graphql-orm-macros/SKILL.md create mode 100644 crates/graphql-orm-backup/.agents/skills/rust-skills/SKILL.md create mode 100644 crates/graphql-orm-backup/.gitignore create mode 100644 crates/graphql-orm-backup/AGENTS.md create mode 100644 crates/graphql-orm-backup/Cargo.lock create mode 100644 crates/graphql-orm-backup/Cargo.toml create mode 100644 crates/graphql-orm-backup/README.md create mode 100644 crates/graphql-orm-backup/docs/architecture.md create mode 100644 crates/graphql-orm-backup/docs/graphql-orm-agent-brief.md create mode 100644 crates/graphql-orm-backup/docs/plan.md create mode 100644 crates/graphql-orm-backup/docs/provider-roadmap.md create mode 100644 crates/graphql-orm-backup/docs/restore-semantics.md create mode 100644 crates/graphql-orm-backup/docs/snapshot-format.md create mode 100644 crates/graphql-orm-backup/docs/usage.md create mode 100644 crates/graphql-orm-backup/src/backup.rs create mode 100644 crates/graphql-orm-backup/src/database.rs create mode 100644 crates/graphql-orm-backup/src/error.rs create mode 100644 crates/graphql-orm-backup/src/lib.rs create mode 100644 crates/graphql-orm-backup/src/local_repository.rs create mode 100644 crates/graphql-orm-backup/src/manifest.rs create mode 100644 crates/graphql-orm-backup/src/object_index.rs create mode 100644 crates/graphql-orm-backup/src/planner.rs create mode 100644 crates/graphql-orm-backup/src/repository.rs create mode 100644 crates/graphql-orm-backup/src/restore.rs create mode 100644 crates/graphql-orm-backup/src/verify.rs create mode 100644 crates/graphql-orm-backup/tests/full_backup_creation.rs create mode 100644 crates/graphql-orm-backup/tests/local_repository_round_trip.rs create mode 100644 crates/graphql-orm-backup/tests/manifest_round_trip.rs diff --git a/crates/graphql-orm-backup/.agents/skills/graphql-orm-macros/SKILL.md b/crates/graphql-orm-backup/.agents/skills/graphql-orm-macros/SKILL.md new file mode 100644 index 00000000..312de2a9 --- /dev/null +++ b/crates/graphql-orm-backup/.agents/skills/graphql-orm-macros/SKILL.md @@ -0,0 +1,90 @@ +--- +name: graphql-orm-macros +description: > + Use when working on the graphql-orm runtime plus graphql-orm-macros derive + layer for GraphQL entities, relation resolvers, CRUD operations, schema roots, + migrations, and runtime metadata integration. +--- + +# graphql-orm Skill + +## Use This Skill When + +- adding `mutation_result!` result types +- deriving `GraphQLEntity` +- deriving `GraphQLRelations` +- deriving `GraphQLOperations` +- composing schema roots with `schema_roots!` +- reviewing relation loading behavior or N+1 implications +- changing runtime metadata, query rendering, schema diffing, or migration support +- checking backend-specific SQLite/PostgreSQL behavior + +## Crates + +- Application dependency: `graphql-orm` +- Runtime repo: `https://github.com/Dastari/graphql-orm` +- Macro repo: `https://github.com/Dastari/graphql-orm-macros` + +## Preferred Usage + +Import through the runtime crate: + +- `use graphql_orm::prelude::*;` +- `use graphql_orm::mutation_result;` +- derive macros by name on structs + +For `digitise`, treat `graphql-orm` as the only normal dependency surface. Do not add a direct `graphql-orm-macros` dependency unless you are explicitly developing or debugging the proc-macro crate itself. + +## Integration Rules + +1. Use the runtime-plus-macro split correctly. +`digitise` should depend on `graphql-orm`. Generated code comes from the re-exported macros, but runtime behavior, metadata, query rendering, relation loading, and migrations belong to `graphql-orm`. + +This is the default assumption for new work. If a change requires touching the macro crate, do that in the shared library repo, but keep `digitise` depending only on `graphql-orm`. + +2. Use the macros for boilerplate, not business logic. +The application should still own domain logic, permission checks, store implementations, and resolver orchestration. + +3. Keep generated types aligned with async-graphql. +If a macro-generated GraphQL object wraps an entity field, ensure the entity type itself is compatible with async-graphql output expectations. + +4. Treat the stack as backend/framework-opinionated. +It is domain-generic, but it still assumes an async-graphql + ORM-style host environment. Do not assume it is a fully generic Rust macro toolkit. + +5. Use the generic notify hook pattern, not project-specific hard-coding. +If a mutation needs side effects after create/update/delete, prefer the `notify` / `notify_with` hook path model exposed by the macro crate. + +6. Watch relation performance. +For nested relations, understand whether the generated path is batched or falls back to direct queries. Use this crate when the problem is macro-generated relation behavior, not when the issue is application auth. + +7. Keep persistence backend-agnostic at the app layer. +Backend-specific SQL rendering, migration planning, and schema introspection belong in `graphql-orm`, not in `digitise`. + +8. Prefer the runtime surface over old host-crate assumptions. +Generated code should target `::graphql_orm::*`. Avoid reintroducing assumptions that the application must expose `crate::db`, `crate::graphql::orm`, or similar legacy module shapes. + +## When Not To Use + +- when implementing authentication, refresh tokens, or guards +- when working on frontend-only GraphQL calls +- when you just need handwritten simple types and the macro would add unnecessary coupling + +## Common Pattern + +```rust +use graphql_orm::mutation_result; + +#[derive(async_graphql::SimpleObject, Clone, Debug)] +struct User { + id: String, +} + +mutation_result!(LoginResult, user: User); +``` + +## Project Guidance + +- use `graphql-orm` as the application-facing dependency and macro re-export surface +- in `digitise`, do not depend on `graphql-orm-macros` directly +- keep `digitise` responsible for choosing when derive-based boilerplate is worth the coupling +- if the needed change would improve multiple projects, prefer updating `graphql-orm` or `graphql-orm-macros` rather than patching around them locally diff --git a/crates/graphql-orm-backup/.agents/skills/rust-skills/SKILL.md b/crates/graphql-orm-backup/.agents/skills/rust-skills/SKILL.md new file mode 100644 index 00000000..c0f008d2 --- /dev/null +++ b/crates/graphql-orm-backup/.agents/skills/rust-skills/SKILL.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/crates/graphql-orm-backup/.gitignore b/crates/graphql-orm-backup/.gitignore new file mode 100644 index 00000000..eccd7b4a --- /dev/null +++ b/crates/graphql-orm-backup/.gitignore @@ -0,0 +1,2 @@ +/target/ +**/*.rs.bk diff --git a/crates/graphql-orm-backup/AGENTS.md b/crates/graphql-orm-backup/AGENTS.md new file mode 100644 index 00000000..f3003b35 --- /dev/null +++ b/crates/graphql-orm-backup/AGENTS.md @@ -0,0 +1,19 @@ +# graphql-orm-backup Agent Guide + +This crate is a reusable backup and restore companion for applications that use `graphql-orm`. + +## Skills + +- Use `.agents/skills/rust-skills/SKILL.md` for all Rust implementation, review, refactoring, performance, and API design work. +- Use `.agents/skills/graphql-orm-macros/SKILL.md` for graphql-orm integration decisions. + +## Rules + +- Keep the crate generic and reusable. +- Do not add Digitise-specific domain names, entity names, collection semantics, accession logic, record logic, media workflows, or policy assumptions. +- Do not store file bytes in a database. +- Prefer traits and small adapters over application-specific coupling. +- Keep provider-specific code behind feature flags. +- Treat restore as a first-class feature. Every backup feature must have restore and verification tests. +- Full backup and restore ship before incremental backup. +- Incremental backup depends on a reliable graphql-orm change journal. diff --git a/crates/graphql-orm-backup/Cargo.lock b/crates/graphql-orm-backup/Cargo.lock new file mode 100644 index 00000000..73e07673 --- /dev/null +++ b/crates/graphql-orm-backup/Cargo.lock @@ -0,0 +1,703 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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 = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "graphql-orm-backup" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml new file mode 100644 index 00000000..870c5962 --- /dev/null +++ b/crates/graphql-orm-backup/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "graphql-orm-backup" +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/Dastari/graphql-orm-backup" +description = "Backup and restore orchestration primitives for graphql-orm applications" + +[features] +default = ["local"] +local = [] +s3 = [] +azure = [] +dropbox = [] +smb = [] + +[dependencies] +async-trait = "0.1" +bytes = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["fs"] } +uuid = { version = "1", features = ["serde", "v4"] } + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] } diff --git a/crates/graphql-orm-backup/README.md b/crates/graphql-orm-backup/README.md new file mode 100644 index 00000000..06af60f9 --- /dev/null +++ b/crates/graphql-orm-backup/README.md @@ -0,0 +1,105 @@ +# graphql-orm-backup + +Backup and restore orchestration primitives for applications that use `graphql-orm`. + +This crate coordinates database export/import adapters, stored-object indexes, backup repositories, snapshot manifests, verification, and restore planning. It does not own application auth, UI, scheduling, or domain-specific workflow behavior. + +## Current Status + +- Snapshot manifest types implemented. +- Manifest checksum support implemented. +- Backup repository trait implemented. +- Local filesystem backup repository implemented. +- Object index and database adapter contracts implemented. +- Full backup planner skeleton implemented. +- Full snapshot creation implemented through `create_full_backup`. +- Verification helpers implemented. +- Restore safety context implemented for empty-target restores. + +`graphql-orm` still needs to provide stable logical export/import and change-journal APIs before complete database backup/restore can be implemented. + +## Documentation + +- [Architecture](docs/architecture.md) +- [Usage guide](docs/usage.md) +- [Snapshot format](docs/snapshot-format.md) +- [Restore semantics](docs/restore-semantics.md) +- [Provider roadmap](docs/provider-roadmap.md) +- [graphql-orm integration brief](docs/graphql-orm-agent-brief.md) + +## Design Rule + +Backups are manifest-based and content-addressed. + +```text +snapshots/{snapshot_id}/manifest.json +snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst +snapshots/{snapshot_id}/database/changes/{table_name}.jsonl.zst +objects/sha256/{first_two}/{next_two}/{sha256} +``` + +The manifest is written last. Its checksum excludes its own `checksum` field. +Table payloads are currently written as uncompressed JSON Lines. The `.zst` +filename suffix is reserved for the stable future compressed layout. + +## Backup Repository Example + +```rust +use bytes::Bytes; +use graphql_orm_backup::{BackupRepository, LocalBackupRepository}; + +# async fn example() -> Result<(), graphql_orm_backup::BackupError> { +let repository = LocalBackupRepository::new("./backup"); +repository + .put_blob("snapshots/example/manifest.json", Bytes::from_static(b"{}")) + .await?; +# Ok(()) +# } +``` + +## Full Backup Creation + +`create_full_backup` coordinates a database adapter, stored-object index, and +backup repository. It writes table payloads and content-addressed object blobs, +then writes the snapshot manifest last. + +```rust +use graphql_orm_backup::{ + FullBackupRequest, LocalBackupRepository, create_full_backup, +}; +use uuid::Uuid; + +# async fn example( +# database: &dyn graphql_orm_backup::GraphqlOrmBackupAdapter, +# objects: &dyn graphql_orm_backup::BackupObjectIndex, +# ) -> Result<(), graphql_orm_backup::BackupError> { +let repository = LocalBackupRepository::new("./backups"); +let result = create_full_backup( + &repository, + database, + objects, + FullBackupRequest { + snapshot_id: Uuid::new_v4(), + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + }, +) +.await?; + +println!("created snapshot {}", result.manifest.snapshot_id); +# Ok(()) +# } +``` + +## Restore Policy + +The first supported restore mode is restore into an empty database and empty object store. In-place replacement is future work. + +## Provider Roadmap + +1. Local filesystem +2. S3 +3. Azure Blob +4. SMB through mounted filesystem path +5. Dropbox diff --git a/crates/graphql-orm-backup/docs/architecture.md b/crates/graphql-orm-backup/docs/architecture.md new file mode 100644 index 00000000..9557a833 --- /dev/null +++ b/crates/graphql-orm-backup/docs/architecture.md @@ -0,0 +1,53 @@ +# graphql-orm-backup Architecture + +## Boundary + +`graphql-orm-backup` orchestrates backup and restore. It delegates database details to `graphql-orm` and delegates application object loading to a `BackupObjectIndex` adapter. + +## Collaborating Components + +- `graphql-orm`: entity metadata, schema hash, row export/import, change journal, restore context. +- `graphql-orm-storage`: stored object metadata and object storage primitives. +- Application: auth, scheduling, admin APIs, audit, and provider configuration. +- `graphql-orm-backup`: manifests, repositories, verification, backup planning, restore orchestration. + +## Full Backup Flow + +`create_full_backup` implements the current full snapshot flow: + +1. Read schema snapshot from `GraphqlOrmBackupAdapter`. +2. Export all backup-enabled tables through `GraphqlOrmBackupAdapter`. +3. List all referenced stored objects through `BackupObjectIndex`. +4. Write table exports to the backup repository as uncompressed JSON Lines. +5. Load and checksum each object from `BackupObjectIndex`. +6. Write object blobs by content-addressed key if missing. +7. Build manifest. +8. Set manifest checksum. +9. Write manifest last. + +## Incremental Backup Flow + +Incremental backup is blocked on graphql-orm change journal support. + +Expected flow: + +1. Load parent snapshot marker. +2. Ask graphql-orm for changed rows and tombstones since the parent. +3. Ask object index for newly referenced or changed objects. +4. Write change files and objects. +5. Write incremental manifest with `parent_snapshot_id`. + +## Restore Flow + +1. Load selected manifest. +2. Load and verify parent manifest chain. +3. Verify manifest checksums. +4. Verify object and table checksums. +5. Confirm target database/object store is empty. +6. Run migrations to compatible schema. +7. Import full snapshot rows in dependency order. +8. Apply incremental snapshots in order. +9. Restore objects. +10. Verify restored row counts and checksums. + +Only the safety scaffolding exists until graphql-orm import/export APIs are finalized. diff --git a/crates/graphql-orm-backup/docs/graphql-orm-agent-brief.md b/crates/graphql-orm-backup/docs/graphql-orm-agent-brief.md new file mode 100644 index 00000000..8d03397b --- /dev/null +++ b/crates/graphql-orm-backup/docs/graphql-orm-agent-brief.md @@ -0,0 +1,100 @@ +# graphql-orm Agent Brief: Backup And Restore Support + +## Goal + +`graphql-orm-backup` needs `graphql-orm` to provide stable database metadata, export, import, restore context, and change-journal APIs. + +Provider SDKs, object stores, Dropbox, SMB, and backup repository implementations are out of scope for `graphql-orm`. + +## Existing Starting Point + +The `/home/toby/graphql-orm` repo already contains early backup metadata primitives in `crates/graphql-orm/src/graphql/orm/core.rs`, including: + +```rust +pub struct EntityBackupDescriptor { + pub entity_name: String, + pub table_name: String, + pub primary_key_column: String, + pub export_order: i32, + pub restore_order: i32, + pub columns: Vec, + pub dependencies: Vec, +} + +pub struct GraphqlOrmSchemaSnapshot { + pub backend: String, + pub migration_version: String, + pub entities: Vec, + pub schema_hash: String, +} +``` + +Please extend and stabilize this surface instead of replacing it. + +## Required graphql-orm Capabilities + +1. Entity backup descriptors for all registered backup-enabled entities. +2. Stable schema hash and migration version. +3. Backend-agnostic full row export. +4. Backend-agnostic row import into an empty database. +5. Restore context that bypasses policies and change journaling. +6. Optional change journal for true incremental backups. +7. Delete tombstones for incremental restore. +8. SQLite and PostgreSQL tests. + +## Proposed Runtime API Shape + +```rust +#[async_trait::async_trait] +pub trait GraphqlOrmBackupRuntime { + async fn schema_snapshot(&self) -> Result; + async fn export_full(&self) -> Result, BackupError>; + async fn export_incremental( + &self, + parent_snapshot_id: uuid::Uuid, + ) -> Result, BackupError>; + async fn restore_full( + &self, + export: Vec, + context: RestoreContext, + ) -> Result<(), BackupError>; + async fn restore_incremental( + &self, + changes: Vec, + context: RestoreContext, + ) -> Result<(), BackupError>; +} +``` + +The backup crate currently defines this as an interim adapter contract. The final API should live in `graphql-orm` or be satisfied by a thin adapter. + +## Change Journal Direction + +Add an optional built-in change journal, probably feature-gated: + +```rust +pub struct OrmChangeLog { + pub id: uuid::Uuid, + pub entity_name: String, + pub table_name: String, + pub primary_key: String, + pub action: String, + pub changed_at: i64, + pub transaction_id: Option, + pub row_hash: Option, + pub actor_id: Option, + pub correlation_id: Option, +} +``` + +Generated CRUD and runtime insert/update/delete paths should write journal entries unless an explicit restore context disables journaling. + +## Please Return + +- Final runtime API names and modules. +- Macro changes required. +- Change journal schema. +- Row value representation. +- Import/export implementation plan. +- Tests added. +- Blockers for SQLite/PostgreSQL parity. diff --git a/crates/graphql-orm-backup/docs/plan.md b/crates/graphql-orm-backup/docs/plan.md new file mode 100644 index 00000000..d394e74b --- /dev/null +++ b/crates/graphql-orm-backup/docs/plan.md @@ -0,0 +1,59 @@ +# graphql-orm-backup Implementation Plan + +## Goal + +Create a reusable backup and restore crate for applications using `graphql-orm`. The crate must support full snapshots first, then incremental snapshots after `graphql-orm` exposes a reliable change journal. + +## What This Crate Provides + +- Snapshot manifest format. +- Backup repository trait. +- Local backup repository. +- Database backup adapter contract. +- Stored-object index adapter contract. +- Full backup planner. +- Full snapshot writer. +- Verification helpers. +- Restore context and initial empty-target safety checks. + +## What This Crate Must Not Provide + +- Application authentication. +- Application authorization or row policy decisions. +- UI or scheduling. +- Digitise-specific entity names or workflow assumptions. +- Primary object storage implementation details beyond reading objects through `BackupObjectIndex`. + +## Initial Implementation Order + +1. Implement manifest types and checksum helpers. +2. Implement `BackupRepository`. +3. Implement `LocalBackupRepository`. +4. Implement `BackupObjectIndex`. +5. Implement `GraphqlOrmBackupAdapter` as an interim integration contract. +6. Implement full backup planning. +7. Implement full snapshot creation. +8. Implement manifest/object verification. +9. Implement restore context and empty-target guard. +10. Wait for finalized `graphql-orm` export/import/change-journal APIs. + +## Expected Output From A Backup Agent + +- A compilable crate under `/home/toby/graphql-orm-backup`. +- Manifest format docs. +- Restore semantics docs. +- Local repository implementation. +- Full snapshot creation API. +- Tests for manifest, local repository, verification, and planning. +- Precise list of missing graphql-orm APIs. + +## Future Work + +- Stream database exports instead of holding rows in memory. +- Add zstd compression for table and change files. +- Add S3 backup repository. +- Add Azure Blob backup repository. +- Add Dropbox backup repository. +- Add SMB mounted-path documentation and validation. +- Implement full restore after graphql-orm import lands. +- Implement incremental backup after graphql-orm change journal lands. diff --git a/crates/graphql-orm-backup/docs/provider-roadmap.md b/crates/graphql-orm-backup/docs/provider-roadmap.md new file mode 100644 index 00000000..293184bc --- /dev/null +++ b/crates/graphql-orm-backup/docs/provider-roadmap.md @@ -0,0 +1,46 @@ +# Provider Roadmap + +## Phase 1: Local Filesystem + +Implemented first as the deterministic baseline. + +Acceptance criteria: + +- put/get/list/delete blob +- nested key support +- path traversal rejection +- delete missing blob succeeds + +## Phase 2: S3 + +Add behind the `s3` feature. + +Expected configuration: + +- endpoint URL +- region +- bucket +- prefix +- credentials +- path-style toggle + +## Phase 3: Azure Blob + +Add behind the `azure` feature. + +Expected configuration: + +- account/container or connection string +- container +- prefix +- credentials + +## Phase 4: SMB + +Initial SMB support should be mounted filesystem support using `LocalBackupRepository`. + +Native SMB protocol support is future work. + +## Phase 5: Dropbox + +Dropbox should be a backup repository provider only. It should not become a primary object storage backend unless a future product requirement justifies that. diff --git a/crates/graphql-orm-backup/docs/restore-semantics.md b/crates/graphql-orm-backup/docs/restore-semantics.md new file mode 100644 index 00000000..9c9cc273 --- /dev/null +++ b/crates/graphql-orm-backup/docs/restore-semantics.md @@ -0,0 +1,46 @@ +# Restore Semantics + +## Initial Supported Mode + +Only restore into an empty target is supported initially. + +```rust +RestoreMode::EmptyDatabase +``` + +In-place restore and replacement are future work. + +## Restore Context + +Restore runs under an explicit context: + +```rust +pub struct RestoreContext { + pub mode: RestoreMode, + pub disable_policies: bool, + pub disable_change_journal: bool, +} +``` + +The default empty restore context disables application policies and change journaling because restore is an administrative data operation, not a GraphQL user mutation. + +## Safety Rules + +- Verify manifests before writing. +- Verify object checksums before final success. +- Preserve primary keys. +- Preserve created and updated timestamps where entities define them. +- Restore rows in dependency order. +- Do not emit change journal entries during restore. +- Do not run normal GraphQL row policies during restore. +- Refuse non-empty target databases in `EmptyDatabase` mode. + +## Future Replacement Mode + +Future in-place restore must require: + +- explicit operator confirmation +- pre-restore backup +- application quiescing or maintenance mode +- rollback strategy +- audit event diff --git a/crates/graphql-orm-backup/docs/snapshot-format.md b/crates/graphql-orm-backup/docs/snapshot-format.md new file mode 100644 index 00000000..35446596 --- /dev/null +++ b/crates/graphql-orm-backup/docs/snapshot-format.md @@ -0,0 +1,51 @@ +# Snapshot Format + +## Layout + +```text +snapshots/{snapshot_id}/manifest.json +snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst +snapshots/{snapshot_id}/database/changes/{table_name}.jsonl.zst +objects/sha256/{first_two}/{next_two}/{sha256} +``` + +## Manifest + +The manifest records: + +- format version +- snapshot id +- parent snapshot id for incremental snapshots +- application id and version +- graphql-orm schema version and hash +- database backend +- backup kind +- database table export entries +- object entries +- tombstones +- manifest checksum + +The manifest checksum is the SHA-256 of the serialized manifest with the `checksum` field cleared. + +## Object Blobs + +Object blobs are content-addressed by SHA-256: + +```text +objects/sha256/ab/cd/abcdef... +``` + +This allows dedupe across snapshots and providers. + +## Database Blobs + +The current table export payload is uncompressed JSON Lines. Each line is one +serialized backup row and ends with `\n`. + +The repository key keeps the planned compressed filename: + +```text +snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst +``` + +Compression is not implemented yet. The filename reserves the intended format so future implementation has a stable layout. diff --git a/crates/graphql-orm-backup/docs/usage.md b/crates/graphql-orm-backup/docs/usage.md new file mode 100644 index 00000000..8afb715e --- /dev/null +++ b/crates/graphql-orm-backup/docs/usage.md @@ -0,0 +1,146 @@ +# Usage Guide + +`graphql-orm-backup` is an orchestration crate. It does not connect directly to +an application database or object store. Host applications provide small adapter +implementations, and the crate handles snapshot layout, checksums, repository +writes, verification, and restore safety scaffolding. + +## Core Concepts + +- `BackupRepository`: destination for backup blobs and manifests. +- `LocalBackupRepository`: filesystem implementation of `BackupRepository`. +- `GraphqlOrmBackupAdapter`: interim database export/import contract until the + final `graphql-orm` runtime backup API lands. +- `BackupObjectIndex`: application adapter that lists and loads stored objects + referenced by a snapshot. +- `BackupSnapshotManifest`: durable record of a snapshot's database files, + object files, checksums, schema hash, and application metadata. +- `RestoreContext`: explicit restore mode and safety flags. + +## Creating A Full Backup + +Full snapshot creation is implemented through `create_full_backup`. + +```rust +use graphql_orm_backup::{ + BackupObjectIndex, FullBackupRequest, GraphqlOrmBackupAdapter, + LocalBackupRepository, create_full_backup, +}; +use uuid::Uuid; + +async fn run_backup( + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, +) -> Result<(), graphql_orm_backup::BackupError> { + let repository = LocalBackupRepository::new("./backups"); + + let result = create_full_backup( + &repository, + database, + objects, + FullBackupRequest { + snapshot_id: Uuid::new_v4(), + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + }, + ) + .await?; + + println!("created snapshot {}", result.manifest.snapshot_id); + Ok(()) +} +``` + +The function performs these steps: + +1. Reads schema metadata from `GraphqlOrmBackupAdapter`. +2. Exports all full-backup table rows through `GraphqlOrmBackupAdapter`. +3. Lists referenced objects through `BackupObjectIndex`. +4. Writes table export payloads to the repository. +5. Loads and verifies object bytes. +6. Writes missing object blobs by content-addressed key. +7. Builds and checksums the manifest. +8. Writes the manifest last. + +## Database Adapter Responsibilities + +`GraphqlOrmBackupAdapter` is intentionally narrow. The host application or a +future `graphql-orm` runtime adapter owns database-specific export/import +details. + +For full backups, implement: + +- `schema_snapshot`: return backend name, migration version, and stable schema + hash. +- `export_full`: return table exports in the order they should be written. + +Restore and incremental methods are present in the trait so the public contract +can evolve in place, but full restore and true incremental backup are not yet +implemented by this crate. + +## Object Index Responsibilities + +`BackupObjectIndex` lets an application expose externally stored objects without +coupling this crate to a specific object-storage implementation. + +For full backups, implement: + +- `list_objects_for_full_backup`: return object ids, original storage keys, + expected SHA-256 hashes, sizes, and optional MIME types. +- `load_object`: return the exact bytes for a listed object. + +`create_full_backup` verifies the loaded bytes against the declared SHA-256 +before the object is referenced in the manifest. + +## Repository Layout + +Full backups use this layout: + +```text +snapshots/{snapshot_id}/manifest.json +snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst +objects/sha256/{first_two}/{next_two}/{sha256} +``` + +The current table payload is uncompressed JSON Lines even though the table key +keeps the reserved `.jsonl.zst` suffix. Compression is future work. + +## Verification + +Use `verify_manifest_and_objects` to validate a completed snapshot manifest +against repository contents: + +```rust +use graphql_orm_backup::{BackupRepository, BackupSnapshotManifest}; + +async fn verify( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, +) -> Result<(), graphql_orm_backup::BackupError> { + graphql_orm_backup::verify_manifest_and_objects(repository, manifest).await +} +``` + +Verification checks: + +- manifest checksum +- object blob checksums +- table export blob checksums + +## Restore Status + +The crate currently provides restore safety scaffolding only: + +- `RestoreContext::empty_database` +- `RestoreContext::dry_run` +- `ensure_empty_restore_target` + +Full restore depends on stable `graphql-orm` row import, dependency ordering, +restore context, and policy/journal bypass APIs. + +## Incremental Backup Status + +Incremental backup is intentionally deferred. It depends on a reliable +`graphql-orm` change journal with row updates, deletes/tombstones, transaction +ordering, and object-change discovery semantics. diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs new file mode 100644 index 00000000..e16a7cdf --- /dev/null +++ b/crates/graphql-orm-backup/src/backup.rs @@ -0,0 +1,191 @@ +use bytes::Bytes; +use serde::Serialize; +use uuid::Uuid; + +use crate::{ + BACKUP_FORMAT_VERSION, BackupError, BackupKind, BackupObjectIndex, BackupRepository, BackupRow, + BackupSnapshotManifest, BackupTableExport, DatabaseBackupManifest, GraphqlOrmBackupAdapter, + ObjectBackupEntry, TableBackupEntry, manifest::sha256_hex, plan_full_backup, + set_manifest_checksum, +}; + +pub const DATABASE_EXPORT_FORMAT: &str = "jsonl"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FullBackupRequest { + pub snapshot_id: Uuid, + pub created_at: i64, + pub app_id: String, + pub app_version: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FullBackupResult { + pub manifest: BackupSnapshotManifest, +} + +#[must_use] +pub fn snapshot_manifest_key(snapshot_id: Uuid) -> String { + format!("snapshots/{snapshot_id}/manifest.json") +} + +/// Returns the reserved table export key. +/// +/// Full backup currently writes uncompressed JSON Lines. The `.zst` suffix is +/// retained for the stable repository layout reserved by the snapshot format. +#[must_use] +pub fn database_table_key(snapshot_id: Uuid, table_name: &str) -> String { + format!("snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst") +} + +#[must_use] +pub fn database_changes_key(snapshot_id: Uuid, table_name: &str) -> String { + format!("snapshots/{snapshot_id}/database/changes/{table_name}.jsonl.zst") +} + +#[must_use] +pub fn object_content_key(sha256_hex: &str) -> String { + let shard_a = sha256_hex.get(0..2).unwrap_or("00"); + let shard_b = sha256_hex.get(2..4).unwrap_or("00"); + format!("objects/sha256/{shard_a}/{shard_b}/{sha256_hex}") +} + +pub async fn create_full_backup( + repository: &dyn BackupRepository, + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, + request: FullBackupRequest, +) -> Result { + let plan = plan_full_backup(database, objects).await?; + + let mut table_entries = Vec::with_capacity(plan.tables.len()); + let mut row_count = 0_u64; + for table in &plan.tables { + let bytes = serialize_table_export(table)?; + let content_key = database_table_key(request.snapshot_id, &table.table_name); + let sha256_hex = sha256_hex(&bytes); + repository + .put_blob(&content_key, Bytes::from(bytes)) + .await?; + + let table_row_count = table.rows.len() as u64; + row_count += table_row_count; + table_entries.push(TableBackupEntry { + table_name: table.table_name.clone(), + row_count: table_row_count, + content_key, + sha256_hex, + }); + } + + let mut object_entries = Vec::with_capacity(plan.objects.len()); + for object in &plan.objects { + let bytes = objects.load_object(object).await?; + let actual = sha256_hex(&bytes); + let content_key = object_content_key(&object.sha256_hex); + if actual != object.sha256_hex { + return Err(BackupError::ChecksumMismatch { + key: content_key, + expected: object.sha256_hex.clone(), + actual, + }); + } + + if repository.blob_exists(&content_key).await? { + let existing = repository.get_blob(&content_key).await?; + let existing_hash = sha256_hex(&existing); + if existing_hash != object.sha256_hex { + return Err(BackupError::ChecksumMismatch { + key: content_key, + expected: object.sha256_hex.clone(), + actual: existing_hash, + }); + } + } else { + repository.put_blob(&content_key, bytes).await?; + } + + object_entries.push(ObjectBackupEntry { + object_id: object.object_id, + storage_key: object.storage_key.clone(), + content_key, + sha256_hex: object.sha256_hex.clone(), + size_bytes: object.size_bytes, + mime_type: object.mime_type.clone(), + }); + } + + let mut manifest = BackupSnapshotManifest { + format_version: BACKUP_FORMAT_VERSION, + snapshot_id: request.snapshot_id, + parent_snapshot_id: None, + created_at: request.created_at, + app_id: request.app_id, + app_version: request.app_version, + graphql_orm_schema_version: plan.schema.migration_version, + graphql_orm_schema_hash: plan.schema.schema_hash, + database_backend: plan.schema.backend, + backup_kind: BackupKind::Full, + database: DatabaseBackupManifest { + export_format: DATABASE_EXPORT_FORMAT.to_string(), + row_count, + table_count: table_entries.len() as u64, + tables: table_entries, + }, + objects: object_entries, + tombstones: Vec::new(), + checksum: String::new(), + }; + + write_manifest(repository, &mut manifest).await?; + + Ok(FullBackupResult { manifest }) +} + +pub async fn write_manifest( + repository: &dyn BackupRepository, + manifest: &mut BackupSnapshotManifest, +) -> Result<(), BackupError> { + set_manifest_checksum(manifest)?; + let body = serde_json::to_vec_pretty(manifest)?; + repository + .put_blob( + &snapshot_manifest_key(manifest.snapshot_id), + Bytes::from(body), + ) + .await +} + +#[must_use] +pub fn bytes_sha256_hex(bytes: &[u8]) -> String { + sha256_hex(bytes) +} + +fn serialize_table_export(table: &BackupTableExport) -> Result, BackupError> { + let mut bytes = Vec::new(); + for row in &table.rows { + let serialized = SerializedBackupRow::from(row); + serde_json::to_writer(&mut bytes, &serialized)?; + bytes.push(b'\n'); + } + Ok(bytes) +} + +#[derive(Serialize)] +struct SerializedBackupRow<'a> { + table_name: &'a str, + primary_key: &'a str, + row_hash: &'a str, + values: &'a serde_json::Map, +} + +impl<'a> From<&'a BackupRow> for SerializedBackupRow<'a> { + fn from(row: &'a BackupRow) -> Self { + Self { + table_name: &row.table_name, + primary_key: &row.primary_key, + row_hash: &row.row_hash, + values: &row.values, + } + } +} diff --git a/crates/graphql-orm-backup/src/database.rs b/crates/graphql-orm-backup/src/database.rs new file mode 100644 index 00000000..f5594ffe --- /dev/null +++ b/crates/graphql-orm-backup/src/database.rs @@ -0,0 +1,66 @@ +use async_trait::async_trait; +use serde_json::Value; +use uuid::Uuid; + +use crate::{BackupError, RestoreContext}; + +#[async_trait] +pub trait GraphqlOrmBackupAdapter: Send + Sync { + async fn schema_snapshot(&self) -> Result; + + async fn export_full(&self) -> Result, BackupError>; + + async fn export_incremental( + &self, + parent_snapshot_id: Uuid, + ) -> Result, BackupError>; + + async fn restore_full( + &self, + export: Vec, + context: RestoreContext, + ) -> Result<(), BackupError>; + + async fn restore_incremental( + &self, + changes: Vec, + context: RestoreContext, + ) -> Result<(), BackupError>; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GraphqlOrmBackupSchema { + pub backend: String, + pub migration_version: String, + pub schema_hash: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BackupTableExport { + pub table_name: String, + pub rows: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BackupRow { + pub table_name: String, + pub primary_key: String, + pub row_hash: String, + pub values: serde_json::Map, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BackupChangeAction { + Create, + Update, + Delete, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BackupChangeExport { + pub table_name: String, + pub primary_key: String, + pub action: BackupChangeAction, + pub row: Option, + pub changed_at: i64, +} diff --git a/crates/graphql-orm-backup/src/error.rs b/crates/graphql-orm-backup/src/error.rs new file mode 100644 index 00000000..a24de35e --- /dev/null +++ b/crates/graphql-orm-backup/src/error.rs @@ -0,0 +1,48 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum BackupError { + #[error("unsupported backup provider: {provider}")] + UnsupportedProvider { provider: String }, + + #[error("invalid backup repository key: {key}")] + InvalidRepositoryKey { key: String }, + + #[error("backup blob is missing: {key}")] + MissingBlob { key: String }, + + #[error("checksum mismatch for {key}: expected {expected}, actual {actual}")] + ChecksumMismatch { + key: String, + expected: String, + actual: String, + }, + + #[error("restore target is not empty")] + RestoreTargetNotEmpty, + + #[error("invalid manifest chain: {reason}")] + InvalidManifestChain { reason: String }, + + #[error("unsupported operation: {operation}")] + UnsupportedOperation { operation: String }, + + #[error("serialization error")] + Serialization(#[from] serde_json::Error), + + #[error("backup io error at {path:?}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +impl BackupError { + pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } +} diff --git a/crates/graphql-orm-backup/src/lib.rs b/crates/graphql-orm-backup/src/lib.rs new file mode 100644 index 00000000..3141e1b8 --- /dev/null +++ b/crates/graphql-orm-backup/src/lib.rs @@ -0,0 +1,39 @@ +//! Backup and restore orchestration primitives for graphql-orm applications. +//! +//! This crate coordinates database export/import adapters, stored object indexes, +//! backup repositories, snapshot manifests, verification, and restore planning. + +mod backup; +mod database; +mod error; +#[cfg(feature = "local")] +mod local_repository; +mod manifest; +mod object_index; +mod planner; +mod repository; +mod restore; +mod verify; + +pub use backup::{ + DATABASE_EXPORT_FORMAT, FullBackupRequest, FullBackupResult, bytes_sha256_hex, + create_full_backup, database_changes_key, database_table_key, object_content_key, + snapshot_manifest_key, write_manifest, +}; +pub use database::{ + BackupChangeAction, BackupChangeExport, BackupRow, BackupTableExport, GraphqlOrmBackupAdapter, + GraphqlOrmBackupSchema, +}; +pub use error::BackupError; +#[cfg(feature = "local")] +pub use local_repository::LocalBackupRepository; +pub use manifest::{ + BACKUP_FORMAT_VERSION, BackupKind, BackupSnapshotManifest, BackupTombstone, + DatabaseBackupManifest, ObjectBackupEntry, TableBackupEntry, manifest_checksum, + set_manifest_checksum, verify_manifest_checksum, +}; +pub use object_index::{BackupObjectIndex, BackupObjectRef}; +pub use planner::{FullBackupPlan, plan_full_backup}; +pub use repository::BackupRepository; +pub use restore::{RestoreContext, RestoreMode, ensure_empty_restore_target}; +pub use verify::{verify_manifest_and_objects, verify_object_checksums}; diff --git a/crates/graphql-orm-backup/src/local_repository.rs b/crates/graphql-orm-backup/src/local_repository.rs new file mode 100644 index 00000000..afed0819 --- /dev/null +++ b/crates/graphql-orm-backup/src/local_repository.rs @@ -0,0 +1,147 @@ +use std::path::{Component, Path, PathBuf}; + +use async_trait::async_trait; +use bytes::Bytes; + +use crate::{BackupError, BackupRepository}; + +#[derive(Clone, Debug)] +pub struct LocalBackupRepository { + root: PathBuf, +} + +impl LocalBackupRepository { + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + fn path_for(&self, key: &str) -> Result { + validate_repository_key(key)?; + Ok(self.root.join(Path::new(key))) + } +} + +#[async_trait] +impl BackupRepository for LocalBackupRepository { + async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError> { + let path = self.path_for(key)?; + let parent = path + .parent() + .ok_or_else(|| BackupError::InvalidRepositoryKey { + key: key.to_string(), + })?; + tokio::fs::create_dir_all(parent) + .await + .map_err(|source| BackupError::io(parent, source))?; + + let temp_path = path.with_extension("uploading"); + tokio::fs::write(&temp_path, body) + .await + .map_err(|source| BackupError::io(&temp_path, source))?; + tokio::fs::rename(&temp_path, &path) + .await + .map_err(|source| BackupError::io(&path, source))?; + Ok(()) + } + + async fn get_blob(&self, key: &str) -> Result { + let path = self.path_for(key)?; + match tokio::fs::read(&path).await { + Ok(bytes) => Ok(Bytes::from(bytes)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Err(BackupError::MissingBlob { + key: key.to_string(), + }) + } + Err(source) => Err(BackupError::io(&path, source)), + } + } + + async fn blob_exists(&self, key: &str) -> Result { + let path = self.path_for(key)?; + match tokio::fs::metadata(&path).await { + Ok(metadata) => Ok(metadata.is_file()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(BackupError::io(&path, source)), + } + } + + async fn list_blobs(&self, prefix: &str) -> Result, BackupError> { + if !prefix.is_empty() { + validate_repository_key(prefix)?; + } + + let start = if prefix.is_empty() { + self.root.clone() + } else { + self.root.join(prefix) + }; + + let mut result = Vec::new(); + let mut stack = vec![start]; + + while let Some(path) = stack.pop() { + let metadata = match tokio::fs::metadata(&path).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(source) => return Err(BackupError::io(&path, source)), + }; + + if metadata.is_file() { + if let Ok(relative) = path.strip_prefix(&self.root) { + result.push(relative.to_string_lossy().replace('\\', "/")); + } + continue; + } + + let mut entries = tokio::fs::read_dir(&path) + .await + .map_err(|source| BackupError::io(&path, source))?; + while let Some(entry) = entries + .next_entry() + .await + .map_err(|source| BackupError::io(&path, source))? + { + stack.push(entry.path()); + } + } + + result.sort(); + Ok(result) + } + + async fn delete_blob(&self, key: &str) -> Result<(), BackupError> { + let path = self.path_for(key)?; + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(BackupError::io(&path, source)), + } + } +} + +fn validate_repository_key(key: &str) -> Result<(), BackupError> { + if key.is_empty() { + return Err(BackupError::InvalidRepositoryKey { + key: key.to_string(), + }); + } + + let path = Path::new(key); + if path.is_absolute() { + return Err(BackupError::InvalidRepositoryKey { + key: key.to_string(), + }); + } + + for component in path.components() { + if !matches!(component, Component::Normal(_)) { + return Err(BackupError::InvalidRepositoryKey { + key: key.to_string(), + }); + } + } + + Ok(()) +} diff --git a/crates/graphql-orm-backup/src/manifest.rs b/crates/graphql-orm-backup/src/manifest.rs new file mode 100644 index 00000000..b5f94b50 --- /dev/null +++ b/crates/graphql-orm-backup/src/manifest.rs @@ -0,0 +1,96 @@ +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::BackupError; + +pub const BACKUP_FORMAT_VERSION: u32 = 1; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum BackupKind { + Full, + Incremental, + SyntheticFull, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct BackupSnapshotManifest { + pub format_version: u32, + pub snapshot_id: Uuid, + pub parent_snapshot_id: Option, + pub created_at: i64, + pub app_id: String, + pub app_version: String, + pub graphql_orm_schema_version: String, + pub graphql_orm_schema_hash: String, + pub database_backend: String, + pub backup_kind: BackupKind, + pub database: DatabaseBackupManifest, + pub objects: Vec, + pub tombstones: Vec, + pub checksum: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct DatabaseBackupManifest { + pub export_format: String, + pub row_count: u64, + pub table_count: u64, + pub tables: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TableBackupEntry { + pub table_name: String, + pub row_count: u64, + pub content_key: String, + pub sha256_hex: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ObjectBackupEntry { + pub object_id: Uuid, + pub storage_key: String, + pub content_key: String, + pub sha256_hex: String, + pub size_bytes: u64, + pub mime_type: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct BackupTombstone { + pub table_name: Option, + pub primary_key: Option, + pub object_id: Option, + pub deleted_at: i64, +} + +pub fn manifest_checksum(manifest: &BackupSnapshotManifest) -> Result { + let mut canonical = manifest.clone(); + canonical.checksum.clear(); + let bytes = serde_json::to_vec(&canonical)?; + Ok(sha256_hex(&bytes)) +} + +pub fn set_manifest_checksum(manifest: &mut BackupSnapshotManifest) -> Result<(), BackupError> { + manifest.checksum = manifest_checksum(manifest)?; + Ok(()) +} + +pub fn verify_manifest_checksum(manifest: &BackupSnapshotManifest) -> Result<(), BackupError> { + let actual = manifest_checksum(manifest)?; + if actual == manifest.checksum { + Ok(()) + } else { + Err(BackupError::ChecksumMismatch { + key: format!("snapshots/{}/manifest.json", manifest.snapshot_id), + expected: manifest.checksum.clone(), + actual, + }) + } +} + +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} diff --git a/crates/graphql-orm-backup/src/object_index.rs b/crates/graphql-orm-backup/src/object_index.rs new file mode 100644 index 00000000..5b25bdbd --- /dev/null +++ b/crates/graphql-orm-backup/src/object_index.rs @@ -0,0 +1,26 @@ +use async_trait::async_trait; +use bytes::Bytes; +use uuid::Uuid; + +use crate::BackupError; + +#[async_trait] +pub trait BackupObjectIndex: Send + Sync { + async fn list_objects_for_full_backup(&self) -> Result, BackupError>; + + async fn list_objects_for_incremental_backup( + &self, + since_snapshot_id: Uuid, + ) -> Result, BackupError>; + + async fn load_object(&self, object: &BackupObjectRef) -> Result; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupObjectRef { + pub object_id: Uuid, + pub storage_key: String, + pub sha256_hex: String, + pub size_bytes: u64, + pub mime_type: Option, +} diff --git a/crates/graphql-orm-backup/src/planner.rs b/crates/graphql-orm-backup/src/planner.rs new file mode 100644 index 00000000..2551471f --- /dev/null +++ b/crates/graphql-orm-backup/src/planner.rs @@ -0,0 +1,26 @@ +use crate::{ + BackupError, BackupObjectIndex, BackupObjectRef, BackupTableExport, GraphqlOrmBackupAdapter, + GraphqlOrmBackupSchema, +}; + +#[derive(Clone, Debug, PartialEq)] +pub struct FullBackupPlan { + pub schema: GraphqlOrmBackupSchema, + pub tables: Vec, + pub objects: Vec, +} + +pub async fn plan_full_backup( + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, +) -> Result { + let schema = database.schema_snapshot().await?; + let tables = database.export_full().await?; + let objects = objects.list_objects_for_full_backup().await?; + + Ok(FullBackupPlan { + schema, + tables, + objects, + }) +} diff --git a/crates/graphql-orm-backup/src/repository.rs b/crates/graphql-orm-backup/src/repository.rs new file mode 100644 index 00000000..4851dd1e --- /dev/null +++ b/crates/graphql-orm-backup/src/repository.rs @@ -0,0 +1,17 @@ +use async_trait::async_trait; +use bytes::Bytes; + +use crate::BackupError; + +#[async_trait] +pub trait BackupRepository: Send + Sync { + async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError>; + + async fn get_blob(&self, key: &str) -> Result; + + async fn blob_exists(&self, key: &str) -> Result; + + async fn list_blobs(&self, prefix: &str) -> Result, BackupError>; + + async fn delete_blob(&self, key: &str) -> Result<(), BackupError>; +} diff --git a/crates/graphql-orm-backup/src/restore.rs b/crates/graphql-orm-backup/src/restore.rs new file mode 100644 index 00000000..d969ad0c --- /dev/null +++ b/crates/graphql-orm-backup/src/restore.rs @@ -0,0 +1,45 @@ +use crate::BackupError; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RestoreMode { + EmptyDatabase, + DryRun, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestoreContext { + pub mode: RestoreMode, + pub disable_policies: bool, + pub disable_change_journal: bool, +} + +impl RestoreContext { + #[must_use] + pub fn empty_database() -> Self { + Self { + mode: RestoreMode::EmptyDatabase, + disable_policies: true, + disable_change_journal: true, + } + } + + #[must_use] + pub fn dry_run() -> Self { + Self { + mode: RestoreMode::DryRun, + disable_policies: true, + disable_change_journal: true, + } + } +} + +pub fn ensure_empty_restore_target( + target_is_empty: bool, + context: &RestoreContext, +) -> Result<(), BackupError> { + match context.mode { + RestoreMode::EmptyDatabase if target_is_empty => Ok(()), + RestoreMode::EmptyDatabase => Err(BackupError::RestoreTargetNotEmpty), + RestoreMode::DryRun => Ok(()), + } +} diff --git a/crates/graphql-orm-backup/src/verify.rs b/crates/graphql-orm-backup/src/verify.rs new file mode 100644 index 00000000..e746e670 --- /dev/null +++ b/crates/graphql-orm-backup/src/verify.rs @@ -0,0 +1,43 @@ +use crate::{ + BackupError, BackupRepository, BackupSnapshotManifest, manifest::sha256_hex, + verify_manifest_checksum, +}; + +pub async fn verify_manifest_and_objects( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, +) -> Result<(), BackupError> { + verify_manifest_checksum(manifest)?; + verify_object_checksums(repository, manifest).await +} + +pub async fn verify_object_checksums( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, +) -> Result<(), BackupError> { + for object in &manifest.objects { + let bytes = repository.get_blob(&object.content_key).await?; + let actual = sha256_hex(&bytes); + if actual != object.sha256_hex { + return Err(BackupError::ChecksumMismatch { + key: object.content_key.clone(), + expected: object.sha256_hex.clone(), + actual, + }); + } + } + + for table in &manifest.database.tables { + let bytes = repository.get_blob(&table.content_key).await?; + let actual = sha256_hex(&bytes); + if actual != table.sha256_hex { + return Err(BackupError::ChecksumMismatch { + key: table.content_key.clone(), + expected: table.sha256_hex.clone(), + actual, + }); + } + } + + Ok(()) +} diff --git a/crates/graphql-orm-backup/tests/full_backup_creation.rs b/crates/graphql-orm-backup/tests/full_backup_creation.rs new file mode 100644 index 00000000..46e53b31 --- /dev/null +++ b/crates/graphql-orm-backup/tests/full_backup_creation.rs @@ -0,0 +1,368 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use bytes::Bytes; +use graphql_orm_backup::{ + BackupChangeExport, BackupError, BackupObjectIndex, BackupObjectRef, BackupRepository, + BackupRow, BackupTableExport, DATABASE_EXPORT_FORMAT, FullBackupRequest, + GraphqlOrmBackupAdapter, GraphqlOrmBackupSchema, RestoreContext, bytes_sha256_hex, + create_full_backup, database_table_key, object_content_key, snapshot_manifest_key, + verify_manifest_and_objects, verify_manifest_checksum, +}; +use serde_json::{Map, Value}; +use uuid::Uuid; + +#[tokio::test] +async fn create_full_backup_writes_tables_objects_and_manifest_last() { + let repository = RecordingRepository::default(); + let object_bytes = Bytes::from_static(b"object"); + let object_hash = bytes_sha256_hex(&object_bytes); + let database = MockDatabase::new(vec![BackupTableExport { + table_name: "users".to_string(), + rows: vec![backup_row("users", "1", &[("name", "Ada")])], + }]); + let objects = MockObjectIndex::new(vec![object_ref(&object_hash)], vec![object_bytes]); + let request = backup_request(); + + let result = create_full_backup(&repository, &database, &objects, request) + .await + .expect("create full backup"); + + verify_manifest_checksum(&result.manifest).expect("manifest checksum verifies"); + verify_manifest_and_objects(&repository, &result.manifest) + .await + .expect("payload checksums verify"); + + let table_key = database_table_key(snapshot_id(), "users"); + assert!(repository.blob_exists(&table_key).await.expect("exists")); + assert!( + repository + .blob_exists(&object_content_key(&object_hash)) + .await + .expect("exists") + ); + assert!( + repository + .blob_exists(&snapshot_manifest_key(snapshot_id())) + .await + .expect("exists") + ); + + let table_bytes = repository.get_blob(&table_key).await.expect("table blob"); + assert!(table_bytes.ends_with(b"\n")); + let first_line = table_bytes + .split(|byte| *byte == b'\n') + .next() + .expect("first jsonl row"); + let row: Value = serde_json::from_slice(first_line).expect("json row"); + assert_eq!(row["table_name"], "users"); + assert_eq!(row["primary_key"], "1"); + + assert_eq!( + repository.write_order().last(), + Some(&snapshot_manifest_key(snapshot_id())) + ); +} + +#[tokio::test] +async fn create_full_backup_deduplicates_existing_object_blob() { + let repository = RecordingRepository::default(); + let object_bytes = Bytes::from_static(b"object"); + let object_hash = bytes_sha256_hex(&object_bytes); + let object_key = object_content_key(&object_hash); + repository + .put_blob(&object_key, object_bytes.clone()) + .await + .expect("prewrite object"); + repository.clear_write_order(); + + let database = MockDatabase::new(vec![BackupTableExport { + table_name: "users".to_string(), + rows: Vec::new(), + }]); + let objects = MockObjectIndex::new(vec![object_ref(&object_hash)], vec![object_bytes]); + + let result = create_full_backup(&repository, &database, &objects, backup_request()) + .await + .expect("create full backup"); + + assert_eq!(result.manifest.objects[0].content_key, object_key); + assert!(!repository.write_order().contains(&object_key)); +} + +#[tokio::test] +async fn create_full_backup_rejects_object_checksum_mismatch() { + let repository = RecordingRepository::default(); + let expected_hash = bytes_sha256_hex(b"expected"); + let database = MockDatabase::new(vec![BackupTableExport { + table_name: "users".to_string(), + rows: Vec::new(), + }]); + let objects = MockObjectIndex::new( + vec![object_ref(&expected_hash)], + vec![Bytes::from_static(b"different")], + ); + + let err = create_full_backup(&repository, &database, &objects, backup_request()) + .await + .expect_err("checksum mismatch"); + + assert!(matches!(err, BackupError::ChecksumMismatch { .. })); + assert!( + !repository + .blob_exists(&snapshot_manifest_key(snapshot_id())) + .await + .expect("exists") + ); +} + +#[tokio::test] +async fn create_full_backup_sets_database_counts() { + let repository = RecordingRepository::default(); + let database = MockDatabase::new(vec![ + BackupTableExport { + table_name: "users".to_string(), + rows: vec![backup_row("users", "1", &[("name", "Ada")])], + }, + BackupTableExport { + table_name: "posts".to_string(), + rows: vec![ + backup_row("posts", "10", &[("title", "First")]), + backup_row("posts", "11", &[("title", "Second")]), + ], + }, + ]); + let objects = MockObjectIndex::new(Vec::new(), Vec::new()); + + let result = create_full_backup(&repository, &database, &objects, backup_request()) + .await + .expect("create full backup"); + + assert_eq!( + result.manifest.database.export_format, + DATABASE_EXPORT_FORMAT + ); + assert_eq!(result.manifest.database.table_count, 2); + assert_eq!(result.manifest.database.row_count, 3); + assert_eq!(result.manifest.database.tables[0].table_name, "users"); + assert_eq!(result.manifest.database.tables[0].row_count, 1); + assert_eq!(result.manifest.database.tables[1].table_name, "posts"); + assert_eq!(result.manifest.database.tables[1].row_count, 2); +} + +#[tokio::test] +async fn create_full_backup_writes_manifest_after_payloads() { + let repository = RecordingRepository::default(); + let database = MockDatabase::new(vec![BackupTableExport { + table_name: "users".to_string(), + rows: Vec::new(), + }]); + let objects = MockObjectIndex::new(Vec::new(), Vec::new()); + + create_full_backup(&repository, &database, &objects, backup_request()) + .await + .expect("create full backup"); + + let writes = repository.write_order(); + assert_eq!(writes.len(), 2); + assert_eq!(writes[0], database_table_key(snapshot_id(), "users")); + assert_eq!(writes[1], snapshot_manifest_key(snapshot_id())); +} + +fn backup_request() -> FullBackupRequest { + FullBackupRequest { + snapshot_id: snapshot_id(), + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + } +} + +fn backup_row(table_name: &str, primary_key: &str, values: &[(&str, &str)]) -> BackupRow { + let mut row_values = Map::new(); + for (key, value) in values { + row_values.insert((*key).to_string(), Value::String((*value).to_string())); + } + + BackupRow { + table_name: table_name.to_string(), + primary_key: primary_key.to_string(), + row_hash: bytes_sha256_hex(primary_key.as_bytes()), + values: row_values, + } +} + +fn object_ref(hash: &str) -> BackupObjectRef { + BackupObjectRef { + object_id: object_id(), + storage_key: "objects/original.txt".to_string(), + sha256_hex: hash.to_string(), + size_bytes: 6, + mime_type: Some("text/plain".to_string()), + } +} + +fn snapshot_id() -> Uuid { + Uuid::parse_str("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb").expect("valid uuid") +} + +fn object_id() -> Uuid { + Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid") +} + +#[derive(Clone, Default)] +struct RecordingRepository { + blobs: Arc>>, + writes: Arc>>, +} + +impl RecordingRepository { + fn write_order(&self) -> Vec { + self.writes.lock().expect("writes lock").clone() + } + + fn clear_write_order(&self) { + self.writes.lock().expect("writes lock").clear(); + } +} + +#[async_trait] +impl BackupRepository for RecordingRepository { + async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError> { + self.blobs + .lock() + .expect("blobs lock") + .insert(key.to_string(), body); + self.writes + .lock() + .expect("writes lock") + .push(key.to_string()); + Ok(()) + } + + async fn get_blob(&self, key: &str) -> Result { + self.blobs + .lock() + .expect("blobs lock") + .get(key) + .cloned() + .ok_or_else(|| BackupError::MissingBlob { + key: key.to_string(), + }) + } + + async fn blob_exists(&self, key: &str) -> Result { + Ok(self.blobs.lock().expect("blobs lock").contains_key(key)) + } + + async fn list_blobs(&self, prefix: &str) -> Result, BackupError> { + let mut keys = self + .blobs + .lock() + .expect("blobs lock") + .keys() + .filter(|key| key.starts_with(prefix)) + .cloned() + .collect::>(); + keys.sort(); + Ok(keys) + } + + async fn delete_blob(&self, key: &str) -> Result<(), BackupError> { + self.blobs.lock().expect("blobs lock").remove(key); + Ok(()) + } +} + +struct MockDatabase { + tables: Vec, +} + +impl MockDatabase { + fn new(tables: Vec) -> Self { + Self { tables } + } +} + +#[async_trait] +impl GraphqlOrmBackupAdapter for MockDatabase { + async fn schema_snapshot(&self) -> Result { + Ok(GraphqlOrmBackupSchema { + backend: "sqlite".to_string(), + migration_version: "20260514000000".to_string(), + schema_hash: "schema-hash".to_string(), + }) + } + + async fn export_full(&self) -> Result, BackupError> { + Ok(self.tables.clone()) + } + + async fn export_incremental( + &self, + _parent_snapshot_id: Uuid, + ) -> Result, BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "mock incremental export".to_string(), + }) + } + + async fn restore_full( + &self, + _export: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "mock full restore".to_string(), + }) + } + + async fn restore_incremental( + &self, + _changes: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "mock incremental restore".to_string(), + }) + } +} + +struct MockObjectIndex { + objects: Vec, + bytes: Vec, +} + +impl MockObjectIndex { + fn new(objects: Vec, bytes: Vec) -> Self { + Self { objects, bytes } + } +} + +#[async_trait] +impl BackupObjectIndex for MockObjectIndex { + async fn list_objects_for_full_backup(&self) -> Result, BackupError> { + Ok(self.objects.clone()) + } + + async fn list_objects_for_incremental_backup( + &self, + _since_snapshot_id: Uuid, + ) -> Result, BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "mock incremental object list".to_string(), + }) + } + + async fn load_object(&self, object: &BackupObjectRef) -> Result { + let index = self + .objects + .iter() + .position(|candidate| candidate.object_id == object.object_id) + .expect("object exists"); + Ok(self.bytes[index].clone()) + } +} diff --git a/crates/graphql-orm-backup/tests/local_repository_round_trip.rs b/crates/graphql-orm-backup/tests/local_repository_round_trip.rs new file mode 100644 index 00000000..e7361260 --- /dev/null +++ b/crates/graphql-orm-backup/tests/local_repository_round_trip.rs @@ -0,0 +1,142 @@ +use bytes::Bytes; +use graphql_orm_backup::{ + BACKUP_FORMAT_VERSION, BackupError, BackupKind, BackupRepository, BackupSnapshotManifest, + DatabaseBackupManifest, LocalBackupRepository, ObjectBackupEntry, TableBackupEntry, + bytes_sha256_hex, object_content_key, set_manifest_checksum, verify_object_checksums, +}; +use tempfile::TempDir; +use uuid::Uuid; + +#[tokio::test] +async fn local_repository_put_get_list_delete_round_trip() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + + repository + .put_blob("snapshots/a/manifest.json", Bytes::from_static(b"manifest")) + .await + .expect("put blob"); + repository + .put_blob("objects/sha256/aa/bb/aabb", Bytes::from_static(b"object")) + .await + .expect("put blob"); + + assert!( + repository + .blob_exists("snapshots/a/manifest.json") + .await + .expect("exists check") + ); + assert_eq!( + repository + .get_blob("snapshots/a/manifest.json") + .await + .expect("get blob"), + Bytes::from_static(b"manifest") + ); + + let listed = repository + .list_blobs("snapshots") + .await + .expect("list blobs"); + assert_eq!(listed, vec!["snapshots/a/manifest.json"]); + + repository + .delete_blob("snapshots/a/manifest.json") + .await + .expect("delete blob"); + assert!( + !repository + .blob_exists("snapshots/a/manifest.json") + .await + .expect("exists check") + ); +} + +#[tokio::test] +async fn local_repository_rejects_path_traversal_keys() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + + let err = repository + .put_blob("../escape", Bytes::from_static(b"bad")) + .await + .expect_err("path traversal rejected"); + + assert!(matches!(err, BackupError::InvalidRepositoryKey { .. })); +} + +#[tokio::test] +async fn verification_fails_when_object_blob_is_missing() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let manifest = sample_manifest_with_object_hash(bytes_sha256_hex(b"object")); + + let err = verify_object_checksums(&repository, &manifest) + .await + .expect_err("missing object should fail verification"); + + assert!(matches!(err, BackupError::MissingBlob { .. })); +} + +#[tokio::test] +async fn verification_fails_when_object_checksum_mismatches() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let manifest = sample_manifest_with_object_hash(bytes_sha256_hex(b"expected")); + + repository + .put_blob( + &manifest.objects[0].content_key, + Bytes::from_static(b"different"), + ) + .await + .expect("put object"); + + let err = verify_object_checksums(&repository, &manifest) + .await + .expect_err("checksum mismatch should fail verification"); + + assert!(matches!(err, BackupError::ChecksumMismatch { .. })); +} + +fn sample_manifest_with_object_hash(object_hash: String) -> BackupSnapshotManifest { + let object_blob_key = object_content_key(&object_hash); + let table_bytes = b""; + let table_hash = bytes_sha256_hex(table_bytes); + let mut manifest = BackupSnapshotManifest { + format_version: BACKUP_FORMAT_VERSION, + snapshot_id: Uuid::parse_str("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb").expect("valid uuid"), + parent_snapshot_id: None, + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + graphql_orm_schema_version: "20260514000000".to_string(), + graphql_orm_schema_hash: "schema-hash".to_string(), + database_backend: "sqlite".to_string(), + backup_kind: BackupKind::Full, + database: DatabaseBackupManifest { + export_format: "jsonl.zst".to_string(), + row_count: 0, + table_count: 1, + tables: vec![TableBackupEntry { + table_name: "storage".to_string(), + row_count: 0, + content_key: object_content_key(&table_hash), + sha256_hex: table_hash, + }], + }, + objects: vec![ObjectBackupEntry { + object_id: Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid"), + storage_key: "originals/aa/bb/object.txt".to_string(), + content_key: object_blob_key, + sha256_hex: object_hash, + size_bytes: 6, + mime_type: Some("text/plain".to_string()), + }], + tombstones: Vec::new(), + checksum: String::new(), + }; + set_manifest_checksum(&mut manifest).expect("set checksum"); + manifest +} diff --git a/crates/graphql-orm-backup/tests/manifest_round_trip.rs b/crates/graphql-orm-backup/tests/manifest_round_trip.rs new file mode 100644 index 00000000..425624b0 --- /dev/null +++ b/crates/graphql-orm-backup/tests/manifest_round_trip.rs @@ -0,0 +1,213 @@ +use async_trait::async_trait; +use bytes::Bytes; +use graphql_orm_backup::{ + BACKUP_FORMAT_VERSION, BackupChangeExport, BackupError, BackupKind, BackupObjectIndex, + BackupObjectRef, BackupSnapshotManifest, BackupTableExport, BackupTombstone, + DatabaseBackupManifest, FullBackupPlan, GraphqlOrmBackupAdapter, GraphqlOrmBackupSchema, + ObjectBackupEntry, RestoreContext, TableBackupEntry, bytes_sha256_hex, + ensure_empty_restore_target, manifest_checksum, object_content_key, plan_full_backup, + set_manifest_checksum, verify_manifest_checksum, +}; +use uuid::Uuid; + +#[test] +fn manifest_serializes_round_trips_and_verifies_checksum() { + let mut manifest = sample_manifest(); + set_manifest_checksum(&mut manifest).expect("set checksum"); + + let encoded = serde_json::to_string(&manifest).expect("serialize manifest"); + let decoded: BackupSnapshotManifest = + serde_json::from_str(&encoded).expect("deserialize manifest"); + + assert_eq!(decoded, manifest); + verify_manifest_checksum(&decoded).expect("checksum verifies"); +} + +#[test] +fn manifest_checksum_is_stable_and_excludes_checksum_field() { + let mut manifest = sample_manifest(); + let first = manifest_checksum(&manifest).expect("checksum"); + manifest.checksum = "ignored-by-canonical-checksum".to_string(); + let second = manifest_checksum(&manifest).expect("checksum"); + + assert_eq!(first, second); +} + +#[test] +fn object_content_key_uses_sha256_shards() { + let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + assert_eq!( + object_content_key(hash), + "objects/sha256/ab/cd/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + ); +} + +#[test] +fn empty_database_restore_refuses_non_empty_target() { + let context = RestoreContext::empty_database(); + let err = ensure_empty_restore_target(false, &context) + .expect_err("non-empty target should be rejected"); + + assert!(matches!(err, BackupError::RestoreTargetNotEmpty)); +} + +#[tokio::test] +async fn full_backup_planner_includes_database_and_objects() { + let database = MockDatabase; + let objects = MockObjectIndex; + + let plan = plan_full_backup(&database, &objects) + .await + .expect("plan full backup"); + + assert_eq!( + plan, + FullBackupPlan { + schema: GraphqlOrmBackupSchema { + backend: "sqlite".to_string(), + migration_version: "20260514000000".to_string(), + schema_hash: "schema-hash".to_string(), + }, + tables: vec![BackupTableExport { + table_name: "storage".to_string(), + rows: Vec::new(), + }], + objects: vec![BackupObjectRef { + object_id: object_id(), + storage_key: "originals/aa/bb/object.txt".to_string(), + sha256_hex: bytes_sha256_hex(b"object"), + size_bytes: 6, + mime_type: Some("text/plain".to_string()), + }], + } + ); +} + +fn sample_manifest() -> BackupSnapshotManifest { + let table_bytes = b"{\"id\":\"1\"}\n"; + let object_bytes = b"object"; + let table_hash = bytes_sha256_hex(table_bytes); + let object_hash = bytes_sha256_hex(object_bytes); + + BackupSnapshotManifest { + format_version: BACKUP_FORMAT_VERSION, + snapshot_id: snapshot_id(), + parent_snapshot_id: None, + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + graphql_orm_schema_version: "20260514000000".to_string(), + graphql_orm_schema_hash: "schema-hash".to_string(), + database_backend: "sqlite".to_string(), + backup_kind: BackupKind::Full, + database: DatabaseBackupManifest { + export_format: "jsonl.zst".to_string(), + row_count: 1, + table_count: 1, + tables: vec![TableBackupEntry { + table_name: "storage".to_string(), + row_count: 1, + content_key: "snapshots/snapshot/database/tables/storage.jsonl.zst".to_string(), + sha256_hex: table_hash, + }], + }, + objects: vec![ObjectBackupEntry { + object_id: object_id(), + storage_key: "originals/aa/bb/object.txt".to_string(), + content_key: object_content_key(&object_hash), + sha256_hex: object_hash, + size_bytes: 6, + mime_type: Some("text/plain".to_string()), + }], + tombstones: vec![BackupTombstone { + table_name: Some("storage".to_string()), + primary_key: Some("deleted-row".to_string()), + object_id: None, + deleted_at: 1_775_174_401, + }], + checksum: String::new(), + } +} + +fn snapshot_id() -> Uuid { + Uuid::parse_str("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb").expect("valid uuid") +} + +fn object_id() -> Uuid { + Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid") +} + +struct MockDatabase; + +#[async_trait] +impl GraphqlOrmBackupAdapter for MockDatabase { + async fn schema_snapshot(&self) -> Result { + Ok(GraphqlOrmBackupSchema { + backend: "sqlite".to_string(), + migration_version: "20260514000000".to_string(), + schema_hash: "schema-hash".to_string(), + }) + } + + async fn export_full(&self) -> Result, BackupError> { + Ok(vec![BackupTableExport { + table_name: "storage".to_string(), + rows: Vec::new(), + }]) + } + + async fn export_incremental( + &self, + _parent_snapshot_id: Uuid, + ) -> Result, BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "mock incremental export".to_string(), + }) + } + + async fn restore_full( + &self, + _export: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + Ok(()) + } + + async fn restore_incremental( + &self, + _changes: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "mock incremental restore".to_string(), + }) + } +} + +struct MockObjectIndex; + +#[async_trait] +impl BackupObjectIndex for MockObjectIndex { + async fn list_objects_for_full_backup(&self) -> Result, BackupError> { + Ok(vec![BackupObjectRef { + object_id: object_id(), + storage_key: "originals/aa/bb/object.txt".to_string(), + sha256_hex: bytes_sha256_hex(b"object"), + size_bytes: 6, + mime_type: Some("text/plain".to_string()), + }]) + } + + async fn list_objects_for_incremental_backup( + &self, + _since_snapshot_id: Uuid, + ) -> Result, BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "mock incremental object list".to_string(), + }) + } + + async fn load_object(&self, _object: &BackupObjectRef) -> Result { + Ok(Bytes::from_static(b"object")) + } +} From 6b52d7300f1b7b5960be5aa9215a2862a08a1ae3 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 14 May 2026 01:46:01 +0000 Subject: [PATCH 002/108] Initial graphql-orm storage library --- .../skills/graphql-orm-macros/SKILL.md | 90 +++ .../.agents/skills/rust-skills/SKILL.md | 335 ++++++++ crates/graphql-orm-storage/.gitignore | 2 + crates/graphql-orm-storage/AGENTS.md | 19 + crates/graphql-orm-storage/Cargo.lock | 748 ++++++++++++++++++ crates/graphql-orm-storage/Cargo.toml | 31 + crates/graphql-orm-storage/LICENSE | 21 + crates/graphql-orm-storage/README.md | 120 +++ .../graphql-orm-storage/docs/architecture.md | 89 +++ .../docs/digitise-extraction-notes.md | 34 + crates/graphql-orm-storage/docs/plan.md | 58 ++ .../docs/provider-roadmap.md | 46 ++ crates/graphql-orm-storage/docs/usage.md | 196 +++++ crates/graphql-orm-storage/src/azure.rs | 89 +++ crates/graphql-orm-storage/src/backend.rs | 81 ++ crates/graphql-orm-storage/src/checksum.rs | 9 + crates/graphql-orm-storage/src/error.rs | 35 + crates/graphql-orm-storage/src/key.rs | 48 ++ crates/graphql-orm-storage/src/lib.rs | 30 + crates/graphql-orm-storage/src/local.rs | 106 +++ crates/graphql-orm-storage/src/object.rs | 49 ++ crates/graphql-orm-storage/src/s3.rs | 89 +++ crates/graphql-orm-storage/src/service.rs | 114 +++ crates/graphql-orm-storage/tests/core.rs | 94 +++ .../tests/local_round_trip.rs | 184 +++++ .../tests/provider_placeholders.rs | 110 +++ 26 files changed, 2827 insertions(+) create mode 100644 crates/graphql-orm-storage/.agents/skills/graphql-orm-macros/SKILL.md create mode 100644 crates/graphql-orm-storage/.agents/skills/rust-skills/SKILL.md create mode 100644 crates/graphql-orm-storage/.gitignore create mode 100644 crates/graphql-orm-storage/AGENTS.md create mode 100644 crates/graphql-orm-storage/Cargo.lock create mode 100644 crates/graphql-orm-storage/Cargo.toml create mode 100644 crates/graphql-orm-storage/LICENSE create mode 100644 crates/graphql-orm-storage/README.md create mode 100644 crates/graphql-orm-storage/docs/architecture.md create mode 100644 crates/graphql-orm-storage/docs/digitise-extraction-notes.md create mode 100644 crates/graphql-orm-storage/docs/plan.md create mode 100644 crates/graphql-orm-storage/docs/provider-roadmap.md create mode 100644 crates/graphql-orm-storage/docs/usage.md create mode 100644 crates/graphql-orm-storage/src/azure.rs create mode 100644 crates/graphql-orm-storage/src/backend.rs create mode 100644 crates/graphql-orm-storage/src/checksum.rs create mode 100644 crates/graphql-orm-storage/src/error.rs create mode 100644 crates/graphql-orm-storage/src/key.rs create mode 100644 crates/graphql-orm-storage/src/lib.rs create mode 100644 crates/graphql-orm-storage/src/local.rs create mode 100644 crates/graphql-orm-storage/src/object.rs create mode 100644 crates/graphql-orm-storage/src/s3.rs create mode 100644 crates/graphql-orm-storage/src/service.rs create mode 100644 crates/graphql-orm-storage/tests/core.rs create mode 100644 crates/graphql-orm-storage/tests/local_round_trip.rs create mode 100644 crates/graphql-orm-storage/tests/provider_placeholders.rs diff --git a/crates/graphql-orm-storage/.agents/skills/graphql-orm-macros/SKILL.md b/crates/graphql-orm-storage/.agents/skills/graphql-orm-macros/SKILL.md new file mode 100644 index 00000000..312de2a9 --- /dev/null +++ b/crates/graphql-orm-storage/.agents/skills/graphql-orm-macros/SKILL.md @@ -0,0 +1,90 @@ +--- +name: graphql-orm-macros +description: > + Use when working on the graphql-orm runtime plus graphql-orm-macros derive + layer for GraphQL entities, relation resolvers, CRUD operations, schema roots, + migrations, and runtime metadata integration. +--- + +# graphql-orm Skill + +## Use This Skill When + +- adding `mutation_result!` result types +- deriving `GraphQLEntity` +- deriving `GraphQLRelations` +- deriving `GraphQLOperations` +- composing schema roots with `schema_roots!` +- reviewing relation loading behavior or N+1 implications +- changing runtime metadata, query rendering, schema diffing, or migration support +- checking backend-specific SQLite/PostgreSQL behavior + +## Crates + +- Application dependency: `graphql-orm` +- Runtime repo: `https://github.com/Dastari/graphql-orm` +- Macro repo: `https://github.com/Dastari/graphql-orm-macros` + +## Preferred Usage + +Import through the runtime crate: + +- `use graphql_orm::prelude::*;` +- `use graphql_orm::mutation_result;` +- derive macros by name on structs + +For `digitise`, treat `graphql-orm` as the only normal dependency surface. Do not add a direct `graphql-orm-macros` dependency unless you are explicitly developing or debugging the proc-macro crate itself. + +## Integration Rules + +1. Use the runtime-plus-macro split correctly. +`digitise` should depend on `graphql-orm`. Generated code comes from the re-exported macros, but runtime behavior, metadata, query rendering, relation loading, and migrations belong to `graphql-orm`. + +This is the default assumption for new work. If a change requires touching the macro crate, do that in the shared library repo, but keep `digitise` depending only on `graphql-orm`. + +2. Use the macros for boilerplate, not business logic. +The application should still own domain logic, permission checks, store implementations, and resolver orchestration. + +3. Keep generated types aligned with async-graphql. +If a macro-generated GraphQL object wraps an entity field, ensure the entity type itself is compatible with async-graphql output expectations. + +4. Treat the stack as backend/framework-opinionated. +It is domain-generic, but it still assumes an async-graphql + ORM-style host environment. Do not assume it is a fully generic Rust macro toolkit. + +5. Use the generic notify hook pattern, not project-specific hard-coding. +If a mutation needs side effects after create/update/delete, prefer the `notify` / `notify_with` hook path model exposed by the macro crate. + +6. Watch relation performance. +For nested relations, understand whether the generated path is batched or falls back to direct queries. Use this crate when the problem is macro-generated relation behavior, not when the issue is application auth. + +7. Keep persistence backend-agnostic at the app layer. +Backend-specific SQL rendering, migration planning, and schema introspection belong in `graphql-orm`, not in `digitise`. + +8. Prefer the runtime surface over old host-crate assumptions. +Generated code should target `::graphql_orm::*`. Avoid reintroducing assumptions that the application must expose `crate::db`, `crate::graphql::orm`, or similar legacy module shapes. + +## When Not To Use + +- when implementing authentication, refresh tokens, or guards +- when working on frontend-only GraphQL calls +- when you just need handwritten simple types and the macro would add unnecessary coupling + +## Common Pattern + +```rust +use graphql_orm::mutation_result; + +#[derive(async_graphql::SimpleObject, Clone, Debug)] +struct User { + id: String, +} + +mutation_result!(LoginResult, user: User); +``` + +## Project Guidance + +- use `graphql-orm` as the application-facing dependency and macro re-export surface +- in `digitise`, do not depend on `graphql-orm-macros` directly +- keep `digitise` responsible for choosing when derive-based boilerplate is worth the coupling +- if the needed change would improve multiple projects, prefer updating `graphql-orm` or `graphql-orm-macros` rather than patching around them locally diff --git a/crates/graphql-orm-storage/.agents/skills/rust-skills/SKILL.md b/crates/graphql-orm-storage/.agents/skills/rust-skills/SKILL.md new file mode 100644 index 00000000..c0f008d2 --- /dev/null +++ b/crates/graphql-orm-storage/.agents/skills/rust-skills/SKILL.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/crates/graphql-orm-storage/.gitignore b/crates/graphql-orm-storage/.gitignore new file mode 100644 index 00000000..eccd7b4a --- /dev/null +++ b/crates/graphql-orm-storage/.gitignore @@ -0,0 +1,2 @@ +/target/ +**/*.rs.bk diff --git a/crates/graphql-orm-storage/AGENTS.md b/crates/graphql-orm-storage/AGENTS.md new file mode 100644 index 00000000..40043b64 --- /dev/null +++ b/crates/graphql-orm-storage/AGENTS.md @@ -0,0 +1,19 @@ +# graphql-orm-storage Agent Guide + +This crate is a reusable storage companion for applications that use `graphql-orm`. + +## Skills + +- Use `.agents/skills/rust-skills/SKILL.md` for all Rust implementation, review, refactoring, performance, and API design work. +- Use `.agents/skills/graphql-orm-macros/SKILL.md` for graphql-orm integration decisions. + +## Rules + +- Keep the crate generic and reusable. +- Do not add Digitise-specific domain names, entity names, collection semantics, accession logic, record logic, media workflows, or policy assumptions. +- Do not store file bytes in a database. +- Prefer traits and small adapters over application-specific coupling. +- Keep provider-specific code behind feature flags. +- Local filesystem support is the baseline provider. +- S3 and Azure Blob support should be explicit feature-gated work; placeholder paths must return clear unsupported errors until implemented. +- Add tests for path safety, checksums, key generation, and provider round trips. diff --git a/crates/graphql-orm-storage/Cargo.lock b/crates/graphql-orm-storage/Cargo.lock new file mode 100644 index 00000000..ae76e8a8 --- /dev/null +++ b/crates/graphql-orm-storage/Cargo.lock @@ -0,0 +1,748 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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 = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "graphql-orm-storage" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "sha2", + "tempfile", + "thiserror", + "time", + "tokio", + "uuid", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/graphql-orm-storage/Cargo.toml b/crates/graphql-orm-storage/Cargo.toml new file mode 100644 index 00000000..b4014465 --- /dev/null +++ b/crates/graphql-orm-storage/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "graphql-orm-storage" +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/Dastari/graphql-orm-storage" +description = "Provider-neutral object storage primitives for graphql-orm applications" + +[features] +default = ["local"] +local = ["dep:tokio"] +s3 = [] +azure = [] + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" +thiserror = "2" +time = { version = "0.3", features = ["serde"] } +tokio = { version = "1", features = ["fs"], optional = true } +uuid = { version = "1", features = ["serde", "v4"] } + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] } + +[[test]] +name = "local_round_trip" +path = "tests/local_round_trip.rs" +required-features = ["local"] diff --git a/crates/graphql-orm-storage/LICENSE b/crates/graphql-orm-storage/LICENSE new file mode 100644 index 00000000..729415d6 --- /dev/null +++ b/crates/graphql-orm-storage/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dastari + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/graphql-orm-storage/README.md b/crates/graphql-orm-storage/README.md new file mode 100644 index 00000000..f61614f4 --- /dev/null +++ b/crates/graphql-orm-storage/README.md @@ -0,0 +1,120 @@ +# graphql-orm-storage + +Provider-neutral object storage primitives for applications that use `graphql-orm`. + +This crate stores bytes in an object backend and returns metadata that an application can persist in its own `graphql-orm` entity. It deliberately does not define application concepts such as collections, records, accessions, tenants, users, or media workflows. + +## Current Status + +- Local filesystem backend implemented. +- Stable object metadata and key generation implemented. +- S3 and Azure Blob expose explicit unsupported placeholder backends behind feature flags for later provider work. + +## Design Rule + +Do not store file bytes in the application database. Store bytes in an object backend and persist only metadata in the database. + +The core crate does not provide default GraphQL resolvers. Upload, download, delete, and metadata mutation resolvers need host-application authorization and row-policy logic. Future GraphQL helpers should require the application to inject an explicit access-policy adapter. + +## Cargo Features + +- `local`: enabled by default; provides `LocalStorageBackend`. +- `s3`: provides `S3StorageBackend` and `S3StorageConfig` placeholders that return an unsupported-backend error until real S3-compatible storage is implemented. +- `azure`: provides `AzureBlobStorageBackend` and `AzureBlobStorageConfig` placeholders that return an unsupported-backend error until real Azure Blob Storage is implemented. + +For detailed integration guidance, see [docs/usage.md](docs/usage.md). + +```rust +use std::sync::Arc; + +use graphql_orm_storage::{ + LocalStorageBackend, StorageNamespace, StoragePutRequest, StorageService, +}; + +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +let service = StorageService::new(Arc::new(LocalStorageBackend::new("./data/storage"))); + +let stored = service + .put_object(StoragePutRequest { + namespace: StorageNamespace::Originals, + file_name: Some("artifact.jpg".to_string()), + mime_type: Some("image/jpeg".to_string()), + bytes: b"image bytes".to_vec(), + }) + .await?; + +// Persist this metadata in your application's graphql-orm entity. +let object_id = stored.object_id; +let storage_key = stored.storage_key; +let sha256_hex = stored.sha256_hex; +# Ok(()) +# } +``` + +## Suggested graphql-orm Entity Shape + +Applications should own their metadata entity so they can attach their own tenant, collection, user, or workflow fields. + +```rust +#[derive(GraphQLEntity, GraphQLRelations, GraphQLOperations, async_graphql::SimpleObject)] +#[graphql_entity( + table = "storage", + plural = "StorageItems", + default_sort = "created_at DESC" +)] +pub struct Storage { + #[primary_key] + pub id: graphql_orm::uuid::Uuid, + + #[unique] + pub object_id: graphql_orm::uuid::Uuid, + + pub namespace: String, + pub backend: String, + + #[unique] + pub storage_key: String, + + pub original_file_name: Option, + pub mime_type: Option, + pub size_bytes: i64, + pub sha256_hex: String, + pub created_at: i64, +} +``` + +## Object Keys + +Default object keys use this format: + +```text +{namespace}/{uuid[0..2]}/{uuid[2..4]}/{uuid}.{extension} +``` + +Example: + +```text +originals/6c/57/6c57a6cc-09e6-4a7f-a320-2f2bde4cfd86.jpg +``` + +Only the file extension is copied from the original filename. The original filename is never used as an object path. + +## Provider Roadmap + +1. Local filesystem +2. S3-compatible object storage +3. Azure Blob Storage + +Backup repositories such as Dropbox and SMB belong in `graphql-orm-backup`, not this crate. + +## Verification + +```bash +cargo fmt --check +cargo test +cargo test --all-features +cargo test --no-default-features +cargo check --features s3,azure --no-default-features +cargo clippy --all-features --all-targets -- -D warnings +cargo clippy --no-default-features --lib -- -D warnings +``` diff --git a/crates/graphql-orm-storage/docs/architecture.md b/crates/graphql-orm-storage/docs/architecture.md new file mode 100644 index 00000000..5e2bfe21 --- /dev/null +++ b/crates/graphql-orm-storage/docs/architecture.md @@ -0,0 +1,89 @@ +# graphql-orm-storage Architecture + +## Boundary + +`graphql-orm-storage` owns object bytes and object locators. It does not own database rows. This keeps the crate usable by any application that wants to persist storage metadata differently. + +## GraphQL Resolver Boundary + +The core crate should not provide default GraphQL upload, download, delete, or metadata mutation resolvers. + +Reason: storage authorization is application-specific. Digitise currently combines: + +- `graphql-orm` read/write policy names on metadata entities +- application row-policy checks +- collection membership checks +- platform-admin bypass rules +- route-level bearer-token validation for file download +- route-level upload checks before bytes are accepted + +A generic crate cannot safely know these rules. Shipping generic resolvers that only check "is authenticated" would be too weak for multi-tenant or collection-scoped applications. + +Future GraphQL support should be optional and should expose resolver building blocks, not ready-to-use unaudited endpoints. Any resolver helper must require the host application to provide an authorization adapter. + +Suggested future shape: + +```rust +#[async_trait::async_trait] +pub trait StorageAccessPolicy: Send + Sync { + async fn can_upload( + &self, + context: &Context, + scope: &StorageUploadScope, + ) -> Result; + + async fn can_read( + &self, + context: &Context, + metadata: &Metadata, + ) -> Result; + + async fn can_delete( + &self, + context: &Context, + metadata: &Metadata, + ) -> Result; +} +``` + +The host app should still own: + +- the `graphql-orm` storage metadata entity +- policy names such as `storage.read` and `storage.manage` +- row ownership checks +- upload/download HTTP routes or GraphQL mutation wrappers +- audit logging + +## Data Flow + +1. Caller provides `StoragePutRequest`. +2. `StorageService` generates a UUID object ID. +3. `StorageService` computes the SHA-256 checksum. +4. `StorageService` creates a sharded storage key. +5. `StorageService` delegates byte persistence to `ObjectStorage`. +6. Backend writes bytes and returns the `StoredObject`. +7. Caller persists returned metadata in its own database transaction. + +## Object Key Safety + +The original filename is metadata only. The key generator copies only a sanitized extension. The local backend validates every `storage_key` before joining it with the root path: + +- no absolute paths +- no `..` +- no `.` +- no platform prefix components +- only normal path components + +## Error Model + +The crate uses `StorageError` through `thiserror`. Application code can convert this into its own API or GraphQL error types. + +## Provider Features + +Provider-specific code should live behind cargo features: + +- `local`: default, implemented now +- `s3`: reserved +- `azure`: reserved + +Provider implementations must satisfy the same `ObjectStorage` trait. diff --git a/crates/graphql-orm-storage/docs/digitise-extraction-notes.md b/crates/graphql-orm-storage/docs/digitise-extraction-notes.md new file mode 100644 index 00000000..a48c9103 --- /dev/null +++ b/crates/graphql-orm-storage/docs/digitise-extraction-notes.md @@ -0,0 +1,34 @@ +# Digitise Extraction Notes + +Digitise currently has storage code in: + +- `/home/toby/digitse/src/storage/mod.rs` +- `/home/toby/digitse/src/storage/local.rs` +- `/home/toby/digitse/src/media/mod.rs` +- `/home/toby/digitse/src/domain/entities/media.rs` + +The generic pieces extracted into this crate are: + +- storage backend enum +- namespace enum +- stored object metadata +- object storage trait +- storage service wrapper +- key generation +- local filesystem backend +- checksum generation + +The Digitise-specific pieces intentionally left out are: + +- `Storage` and `Media` entity definitions +- collection ownership +- upload authorization +- download authorization +- content classification +- MIME sniffing +- thumbnail generation +- document preview generation +- audit events +- AI analysis hooks + +Digitise adoption should replace its local storage module with this crate, then keep `MediaService` as the application service that classifies content, writes `Storage` rows, creates optional `Media` rows, and queues derivative work. diff --git a/crates/graphql-orm-storage/docs/plan.md b/crates/graphql-orm-storage/docs/plan.md new file mode 100644 index 00000000..11c37bf3 --- /dev/null +++ b/crates/graphql-orm-storage/docs/plan.md @@ -0,0 +1,58 @@ +# graphql-orm-storage Implementation Plan + +## Goal + +Create a reusable object storage crate for applications that use `graphql-orm`. The crate owns byte storage concerns only. Applications remain responsible for authorization, domain ownership, GraphQL entities, upload routes, download routes, and workflow-specific behavior. + +## What This Crate Provides + +- Provider-neutral object metadata. +- Provider-neutral object storage trait. +- Storage service that generates object IDs, keys, sizes, hashes, and timestamps. +- Local filesystem backend. +- Feature placeholders for S3 and Azure Blob. +- Tests for key generation, checksum generation, local round trips, and path safety. + +## What This Crate Must Not Provide + +- Application auth or policy checks. +- Default GraphQL upload/download resolvers. +- Digitise collection, record, accession, media, or tenant assumptions. +- Database entities that force one application schema. +- File blobs in database rows. +- Backup repository providers such as Dropbox or SMB. + +## Initial Implementation + +1. Define `StorageBackend` and `StorageNamespace`. +2. Define `StoragePutRequest`, `StoredObject`, and `StorageObjectBody`. +3. Define `ObjectStorage`. +4. Define `StorageService`. +5. Implement SHA-256 checksums. +6. Implement safe sharded object key generation. +7. Implement `LocalStorageBackend`. +8. Add tests for the local backend and key safety. + +## Integration Pattern For Applications + +Applications should call `StorageService::put_object`, then persist the returned `StoredObject` fields into their own `graphql-orm` entity. + +If database insertion fails after object storage succeeds, application code should delete the stored object or enqueue an orphan cleanup job. This crate deliberately does not know the application transaction boundary. + +Applications should also own GraphQL resolvers and route handlers. A future optional GraphQL helper must be authorization-adapter driven and must not expose generic upload/download operations without host-provided access checks. + +## Expected Output From A Storage Agent + +- A compilable crate under `/home/toby/graphql-orm-storage`. +- Public API documented in `README.md`. +- Local backend tests passing with `cargo test`. +- Provider roadmap documented. +- Notes explaining what Digitise must change to consume this crate. + +## Future Work + +- Add streaming upload/download APIs so large files do not need to fit in memory. +- Add S3-compatible provider behind the `s3` feature. +- Add Azure Blob provider behind the `azure` feature. +- Add optional object existence and metadata APIs if backup verification needs them. +- Add optional server-side encryption hooks if applications need provider-managed keys. diff --git a/crates/graphql-orm-storage/docs/provider-roadmap.md b/crates/graphql-orm-storage/docs/provider-roadmap.md new file mode 100644 index 00000000..5d01a80d --- /dev/null +++ b/crates/graphql-orm-storage/docs/provider-roadmap.md @@ -0,0 +1,46 @@ +# Provider Roadmap + +## Phase 1: Local Filesystem + +Implemented first because it is required for standalone and self-hosted deployments and is easiest to test deterministically. + +Acceptance criteria: + +- atomic-ish temp write then rename +- parent directory creation +- delete missing object succeeds +- path traversal rejected +- round-trip tests pass + +## Phase 2: S3-Compatible Storage + +Add behind the `s3` feature. + +Expected configuration: + +- endpoint URL +- region +- bucket +- key prefix +- access key +- secret key +- path-style toggle + +The implementation must use the same `storage_key` values as local storage. + +## Phase 3: Azure Blob Storage + +Add behind the `azure` feature. + +Expected configuration: + +- account/container or connection string +- container name +- key prefix +- credentials + +The implementation must use the same `storage_key` values as local storage. + +## Out Of Scope + +Dropbox and SMB are backup repository targets for `graphql-orm-backup`, not primary object storage backends for this crate. diff --git a/crates/graphql-orm-storage/docs/usage.md b/crates/graphql-orm-storage/docs/usage.md new file mode 100644 index 00000000..dd1102df --- /dev/null +++ b/crates/graphql-orm-storage/docs/usage.md @@ -0,0 +1,196 @@ +# Usage Guide + +`graphql-orm-storage` is a byte-storage companion crate. It stores object bytes +in a provider backend and returns metadata that the host application can persist +in its own `graphql-orm` entity. + +The crate deliberately avoids application policy. It does not decide who can +upload, read, delete, or list objects. It also does not define database tables, +GraphQL resolvers, upload routes, download routes, tenant behavior, collection +behavior, media workflows, or audit events. + +## Dependency + +Default local filesystem support: + +```toml +[dependencies] +graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm-storage" } +``` + +Provider-placeholder-only builds: + +```toml +[dependencies] +graphql-orm-storage = { + git = "https://github.com/Dastari/graphql-orm-storage", + default-features = false, + features = ["s3", "azure"], +} +``` + +## Store An Object + +```rust +use std::sync::Arc; + +use graphql_orm_storage::{ + LocalStorageBackend, StorageNamespace, StoragePutRequest, StorageService, +}; + +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +let service = StorageService::new(Arc::new(LocalStorageBackend::new("./data/storage"))); + +let stored = service + .put_object(StoragePutRequest { + namespace: StorageNamespace::Originals, + file_name: Some("artifact.jpg".to_string()), + mime_type: Some("image/jpeg".to_string()), + bytes: b"image bytes".to_vec(), + }) + .await?; + +// Persist these fields in the host application's graphql-orm entity. +let object_id = stored.object_id; +let backend = stored.backend.as_str(); +let namespace = stored.namespace.as_str(); +let storage_key = stored.storage_key; +let size_bytes = stored.size_bytes; +let sha256_hex = stored.sha256_hex; +let created_at = stored.created_at; +# Ok(()) +# } +``` + +## Load Or Delete An Object + +The host application loads its own metadata row first, performs authorization, +then passes the stored metadata to the storage service. + +```rust +# use std::sync::Arc; +# use graphql_orm_storage::{ +# LocalStorageBackend, StorageNamespace, StoragePutRequest, StorageService, +# }; +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +# let service = StorageService::new(Arc::new(LocalStorageBackend::new("./data/storage"))); +# let stored = service.put_object(StoragePutRequest { +# namespace: StorageNamespace::Originals, +# file_name: Some("artifact.jpg".to_string()), +# mime_type: Some("image/jpeg".to_string()), +# bytes: b"image bytes".to_vec(), +# }).await?; +let body = service.get_object(&stored).await?; +assert_eq!(body.object.object_id, stored.object_id); + +service.delete_object(&stored).await?; +# Ok(()) +# } +``` + +## Suggested Metadata Flow + +1. Validate the upload request in the host application. +2. Check application-specific authorization before accepting bytes. +3. Call `StorageService::put_object`. +4. Insert the returned metadata into the host application's `graphql-orm` table. +5. If database insertion fails after storage succeeds, delete the object or + enqueue an orphan cleanup job. +6. On downloads, load the metadata row first, check row-level access, then call + `StorageService::get_object`. +7. On deletes, check access first, delete bytes, then update or delete metadata + according to the host application's workflow. + +## Suggested `graphql-orm` Entity + +Applications own this entity. Add tenant, collection, user, workflow, policy, +or audit fields in the application, not in this crate. + +```rust +#[derive(GraphQLEntity, GraphQLRelations, GraphQLOperations, async_graphql::SimpleObject)] +#[graphql_entity( + table = "storage", + plural = "StorageItems", + default_sort = "created_at DESC" +)] +pub struct Storage { + #[primary_key] + pub id: graphql_orm::uuid::Uuid, + + #[unique] + pub object_id: graphql_orm::uuid::Uuid, + + pub namespace: String, + pub backend: String, + + #[unique] + pub storage_key: String, + + pub original_file_name: Option, + pub mime_type: Option, + pub size_bytes: i64, + pub sha256_hex: String, + pub created_at: i64, +} +``` + +## Object Key Safety + +The original filename is metadata only. The generated storage key copies only a +sanitized extension from the filename. Local storage rejects unsafe keys before +joining them with the root path: + +- empty keys +- absolute paths +- `.` path components +- `..` path components +- empty path components +- backslashes +- NUL bytes +- platform prefix components + +Generated keys use: + +```text +{namespace}/{uuid[0..2]}/{uuid[2..4]}/{uuid}.{extension} +``` + +## Provider Features + +| Feature | Status | Public API | +| --- | --- | --- | +| `local` | Implemented and enabled by default | `LocalStorageBackend` | +| `s3` | Placeholder only | `S3StorageBackend`, `S3StorageConfig` | +| `azure` | Placeholder only | `AzureBlobStorageBackend`, `AzureBlobStorageConfig` | + +The S3 and Azure placeholder backends are intentionally explicit. They expose +the planned configuration shape but return `StorageError::UnsupportedBackend` +for put, get, and delete operations until real provider implementations land. + +## GraphQL Boundary + +The core crate does not provide default GraphQL upload, download, delete, or +metadata mutation resolvers. + +Storage access rules are application-specific. A host application may need +global policy checks, row ownership checks, collection membership checks, +admin bypass behavior, route-level bearer-token validation, audit logging, or +workflow-specific side effects. A reusable storage crate cannot safely infer +those rules. + +Future GraphQL helper work should provide building blocks only and require an +application-supplied authorization adapter. + +## Verification + +Before publishing changes, run: + +```bash +cargo fmt --check +cargo test +cargo test --all-features +cargo test --no-default-features +cargo check --features s3,azure --no-default-features +cargo clippy --all-features --all-targets -- -D warnings +cargo clippy --no-default-features --lib -- -D warnings +``` diff --git a/crates/graphql-orm-storage/src/azure.rs b/crates/graphql-orm-storage/src/azure.rs new file mode 100644 index 00000000..3f62f9ac --- /dev/null +++ b/crates/graphql-orm-storage/src/azure.rs @@ -0,0 +1,89 @@ +use std::fmt; + +use async_trait::async_trait; + +use crate::{ + ObjectStorage, StorageBackend, StorageError, StorageObjectBody, StoredObject, + unsupported_backend, +}; + +/// Configuration for a future Azure Blob Storage backend. +#[derive(Clone, PartialEq, Eq)] +pub struct AzureBlobStorageConfig { + /// Azure storage account name, when not using a connection string. + pub account: Option, + /// Azure connection string. Redacted from debug output. + pub connection_string: Option, + /// Blob container name. + pub container: String, + /// Optional key prefix prepended by the provider implementation. + pub key_prefix: Option, + /// Provider credential material. Redacted from debug output. + pub credential: Option, +} + +impl fmt::Debug for AzureBlobStorageConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AzureBlobStorageConfig") + .field("account", &self.account) + .field( + "connection_string", + &self.connection_string.as_ref().map(|_| ""), + ) + .field("container", &self.container) + .field("key_prefix", &self.key_prefix) + .field( + "credential", + &self.credential.as_ref().map(|_| ""), + ) + .finish() + } +} + +/// Placeholder Azure Blob Storage backend. +/// +/// This type exposes the planned provider shape behind the `azure` feature, but +/// object operations return [`StorageError::UnsupportedBackend`] until real +/// Azure Blob support is implemented. +#[derive(Clone, Debug)] +pub struct AzureBlobStorageBackend { + config: AzureBlobStorageConfig, +} + +impl AzureBlobStorageBackend { + /// Creates a new unsupported Azure Blob backend placeholder. + #[must_use] + pub fn new(config: AzureBlobStorageConfig) -> Self { + Self { config } + } + + /// Returns the backend configuration. + #[must_use] + pub const fn config(&self) -> &AzureBlobStorageConfig { + &self.config + } +} + +#[async_trait] +impl ObjectStorage for AzureBlobStorageBackend { + fn backend(&self) -> StorageBackend { + StorageBackend::AzureBlob + } + + async fn put_object( + &self, + _object: StoredObject, + _bytes: Vec, + ) -> Result { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + + async fn get_object(&self, _object: &StoredObject) -> Result { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + + async fn delete_object(&self, _object: &StoredObject) -> Result<(), StorageError> { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } +} diff --git a/crates/graphql-orm-storage/src/backend.rs b/crates/graphql-orm-storage/src/backend.rs new file mode 100644 index 00000000..88151080 --- /dev/null +++ b/crates/graphql-orm-storage/src/backend.rs @@ -0,0 +1,81 @@ +use std::str::FromStr; + +use crate::StorageError; + +/// Storage provider identifiers understood by this crate. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum StorageBackend { + /// Local filesystem object storage. + Local, + /// S3-compatible object storage. + S3, + /// Azure Blob Storage. + AzureBlob, +} + +impl StorageBackend { + /// Returns the stable string representation used in persisted metadata. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::S3 => "s3", + Self::AzureBlob => "azure_blob", + } + } +} + +impl FromStr for StorageBackend { + type Err = StorageError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "local" => Ok(Self::Local), + "s3" | "s3-compatible" | "s3_compatible" => Ok(Self::S3), + "azure_blob" | "azure-blob" | "azureblob" => Ok(Self::AzureBlob), + other => Err(StorageError::UnsupportedBackend { + backend: other.to_string(), + }), + } + } +} + +/// Logical storage namespace used as the first path segment of generated keys. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum StorageNamespace { + /// Original uploaded objects. + Originals, + /// Objects retained before permanent deletion. + RecycleBin, + /// Generated thumbnail objects. + Thumbnails, + /// Generated derivative objects. + Derivatives, + /// Exported objects. + Exports, + /// Temporary objects. + Temp, +} + +impl StorageNamespace { + /// Returns the stable string representation used in storage keys. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Originals => "originals", + Self::RecycleBin => "recycle_bin", + Self::Thumbnails => "thumbnails", + Self::Derivatives => "derivatives", + Self::Exports => "exports", + Self::Temp => "temp", + } + } +} + +/// Builds an unsupported-backend error for a known provider. +#[must_use] +pub fn unsupported_backend(backend: StorageBackend) -> StorageError { + StorageError::UnsupportedBackend { + backend: backend.as_str().to_string(), + } +} diff --git a/crates/graphql-orm-storage/src/checksum.rs b/crates/graphql-orm-storage/src/checksum.rs new file mode 100644 index 00000000..76eb4d6c --- /dev/null +++ b/crates/graphql-orm-storage/src/checksum.rs @@ -0,0 +1,9 @@ +use sha2::{Digest, Sha256}; + +/// Computes a lowercase hexadecimal SHA-256 checksum for object bytes. +#[must_use] +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} diff --git a/crates/graphql-orm-storage/src/error.rs b/crates/graphql-orm-storage/src/error.rs new file mode 100644 index 00000000..a83aba16 --- /dev/null +++ b/crates/graphql-orm-storage/src/error.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +/// Errors returned by storage services and provider backends. +#[derive(Debug, thiserror::Error)] +pub enum StorageError { + /// The selected provider is known but is not implemented by this build. + #[error("unsupported storage backend: {backend}")] + UnsupportedBackend { backend: String }, + + /// A storage key is empty, absolute, or contains unsafe path components. + #[error("invalid storage key: {key}")] + InvalidStorageKey { key: String }, + + /// A local filesystem object path did not have a writable parent directory. + #[error("local storage path has no parent: {path:?}")] + MissingParent { path: PathBuf }, + + /// A filesystem operation failed. + #[error("storage io error at {path:?}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +impl StorageError { + #[cfg(feature = "local")] + pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } +} diff --git a/crates/graphql-orm-storage/src/key.rs b/crates/graphql-orm-storage/src/key.rs new file mode 100644 index 00000000..b7571e4d --- /dev/null +++ b/crates/graphql-orm-storage/src/key.rs @@ -0,0 +1,48 @@ +use uuid::Uuid; + +use crate::StorageNamespace; + +/// Builds a sharded, provider-neutral object key. +/// +/// Keys use the format `{namespace}/{uuid[0..2]}/{uuid[2..4]}/{uuid}.{extension}`. +#[must_use] +pub fn build_storage_key( + namespace: StorageNamespace, + object_id: &Uuid, + extension: Option<&str>, +) -> String { + let object_text = object_id.to_string(); + let shard_a = &object_text[0..2]; + let shard_b = &object_text[2..4]; + let file_name = match extension { + Some(ext) if !ext.is_empty() => format!("{object_text}.{}", ext.to_ascii_lowercase()), + _ => object_text.clone(), + }; + + format!( + "{}/{}/{}/{}", + namespace.as_str(), + shard_a, + shard_b, + file_name + ) +} + +/// Extracts a safe filename extension candidate. +/// +/// The original filename is never used as a path. This helper copies only the +/// final extension segment and rejects empty, path-like, or NUL-containing +/// extension candidates. +#[must_use] +pub fn file_extension(file_name: &str) -> Option<&str> { + let candidate = file_name.rsplit_once('.')?.1.trim(); + if candidate.is_empty() + || candidate.contains('/') + || candidate.contains('\\') + || candidate.contains('\0') + { + None + } else { + Some(candidate) + } +} diff --git a/crates/graphql-orm-storage/src/lib.rs b/crates/graphql-orm-storage/src/lib.rs new file mode 100644 index 00000000..7a715b0e --- /dev/null +++ b/crates/graphql-orm-storage/src/lib.rs @@ -0,0 +1,30 @@ +//! Provider-neutral object storage primitives for applications using graphql-orm. +//! +//! This crate stores file bytes in an object backend and returns metadata that an +//! application can persist in its own graphql-orm entity. + +#[cfg(feature = "azure")] +mod azure; +mod backend; +mod checksum; +mod error; +mod key; +#[cfg(feature = "local")] +mod local; +mod object; +#[cfg(feature = "s3")] +mod s3; +mod service; + +#[cfg(feature = "azure")] +pub use azure::{AzureBlobStorageBackend, AzureBlobStorageConfig}; +pub use backend::{StorageBackend, StorageNamespace, unsupported_backend}; +pub use checksum::sha256_hex; +pub use error::StorageError; +pub use key::{build_storage_key, file_extension}; +#[cfg(feature = "local")] +pub use local::LocalStorageBackend; +pub use object::{StorageObjectBody, StoragePutRequest, StoredObject}; +#[cfg(feature = "s3")] +pub use s3::{S3StorageBackend, S3StorageConfig}; +pub use service::{ObjectStorage, StorageService}; diff --git a/crates/graphql-orm-storage/src/local.rs b/crates/graphql-orm-storage/src/local.rs new file mode 100644 index 00000000..10d05f8f --- /dev/null +++ b/crates/graphql-orm-storage/src/local.rs @@ -0,0 +1,106 @@ +use std::path::{Component, Path, PathBuf}; + +use async_trait::async_trait; + +use crate::{ObjectStorage, StorageBackend, StorageError, StorageObjectBody, StoredObject}; + +/// Local filesystem object storage backend. +#[derive(Clone, Debug)] +pub struct LocalStorageBackend { + root: PathBuf, +} + +impl LocalStorageBackend { + /// Creates a local storage backend rooted at the given directory. + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + fn path_for(&self, object: &StoredObject) -> Result { + validate_storage_key(&object.storage_key)?; + Ok(self.root.join(Path::new(&object.storage_key))) + } +} + +#[async_trait] +impl ObjectStorage for LocalStorageBackend { + fn backend(&self) -> StorageBackend { + StorageBackend::Local + } + + async fn put_object( + &self, + object: StoredObject, + bytes: Vec, + ) -> Result { + let path = self.path_for(&object)?; + let parent = path + .parent() + .ok_or_else(|| StorageError::MissingParent { path: path.clone() })?; + tokio::fs::create_dir_all(parent) + .await + .map_err(|source| StorageError::io(parent, source))?; + + let temp_path = path.with_extension("uploading"); + tokio::fs::write(&temp_path, bytes) + .await + .map_err(|source| StorageError::io(&temp_path, source))?; + tokio::fs::rename(&temp_path, &path) + .await + .map_err(|source| StorageError::io(&path, source))?; + + Ok(object) + } + + async fn get_object(&self, object: &StoredObject) -> Result { + let path = self.path_for(object)?; + let bytes = tokio::fs::read(&path) + .await + .map_err(|source| StorageError::io(&path, source))?; + Ok(StorageObjectBody { + object: object.clone(), + bytes, + }) + } + + async fn delete_object(&self, object: &StoredObject) -> Result<(), StorageError> { + let path = self.path_for(object)?; + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(StorageError::io(&path, source)), + } + } +} + +fn validate_storage_key(key: &str) -> Result<(), StorageError> { + if key.is_empty() + || key.contains('\\') + || key.contains('\0') + || key + .split('/') + .any(|component| component.is_empty() || component == "." || component == "..") + { + return Err(StorageError::InvalidStorageKey { + key: key.to_string(), + }); + } + + let path = Path::new(key); + if path.is_absolute() { + return Err(StorageError::InvalidStorageKey { + key: key.to_string(), + }); + } + + for component in path.components() { + if !matches!(component, Component::Normal(_)) { + return Err(StorageError::InvalidStorageKey { + key: key.to_string(), + }); + } + } + + Ok(()) +} diff --git a/crates/graphql-orm-storage/src/object.rs b/crates/graphql-orm-storage/src/object.rs new file mode 100644 index 00000000..c86433bb --- /dev/null +++ b/crates/graphql-orm-storage/src/object.rs @@ -0,0 +1,49 @@ +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{StorageBackend, StorageNamespace}; + +/// Request body for storing a new object. +#[derive(Clone, Debug)] +pub struct StoragePutRequest { + /// Logical namespace for the generated storage key. + pub namespace: StorageNamespace, + /// Original filename retained as metadata only. + pub file_name: Option, + /// Caller-provided MIME type metadata. + pub mime_type: Option, + /// Full object bytes to store. + pub bytes: Vec, +} + +/// Provider-neutral metadata describing a stored object. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct StoredObject { + /// Stable object identifier generated by [`crate::StorageService`]. + pub object_id: Uuid, + /// Logical namespace used in the object key. + pub namespace: StorageNamespace, + /// Provider that stores the object bytes. + pub backend: StorageBackend, + /// Provider-neutral storage key. + pub storage_key: String, + /// Original filename retained as metadata only. + pub original_file_name: Option, + /// Caller-provided MIME type metadata. + pub mime_type: Option, + /// Object size in bytes. + pub size_bytes: u64, + /// Lowercase hexadecimal SHA-256 checksum. + pub sha256_hex: String, + /// UTC creation timestamp assigned by the storage service. + pub created_at: OffsetDateTime, +} + +/// Object metadata plus loaded bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StorageObjectBody { + /// Stored object metadata. + pub object: StoredObject, + /// Loaded object bytes. + pub bytes: Vec, +} diff --git a/crates/graphql-orm-storage/src/s3.rs b/crates/graphql-orm-storage/src/s3.rs new file mode 100644 index 00000000..42142411 --- /dev/null +++ b/crates/graphql-orm-storage/src/s3.rs @@ -0,0 +1,89 @@ +use std::fmt; + +use async_trait::async_trait; + +use crate::{ + ObjectStorage, StorageBackend, StorageError, StorageObjectBody, StoredObject, + unsupported_backend, +}; + +/// Configuration for a future S3-compatible storage backend. +#[derive(Clone, PartialEq, Eq)] +pub struct S3StorageConfig { + /// S3-compatible endpoint URL. + pub endpoint_url: String, + /// Provider region. + pub region: String, + /// Bucket name. + pub bucket: String, + /// Optional key prefix prepended by the provider implementation. + pub key_prefix: Option, + /// Access key identifier. + pub access_key_id: String, + /// Secret access key. Redacted from debug output. + pub secret_access_key: String, + /// Whether to use path-style addressing. + pub path_style: bool, +} + +impl fmt::Debug for S3StorageConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("S3StorageConfig") + .field("endpoint_url", &self.endpoint_url) + .field("region", &self.region) + .field("bucket", &self.bucket) + .field("key_prefix", &self.key_prefix) + .field("access_key_id", &self.access_key_id) + .field("secret_access_key", &"") + .field("path_style", &self.path_style) + .finish() + } +} + +/// Placeholder S3-compatible storage backend. +/// +/// This type exposes the planned provider shape behind the `s3` feature, but +/// object operations return [`StorageError::UnsupportedBackend`] until real S3 +/// support is implemented. +#[derive(Clone, Debug)] +pub struct S3StorageBackend { + config: S3StorageConfig, +} + +impl S3StorageBackend { + /// Creates a new unsupported S3-compatible backend placeholder. + #[must_use] + pub fn new(config: S3StorageConfig) -> Self { + Self { config } + } + + /// Returns the backend configuration. + #[must_use] + pub const fn config(&self) -> &S3StorageConfig { + &self.config + } +} + +#[async_trait] +impl ObjectStorage for S3StorageBackend { + fn backend(&self) -> StorageBackend { + StorageBackend::S3 + } + + async fn put_object( + &self, + _object: StoredObject, + _bytes: Vec, + ) -> Result { + Err(unsupported_backend(StorageBackend::S3)) + } + + async fn get_object(&self, _object: &StoredObject) -> Result { + Err(unsupported_backend(StorageBackend::S3)) + } + + async fn delete_object(&self, _object: &StoredObject) -> Result<(), StorageError> { + Err(unsupported_backend(StorageBackend::S3)) + } +} diff --git a/crates/graphql-orm-storage/src/service.rs b/crates/graphql-orm-storage/src/service.rs new file mode 100644 index 00000000..5b57c5d8 --- /dev/null +++ b/crates/graphql-orm-storage/src/service.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + StorageBackend, StorageError, StorageObjectBody, StoragePutRequest, StoredObject, + build_storage_key, file_extension, sha256_hex, +}; + +/// Provider implementation contract for object storage backends. +#[async_trait] +pub trait ObjectStorage: Send + Sync { + /// Returns the provider identifier for this backend. + fn backend(&self) -> StorageBackend; + + /// Persists object bytes and returns stored metadata. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot persist the object. + async fn put_object( + &self, + object: StoredObject, + bytes: Vec, + ) -> Result; + + /// Loads object bytes for existing metadata. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot load the object. + async fn get_object(&self, object: &StoredObject) -> Result; + + /// Deletes an object from the backend. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot delete the object. + async fn delete_object(&self, object: &StoredObject) -> Result<(), StorageError>; +} + +/// Service that generates object metadata before delegating bytes to a backend. +#[derive(Clone)] +pub struct StorageService { + backend: Arc, +} + +impl StorageService { + /// Creates a storage service backed by a provider implementation. + #[must_use] + pub fn new(backend: Arc) -> Self { + Self { backend } + } + + /// Returns the provider identifier for the configured backend. + #[must_use] + pub fn backend(&self) -> StorageBackend { + self.backend.backend() + } + + /// Stores bytes and returns provider-neutral object metadata. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot persist the object. + pub async fn put_object( + &self, + request: StoragePutRequest, + ) -> Result { + let object = build_stored_object(self.backend.backend(), &request); + self.backend.put_object(object, request.bytes).await + } + + /// Loads object bytes for existing metadata. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot load the object. + pub async fn get_object( + &self, + object: &StoredObject, + ) -> Result { + self.backend.get_object(object).await + } + + /// Deletes an object from the configured backend. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot delete the object. + pub async fn delete_object(&self, object: &StoredObject) -> Result<(), StorageError> { + self.backend.delete_object(object).await + } +} + +fn build_stored_object(backend: StorageBackend, request: &StoragePutRequest) -> StoredObject { + let object_id = Uuid::new_v4(); + let extension = request.file_name.as_deref().and_then(file_extension); + let storage_key = build_storage_key(request.namespace, &object_id, extension); + + StoredObject { + object_id, + namespace: request.namespace, + backend, + storage_key, + original_file_name: request.file_name.clone(), + mime_type: request.mime_type.clone(), + size_bytes: u64::try_from(request.bytes.len()).unwrap_or(u64::MAX), + sha256_hex: sha256_hex(&request.bytes), + created_at: OffsetDateTime::now_utc(), + } +} diff --git a/crates/graphql-orm-storage/tests/core.rs b/crates/graphql-orm-storage/tests/core.rs new file mode 100644 index 00000000..fe3c4c17 --- /dev/null +++ b/crates/graphql-orm-storage/tests/core.rs @@ -0,0 +1,94 @@ +use std::str::FromStr; + +use graphql_orm_storage::{ + StorageBackend, StorageError, StorageNamespace, build_storage_key, file_extension, sha256_hex, + unsupported_backend, +}; +use uuid::Uuid; + +#[test] +fn checksum_matches_known_sha256() { + assert_eq!( + sha256_hex(b"hello storage"), + "ada7ad17eeff1826bdf1e69d6a70d542548a6f0a3c3809748a36076d97671047" + ); +} + +#[test] +fn generated_key_uses_namespace_uuid_shards_and_lowercase_extension() { + let object_id = Uuid::parse_str("6c57a6cc-09e6-4a7f-a320-2f2bde4cfd86").expect("valid uuid"); + let key = build_storage_key(StorageNamespace::Originals, &object_id, Some("JPG")); + assert_eq!( + key, + "originals/6c/57/6c57a6cc-09e6-4a7f-a320-2f2bde4cfd86.jpg" + ); +} + +#[test] +fn generated_key_without_extension_has_no_extension_suffix() { + let object_id = Uuid::parse_str("6c57a6cc-09e6-4a7f-a320-2f2bde4cfd86").expect("valid uuid"); + let key = build_storage_key(StorageNamespace::Originals, &object_id, None); + assert_eq!(key, "originals/6c/57/6c57a6cc-09e6-4a7f-a320-2f2bde4cfd86"); +} + +#[test] +fn extension_parser_rejects_unsafe_extension_candidates() { + assert_eq!(file_extension("artifact.jpg"), Some("jpg")); + assert_eq!(file_extension("artifact."), None); + assert_eq!(file_extension("artifact.jp/g"), None); + assert_eq!(file_extension("artifact.jp\\g"), None); + assert_eq!(file_extension("artifact.jp\0g"), None); +} + +#[test] +fn backend_string_representations_are_stable() { + assert_eq!(StorageBackend::Local.as_str(), "local"); + assert_eq!(StorageBackend::S3.as_str(), "s3"); + assert_eq!(StorageBackend::AzureBlob.as_str(), "azure_blob"); +} + +#[test] +fn namespace_string_representations_are_stable() { + assert_eq!(StorageNamespace::Originals.as_str(), "originals"); + assert_eq!(StorageNamespace::RecycleBin.as_str(), "recycle_bin"); + assert_eq!(StorageNamespace::Thumbnails.as_str(), "thumbnails"); + assert_eq!(StorageNamespace::Derivatives.as_str(), "derivatives"); + assert_eq!(StorageNamespace::Exports.as_str(), "exports"); + assert_eq!(StorageNamespace::Temp.as_str(), "temp"); +} + +#[test] +fn parses_known_backend_names() { + assert_eq!( + StorageBackend::from_str("local").expect("local"), + StorageBackend::Local + ); + assert_eq!( + StorageBackend::from_str("s3-compatible").expect("s3"), + StorageBackend::S3 + ); + assert_eq!( + StorageBackend::from_str("azure-blob").expect("azure"), + StorageBackend::AzureBlob + ); +} + +#[test] +fn invalid_backend_names_return_unsupported_backend_error() { + let err = StorageBackend::from_str("dropbox").expect_err("invalid backend"); + + assert!(matches!( + err, + StorageError::UnsupportedBackend { backend } if backend == "dropbox" + )); +} + +#[test] +fn unsupported_backend_uses_stable_backend_name() { + let err = unsupported_backend(StorageBackend::S3); + + assert!(matches!( + err, + StorageError::UnsupportedBackend { backend } if backend == "s3" + )); +} diff --git a/crates/graphql-orm-storage/tests/local_round_trip.rs b/crates/graphql-orm-storage/tests/local_round_trip.rs new file mode 100644 index 00000000..3c8de071 --- /dev/null +++ b/crates/graphql-orm-storage/tests/local_round_trip.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; + +use graphql_orm_storage::{ + LocalStorageBackend, StorageBackend, StorageError, StorageNamespace, StoragePutRequest, + StorageService, StoredObject, sha256_hex, +}; +use tempfile::TempDir; +use time::OffsetDateTime; +use uuid::Uuid; + +#[tokio::test] +async fn local_put_get_delete_round_trip_preserves_bytes_and_metadata() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + + let stored = service + .put_object(StoragePutRequest { + namespace: StorageNamespace::Originals, + file_name: Some("artifact.JPEG".to_string()), + mime_type: Some("image/jpeg".to_string()), + bytes: b"hello storage".to_vec(), + }) + .await + .expect("put object"); + + assert_eq!(stored.backend, StorageBackend::Local); + assert_eq!(stored.namespace, StorageNamespace::Originals); + assert_eq!(stored.original_file_name.as_deref(), Some("artifact.JPEG")); + assert_eq!(stored.mime_type.as_deref(), Some("image/jpeg")); + assert_eq!(stored.size_bytes, 13); + assert_eq!(stored.sha256_hex, sha256_hex(b"hello storage")); + assert!(stored.storage_key.ends_with(".jpeg")); + + let loaded = service.get_object(&stored).await.expect("get object"); + assert_eq!(loaded.bytes, b"hello storage"); + assert_eq!(loaded.object, stored); + assert!( + tokio::fs::metadata(temp.path().join(&stored.storage_key)) + .await + .is_ok() + ); + + service.delete_object(&stored).await.expect("delete object"); + assert!(service.get_object(&stored).await.is_err()); +} + +#[tokio::test] +async fn delete_missing_object_succeeds() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + let object = test_object("originals/aa/bb/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa.txt"); + + service + .delete_object(&object) + .await + .expect("delete missing object"); +} + +#[tokio::test] +async fn local_backend_rejects_path_traversal_storage_keys() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + let object = test_object("../escape.txt"); + + let err = service + .get_object(&object) + .await + .expect_err("path traversal should be rejected"); + + assert!(matches!(err, StorageError::InvalidStorageKey { .. })); +} + +#[tokio::test] +async fn local_backend_rejects_absolute_storage_keys() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + let object = test_object("/tmp/escape.txt"); + + let err = service + .get_object(&object) + .await + .expect_err("absolute path should be rejected"); + + assert!(matches!(err, StorageError::InvalidStorageKey { .. })); +} + +#[tokio::test] +async fn local_backend_rejects_dot_storage_key_components() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + let object = test_object("originals/./escape.txt"); + + let err = service + .get_object(&object) + .await + .expect_err("dot path component should be rejected"); + + assert!(matches!(err, StorageError::InvalidStorageKey { .. })); +} + +#[tokio::test] +async fn local_backend_rejects_parent_storage_key_components() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + let object = test_object("originals/aa/../escape.txt"); + + let err = service + .get_object(&object) + .await + .expect_err("parent path component should be rejected"); + + assert!(matches!(err, StorageError::InvalidStorageKey { .. })); +} + +#[tokio::test] +async fn local_backend_rejects_backslash_storage_keys() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + let object = test_object("originals\\aa\\escape.txt"); + + let err = service + .get_object(&object) + .await + .expect_err("backslash path should be rejected"); + + assert!(matches!(err, StorageError::InvalidStorageKey { .. })); +} + +#[tokio::test] +async fn local_backend_rejects_nul_storage_keys() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + let object = test_object("originals/aa/escape\0.txt"); + + let err = service + .get_object(&object) + .await + .expect_err("nul path should be rejected"); + + assert!(matches!(err, StorageError::InvalidStorageKey { .. })); +} + +#[tokio::test] +async fn local_backend_creates_parent_directories() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + + let stored = service + .put_object(StoragePutRequest { + namespace: StorageNamespace::Derivatives, + file_name: Some("preview.txt".to_string()), + mime_type: Some("text/plain".to_string()), + bytes: b"derived bytes".to_vec(), + }) + .await + .expect("put object"); + + let stored_path = temp.path().join(&stored.storage_key); + let parent = stored_path.parent().expect("stored path has parent"); + assert!( + tokio::fs::metadata(parent) + .await + .expect("parent metadata") + .is_dir() + ); + assert_eq!( + tokio::fs::read(stored_path).await.expect("stored bytes"), + b"derived bytes" + ); +} + +fn test_object(storage_key: &str) -> StoredObject { + StoredObject { + object_id: Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid"), + namespace: StorageNamespace::Originals, + backend: StorageBackend::Local, + storage_key: storage_key.to_string(), + original_file_name: Some("test.txt".to_string()), + mime_type: Some("text/plain".to_string()), + size_bytes: 0, + sha256_hex: sha256_hex(b""), + created_at: OffsetDateTime::UNIX_EPOCH, + } +} diff --git a/crates/graphql-orm-storage/tests/provider_placeholders.rs b/crates/graphql-orm-storage/tests/provider_placeholders.rs new file mode 100644 index 00000000..7a0940cd --- /dev/null +++ b/crates/graphql-orm-storage/tests/provider_placeholders.rs @@ -0,0 +1,110 @@ +#[cfg(feature = "azure")] +use graphql_orm_storage::{AzureBlobStorageBackend, AzureBlobStorageConfig}; +#[cfg(any(feature = "azure", feature = "s3"))] +use graphql_orm_storage::{ + ObjectStorage, StorageBackend, StorageError, StorageNamespace, StoredObject, sha256_hex, +}; +#[cfg(feature = "s3")] +use graphql_orm_storage::{S3StorageBackend, S3StorageConfig}; +#[cfg(any(feature = "azure", feature = "s3"))] +use time::OffsetDateTime; +#[cfg(any(feature = "azure", feature = "s3"))] +use uuid::Uuid; + +#[cfg(feature = "s3")] +#[test] +fn s3_debug_output_redacts_secret_access_key() { + let config = s3_config(); + let debug = format!("{config:?}"); + assert!(!debug.contains("super-secret-s3-key")); + assert!(debug.contains("")); +} + +#[cfg(feature = "s3")] +#[tokio::test] +async fn s3_placeholder_backend_returns_unsupported_errors() { + let backend = S3StorageBackend::new(s3_config()); + let object = test_object(StorageBackend::S3); + + assert_eq!(backend.backend(), StorageBackend::S3); + assert_unsupported( + backend.put_object(object.clone(), b"bytes".to_vec()).await, + "s3", + ); + assert_unsupported(backend.get_object(&object).await, "s3"); + assert_unsupported(backend.delete_object(&object).await, "s3"); +} + +#[cfg(feature = "azure")] +#[test] +fn azure_debug_output_redacts_connection_string_and_credential() { + let config = azure_config(); + let debug = format!("{config:?}"); + assert!(!debug.contains("DefaultEndpointsProtocol=https;AccountKey=azure-secret")); + assert!(!debug.contains("azure-token")); + assert!(debug.contains("")); +} + +#[cfg(feature = "azure")] +#[tokio::test] +async fn azure_placeholder_backend_returns_unsupported_errors() { + let backend = AzureBlobStorageBackend::new(azure_config()); + let object = test_object(StorageBackend::AzureBlob); + + assert_eq!(backend.backend(), StorageBackend::AzureBlob); + assert_unsupported( + backend.put_object(object.clone(), b"bytes".to_vec()).await, + "azure_blob", + ); + assert_unsupported(backend.get_object(&object).await, "azure_blob"); + assert_unsupported(backend.delete_object(&object).await, "azure_blob"); +} + +#[cfg(feature = "s3")] +fn s3_config() -> S3StorageConfig { + S3StorageConfig { + endpoint_url: "https://s3.example.test".to_string(), + region: "test-region".to_string(), + bucket: "objects".to_string(), + key_prefix: Some("prefix".to_string()), + access_key_id: "access-key".to_string(), + secret_access_key: "super-secret-s3-key".to_string(), + path_style: true, + } +} + +#[cfg(feature = "azure")] +fn azure_config() -> AzureBlobStorageConfig { + AzureBlobStorageConfig { + account: Some("account".to_string()), + connection_string: Some( + "DefaultEndpointsProtocol=https;AccountKey=azure-secret".to_string(), + ), + container: "objects".to_string(), + key_prefix: Some("prefix".to_string()), + credential: Some("azure-token".to_string()), + } +} + +#[cfg(any(feature = "azure", feature = "s3"))] +fn test_object(backend: StorageBackend) -> StoredObject { + StoredObject { + object_id: Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid"), + namespace: StorageNamespace::Originals, + backend, + storage_key: "originals/aa/aa/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa.txt".to_string(), + original_file_name: Some("test.txt".to_string()), + mime_type: Some("text/plain".to_string()), + size_bytes: 0, + sha256_hex: sha256_hex(b""), + created_at: OffsetDateTime::UNIX_EPOCH, + } +} + +#[cfg(any(feature = "azure", feature = "s3"))] +fn assert_unsupported(result: Result, expected_backend: &str) { + assert!(matches!( + result, + Err(StorageError::UnsupportedBackend { backend }) if backend == expected_backend + )); +} From 5d56657f61bd4f2470e7a579e2ddbfd02193f996 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 14 May 2026 02:29:24 +0000 Subject: [PATCH 003/108] Add compressed snapshots and manifest chains --- crates/graphql-orm-backup/Cargo.lock | 93 ++++++- crates/graphql-orm-backup/Cargo.toml | 6 + crates/graphql-orm-backup/README.md | 9 +- .../graphql-orm-backup/docs/architecture.md | 13 +- .../docs/cloud-provider-direction.md | 50 ++++ crates/graphql-orm-backup/docs/plan.md | 10 +- .../docs/provider-roadmap.md | 16 +- .../docs/restore-semantics.md | 3 + crates/graphql-orm-backup/docs/smb.md | 54 ++++ .../docs/snapshot-format.md | 20 +- crates/graphql-orm-backup/docs/usage.md | 15 +- crates/graphql-orm-backup/src/backup.rs | 5 +- crates/graphql-orm-backup/src/error.rs | 14 + crates/graphql-orm-backup/src/lib.rs | 7 +- .../src/local_repository.rs | 32 ++- crates/graphql-orm-backup/src/manifest.rs | 137 +++++++++- .../graphql-orm-backup/tests/compression.rs | 159 ++++++++++++ .../tests/full_backup_creation.rs | 13 +- .../tests/local_repository_round_trip.rs | 148 ++++++++++- .../tests/manifest_chain.rs | 243 ++++++++++++++++++ .../tests/manifest_round_trip.rs | 22 +- 21 files changed, 1019 insertions(+), 50 deletions(-) create mode 100644 crates/graphql-orm-backup/docs/cloud-provider-direction.md create mode 100644 crates/graphql-orm-backup/docs/smb.md create mode 100644 crates/graphql-orm-backup/tests/compression.rs create mode 100644 crates/graphql-orm-backup/tests/manifest_chain.rs diff --git a/crates/graphql-orm-backup/Cargo.lock b/crates/graphql-orm-backup/Cargo.lock index 73e07673..17da72c2 100644 --- a/crates/graphql-orm-backup/Cargo.lock +++ b/crates/graphql-orm-backup/Cargo.lock @@ -46,6 +46,18 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -103,6 +115,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.1.5" @@ -143,6 +161,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -151,7 +181,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -169,6 +199,7 @@ dependencies = [ "thiserror", "tokio", "uuid", + "zstd", ] [[package]] @@ -216,6 +247,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -270,6 +311,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "prettyplease" version = "0.2.37" @@ -298,6 +345,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -383,6 +436,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.12" @@ -407,7 +466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys", @@ -478,7 +537,7 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -701,3 +760,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml index 870c5962..33e40229 100644 --- a/crates/graphql-orm-backup/Cargo.toml +++ b/crates/graphql-orm-backup/Cargo.toml @@ -23,7 +23,13 @@ sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["fs"] } uuid = { version = "1", features = ["serde", "v4"] } +zstd = "0.13" [dev-dependencies] tempfile = "3" tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] } + +[[test]] +name = "local_repository_round_trip" +path = "tests/local_repository_round_trip.rs" +required-features = ["local"] diff --git a/crates/graphql-orm-backup/README.md b/crates/graphql-orm-backup/README.md index 06af60f9..81177493 100644 --- a/crates/graphql-orm-backup/README.md +++ b/crates/graphql-orm-backup/README.md @@ -14,6 +14,9 @@ This crate coordinates database export/import adapters, stored-object indexes, b - Full backup planner skeleton implemented. - Full snapshot creation implemented through `create_full_backup`. - Verification helpers implemented. +- Manifest chain loading and validation implemented. +- Table payloads are zstd-compressed JSON Lines. +- Mounted SMB paths are supported through `LocalBackupRepository` filesystem semantics and `open_existing` root validation. - Restore safety context implemented for empty-target restores. `graphql-orm` still needs to provide stable logical export/import and change-journal APIs before complete database backup/restore can be implemented. @@ -25,6 +28,8 @@ This crate coordinates database export/import adapters, stored-object indexes, b - [Snapshot format](docs/snapshot-format.md) - [Restore semantics](docs/restore-semantics.md) - [Provider roadmap](docs/provider-roadmap.md) +- [Cloud provider direction](docs/cloud-provider-direction.md) +- [SMB mounted repository guidance](docs/smb.md) - [graphql-orm integration brief](docs/graphql-orm-agent-brief.md) ## Design Rule @@ -39,8 +44,8 @@ objects/sha256/{first_two}/{next_two}/{sha256} ``` The manifest is written last. Its checksum excludes its own `checksum` field. -Table payloads are currently written as uncompressed JSON Lines. The `.zst` -filename suffix is reserved for the stable future compressed layout. +Table payloads are written as zstd-compressed JSON Lines and table checksums +cover the stored compressed bytes. ## Backup Repository Example diff --git a/crates/graphql-orm-backup/docs/architecture.md b/crates/graphql-orm-backup/docs/architecture.md index 9557a833..9372e81c 100644 --- a/crates/graphql-orm-backup/docs/architecture.md +++ b/crates/graphql-orm-backup/docs/architecture.md @@ -18,12 +18,13 @@ 1. Read schema snapshot from `GraphqlOrmBackupAdapter`. 2. Export all backup-enabled tables through `GraphqlOrmBackupAdapter`. 3. List all referenced stored objects through `BackupObjectIndex`. -4. Write table exports to the backup repository as uncompressed JSON Lines. -5. Load and checksum each object from `BackupObjectIndex`. -6. Write object blobs by content-addressed key if missing. -7. Build manifest. -8. Set manifest checksum. -9. Write manifest last. +4. Serialize table exports as JSON Lines. +5. Compress table exports with zstd, checksum the stored compressed bytes, and write them to the backup repository. +6. Load and checksum each object from `BackupObjectIndex`. +7. Write object blobs by content-addressed key if missing. +8. Build manifest. +9. Set manifest checksum. +10. Write manifest last. ## Incremental Backup Flow diff --git a/crates/graphql-orm-backup/docs/cloud-provider-direction.md b/crates/graphql-orm-backup/docs/cloud-provider-direction.md new file mode 100644 index 00000000..202eada5 --- /dev/null +++ b/crates/graphql-orm-backup/docs/cloud-provider-direction.md @@ -0,0 +1,50 @@ +# Cloud Provider Direction + +`graphql-orm-backup` should not duplicate S3 or Azure Blob SDK integrations +while `graphql-orm-storage` is expected to grow shared cloud blob support. + +## Shared Layer + +The intended shared point is a future lower-level `graphql-orm-storage::BlobStore` +abstraction, not the current high-level primary-object storage APIs. + +Backup repositories and primary object storage have different semantics: + +- backup repositories use arbitrary manifest, table, change, and content-addressed object keys +- primary object storage uses generated object ids, namespaces, checksums, and app-persisted metadata +- backup repositories need list operations for prefixes +- primary object storage should not inherit backup manifest semantics + +## Future Adapter + +Once `graphql-orm-storage::BlobStore` exists, add an optional adapter in this +crate: + +```rust +pub struct BlobStoreBackupRepository { + store: Arc, + prefix: Option, +} +``` + +Mapping: + +- `BackupRepository::put_blob` calls `BlobStore::put_blob` +- `BackupRepository::get_blob` collects the blob stream into `bytes::Bytes` +- `BackupRepository::blob_exists` calls `BlobStore::blob_exists` +- `BackupRepository::list_blobs` calls `BlobStore::list_blobs` +- `BackupRepository::delete_blob` calls `BlobStore::delete_blob` + +The adapter must apply and strip its configured repository prefix consistently. + +## Provider Ownership + +- S3-compatible and Azure Blob provider SDK integration should live in + `graphql-orm-storage` once `BlobStore` exists. +- Dropbox is backup-specific and belongs in this crate. +- SMB starts as mounted filesystem support through `LocalBackupRepository`. + +## Current Rule + +Do not add direct AWS or Azure SDK dependencies to this crate until the shared +`BlobStore` path has been implemented or explicitly rejected. diff --git a/crates/graphql-orm-backup/docs/plan.md b/crates/graphql-orm-backup/docs/plan.md index d394e74b..459fcf41 100644 --- a/crates/graphql-orm-backup/docs/plan.md +++ b/crates/graphql-orm-backup/docs/plan.md @@ -7,6 +7,7 @@ Create a reusable backup and restore crate for applications using `graphql-orm`. ## What This Crate Provides - Snapshot manifest format. +- Manifest chain loading and validation. - Backup repository trait. - Local backup repository. - Database backup adapter contract. @@ -14,6 +15,7 @@ Create a reusable backup and restore crate for applications using `graphql-orm`. - Full backup planner. - Full snapshot writer. - Verification helpers. +- Zstd-compressed table payloads. - Restore context and initial empty-target safety checks. ## What This Crate Must Not Provide @@ -50,10 +52,10 @@ Create a reusable backup and restore crate for applications using `graphql-orm`. ## Future Work - Stream database exports instead of holding rows in memory. -- Add zstd compression for table and change files. -- Add S3 backup repository. -- Add Azure Blob backup repository. +- Add zstd compression for future change files. +- Add a `graphql-orm-storage::BlobStore` backup repository adapter after the shared storage crate exposes that lower-level abstraction. +- Add S3 backup repository through the shared `BlobStore` adapter path. +- Add Azure Blob backup repository through the shared `BlobStore` adapter path. - Add Dropbox backup repository. -- Add SMB mounted-path documentation and validation. - Implement full restore after graphql-orm import lands. - Implement incremental backup after graphql-orm change journal lands. diff --git a/crates/graphql-orm-backup/docs/provider-roadmap.md b/crates/graphql-orm-backup/docs/provider-roadmap.md index 293184bc..f1c50390 100644 --- a/crates/graphql-orm-backup/docs/provider-roadmap.md +++ b/crates/graphql-orm-backup/docs/provider-roadmap.md @@ -13,7 +13,12 @@ Acceptance criteria: ## Phase 2: S3 -Add behind the `s3` feature. +Do not implement direct AWS SDK integration in this crate yet. + +`graphql-orm-storage` should first expose a shared lower-level streaming +`BlobStore` abstraction. `graphql-orm-backup` should then adapt that abstraction +to `BackupRepository` so primary object storage and backup repositories can +share S3-compatible provider code without sharing higher-level semantics. Expected configuration: @@ -26,7 +31,9 @@ Expected configuration: ## Phase 3: Azure Blob -Add behind the `azure` feature. +Do not implement direct Azure SDK integration in this crate yet. Azure Blob +should follow the same future `graphql-orm-storage::BlobStore` adapter path as +S3 once the shared abstraction exists. Expected configuration: @@ -39,7 +46,10 @@ Expected configuration: Initial SMB support should be mounted filesystem support using `LocalBackupRepository`. -Native SMB protocol support is future work. +Native SMB protocol support is future work. Mounts, credentials, reconnect +behavior, and OS-level permissions are managed outside this crate. Use +`LocalBackupRepository::open_existing` to validate that the mounted path exists +and is a directory before using it as a repository root. ## Phase 5: Dropbox diff --git a/crates/graphql-orm-backup/docs/restore-semantics.md b/crates/graphql-orm-backup/docs/restore-semantics.md index 9c9cc273..443d783e 100644 --- a/crates/graphql-orm-backup/docs/restore-semantics.md +++ b/crates/graphql-orm-backup/docs/restore-semantics.md @@ -26,8 +26,11 @@ The default empty restore context disables application policies and change journ ## Safety Rules +- Load and validate the selected manifest chain before writing. - Verify manifests before writing. - Verify object checksums before final success. +- Verify table payload checksums against the stored compressed bytes. +- Decompress table payloads only after checksum verification. - Preserve primary keys. - Preserve created and updated timestamps where entities define them. - Restore rows in dependency order. diff --git a/crates/graphql-orm-backup/docs/smb.md b/crates/graphql-orm-backup/docs/smb.md new file mode 100644 index 00000000..5aa2fa9f --- /dev/null +++ b/crates/graphql-orm-backup/docs/smb.md @@ -0,0 +1,54 @@ +# SMB Mounted Repository Guidance + +Native SMB protocol support is out of scope for the current crate. Use an +operating-system mounted SMB share as a filesystem path and point +`LocalBackupRepository` at that mount. + +## Responsibilities Outside This Crate + +The host system or application deployment must manage: + +- SMB mount creation +- credentials +- reconnect behavior +- network availability +- filesystem permissions +- available space monitoring + +## Repository Root Validation + +Use `LocalBackupRepository::open_existing` when a repository root should already +exist: + +```rust +use graphql_orm_backup::LocalBackupRepository; + +# async fn example() -> Result<(), graphql_orm_backup::BackupError> { +let repository = LocalBackupRepository::open_existing("/mnt/backups").await?; +# Ok(()) +# } +``` + +`open_existing` validates that the path exists and is a directory. It does not +create the mount or change permissions. + +## Write Semantics + +The local repository writes blobs by creating parent directories, writing a +temporary file, and renaming it into place. Mounted SMB deployments must support +that workflow reliably enough for the application’s backup requirements. + +## Key Safety + +Repository keys are validated before joining them to the root path. Keys reject: + +- empty keys +- absolute paths +- empty path segments +- `.` +- `..` +- backslashes +- NUL bytes +- platform prefix components + +`list_blobs("")` is intentionally allowed and lists the whole repository. diff --git a/crates/graphql-orm-backup/docs/snapshot-format.md b/crates/graphql-orm-backup/docs/snapshot-format.md index 35446596..c6d2f5ed 100644 --- a/crates/graphql-orm-backup/docs/snapshot-format.md +++ b/crates/graphql-orm-backup/docs/snapshot-format.md @@ -21,6 +21,7 @@ The manifest records: - database backend - backup kind - database table export entries +- database payload compression - object entries - tombstones - manifest checksum @@ -39,13 +40,24 @@ This allows dedupe across snapshots and providers. ## Database Blobs -The current table export payload is uncompressed JSON Lines. Each line is one -serialized backup row and ends with `\n`. +The table export payload is JSON Lines compressed with zstd. Each decompressed +line is one serialized backup row and ends with `\n`. -The repository key keeps the planned compressed filename: +The repository key uses the compressed filename: ```text snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst ``` -Compression is not implemented yet. The filename reserves the intended format so future implementation has a stable layout. +The manifest records: + +```json +{ + "export_format": "jsonl", + "compression": "Zstd" +} +``` + +Table entry checksums are computed over the stored compressed bytes, not the +decompressed JSON Lines payload. This lets repository verification validate the +exact bytes stored in the backup repository. diff --git a/crates/graphql-orm-backup/docs/usage.md b/crates/graphql-orm-backup/docs/usage.md index 8afb715e..9fd58e22 100644 --- a/crates/graphql-orm-backup/docs/usage.md +++ b/crates/graphql-orm-backup/docs/usage.md @@ -57,11 +57,12 @@ The function performs these steps: 1. Reads schema metadata from `GraphqlOrmBackupAdapter`. 2. Exports all full-backup table rows through `GraphqlOrmBackupAdapter`. 3. Lists referenced objects through `BackupObjectIndex`. -4. Writes table export payloads to the repository. -5. Loads and verifies object bytes. -6. Writes missing object blobs by content-addressed key. -7. Builds and checksums the manifest. -8. Writes the manifest last. +4. Serializes table exports as JSON Lines. +5. Compresses table payloads with zstd and writes them to the repository. +6. Loads and verifies object bytes. +7. Writes missing object blobs by content-addressed key. +8. Builds and checksums the manifest. +9. Writes the manifest last. ## Database Adapter Responsibilities @@ -103,8 +104,8 @@ snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst objects/sha256/{first_two}/{next_two}/{sha256} ``` -The current table payload is uncompressed JSON Lines even though the table key -keeps the reserved `.jsonl.zst` suffix. Compression is future work. +Table payloads are zstd-compressed JSON Lines. Manifest table checksums cover +the stored compressed bytes. ## Verification diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs index e16a7cdf..dc9f2ca1 100644 --- a/crates/graphql-orm-backup/src/backup.rs +++ b/crates/graphql-orm-backup/src/backup.rs @@ -31,8 +31,7 @@ pub fn snapshot_manifest_key(snapshot_id: Uuid) -> String { /// Returns the reserved table export key. /// -/// Full backup currently writes uncompressed JSON Lines. The `.zst` suffix is -/// retained for the stable repository layout reserved by the snapshot format. +/// Returns the zstd-compressed table export key used by the snapshot format. #[must_use] pub fn database_table_key(snapshot_id: Uuid, table_name: &str) -> String { format!("snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst") @@ -62,6 +61,7 @@ pub async fn create_full_backup( let mut row_count = 0_u64; for table in &plan.tables { let bytes = serialize_table_export(table)?; + let bytes = crate::compress_payload(&bytes)?; let content_key = database_table_key(request.snapshot_id, &table.table_name); let sha256_hex = sha256_hex(&bytes); repository @@ -128,6 +128,7 @@ pub async fn create_full_backup( backup_kind: BackupKind::Full, database: DatabaseBackupManifest { export_format: DATABASE_EXPORT_FORMAT.to_string(), + compression: crate::BackupCompression::Zstd, row_count, table_count: table_entries.len() as u64, tables: table_entries, diff --git a/crates/graphql-orm-backup/src/error.rs b/crates/graphql-orm-backup/src/error.rs index a24de35e..c7796097 100644 --- a/crates/graphql-orm-backup/src/error.rs +++ b/crates/graphql-orm-backup/src/error.rs @@ -8,6 +8,9 @@ pub enum BackupError { #[error("invalid backup repository key: {key}")] InvalidRepositoryKey { key: String }, + #[error("invalid backup repository root: {path:?}")] + InvalidRepositoryRoot { path: PathBuf }, + #[error("backup blob is missing: {key}")] MissingBlob { key: String }, @@ -24,6 +27,12 @@ pub enum BackupError { #[error("invalid manifest chain: {reason}")] InvalidManifestChain { reason: String }, + #[error("backup payload compression error")] + Compression { + #[source] + source: std::io::Error, + }, + #[error("unsupported operation: {operation}")] UnsupportedOperation { operation: String }, @@ -39,6 +48,11 @@ pub enum BackupError { } impl BackupError { + pub(crate) fn compression(source: std::io::Error) -> Self { + Self::Compression { source } + } + + #[cfg(feature = "local")] pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { Self::Io { path: path.into(), diff --git a/crates/graphql-orm-backup/src/lib.rs b/crates/graphql-orm-backup/src/lib.rs index 3141e1b8..f5b1bd58 100644 --- a/crates/graphql-orm-backup/src/lib.rs +++ b/crates/graphql-orm-backup/src/lib.rs @@ -28,9 +28,10 @@ pub use error::BackupError; #[cfg(feature = "local")] pub use local_repository::LocalBackupRepository; pub use manifest::{ - BACKUP_FORMAT_VERSION, BackupKind, BackupSnapshotManifest, BackupTombstone, - DatabaseBackupManifest, ObjectBackupEntry, TableBackupEntry, manifest_checksum, - set_manifest_checksum, verify_manifest_checksum, + BACKUP_FORMAT_VERSION, BackupCompression, BackupKind, BackupSnapshotManifest, BackupTombstone, + DatabaseBackupManifest, ObjectBackupEntry, TableBackupEntry, compress_payload, + decompress_payload, load_manifest, load_manifest_chain, manifest_checksum, + set_manifest_checksum, validate_manifest_chain, verify_manifest_checksum, }; pub use object_index::{BackupObjectIndex, BackupObjectRef}; pub use planner::{FullBackupPlan, plan_full_backup}; diff --git a/crates/graphql-orm-backup/src/local_repository.rs b/crates/graphql-orm-backup/src/local_repository.rs index afed0819..1b239e0d 100644 --- a/crates/graphql-orm-backup/src/local_repository.rs +++ b/crates/graphql-orm-backup/src/local_repository.rs @@ -16,6 +16,18 @@ impl LocalBackupRepository { Self { root: root.into() } } + pub async fn open_existing(root: impl Into) -> Result { + let root = root.into(); + let metadata = tokio::fs::metadata(&root) + .await + .map_err(|source| BackupError::io(&root, source))?; + if !metadata.is_dir() { + return Err(BackupError::InvalidRepositoryRoot { path: root }); + } + + Ok(Self { root }) + } + fn path_for(&self, key: &str) -> Result { validate_repository_key(key)?; Ok(self.root.join(Path::new(key))) @@ -68,9 +80,7 @@ impl BackupRepository for LocalBackupRepository { } async fn list_blobs(&self, prefix: &str) -> Result, BackupError> { - if !prefix.is_empty() { - validate_repository_key(prefix)?; - } + validate_repository_prefix(prefix)?; let start = if prefix.is_empty() { self.root.clone() @@ -122,7 +132,13 @@ impl BackupRepository for LocalBackupRepository { } fn validate_repository_key(key: &str) -> Result<(), BackupError> { - if key.is_empty() { + if key.is_empty() + || key.contains('\\') + || key.contains('\0') + || key + .split('/') + .any(|component| component.is_empty() || component == "." || component == "..") + { return Err(BackupError::InvalidRepositoryKey { key: key.to_string(), }); @@ -145,3 +161,11 @@ fn validate_repository_key(key: &str) -> Result<(), BackupError> { Ok(()) } + +fn validate_repository_prefix(prefix: &str) -> Result<(), BackupError> { + if prefix.is_empty() { + return Ok(()); + } + + validate_repository_key(prefix) +} diff --git a/crates/graphql-orm-backup/src/manifest.rs b/crates/graphql-orm-backup/src/manifest.rs index b5f94b50..11704503 100644 --- a/crates/graphql-orm-backup/src/manifest.rs +++ b/crates/graphql-orm-backup/src/manifest.rs @@ -1,7 +1,9 @@ +use std::collections::HashSet; + use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::BackupError; +use crate::{BackupError, BackupRepository}; pub const BACKUP_FORMAT_VERSION: u32 = 1; @@ -12,6 +14,13 @@ pub enum BackupKind { SyntheticFull, } +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum BackupCompression { + #[default] + None, + Zstd, +} + #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupSnapshotManifest { pub format_version: u32, @@ -33,6 +42,8 @@ pub struct BackupSnapshotManifest { #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct DatabaseBackupManifest { pub export_format: String, + #[serde(default)] + pub compression: BackupCompression, pub row_count: u64, pub table_count: u64, pub tables: Vec, @@ -89,8 +100,132 @@ pub fn verify_manifest_checksum(manifest: &BackupSnapshotManifest) -> Result<(), } } +pub async fn load_manifest( + repository: &dyn BackupRepository, + snapshot_id: Uuid, +) -> Result { + let key = manifest_key(snapshot_id); + let bytes = repository.get_blob(&key).await?; + let manifest: BackupSnapshotManifest = serde_json::from_slice(&bytes)?; + verify_manifest_checksum(&manifest)?; + Ok(manifest) +} + +pub async fn load_manifest_chain( + repository: &dyn BackupRepository, + snapshot_id: Uuid, +) -> Result, BackupError> { + let mut chain = Vec::new(); + let mut seen = HashSet::new(); + let mut next = Some(snapshot_id); + + while let Some(current_snapshot_id) = next { + if !seen.insert(current_snapshot_id) { + return Err(BackupError::InvalidManifestChain { + reason: format!("duplicate snapshot id {current_snapshot_id}"), + }); + } + + let manifest = load_manifest(repository, current_snapshot_id).await?; + next = manifest.parent_snapshot_id; + chain.push(manifest); + } + + chain.reverse(); + validate_manifest_chain(&chain)?; + Ok(chain) +} + +pub fn validate_manifest_chain(chain: &[BackupSnapshotManifest]) -> Result<(), BackupError> { + let Some(first) = chain.first() else { + return Err(BackupError::InvalidManifestChain { + reason: "manifest chain is empty".to_string(), + }); + }; + + if !matches!( + first.backup_kind, + BackupKind::Full | BackupKind::SyntheticFull + ) { + return Err(BackupError::InvalidManifestChain { + reason: "manifest chain does not start with a full or synthetic-full snapshot" + .to_string(), + }); + } + + if first.parent_snapshot_id.is_some() { + return Err(BackupError::InvalidManifestChain { + reason: "root full snapshot must not have a parent".to_string(), + }); + } + + let mut seen = HashSet::new(); + seen.insert(first.snapshot_id); + + for pair in chain.windows(2) { + let parent = &pair[0]; + let child = &pair[1]; + + if !seen.insert(child.snapshot_id) { + return Err(BackupError::InvalidManifestChain { + reason: format!("duplicate snapshot id {}", child.snapshot_id), + }); + } + + if child.parent_snapshot_id != Some(parent.snapshot_id) { + return Err(BackupError::InvalidManifestChain { + reason: format!( + "snapshot {} does not reference expected parent {}", + child.snapshot_id, parent.snapshot_id + ), + }); + } + + if child.app_id != parent.app_id { + return Err(BackupError::InvalidManifestChain { + reason: format!( + "snapshot {} app_id does not match parent {}", + child.snapshot_id, parent.snapshot_id + ), + }); + } + + if child.database_backend != parent.database_backend { + return Err(BackupError::InvalidManifestChain { + reason: format!( + "snapshot {} database backend does not match parent {}", + child.snapshot_id, parent.snapshot_id + ), + }); + } + + if child.graphql_orm_schema_hash != parent.graphql_orm_schema_hash { + return Err(BackupError::InvalidManifestChain { + reason: format!( + "snapshot {} schema hash does not match parent {}", + child.snapshot_id, parent.snapshot_id + ), + }); + } + } + + Ok(()) +} + +pub fn compress_payload(bytes: &[u8]) -> Result, BackupError> { + zstd::stream::encode_all(bytes, 0).map_err(BackupError::compression) +} + +pub fn decompress_payload(bytes: &[u8]) -> Result, BackupError> { + zstd::stream::decode_all(bytes).map_err(BackupError::compression) +} + pub(crate) fn sha256_hex(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); format!("{:x}", hasher.finalize()) } + +fn manifest_key(snapshot_id: Uuid) -> String { + format!("snapshots/{snapshot_id}/manifest.json") +} diff --git a/crates/graphql-orm-backup/tests/compression.rs b/crates/graphql-orm-backup/tests/compression.rs new file mode 100644 index 00000000..58fdab4a --- /dev/null +++ b/crates/graphql-orm-backup/tests/compression.rs @@ -0,0 +1,159 @@ +use bytes::Bytes; +use graphql_orm_backup::{ + BACKUP_FORMAT_VERSION, BackupCompression, BackupKind, BackupRepository, BackupSnapshotManifest, + DatabaseBackupManifest, TableBackupEntry, bytes_sha256_hex, compress_payload, + decompress_payload, set_manifest_checksum, verify_object_checksums, +}; +use uuid::Uuid; + +mod support { + use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + }; + + use async_trait::async_trait; + use bytes::Bytes; + use graphql_orm_backup::{BackupError, BackupRepository}; + + #[derive(Clone, Default)] + pub struct RecordingRepository { + blobs: Arc>>, + } + + #[async_trait] + impl BackupRepository for RecordingRepository { + async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError> { + self.blobs + .lock() + .expect("blobs lock") + .insert(key.to_string(), body); + Ok(()) + } + + async fn get_blob(&self, key: &str) -> Result { + self.blobs + .lock() + .expect("blobs lock") + .get(key) + .cloned() + .ok_or_else(|| BackupError::MissingBlob { + key: key.to_string(), + }) + } + + async fn blob_exists(&self, key: &str) -> Result { + Ok(self.blobs.lock().expect("blobs lock").contains_key(key)) + } + + async fn list_blobs(&self, prefix: &str) -> Result, BackupError> { + let mut keys = self + .blobs + .lock() + .expect("blobs lock") + .keys() + .filter(|key| key.starts_with(prefix)) + .cloned() + .collect::>(); + keys.sort(); + Ok(keys) + } + + async fn delete_blob(&self, key: &str) -> Result<(), BackupError> { + self.blobs.lock().expect("blobs lock").remove(key); + Ok(()) + } + } +} + +#[test] +fn compressed_payload_round_trips() { + let payload = b"{\"table_name\":\"users\"}\n{\"table_name\":\"posts\"}\n"; + let compressed = compress_payload(payload).expect("compress payload"); + let decompressed = decompress_payload(&compressed).expect("decompress payload"); + + assert_eq!(decompressed, payload); +} + +#[test] +fn compressed_table_export_decompresses_to_json_lines() { + let payload = b"{\"table_name\":\"users\",\"primary_key\":\"1\"}\n"; + let compressed = compress_payload(payload).expect("compress payload"); + let decompressed = decompress_payload(&compressed).expect("decompress payload"); + + assert!(decompressed.ends_with(b"\n")); + let first_line = decompressed + .split(|byte| *byte == b'\n') + .next() + .expect("first line"); + let row: serde_json::Value = serde_json::from_slice(first_line).expect("json row"); + assert_eq!(row["table_name"], "users"); + assert_eq!(row["primary_key"], "1"); +} + +#[test] +fn manifest_checksum_changes_when_compressed_content_hash_changes() { + let mut first = manifest_with_table_hash(bytes_sha256_hex(b"compressed-one")); + let mut second = manifest_with_table_hash(bytes_sha256_hex(b"compressed-two")); + set_manifest_checksum(&mut first).expect("first checksum"); + set_manifest_checksum(&mut second).expect("second checksum"); + + assert_ne!(first.checksum, second.checksum); +} + +#[tokio::test] +async fn table_checksum_validates_compressed_bytes() { + let repository = support::RecordingRepository::default(); + let compressed = Bytes::from(compress_payload(b"{\"id\":\"1\"}\n").expect("compress payload")); + let hash = bytes_sha256_hex(&compressed); + let manifest = manifest_with_table_hash(hash); + + repository + .put_blob(&manifest.database.tables[0].content_key, compressed) + .await + .expect("put table"); + + verify_object_checksums(&repository, &manifest) + .await + .expect("compressed table checksum verifies"); +} + +#[test] +fn corrupted_compressed_payload_returns_error() { + let err = decompress_payload(b"not a valid zstd frame").expect_err("corrupt payload rejected"); + + assert!(matches!( + err, + graphql_orm_backup::BackupError::Compression { .. } + )); +} + +fn manifest_with_table_hash(table_hash: String) -> BackupSnapshotManifest { + BackupSnapshotManifest { + format_version: BACKUP_FORMAT_VERSION, + snapshot_id: Uuid::parse_str("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb").expect("valid uuid"), + parent_snapshot_id: None, + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + graphql_orm_schema_version: "20260514000000".to_string(), + graphql_orm_schema_hash: "schema-hash".to_string(), + database_backend: "sqlite".to_string(), + backup_kind: BackupKind::Full, + database: DatabaseBackupManifest { + export_format: "jsonl".to_string(), + compression: BackupCompression::Zstd, + row_count: 1, + table_count: 1, + tables: vec![TableBackupEntry { + table_name: "users".to_string(), + row_count: 1, + content_key: "snapshots/snapshot/database/tables/users.jsonl.zst".to_string(), + sha256_hex: table_hash, + }], + }, + objects: Vec::new(), + tombstones: Vec::new(), + checksum: String::new(), + } +} diff --git a/crates/graphql-orm-backup/tests/full_backup_creation.rs b/crates/graphql-orm-backup/tests/full_backup_creation.rs index 46e53b31..28f2f536 100644 --- a/crates/graphql-orm-backup/tests/full_backup_creation.rs +++ b/crates/graphql-orm-backup/tests/full_backup_creation.rs @@ -6,11 +6,11 @@ use std::{ use async_trait::async_trait; use bytes::Bytes; use graphql_orm_backup::{ - BackupChangeExport, BackupError, BackupObjectIndex, BackupObjectRef, BackupRepository, - BackupRow, BackupTableExport, DATABASE_EXPORT_FORMAT, FullBackupRequest, + BackupChangeExport, BackupCompression, BackupError, BackupObjectIndex, BackupObjectRef, + BackupRepository, BackupRow, BackupTableExport, DATABASE_EXPORT_FORMAT, FullBackupRequest, GraphqlOrmBackupAdapter, GraphqlOrmBackupSchema, RestoreContext, bytes_sha256_hex, - create_full_backup, database_table_key, object_content_key, snapshot_manifest_key, - verify_manifest_and_objects, verify_manifest_checksum, + create_full_backup, database_table_key, decompress_payload, object_content_key, + snapshot_manifest_key, verify_manifest_and_objects, verify_manifest_checksum, }; use serde_json::{Map, Value}; use uuid::Uuid; @@ -52,6 +52,7 @@ async fn create_full_backup_writes_tables_objects_and_manifest_last() { ); let table_bytes = repository.get_blob(&table_key).await.expect("table blob"); + let table_bytes = decompress_payload(&table_bytes).expect("decompress table blob"); assert!(table_bytes.ends_with(b"\n")); let first_line = table_bytes .split(|byte| *byte == b'\n') @@ -145,6 +146,10 @@ async fn create_full_backup_sets_database_counts() { result.manifest.database.export_format, DATABASE_EXPORT_FORMAT ); + assert_eq!( + result.manifest.database.compression, + BackupCompression::Zstd + ); assert_eq!(result.manifest.database.table_count, 2); assert_eq!(result.manifest.database.row_count, 3); assert_eq!(result.manifest.database.tables[0].table_name, "users"); diff --git a/crates/graphql-orm-backup/tests/local_repository_round_trip.rs b/crates/graphql-orm-backup/tests/local_repository_round_trip.rs index e7361260..8799eb15 100644 --- a/crates/graphql-orm-backup/tests/local_repository_round_trip.rs +++ b/crates/graphql-orm-backup/tests/local_repository_round_trip.rs @@ -1,8 +1,9 @@ use bytes::Bytes; use graphql_orm_backup::{ - BACKUP_FORMAT_VERSION, BackupError, BackupKind, BackupRepository, BackupSnapshotManifest, - DatabaseBackupManifest, LocalBackupRepository, ObjectBackupEntry, TableBackupEntry, - bytes_sha256_hex, object_content_key, set_manifest_checksum, verify_object_checksums, + BACKUP_FORMAT_VERSION, BackupCompression, BackupError, BackupKind, BackupRepository, + BackupSnapshotManifest, DatabaseBackupManifest, LocalBackupRepository, ObjectBackupEntry, + TableBackupEntry, bytes_sha256_hex, object_content_key, set_manifest_checksum, + verify_object_checksums, }; use tempfile::TempDir; use uuid::Uuid; @@ -66,6 +67,144 @@ async fn local_repository_rejects_path_traversal_keys() { assert!(matches!(err, BackupError::InvalidRepositoryKey { .. })); } +#[tokio::test] +async fn local_repository_rejects_absolute_keys() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + + let err = repository + .put_blob("/tmp/escape", Bytes::from_static(b"bad")) + .await + .expect_err("absolute key rejected"); + + assert!(matches!(err, BackupError::InvalidRepositoryKey { .. })); +} + +#[tokio::test] +async fn local_repository_rejects_dot_components() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + + let err = repository + .put_blob("snapshots/./manifest.json", Bytes::from_static(b"bad")) + .await + .expect_err("dot component rejected"); + + assert!(matches!(err, BackupError::InvalidRepositoryKey { .. })); +} + +#[tokio::test] +async fn local_repository_rejects_parent_components() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + + let err = repository + .put_blob("snapshots/../manifest.json", Bytes::from_static(b"bad")) + .await + .expect_err("parent component rejected"); + + assert!(matches!(err, BackupError::InvalidRepositoryKey { .. })); +} + +#[tokio::test] +async fn local_repository_rejects_empty_segments() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + + let err = repository + .put_blob("snapshots//manifest.json", Bytes::from_static(b"bad")) + .await + .expect_err("empty segment rejected"); + + assert!(matches!(err, BackupError::InvalidRepositoryKey { .. })); +} + +#[tokio::test] +async fn local_repository_rejects_backslashes() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + + let err = repository + .put_blob("snapshots\\manifest.json", Bytes::from_static(b"bad")) + .await + .expect_err("backslash rejected"); + + assert!(matches!(err, BackupError::InvalidRepositoryKey { .. })); +} + +#[tokio::test] +async fn local_repository_rejects_nul_bytes() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + + let err = repository + .put_blob("snapshots/manifest\0.json", Bytes::from_static(b"bad")) + .await + .expect_err("nul rejected"); + + assert!(matches!(err, BackupError::InvalidRepositoryKey { .. })); +} + +#[tokio::test] +async fn local_repository_empty_prefix_lists_all_blobs() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + + repository + .put_blob("snapshots/a/manifest.json", Bytes::from_static(b"manifest")) + .await + .expect("put manifest"); + repository + .put_blob("objects/sha256/aa/bb/aabb", Bytes::from_static(b"object")) + .await + .expect("put object"); + + let listed = repository.list_blobs("").await.expect("list all blobs"); + assert_eq!( + listed, + vec![ + "objects/sha256/aa/bb/aabb".to_string(), + "snapshots/a/manifest.json".to_string() + ] + ); +} + +#[tokio::test] +async fn local_repository_open_existing_accepts_existing_directory() { + let temp = TempDir::new().expect("temp dir"); + + LocalBackupRepository::open_existing(temp.path()) + .await + .expect("open existing directory"); +} + +#[tokio::test] +async fn local_repository_open_existing_rejects_missing_path() { + let temp = TempDir::new().expect("temp dir"); + let missing = temp.path().join("missing"); + + let err = LocalBackupRepository::open_existing(missing) + .await + .expect_err("missing path rejected"); + + assert!(matches!(err, BackupError::Io { .. })); +} + +#[tokio::test] +async fn local_repository_open_existing_rejects_file_path() { + let temp = TempDir::new().expect("temp dir"); + let file = temp.path().join("file"); + tokio::fs::write(&file, b"not a directory") + .await + .expect("write file"); + + let err = LocalBackupRepository::open_existing(file) + .await + .expect_err("file path rejected"); + + assert!(matches!(err, BackupError::InvalidRepositoryRoot { .. })); +} + #[tokio::test] async fn verification_fails_when_object_blob_is_missing() { let temp = TempDir::new().expect("temp dir"); @@ -116,7 +255,8 @@ fn sample_manifest_with_object_hash(object_hash: String) -> BackupSnapshotManife database_backend: "sqlite".to_string(), backup_kind: BackupKind::Full, database: DatabaseBackupManifest { - export_format: "jsonl.zst".to_string(), + export_format: "jsonl".to_string(), + compression: BackupCompression::Zstd, row_count: 0, table_count: 1, tables: vec![TableBackupEntry { diff --git a/crates/graphql-orm-backup/tests/manifest_chain.rs b/crates/graphql-orm-backup/tests/manifest_chain.rs new file mode 100644 index 00000000..8ab08fb5 --- /dev/null +++ b/crates/graphql-orm-backup/tests/manifest_chain.rs @@ -0,0 +1,243 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use bytes::Bytes; +use graphql_orm_backup::{ + BACKUP_FORMAT_VERSION, BackupCompression, BackupError, BackupKind, BackupRepository, + BackupSnapshotManifest, DatabaseBackupManifest, load_manifest_chain, set_manifest_checksum, + snapshot_manifest_key, validate_manifest_chain, +}; +use uuid::Uuid; + +#[tokio::test] +async fn load_manifest_chain_returns_single_full_snapshot() { + let repository = RecordingRepository::default(); + let manifest = sample_manifest(full_id(), None, BackupKind::Full); + write_manifest_blob(&repository, &manifest).await; + + let chain = load_manifest_chain(&repository, full_id()) + .await + .expect("load chain"); + + assert_eq!(chain, vec![manifest]); +} + +#[tokio::test] +async fn load_manifest_chain_returns_full_then_incremental() { + let repository = RecordingRepository::default(); + let full = sample_manifest(full_id(), None, BackupKind::Full); + let incremental = sample_manifest( + incremental_id(), + Some(full.snapshot_id), + BackupKind::Incremental, + ); + write_manifest_blob(&repository, &full).await; + write_manifest_blob(&repository, &incremental).await; + + let chain = load_manifest_chain(&repository, incremental_id()) + .await + .expect("load chain"); + + assert_eq!(chain, vec![full, incremental]); +} + +#[tokio::test] +async fn load_manifest_chain_rejects_missing_parent() { + let repository = RecordingRepository::default(); + let incremental = sample_manifest(incremental_id(), Some(full_id()), BackupKind::Incremental); + write_manifest_blob(&repository, &incremental).await; + + let err = load_manifest_chain(&repository, incremental_id()) + .await + .expect_err("missing parent rejected"); + + assert!(matches!(err, BackupError::MissingBlob { .. })); +} + +#[tokio::test] +async fn load_manifest_chain_rejects_checksum_mismatch() { + let repository = RecordingRepository::default(); + let mut manifest = sample_manifest(full_id(), None, BackupKind::Full); + manifest.checksum = "not-the-real-checksum".to_string(); + repository + .put_blob( + &snapshot_manifest_key(manifest.snapshot_id), + Bytes::from(serde_json::to_vec_pretty(&manifest).expect("serialize manifest")), + ) + .await + .expect("write manifest"); + + let err = load_manifest_chain(&repository, full_id()) + .await + .expect_err("checksum mismatch rejected"); + + assert!(matches!(err, BackupError::ChecksumMismatch { .. })); +} + +#[test] +fn validate_manifest_chain_rejects_duplicate_snapshot_id() { + let first = sample_manifest(full_id(), None, BackupKind::Full); + let duplicate = sample_manifest(full_id(), Some(first.snapshot_id), BackupKind::Incremental); + + let err = validate_manifest_chain(&[first, duplicate]).expect_err("duplicate rejected"); + + assert!(matches!(err, BackupError::InvalidManifestChain { .. })); +} + +#[test] +fn validate_manifest_chain_rejects_chain_without_full_root() { + let incremental = sample_manifest(incremental_id(), None, BackupKind::Incremental); + + let err = validate_manifest_chain(&[incremental]).expect_err("root kind rejected"); + + assert!(matches!(err, BackupError::InvalidManifestChain { .. })); +} + +#[test] +fn validate_manifest_chain_rejects_schema_hash_mismatch() { + let full = sample_manifest(full_id(), None, BackupKind::Full); + let mut incremental = sample_manifest( + incremental_id(), + Some(full.snapshot_id), + BackupKind::Incremental, + ); + incremental.graphql_orm_schema_hash = "different-schema".to_string(); + set_manifest_checksum(&mut incremental).expect("reset checksum"); + + let err = validate_manifest_chain(&[full, incremental]).expect_err("schema mismatch rejected"); + + assert!(matches!(err, BackupError::InvalidManifestChain { .. })); +} + +#[test] +fn validate_manifest_chain_rejects_database_backend_mismatch() { + let full = sample_manifest(full_id(), None, BackupKind::Full); + let mut incremental = sample_manifest( + incremental_id(), + Some(full.snapshot_id), + BackupKind::Incremental, + ); + incremental.database_backend = "postgres".to_string(); + set_manifest_checksum(&mut incremental).expect("reset checksum"); + + let err = validate_manifest_chain(&[full, incremental]).expect_err("backend mismatch rejected"); + + assert!(matches!(err, BackupError::InvalidManifestChain { .. })); +} + +#[test] +fn validate_manifest_chain_rejects_app_id_mismatch() { + let full = sample_manifest(full_id(), None, BackupKind::Full); + let mut incremental = sample_manifest( + incremental_id(), + Some(full.snapshot_id), + BackupKind::Incremental, + ); + incremental.app_id = "other-app".to_string(); + set_manifest_checksum(&mut incremental).expect("reset checksum"); + + let err = validate_manifest_chain(&[full, incremental]).expect_err("app mismatch rejected"); + + assert!(matches!(err, BackupError::InvalidManifestChain { .. })); +} + +async fn write_manifest_blob(repository: &RecordingRepository, manifest: &BackupSnapshotManifest) { + repository + .put_blob( + &snapshot_manifest_key(manifest.snapshot_id), + Bytes::from(serde_json::to_vec_pretty(manifest).expect("serialize manifest")), + ) + .await + .expect("write manifest"); +} + +fn sample_manifest( + snapshot_id: Uuid, + parent_snapshot_id: Option, + backup_kind: BackupKind, +) -> BackupSnapshotManifest { + let mut manifest = BackupSnapshotManifest { + format_version: BACKUP_FORMAT_VERSION, + snapshot_id, + parent_snapshot_id, + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + graphql_orm_schema_version: "20260514000000".to_string(), + graphql_orm_schema_hash: "schema-hash".to_string(), + database_backend: "sqlite".to_string(), + backup_kind, + database: DatabaseBackupManifest { + export_format: "jsonl".to_string(), + compression: BackupCompression::Zstd, + row_count: 0, + table_count: 0, + tables: Vec::new(), + }, + objects: Vec::new(), + tombstones: Vec::new(), + checksum: String::new(), + }; + set_manifest_checksum(&mut manifest).expect("set checksum"); + manifest +} + +fn full_id() -> Uuid { + Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid") +} + +fn incremental_id() -> Uuid { + Uuid::parse_str("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb").expect("valid uuid") +} + +#[derive(Clone, Default)] +struct RecordingRepository { + blobs: Arc>>, +} + +#[async_trait] +impl BackupRepository for RecordingRepository { + async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError> { + self.blobs + .lock() + .expect("blobs lock") + .insert(key.to_string(), body); + Ok(()) + } + + async fn get_blob(&self, key: &str) -> Result { + self.blobs + .lock() + .expect("blobs lock") + .get(key) + .cloned() + .ok_or_else(|| BackupError::MissingBlob { + key: key.to_string(), + }) + } + + async fn blob_exists(&self, key: &str) -> Result { + Ok(self.blobs.lock().expect("blobs lock").contains_key(key)) + } + + async fn list_blobs(&self, prefix: &str) -> Result, BackupError> { + let mut keys = self + .blobs + .lock() + .expect("blobs lock") + .keys() + .filter(|key| key.starts_with(prefix)) + .cloned() + .collect::>(); + keys.sort(); + Ok(keys) + } + + async fn delete_blob(&self, key: &str) -> Result<(), BackupError> { + self.blobs.lock().expect("blobs lock").remove(key); + Ok(()) + } +} diff --git a/crates/graphql-orm-backup/tests/manifest_round_trip.rs b/crates/graphql-orm-backup/tests/manifest_round_trip.rs index 425624b0..e0e171e2 100644 --- a/crates/graphql-orm-backup/tests/manifest_round_trip.rs +++ b/crates/graphql-orm-backup/tests/manifest_round_trip.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; use bytes::Bytes; use graphql_orm_backup::{ - BACKUP_FORMAT_VERSION, BackupChangeExport, BackupError, BackupKind, BackupObjectIndex, - BackupObjectRef, BackupSnapshotManifest, BackupTableExport, BackupTombstone, + BACKUP_FORMAT_VERSION, BackupChangeExport, BackupCompression, BackupError, BackupKind, + BackupObjectIndex, BackupObjectRef, BackupSnapshotManifest, BackupTableExport, BackupTombstone, DatabaseBackupManifest, FullBackupPlan, GraphqlOrmBackupAdapter, GraphqlOrmBackupSchema, ObjectBackupEntry, RestoreContext, TableBackupEntry, bytes_sha256_hex, ensure_empty_restore_target, manifest_checksum, object_content_key, plan_full_backup, @@ -33,6 +33,21 @@ fn manifest_checksum_is_stable_and_excludes_checksum_field() { assert_eq!(first, second); } +#[test] +fn manifest_deserialization_defaults_missing_compression_to_none() { + let json = r#"{ + "export_format": "jsonl", + "row_count": 0, + "table_count": 0, + "tables": [] + }"#; + + let database: DatabaseBackupManifest = + serde_json::from_str(json).expect("deserialize database manifest"); + + assert_eq!(database.compression, BackupCompression::None); +} + #[test] fn object_content_key_uses_sha256_shards() { let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; @@ -101,7 +116,8 @@ fn sample_manifest() -> BackupSnapshotManifest { database_backend: "sqlite".to_string(), backup_kind: BackupKind::Full, database: DatabaseBackupManifest { - export_format: "jsonl.zst".to_string(), + export_format: "jsonl".to_string(), + compression: BackupCompression::Zstd, row_count: 1, table_count: 1, tables: vec![TableBackupEntry { From 0fb7998267b7c72b6fe83c97297586a8da52830c Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 14 May 2026 02:49:55 +0000 Subject: [PATCH 004/108] Add streaming blob store layer --- crates/graphql-orm-storage/AGENTS.md | 10 + crates/graphql-orm-storage/Cargo.lock | 44 +++- crates/graphql-orm-storage/Cargo.toml | 15 +- crates/graphql-orm-storage/README.md | 10 +- .../graphql-orm-storage/docs/agent-update.md | 51 ++++ .../graphql-orm-storage/docs/architecture.md | 23 +- .../docs/backup-integration.md | 56 ++++ crates/graphql-orm-storage/docs/blob-store.md | 76 ++++++ crates/graphql-orm-storage/docs/plan.md | 10 +- .../docs/provider-roadmap.md | 8 + crates/graphql-orm-storage/docs/streaming.md | 79 ++++++ crates/graphql-orm-storage/docs/usage.md | 31 +++ crates/graphql-orm-storage/src/azure.rs | 37 ++- crates/graphql-orm-storage/src/blob.rs | 232 +++++++++++++++++ crates/graphql-orm-storage/src/error.rs | 4 + crates/graphql-orm-storage/src/lib.rs | 10 +- crates/graphql-orm-storage/src/local.rs | 240 ++++++++++++++---- crates/graphql-orm-storage/src/object.rs | 24 +- crates/graphql-orm-storage/src/s3.rs | 37 ++- crates/graphql-orm-storage/src/service.rs | 91 ++++++- crates/graphql-orm-storage/tests/blob.rs | 46 ++++ .../graphql-orm-storage/tests/local_blob.rs | 192 ++++++++++++++ .../tests/local_round_trip.rs | 58 ++++- .../tests/provider_placeholders.rs | 31 ++- 24 files changed, 1326 insertions(+), 89 deletions(-) create mode 100644 crates/graphql-orm-storage/docs/agent-update.md create mode 100644 crates/graphql-orm-storage/docs/backup-integration.md create mode 100644 crates/graphql-orm-storage/docs/blob-store.md create mode 100644 crates/graphql-orm-storage/docs/streaming.md create mode 100644 crates/graphql-orm-storage/src/blob.rs create mode 100644 crates/graphql-orm-storage/tests/blob.rs create mode 100644 crates/graphql-orm-storage/tests/local_blob.rs diff --git a/crates/graphql-orm-storage/AGENTS.md b/crates/graphql-orm-storage/AGENTS.md index 40043b64..1e3eb9d5 100644 --- a/crates/graphql-orm-storage/AGENTS.md +++ b/crates/graphql-orm-storage/AGENTS.md @@ -17,3 +17,13 @@ This crate is a reusable storage companion for applications that use `graphql-or - Local filesystem support is the baseline provider. - S3 and Azure Blob support should be explicit feature-gated work; placeholder paths must return clear unsupported errors until implemented. - Add tests for path safety, checksums, key generation, and provider round trips. + +## Current Agent Handoff + +- Current crate version is `0.2.0`. +- The storage provider boundary is now the streaming `BlobStore` trait. +- `ObjectStorage` extends `BlobStore`; custom providers must implement `BlobStore` first. +- `StorageService` remains the high-level primary object API for generated object metadata. +- `graphql-orm-backup` should adapt `BlobStore` directly for backup repository semantics; it should not use `StorageService`. +- S3 and Azure Blob are still feature-gated unsupported placeholders. Do not add real SDK code without implementing the shared `BlobStore` provider layer first. +- See `docs/agent-update.md`, `docs/blob-store.md`, `docs/streaming.md`, and `docs/backup-integration.md` before making provider or backup-facing changes. diff --git a/crates/graphql-orm-storage/Cargo.lock b/crates/graphql-orm-storage/Cargo.lock index ae76e8a8..ce79c722 100644 --- a/crates/graphql-orm-storage/Cargo.lock +++ b/crates/graphql-orm-storage/Cargo.lock @@ -40,6 +40,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cfg-if" version = "1.0.4" @@ -119,6 +125,23 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -132,6 +155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", @@ -162,15 +186,19 @@ dependencies = [ [[package]] name = "graphql-orm-storage" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", + "bytes", + "futures-core", + "futures-util", "serde", "sha2", "tempfile", "thiserror", "time", "tokio", + "tokio-util", "uuid", ] @@ -484,6 +512,7 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "pin-project-lite", "tokio-macros", ] @@ -499,6 +528,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "typenum" version = "1.20.0" diff --git a/crates/graphql-orm-storage/Cargo.toml b/crates/graphql-orm-storage/Cargo.toml index b4014465..7e9d5dfb 100644 --- a/crates/graphql-orm-storage/Cargo.toml +++ b/crates/graphql-orm-storage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-storage" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm-storage" @@ -8,17 +8,21 @@ description = "Provider-neutral object storage primitives for graphql-orm applic [features] default = ["local"] -local = ["dep:tokio"] +local = ["dep:tokio", "dep:tokio-util"] s3 = [] azure = [] [dependencies] async-trait = "0.1" +bytes = "1" +futures-core = "0.3" +futures-util = "0.3" serde = { version = "1", features = ["derive"] } sha2 = "0.10" thiserror = "2" time = { version = "0.3", features = ["serde"] } -tokio = { version = "1", features = ["fs"], optional = true } +tokio = { version = "1", features = ["fs", "io-util"], optional = true } +tokio-util = { version = "0.7", features = ["io"], optional = true } uuid = { version = "1", features = ["serde", "v4"] } [dev-dependencies] @@ -29,3 +33,8 @@ tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] } name = "local_round_trip" path = "tests/local_round_trip.rs" required-features = ["local"] + +[[test]] +name = "local_blob" +path = "tests/local_blob.rs" +required-features = ["local"] diff --git a/crates/graphql-orm-storage/README.md b/crates/graphql-orm-storage/README.md index f61614f4..968e4ec3 100644 --- a/crates/graphql-orm-storage/README.md +++ b/crates/graphql-orm-storage/README.md @@ -4,9 +4,12 @@ Provider-neutral object storage primitives for applications that use `graphql-or This crate stores bytes in an object backend and returns metadata that an application can persist in its own `graphql-orm` entity. It deliberately does not define application concepts such as collections, records, accessions, tenants, users, or media workflows. +Current crate version: `0.2.0`. + ## Current Status - Local filesystem backend implemented. +- Streaming `BlobStore` abstraction implemented. - Stable object metadata and key generation implemented. - S3 and Azure Blob expose explicit unsupported placeholder backends behind feature flags for later provider work. @@ -23,6 +26,9 @@ The core crate does not provide default GraphQL resolvers. Upload, download, del - `azure`: provides `AzureBlobStorageBackend` and `AzureBlobStorageConfig` placeholders that return an unsupported-backend error until real Azure Blob Storage is implemented. For detailed integration guidance, see [docs/usage.md](docs/usage.md). +For the lower-level blob abstraction, see [docs/blob-store.md](docs/blob-store.md). +For streaming object APIs, see [docs/streaming.md](docs/streaming.md). +For backup integration guidance, see [docs/backup-integration.md](docs/backup-integration.md). ```rust use std::sync::Arc; @@ -102,8 +108,8 @@ Only the file extension is copied from the original filename. The original filen ## Provider Roadmap 1. Local filesystem -2. S3-compatible object storage -3. Azure Blob Storage +2. S3-compatible object storage implemented as `BlobStore` first, then `ObjectStorage` +3. Azure Blob Storage implemented as `BlobStore` first, then `ObjectStorage` Backup repositories such as Dropbox and SMB belong in `graphql-orm-backup`, not this crate. diff --git a/crates/graphql-orm-storage/docs/agent-update.md b/crates/graphql-orm-storage/docs/agent-update.md new file mode 100644 index 00000000..4417d55f --- /dev/null +++ b/crates/graphql-orm-storage/docs/agent-update.md @@ -0,0 +1,51 @@ +# Agent Update + +This update summarizes the `0.2.0` storage-provider boundary for agents working +on `graphql-orm-storage` or downstream crates. + +## What Changed + +- Added the streaming `BlobStore` trait as the low-level provider abstraction. +- Added `StorageByteStream`, `BlobBody`, `BlobMetadata`, and + `BlobWriteOutcome`. +- Added `validate_blob_key` for consistent provider key validation. +- Added `StorageService::put_object_stream` and + `StorageService::get_object_stream`. +- Buffered object APIs still exist and delegate through the streaming layer. +- `LocalStorageBackend` now implements `BlobStore` and `ObjectStorage`. +- S3 and Azure Blob placeholders now implement `BlobStore` and still return + `UnsupportedBackend`. + +## Provider Guidance + +New providers should implement `BlobStore` first. `ObjectStorage` should remain a +thin layer over blob operations unless a provider has object-specific behavior +that belongs in this crate. + +Do not add default GraphQL upload, download, or delete resolvers. Applications +own GraphQL schema design, authorization, and persistence of `StoredObject` +metadata. + +## Backup Guidance + +`graphql-orm-backup` should adapt `BlobStore` directly. It should not use +`StorageService`, because backup repositories use arbitrary manifest, table, and +object keys rather than primary object namespaces and generated `StoredObject` +metadata. + +Future adapter shape: + +```rust +pub struct BlobStoreBackupRepository { + store: std::sync::Arc, + prefix: Option, +} +``` + +## Still Pending + +- Real S3 `BlobStore` provider. +- Real Azure Blob `BlobStore` provider. +- Backup adapter implementation after downstream crate alignment. +- Any cloud SDK dependency decisions. +- Provider integration tests that require external services. diff --git a/crates/graphql-orm-storage/docs/architecture.md b/crates/graphql-orm-storage/docs/architecture.md index 5e2bfe21..9f546265 100644 --- a/crates/graphql-orm-storage/docs/architecture.md +++ b/crates/graphql-orm-storage/docs/architecture.md @@ -4,6 +4,19 @@ `graphql-orm-storage` owns object bytes and object locators. It does not own database rows. This keeps the crate usable by any application that wants to persist storage metadata differently. +## BlobStore Boundary + +`BlobStore` is the low-level key-addressed storage abstraction. It stores and +loads safe relative blob keys, supports streaming bodies, and exposes existence, +metadata, listing, and delete operations. + +`StorageService` is the high-level primary object workflow. It generates object +IDs, namespaces, storage keys, byte counts, checksums, and timestamps before +applications persist metadata in their own database rows. + +`graphql-orm-backup` should reuse future cloud provider implementations through +a `BlobStore` adapter, not through `StorageService`. + ## GraphQL Resolver Boundary The core crate should not provide default GraphQL upload, download, delete, or metadata mutation resolvers. @@ -58,10 +71,10 @@ The host app should still own: 1. Caller provides `StoragePutRequest`. 2. `StorageService` generates a UUID object ID. -3. `StorageService` computes the SHA-256 checksum. -4. `StorageService` creates a sharded storage key. -5. `StorageService` delegates byte persistence to `ObjectStorage`. -6. Backend writes bytes and returns the `StoredObject`. +3. `StorageService` creates a sharded storage key. +4. `StorageService` delegates byte persistence to `BlobStore`. +5. Backend writes bytes and returns size plus SHA-256. +6. `StorageService` returns the `StoredObject`. 7. Caller persists returned metadata in its own database transaction. ## Object Key Safety @@ -87,3 +100,5 @@ Provider-specific code should live behind cargo features: - `azure`: reserved Provider implementations must satisfy the same `ObjectStorage` trait. +Provider implementations should implement `BlobStore` first, then expose +`ObjectStorage` behavior on top of it. diff --git a/crates/graphql-orm-storage/docs/backup-integration.md b/crates/graphql-orm-storage/docs/backup-integration.md new file mode 100644 index 00000000..1b221108 --- /dev/null +++ b/crates/graphql-orm-storage/docs/backup-integration.md @@ -0,0 +1,56 @@ +# Backup Integration + +`graphql-orm-backup` should reuse storage provider code through `BlobStore`, not +through `StorageService`. + +## Why Not StorageService? + +`StorageService` is for primary object workflows: + +- generated object IDs +- logical namespaces +- generated storage keys +- size and SHA-256 metadata +- app-persisted `StoredObject` fields + +Backup repositories have different semantics: + +- arbitrary manifest keys +- table payload keys +- change payload keys +- content-addressed object keys +- prefix listing + +Those repository keys should not be forced through primary object metadata. + +## Future Adapter Shape + +Once `graphql-orm-backup` depends on a version of this crate with `BlobStore`, it +can add an adapter like: + +```rust +pub struct BlobStoreBackupRepository { + store: Arc, + prefix: Option, +} +``` + +Mapping: + +- `BackupRepository::put_blob` calls `BlobStore::put_blob` +- `BackupRepository::get_blob` collects or streams `BlobStore::get_blob` +- `BackupRepository::blob_exists` calls `BlobStore::blob_exists` +- `BackupRepository::list_blobs` calls `BlobStore::list_blobs` +- `BackupRepository::delete_blob` calls `BlobStore::delete_blob` + +The adapter should apply and strip its configured repository prefix +consistently. + +## Provider Ownership + +S3-compatible and Azure Blob SDK integrations should live in this crate as +`BlobStore` implementations. `graphql-orm-backup` should adapt them instead of +duplicating cloud SDK code. + +Dropbox remains backup-specific and is not a primary object storage provider for +this crate. diff --git a/crates/graphql-orm-storage/docs/blob-store.md b/crates/graphql-orm-storage/docs/blob-store.md new file mode 100644 index 00000000..37a4b08f --- /dev/null +++ b/crates/graphql-orm-storage/docs/blob-store.md @@ -0,0 +1,76 @@ +# BlobStore + +`BlobStore` is the low-level, key-addressed storage abstraction in +`graphql-orm-storage`. + +Use it when code needs to put, get, list, inspect, or delete arbitrary safe blob +keys without creating primary object metadata. + +## Boundary + +`BlobStore` does not know about: + +- GraphQL +- application authorization +- tenants +- collections +- database rows +- object namespaces as domain policy +- upload or download routes + +It only stores bytes at provider-neutral keys. + +## API Shape + +```rust +#[async_trait::async_trait] +pub trait BlobStore: Send + Sync { + fn backend(&self) -> StorageBackend; + + async fn put_blob( + &self, + key: &str, + body: StorageByteStream, + ) -> Result; + + async fn get_blob(&self, key: &str) -> Result; + + async fn blob_exists(&self, key: &str) -> Result; + + async fn head_blob(&self, key: &str) -> Result, StorageError>; + + async fn list_blobs(&self, prefix: &str) -> Result, StorageError>; + + async fn delete_blob(&self, key: &str) -> Result<(), StorageError>; +} +``` + +## Key Safety + +Blob keys are `/`-separated relative keys. `validate_blob_key` rejects: + +- empty keys +- absolute paths +- empty path segments +- `.` +- `..` +- backslashes +- NUL bytes +- platform prefix components + +`list_blobs("")` is allowed and lists all blobs. + +## Relationship To ObjectStorage + +`ObjectStorage` builds on `BlobStore`. Provider implementations should implement +`BlobStore` first, then expose object-storage behavior on top of it. + +`StorageService` remains the high-level API for primary object metadata. It +generates object IDs, storage keys, sizes, hashes, and timestamps. + +## Backup Integration + +`BlobStore` is the intended future sharing point for `graphql-orm-backup`. +Backup repositories should adapt `BlobStore`; they should not use +`StorageService` or `StoredObject`, because backup keys and primary object +metadata have different semantics. diff --git a/crates/graphql-orm-storage/docs/plan.md b/crates/graphql-orm-storage/docs/plan.md index 11c37bf3..71f69d34 100644 --- a/crates/graphql-orm-storage/docs/plan.md +++ b/crates/graphql-orm-storage/docs/plan.md @@ -7,6 +7,7 @@ Create a reusable object storage crate for applications that use `graphql-orm`. ## What This Crate Provides - Provider-neutral object metadata. +- Provider-neutral streaming blob storage trait. - Provider-neutral object storage trait. - Storage service that generates object IDs, keys, sizes, hashes, and timestamps. - Local filesystem backend. @@ -32,6 +33,8 @@ Create a reusable object storage crate for applications that use `graphql-orm`. 6. Implement safe sharded object key generation. 7. Implement `LocalStorageBackend`. 8. Add tests for the local backend and key safety. +9. Add `BlobStore` as the shared low-level provider abstraction. +10. Add streaming object APIs while preserving buffered object APIs. ## Integration Pattern For Applications @@ -51,8 +54,7 @@ Applications should also own GraphQL resolvers and route handlers. A future opti ## Future Work -- Add streaming upload/download APIs so large files do not need to fit in memory. -- Add S3-compatible provider behind the `s3` feature. -- Add Azure Blob provider behind the `azure` feature. -- Add optional object existence and metadata APIs if backup verification needs them. +- Add S3-compatible provider behind the `s3` feature, implemented as `BlobStore` first. +- Add Azure Blob provider behind the `azure` feature, implemented as `BlobStore` first. +- Add a `graphql-orm-backup` adapter that wraps `BlobStore` as a backup repository. - Add optional server-side encryption hooks if applications need provider-managed keys. diff --git a/crates/graphql-orm-storage/docs/provider-roadmap.md b/crates/graphql-orm-storage/docs/provider-roadmap.md index 5d01a80d..c60354c8 100644 --- a/crates/graphql-orm-storage/docs/provider-roadmap.md +++ b/crates/graphql-orm-storage/docs/provider-roadmap.md @@ -16,6 +16,10 @@ Acceptance criteria: Add behind the `s3` feature. +Implement S3 as a `BlobStore` first. The high-level `ObjectStorage` behavior +should delegate to the same S3 blob operations so backup integrations can reuse +the provider through a future adapter. + Expected configuration: - endpoint URL @@ -32,6 +36,10 @@ The implementation must use the same `storage_key` values as local storage. Add behind the `azure` feature. +Implement Azure Blob as a `BlobStore` first. Azure should follow the same +provider layering as S3 after the S3 implementation proves the shared blob +interface. + Expected configuration: - account/container or connection string diff --git a/crates/graphql-orm-storage/docs/streaming.md b/crates/graphql-orm-storage/docs/streaming.md new file mode 100644 index 00000000..afb71549 --- /dev/null +++ b/crates/graphql-orm-storage/docs/streaming.md @@ -0,0 +1,79 @@ +# Streaming API + +The crate supports both buffered and streaming object APIs. + +Use buffered APIs for small files and simple application flows: + +```rust +StorageService::put_object(request).await?; +StorageService::get_object(&stored).await?; +``` + +Use streaming APIs when the caller already has a stream or when large objects +should not be represented as a caller-owned `Vec`. + +## Store A Streaming Object + +```rust +use std::sync::Arc; + +use graphql_orm_storage::{ + LocalStorageBackend, StorageByteStream, StorageNamespace, + StoragePutStreamRequest, StorageService, +}; + +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +let service = StorageService::new(Arc::new(LocalStorageBackend::new("./data/storage"))); + +let stored = service + .put_object_stream(StoragePutStreamRequest { + namespace: StorageNamespace::Originals, + file_name: Some("artifact.bin".to_string()), + mime_type: Some("application/octet-stream".to_string()), + body: StorageByteStream::from_bytes(b"bytes".to_vec()), + }) + .await?; + +assert_eq!(stored.size_bytes, 5); +# Ok(()) +# } +``` + +`put_object_stream` computes the final byte count and SHA-256 checksum while the +backend writes the stream. + +## Load A Streaming Object + +```rust +# use std::sync::Arc; +# use graphql_orm_storage::{ +# LocalStorageBackend, StorageByteStream, StorageNamespace, +# StoragePutStreamRequest, StorageService, collect_storage_stream, +# }; +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +# let service = StorageService::new(Arc::new(LocalStorageBackend::new("./data/storage"))); +# let stored = service.put_object_stream(StoragePutStreamRequest { +# namespace: StorageNamespace::Originals, +# file_name: Some("artifact.bin".to_string()), +# mime_type: Some("application/octet-stream".to_string()), +# body: StorageByteStream::from_bytes(b"bytes".to_vec()), +# }).await?; +let loaded = service.get_object_stream(&stored).await?; +let bytes = collect_storage_stream(loaded.body).await?; +# Ok(()) +# } +``` + +Applications can use the stream directly instead of collecting it. + +## Buffered Compatibility + +The original buffered APIs remain available and delegate through the streaming +path: + +- `StoragePutRequest` +- `StorageObjectBody` +- `StorageService::put_object` +- `StorageService::get_object` + +They are convenient wrappers, not a separate provider implementation path. diff --git a/crates/graphql-orm-storage/docs/usage.md b/crates/graphql-orm-storage/docs/usage.md index dd1102df..c7cb2300 100644 --- a/crates/graphql-orm-storage/docs/usage.md +++ b/crates/graphql-orm-storage/docs/usage.md @@ -9,6 +9,10 @@ upload, read, delete, or list objects. It also does not define database tables, GraphQL resolvers, upload routes, download routes, tenant behavior, collection behavior, media workflows, or audit events. +Use `StorageService` for primary object metadata workflows. Use `BlobStore` for +low-level key-addressed blob operations that do not need generated object +metadata. + ## Dependency Default local filesystem support: @@ -62,6 +66,33 @@ let created_at = stored.created_at; # } ``` +## Store A Streaming Object + +```rust +use std::sync::Arc; + +use graphql_orm_storage::{ + LocalStorageBackend, StorageByteStream, StorageNamespace, + StoragePutStreamRequest, StorageService, +}; + +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +let service = StorageService::new(Arc::new(LocalStorageBackend::new("./data/storage"))); + +let stored = service + .put_object_stream(StoragePutStreamRequest { + namespace: StorageNamespace::Originals, + file_name: Some("artifact.bin".to_string()), + mime_type: Some("application/octet-stream".to_string()), + body: StorageByteStream::from_bytes(b"streamed bytes".to_vec()), + }) + .await?; + +assert_eq!(stored.size_bytes, 14); +# Ok(()) +# } +``` + ## Load Or Delete An Object The host application loads its own metadata row first, performs authorization, diff --git a/crates/graphql-orm-storage/src/azure.rs b/crates/graphql-orm-storage/src/azure.rs index 3f62f9ac..22f618f9 100644 --- a/crates/graphql-orm-storage/src/azure.rs +++ b/crates/graphql-orm-storage/src/azure.rs @@ -3,8 +3,8 @@ use std::fmt; use async_trait::async_trait; use crate::{ - ObjectStorage, StorageBackend, StorageError, StorageObjectBody, StoredObject, - unsupported_backend, + BlobBody, BlobMetadata, BlobStore, BlobWriteOutcome, ObjectStorage, StorageBackend, + StorageByteStream, StorageError, StorageObjectBody, StoredObject, unsupported_backend, }; /// Configuration for a future Azure Blob Storage backend. @@ -66,11 +66,42 @@ impl AzureBlobStorageBackend { } #[async_trait] -impl ObjectStorage for AzureBlobStorageBackend { +impl BlobStore for AzureBlobStorageBackend { fn backend(&self) -> StorageBackend { StorageBackend::AzureBlob } + async fn put_blob( + &self, + _key: &str, + _body: StorageByteStream, + ) -> Result { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + + async fn get_blob(&self, _key: &str) -> Result { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + + async fn blob_exists(&self, _key: &str) -> Result { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + + async fn head_blob(&self, _key: &str) -> Result, StorageError> { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + + async fn list_blobs(&self, _prefix: &str) -> Result, StorageError> { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + + async fn delete_blob(&self, _key: &str) -> Result<(), StorageError> { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } +} + +#[async_trait] +impl ObjectStorage for AzureBlobStorageBackend { async fn put_object( &self, _object: StoredObject, diff --git a/crates/graphql-orm-storage/src/blob.rs b/crates/graphql-orm-storage/src/blob.rs new file mode 100644 index 00000000..d372c521 --- /dev/null +++ b/crates/graphql-orm-storage/src/blob.rs @@ -0,0 +1,232 @@ +use std::{ + fmt, + path::{Component, Path}, + pin::Pin, +}; + +use async_trait::async_trait; +use bytes::{Bytes, BytesMut}; +use futures_core::Stream; +use futures_util::{StreamExt, stream}; +use time::OffsetDateTime; + +use crate::{StorageBackend, StorageError}; + +/// Boxed stream of storage byte chunks. +pub type BoxedStorageStream = + Pin> + Send + 'static>>; + +/// Streaming object or blob body. +pub struct StorageByteStream { + inner: BoxedStorageStream, + size_hint: Option, +} + +impl StorageByteStream { + /// Creates a stream without a known size. + #[must_use] + pub fn new(inner: BoxedStorageStream) -> Self { + Self { + inner, + size_hint: None, + } + } + + /// Creates a stream with a known byte size. + #[must_use] + pub fn with_size_hint(inner: BoxedStorageStream, size_hint: u64) -> Self { + Self { + inner, + size_hint: Some(size_hint), + } + } + + /// Creates a single-chunk stream from bytes. + #[must_use] + pub fn from_bytes(bytes: impl Into) -> Self { + let bytes = bytes.into(); + let size_hint = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + Self::with_size_hint(Box::pin(stream::once(async move { Ok(bytes) })), size_hint) + } + + /// Returns the known size hint when available. + #[must_use] + pub const fn size_hint(&self) -> Option { + self.size_hint + } + + /// Consumes the wrapper and returns the inner stream. + #[must_use] + pub fn into_inner(self) -> BoxedStorageStream { + self.inner + } +} + +impl fmt::Debug for StorageByteStream { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StorageByteStream") + .field("size_hint", &self.size_hint) + .finish_non_exhaustive() + } +} + +/// Collects a storage byte stream into one byte buffer. +/// +/// # Errors +/// +/// Returns [`StorageError`] when the stream yields an error. +pub async fn collect_storage_stream(stream: StorageByteStream) -> Result { + let capacity = stream + .size_hint() + .and_then(|size| usize::try_from(size).ok()) + .unwrap_or_default(); + let mut bytes = BytesMut::with_capacity(capacity); + let mut inner = stream.into_inner(); + + while let Some(chunk) = inner.next().await { + bytes.extend_from_slice(&chunk?); + } + + Ok(bytes.freeze()) +} + +/// Result of writing a blob. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BlobWriteOutcome { + /// Number of bytes written. + pub size_bytes: u64, + /// Lowercase hexadecimal SHA-256 checksum for the bytes written. + pub sha256_hex: String, +} + +/// Provider metadata for an existing blob. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BlobMetadata { + /// Provider-neutral blob key. + pub key: String, + /// Blob size in bytes, when available. + pub size_bytes: Option, + /// Lowercase hexadecimal SHA-256 checksum, when known by the provider. + pub sha256_hex: Option, + /// Provider ETag, when available. + pub etag: Option, + /// Last modified timestamp, when available. + pub last_modified: Option, +} + +/// Blob metadata plus a streaming byte body. +#[derive(Debug)] +pub struct BlobBody { + /// Provider-neutral blob key. + pub key: String, + /// Provider metadata, when available. + pub metadata: Option, + /// Streaming blob bytes. + pub body: StorageByteStream, +} + +/// Low-level key-addressed blob storage contract. +#[async_trait] +pub trait BlobStore: Send + Sync { + /// Returns the provider identifier for this store. + fn backend(&self) -> StorageBackend; + + /// Writes a blob stream. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the key is invalid or the provider cannot + /// write the blob. + async fn put_blob( + &self, + key: &str, + body: StorageByteStream, + ) -> Result; + + /// Loads a blob stream. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the key is invalid or the provider cannot + /// load the blob. + async fn get_blob(&self, key: &str) -> Result; + + /// Checks whether a blob exists. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the key is invalid or the provider cannot + /// check the blob. + async fn blob_exists(&self, key: &str) -> Result; + + /// Loads provider metadata for a blob. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the key is invalid or the provider cannot + /// load metadata. + async fn head_blob(&self, key: &str) -> Result, StorageError>; + + /// Lists blob keys under a prefix. An empty prefix lists all blobs. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the prefix is invalid or the provider cannot + /// list blobs. + async fn list_blobs(&self, prefix: &str) -> Result, StorageError>; + + /// Deletes a blob. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the key is invalid or the provider cannot + /// delete the blob. + async fn delete_blob(&self, key: &str) -> Result<(), StorageError>; +} + +/// Validates a provider-neutral blob key. +/// +/// # Errors +/// +/// Returns [`StorageError::InvalidStorageKey`] when the key can escape the +/// provider namespace or cannot be represented as a safe relative path. +pub fn validate_blob_key(key: &str) -> Result<(), StorageError> { + if key.is_empty() + || key.contains('\\') + || key.contains('\0') + || key + .split('/') + .any(|component| component.is_empty() || component == "." || component == "..") + { + return Err(StorageError::InvalidStorageKey { + key: key.to_string(), + }); + } + + let path = Path::new(key); + if path.is_absolute() { + return Err(StorageError::InvalidStorageKey { + key: key.to_string(), + }); + } + + for component in path.components() { + if !matches!(component, Component::Normal(_)) { + return Err(StorageError::InvalidStorageKey { + key: key.to_string(), + }); + } + } + + Ok(()) +} + +#[cfg(feature = "local")] +pub(crate) fn validate_blob_prefix(prefix: &str) -> Result<(), StorageError> { + if prefix.is_empty() { + Ok(()) + } else { + validate_blob_key(prefix) + } +} diff --git a/crates/graphql-orm-storage/src/error.rs b/crates/graphql-orm-storage/src/error.rs index a83aba16..926e7e46 100644 --- a/crates/graphql-orm-storage/src/error.rs +++ b/crates/graphql-orm-storage/src/error.rs @@ -11,6 +11,10 @@ pub enum StorageError { #[error("invalid storage key: {key}")] InvalidStorageKey { key: String }, + /// A requested blob is missing from the storage backend. + #[error("storage blob is missing: {key}")] + MissingBlob { key: String }, + /// A local filesystem object path did not have a writable parent directory. #[error("local storage path has no parent: {path:?}")] MissingParent { path: PathBuf }, diff --git a/crates/graphql-orm-storage/src/lib.rs b/crates/graphql-orm-storage/src/lib.rs index 7a715b0e..bba8400f 100644 --- a/crates/graphql-orm-storage/src/lib.rs +++ b/crates/graphql-orm-storage/src/lib.rs @@ -6,6 +6,7 @@ #[cfg(feature = "azure")] mod azure; mod backend; +mod blob; mod checksum; mod error; mod key; @@ -19,12 +20,19 @@ mod service; #[cfg(feature = "azure")] pub use azure::{AzureBlobStorageBackend, AzureBlobStorageConfig}; pub use backend::{StorageBackend, StorageNamespace, unsupported_backend}; +pub use blob::{ + BlobBody, BlobMetadata, BlobStore, BlobWriteOutcome, BoxedStorageStream, StorageByteStream, + collect_storage_stream, validate_blob_key, +}; pub use checksum::sha256_hex; pub use error::StorageError; pub use key::{build_storage_key, file_extension}; #[cfg(feature = "local")] pub use local::LocalStorageBackend; -pub use object::{StorageObjectBody, StoragePutRequest, StoredObject}; +pub use object::{ + StorageObjectBody, StorageObjectStream, StoragePutRequest, StoragePutStreamRequest, + StoredObject, +}; #[cfg(feature = "s3")] pub use s3::{S3StorageBackend, S3StorageConfig}; pub use service::{ObjectStorage, StorageService}; diff --git a/crates/graphql-orm-storage/src/local.rs b/crates/graphql-orm-storage/src/local.rs index 10d05f8f..23176d3f 100644 --- a/crates/graphql-orm-storage/src/local.rs +++ b/crates/graphql-orm-storage/src/local.rs @@ -1,8 +1,18 @@ -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use async_trait::async_trait; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; +use tokio::io::AsyncWriteExt; +use tokio_util::io::ReaderStream; +use uuid::Uuid; -use crate::{ObjectStorage, StorageBackend, StorageError, StorageObjectBody, StoredObject}; +use crate::{ + BlobBody, BlobMetadata, BlobStore, BlobWriteOutcome, ObjectStorage, StorageBackend, + StorageByteStream, StorageError, StorageObjectBody, StoredObject, collect_storage_stream, + validate_blob_key, +}; /// Local filesystem object storage backend. #[derive(Clone, Debug)] @@ -17,24 +27,24 @@ impl LocalStorageBackend { Self { root: root.into() } } - fn path_for(&self, object: &StoredObject) -> Result { - validate_storage_key(&object.storage_key)?; - Ok(self.root.join(Path::new(&object.storage_key))) + fn path_for(&self, key: &str) -> Result { + validate_blob_key(key)?; + Ok(self.root.join(Path::new(key))) } } #[async_trait] -impl ObjectStorage for LocalStorageBackend { +impl BlobStore for LocalStorageBackend { fn backend(&self) -> StorageBackend { StorageBackend::Local } - async fn put_object( + async fn put_blob( &self, - object: StoredObject, - bytes: Vec, - ) -> Result { - let path = self.path_for(&object)?; + key: &str, + body: StorageByteStream, + ) -> Result { + let path = self.path_for(key)?; let parent = path .parent() .ok_or_else(|| StorageError::MissingParent { path: path.clone() })?; @@ -42,30 +52,125 @@ impl ObjectStorage for LocalStorageBackend { .await .map_err(|source| StorageError::io(parent, source))?; - let temp_path = path.with_extension("uploading"); - tokio::fs::write(&temp_path, bytes) - .await - .map_err(|source| StorageError::io(&temp_path, source))?; - tokio::fs::rename(&temp_path, &path) - .await - .map_err(|source| StorageError::io(&path, source))?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| StorageError::InvalidStorageKey { + key: key.to_string(), + })?; + let temp_path = path.with_file_name(format!("{file_name}.{}.uploading", Uuid::new_v4())); - Ok(object) + let write_result = write_stream_to_temp(&temp_path, body).await; + let outcome = match write_result { + Ok(outcome) => outcome, + Err(err) => { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(err); + } + }; + + if let Err(source) = tokio::fs::rename(&temp_path, &path).await { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(StorageError::io(&path, source)); + } + + Ok(outcome) } - async fn get_object(&self, object: &StoredObject) -> Result { - let path = self.path_for(object)?; - let bytes = tokio::fs::read(&path) - .await - .map_err(|source| StorageError::io(&path, source))?; - Ok(StorageObjectBody { - object: object.clone(), - bytes, + async fn get_blob(&self, key: &str) -> Result { + let path = self.path_for(key)?; + let file = match tokio::fs::File::open(&path).await { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(StorageError::MissingBlob { + key: key.to_string(), + }); + } + Err(source) => return Err(StorageError::io(&path, source)), + }; + let metadata = self.head_blob(key).await?; + let stream_path = path.clone(); + let stream = ReaderStream::new(file) + .map(move |chunk| chunk.map_err(|source| StorageError::io(&stream_path, source))); + + Ok(BlobBody { + key: key.to_string(), + metadata, + body: StorageByteStream::new(Box::pin(stream)), }) } - async fn delete_object(&self, object: &StoredObject) -> Result<(), StorageError> { - let path = self.path_for(object)?; + async fn blob_exists(&self, key: &str) -> Result { + let path = self.path_for(key)?; + match tokio::fs::metadata(&path).await { + Ok(metadata) => Ok(metadata.is_file()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(StorageError::io(&path, source)), + } + } + + async fn head_blob(&self, key: &str) -> Result, StorageError> { + let path = self.path_for(key)?; + match tokio::fs::metadata(&path).await { + Ok(metadata) => Ok(Some(BlobMetadata { + key: key.to_string(), + size_bytes: Some(metadata.len()), + sha256_hex: None, + etag: None, + last_modified: metadata.modified().ok().map(OffsetDateTime::from), + })), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(StorageError::io(&path, source)), + } + } + + async fn list_blobs(&self, prefix: &str) -> Result, StorageError> { + crate::blob::validate_blob_prefix(prefix)?; + + let start = if prefix.is_empty() { + self.root.clone() + } else { + self.root.join(prefix) + }; + + let mut result = Vec::new(); + let mut stack = vec![start]; + + while let Some(path) = stack.pop() { + let metadata = match tokio::fs::metadata(&path).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(source) => return Err(StorageError::io(&path, source)), + }; + + if metadata.is_file() { + if is_uploading_temp_file(&path) { + continue; + } + if let Ok(relative) = path.strip_prefix(&self.root) { + result.push(relative.to_string_lossy().replace('\\', "/")); + } + continue; + } + + let mut entries = tokio::fs::read_dir(&path) + .await + .map_err(|source| StorageError::io(&path, source))?; + while let Some(entry) = entries + .next_entry() + .await + .map_err(|source| StorageError::io(&path, source))? + { + stack.push(entry.path()); + } + } + + result.sort(); + Ok(result) + } + + async fn delete_blob(&self, key: &str) -> Result<(), StorageError> { + let path = self.path_for(key)?; match tokio::fs::remove_file(&path).await { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -74,33 +179,64 @@ impl ObjectStorage for LocalStorageBackend { } } -fn validate_storage_key(key: &str) -> Result<(), StorageError> { - if key.is_empty() - || key.contains('\\') - || key.contains('\0') - || key - .split('/') - .any(|component| component.is_empty() || component == "." || component == "..") - { - return Err(StorageError::InvalidStorageKey { - key: key.to_string(), - }); +#[async_trait] +impl ObjectStorage for LocalStorageBackend { + async fn put_object( + &self, + object: StoredObject, + bytes: Vec, + ) -> Result { + self.put_blob(&object.storage_key, StorageByteStream::from_bytes(bytes)) + .await?; + Ok(object) } - let path = Path::new(key); - if path.is_absolute() { - return Err(StorageError::InvalidStorageKey { - key: key.to_string(), - }); + async fn get_object(&self, object: &StoredObject) -> Result { + let body = self.get_blob(&object.storage_key).await?; + let bytes = collect_storage_stream(body.body).await?; + Ok(StorageObjectBody { + object: object.clone(), + bytes: bytes.to_vec(), + }) } - for component in path.components() { - if !matches!(component, Component::Normal(_)) { - return Err(StorageError::InvalidStorageKey { - key: key.to_string(), - }); - } + async fn delete_object(&self, object: &StoredObject) -> Result<(), StorageError> { + self.delete_blob(&object.storage_key).await } +} + +async fn write_stream_to_temp( + temp_path: &Path, + body: StorageByteStream, +) -> Result { + let mut file = tokio::fs::File::create(temp_path) + .await + .map_err(|source| StorageError::io(temp_path, source))?; + let mut stream = body.into_inner(); + let mut hasher = Sha256::new(); + let mut size_bytes = 0_u64; + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + size_bytes = size_bytes.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + hasher.update(&chunk); + file.write_all(&chunk) + .await + .map_err(|source| StorageError::io(temp_path, source))?; + } + + file.flush() + .await + .map_err(|source| StorageError::io(temp_path, source))?; + + Ok(BlobWriteOutcome { + size_bytes, + sha256_hex: format!("{:x}", hasher.finalize()), + }) +} - Ok(()) +fn is_uploading_temp_file(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".uploading")) } diff --git a/crates/graphql-orm-storage/src/object.rs b/crates/graphql-orm-storage/src/object.rs index c86433bb..928235aa 100644 --- a/crates/graphql-orm-storage/src/object.rs +++ b/crates/graphql-orm-storage/src/object.rs @@ -1,7 +1,7 @@ use time::OffsetDateTime; use uuid::Uuid; -use crate::{StorageBackend, StorageNamespace}; +use crate::{StorageBackend, StorageByteStream, StorageNamespace}; /// Request body for storing a new object. #[derive(Clone, Debug)] @@ -16,6 +16,19 @@ pub struct StoragePutRequest { pub bytes: Vec, } +/// Streaming request body for storing a new object. +#[derive(Debug)] +pub struct StoragePutStreamRequest { + /// Logical namespace for the generated storage key. + pub namespace: StorageNamespace, + /// Original filename retained as metadata only. + pub file_name: Option, + /// Caller-provided MIME type metadata. + pub mime_type: Option, + /// Streaming object bytes to store. + pub body: StorageByteStream, +} + /// Provider-neutral metadata describing a stored object. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct StoredObject { @@ -47,3 +60,12 @@ pub struct StorageObjectBody { /// Loaded object bytes. pub bytes: Vec, } + +/// Object metadata plus streaming bytes. +#[derive(Debug)] +pub struct StorageObjectStream { + /// Stored object metadata. + pub object: StoredObject, + /// Streaming object bytes. + pub body: StorageByteStream, +} diff --git a/crates/graphql-orm-storage/src/s3.rs b/crates/graphql-orm-storage/src/s3.rs index 42142411..a134f3bb 100644 --- a/crates/graphql-orm-storage/src/s3.rs +++ b/crates/graphql-orm-storage/src/s3.rs @@ -3,8 +3,8 @@ use std::fmt; use async_trait::async_trait; use crate::{ - ObjectStorage, StorageBackend, StorageError, StorageObjectBody, StoredObject, - unsupported_backend, + BlobBody, BlobMetadata, BlobStore, BlobWriteOutcome, ObjectStorage, StorageBackend, + StorageByteStream, StorageError, StorageObjectBody, StoredObject, unsupported_backend, }; /// Configuration for a future S3-compatible storage backend. @@ -66,11 +66,42 @@ impl S3StorageBackend { } #[async_trait] -impl ObjectStorage for S3StorageBackend { +impl BlobStore for S3StorageBackend { fn backend(&self) -> StorageBackend { StorageBackend::S3 } + async fn put_blob( + &self, + _key: &str, + _body: StorageByteStream, + ) -> Result { + Err(unsupported_backend(StorageBackend::S3)) + } + + async fn get_blob(&self, _key: &str) -> Result { + Err(unsupported_backend(StorageBackend::S3)) + } + + async fn blob_exists(&self, _key: &str) -> Result { + Err(unsupported_backend(StorageBackend::S3)) + } + + async fn head_blob(&self, _key: &str) -> Result, StorageError> { + Err(unsupported_backend(StorageBackend::S3)) + } + + async fn list_blobs(&self, _prefix: &str) -> Result, StorageError> { + Err(unsupported_backend(StorageBackend::S3)) + } + + async fn delete_blob(&self, _key: &str) -> Result<(), StorageError> { + Err(unsupported_backend(StorageBackend::S3)) + } +} + +#[async_trait] +impl ObjectStorage for S3StorageBackend { async fn put_object( &self, _object: StoredObject, diff --git a/crates/graphql-orm-storage/src/service.rs b/crates/graphql-orm-storage/src/service.rs index 5b57c5d8..be39f464 100644 --- a/crates/graphql-orm-storage/src/service.rs +++ b/crates/graphql-orm-storage/src/service.rs @@ -5,16 +5,14 @@ use time::OffsetDateTime; use uuid::Uuid; use crate::{ - StorageBackend, StorageError, StorageObjectBody, StoragePutRequest, StoredObject, - build_storage_key, file_extension, sha256_hex, + BlobMetadata, BlobStore, StorageBackend, StorageByteStream, StorageError, StorageObjectBody, + StorageObjectStream, StoragePutRequest, StoragePutStreamRequest, StoredObject, + build_storage_key, collect_storage_stream, file_extension, }; /// Provider implementation contract for object storage backends. #[async_trait] -pub trait ObjectStorage: Send + Sync { - /// Returns the provider identifier for this backend. - fn backend(&self) -> StorageBackend; - +pub trait ObjectStorage: BlobStore { /// Persists object bytes and returns stored metadata. /// /// # Errors @@ -69,8 +67,32 @@ impl StorageService { &self, request: StoragePutRequest, ) -> Result { - let object = build_stored_object(self.backend.backend(), &request); - self.backend.put_object(object, request.bytes).await + self.put_object_stream(StoragePutStreamRequest { + namespace: request.namespace, + file_name: request.file_name, + mime_type: request.mime_type, + body: StorageByteStream::from_bytes(request.bytes), + }) + .await + } + + /// Stores streaming bytes and returns provider-neutral object metadata. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot persist the object. + pub async fn put_object_stream( + &self, + request: StoragePutStreamRequest, + ) -> Result { + let mut object = build_stream_stored_object(self.backend.backend(), &request); + let outcome = self + .backend + .put_blob(&object.storage_key, request.body) + .await?; + object.size_bytes = outcome.size_bytes; + object.sha256_hex = outcome.sha256_hex; + Ok(object) } /// Loads object bytes for existing metadata. @@ -82,7 +104,28 @@ impl StorageService { &self, object: &StoredObject, ) -> Result { - self.backend.get_object(object).await + let object_stream = self.get_object_stream(object).await?; + let bytes = collect_storage_stream(object_stream.body).await?; + Ok(StorageObjectBody { + object: object_stream.object, + bytes: bytes.to_vec(), + }) + } + + /// Loads a streaming object body for existing metadata. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot load the object. + pub async fn get_object_stream( + &self, + object: &StoredObject, + ) -> Result { + let body = self.backend.get_blob(&object.storage_key).await?; + Ok(StorageObjectStream { + object: object.clone(), + body: body.body, + }) } /// Deletes an object from the configured backend. @@ -93,9 +136,33 @@ impl StorageService { pub async fn delete_object(&self, object: &StoredObject) -> Result<(), StorageError> { self.backend.delete_object(object).await } + + /// Checks whether an object's backend blob exists. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot check the object. + pub async fn object_exists(&self, object: &StoredObject) -> Result { + self.backend.blob_exists(&object.storage_key).await + } + + /// Loads backend metadata for an object's blob. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot load metadata. + pub async fn object_backend_metadata( + &self, + object: &StoredObject, + ) -> Result, StorageError> { + self.backend.head_blob(&object.storage_key).await + } } -fn build_stored_object(backend: StorageBackend, request: &StoragePutRequest) -> StoredObject { +fn build_stream_stored_object( + backend: StorageBackend, + request: &StoragePutStreamRequest, +) -> StoredObject { let object_id = Uuid::new_v4(); let extension = request.file_name.as_deref().and_then(file_extension); let storage_key = build_storage_key(request.namespace, &object_id, extension); @@ -107,8 +174,8 @@ fn build_stored_object(backend: StorageBackend, request: &StoragePutRequest) -> storage_key, original_file_name: request.file_name.clone(), mime_type: request.mime_type.clone(), - size_bytes: u64::try_from(request.bytes.len()).unwrap_or(u64::MAX), - sha256_hex: sha256_hex(&request.bytes), + size_bytes: 0, + sha256_hex: String::new(), created_at: OffsetDateTime::now_utc(), } } diff --git a/crates/graphql-orm-storage/tests/blob.rs b/crates/graphql-orm-storage/tests/blob.rs new file mode 100644 index 00000000..88685237 --- /dev/null +++ b/crates/graphql-orm-storage/tests/blob.rs @@ -0,0 +1,46 @@ +use bytes::Bytes; +use graphql_orm_storage::{ + StorageByteStream, StorageError, collect_storage_stream, validate_blob_key, +}; + +#[test] +fn validate_blob_key_accepts_safe_relative_keys() { + assert!(validate_blob_key("snapshots/a/manifest.json").is_ok()); + assert!(validate_blob_key("objects/sha256/aa/bb/hash").is_ok()); + assert!(validate_blob_key("originals/aa/bb/object.jpg").is_ok()); +} + +#[test] +fn validate_blob_key_rejects_unsafe_keys() { + for key in [ + "", + "/absolute/path", + "../escape", + "a/../escape", + "a/./b", + "a//b", + "a\\b", + "a\0b", + ] { + let err = validate_blob_key(key).expect_err("unsafe key should be rejected"); + assert!(matches!(err, StorageError::InvalidStorageKey { .. })); + } +} + +#[test] +fn byte_stream_from_bytes_preserves_size_hint() { + let stream = StorageByteStream::from_bytes(Bytes::from_static(b"hello storage")); + + assert_eq!(stream.size_hint(), Some(13)); +} + +#[tokio::test] +async fn collect_storage_stream_returns_original_bytes() { + let bytes = collect_storage_stream(StorageByteStream::from_bytes(Bytes::from_static( + b"hello storage", + ))) + .await + .expect("collect stream"); + + assert_eq!(bytes, Bytes::from_static(b"hello storage")); +} diff --git a/crates/graphql-orm-storage/tests/local_blob.rs b/crates/graphql-orm-storage/tests/local_blob.rs new file mode 100644 index 00000000..aabf7556 --- /dev/null +++ b/crates/graphql-orm-storage/tests/local_blob.rs @@ -0,0 +1,192 @@ +use bytes::Bytes; +use graphql_orm_storage::{ + BlobStore, LocalStorageBackend, StorageByteStream, StorageError, collect_storage_stream, + sha256_hex, +}; +use tempfile::TempDir; + +#[tokio::test] +async fn local_blob_put_get_delete_round_trip() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + let outcome = backend + .put_blob( + "snapshots/a/manifest.json", + StorageByteStream::from_bytes(Bytes::from_static(b"manifest")), + ) + .await + .expect("put blob"); + + assert_eq!(outcome.size_bytes, 8); + assert_eq!(outcome.sha256_hex, sha256_hex(b"manifest")); + assert!( + backend + .blob_exists("snapshots/a/manifest.json") + .await + .expect("exists") + ); + + let body = backend + .get_blob("snapshots/a/manifest.json") + .await + .expect("get blob"); + assert_eq!(body.key, "snapshots/a/manifest.json"); + assert_eq!( + collect_storage_stream(body.body) + .await + .expect("collect body"), + Bytes::from_static(b"manifest") + ); + + backend + .delete_blob("snapshots/a/manifest.json") + .await + .expect("delete blob"); + assert!( + !backend + .blob_exists("snapshots/a/manifest.json") + .await + .expect("exists") + ); +} + +#[tokio::test] +async fn local_blob_head_and_exists_handle_present_and_missing_blobs() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + assert!( + !backend + .blob_exists("objects/sha256/aa/bb/hash") + .await + .expect("exists") + ); + assert_eq!( + backend + .head_blob("objects/sha256/aa/bb/hash") + .await + .expect("head missing"), + None + ); + + backend + .put_blob( + "objects/sha256/aa/bb/hash", + StorageByteStream::from_bytes(Bytes::from_static(b"object")), + ) + .await + .expect("put blob"); + + let metadata = backend + .head_blob("objects/sha256/aa/bb/hash") + .await + .expect("head blob") + .expect("metadata"); + assert_eq!(metadata.key, "objects/sha256/aa/bb/hash"); + assert_eq!(metadata.size_bytes, Some(6)); + assert_eq!(metadata.sha256_hex, None); +} + +#[tokio::test] +async fn local_blob_list_blobs_supports_empty_and_non_empty_prefixes() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + backend + .put_blob( + "snapshots/a/manifest.json", + StorageByteStream::from_bytes(Bytes::from_static(b"manifest")), + ) + .await + .expect("put manifest"); + backend + .put_blob( + "objects/sha256/aa/bb/hash", + StorageByteStream::from_bytes(Bytes::from_static(b"object")), + ) + .await + .expect("put object"); + + assert_eq!( + backend.list_blobs("").await.expect("list all"), + vec![ + "objects/sha256/aa/bb/hash".to_string(), + "snapshots/a/manifest.json".to_string() + ] + ); + assert_eq!( + backend.list_blobs("snapshots").await.expect("list prefix"), + vec!["snapshots/a/manifest.json".to_string()] + ); + assert!( + backend + .list_blobs("missing") + .await + .expect("list missing") + .is_empty() + ); +} + +#[tokio::test] +async fn local_blob_delete_missing_succeeds() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + backend + .delete_blob("objects/sha256/aa/bb/hash") + .await + .expect("delete missing"); +} + +#[tokio::test] +async fn local_blob_get_missing_returns_missing_blob() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + let err = backend + .get_blob("objects/sha256/aa/bb/hash") + .await + .expect_err("missing blob"); + + assert!(matches!(err, StorageError::MissingBlob { .. })); +} + +#[tokio::test] +async fn local_blob_rejects_invalid_keys_for_all_operations() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + assert_invalid( + backend + .put_blob("../escape", StorageByteStream::from_bytes(Bytes::new())) + .await, + ); + assert_invalid(backend.get_blob("../escape").await); + assert_invalid(backend.blob_exists("../escape").await); + assert_invalid(backend.head_blob("../escape").await); + assert_invalid(backend.list_blobs("../escape").await); + assert_invalid(backend.delete_blob("../escape").await); +} + +#[tokio::test] +async fn local_blob_list_blobs_ignores_uploading_temp_files() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + let temp_path = temp.path().join("snapshots/a/manifest.json.temp.uploading"); + tokio::fs::create_dir_all(temp_path.parent().expect("temp parent")) + .await + .expect("create parent"); + tokio::fs::write(&temp_path, b"partial") + .await + .expect("write temp"); + + assert!(backend.list_blobs("").await.expect("list all").is_empty()); +} + +fn assert_invalid(result: Result) { + assert!(matches!( + result, + Err(StorageError::InvalidStorageKey { .. }) + )); +} diff --git a/crates/graphql-orm-storage/tests/local_round_trip.rs b/crates/graphql-orm-storage/tests/local_round_trip.rs index 3c8de071..7522f135 100644 --- a/crates/graphql-orm-storage/tests/local_round_trip.rs +++ b/crates/graphql-orm-storage/tests/local_round_trip.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use graphql_orm_storage::{ - LocalStorageBackend, StorageBackend, StorageError, StorageNamespace, StoragePutRequest, - StorageService, StoredObject, sha256_hex, + LocalStorageBackend, StorageBackend, StorageByteStream, StorageError, StorageNamespace, + StoragePutRequest, StoragePutStreamRequest, StorageService, StoredObject, + collect_storage_stream, sha256_hex, }; use tempfile::TempDir; use time::OffsetDateTime; @@ -44,6 +45,59 @@ async fn local_put_get_delete_round_trip_preserves_bytes_and_metadata() { assert!(service.get_object(&stored).await.is_err()); } +#[tokio::test] +async fn local_put_get_stream_round_trip_preserves_bytes_and_metadata() { + let temp = TempDir::new().expect("temp dir"); + let service = StorageService::new(Arc::new(LocalStorageBackend::new(temp.path()))); + + let stored = service + .put_object_stream(StoragePutStreamRequest { + namespace: StorageNamespace::Originals, + file_name: Some("artifact.TXT".to_string()), + mime_type: Some("text/plain".to_string()), + body: StorageByteStream::from_bytes(b"streamed object".to_vec()), + }) + .await + .expect("put stream"); + + assert_eq!(stored.backend, StorageBackend::Local); + assert_eq!(stored.namespace, StorageNamespace::Originals); + assert_eq!(stored.original_file_name.as_deref(), Some("artifact.TXT")); + assert_eq!(stored.mime_type.as_deref(), Some("text/plain")); + assert_eq!(stored.size_bytes, 15); + assert_eq!(stored.sha256_hex, sha256_hex(b"streamed object")); + assert!(stored.storage_key.ends_with(".txt")); + assert!(service.object_exists(&stored).await.expect("object exists")); + + let metadata = service + .object_backend_metadata(&stored) + .await + .expect("metadata") + .expect("metadata exists"); + assert_eq!(metadata.key, stored.storage_key); + assert_eq!(metadata.size_bytes, Some(15)); + + let loaded = service + .get_object_stream(&stored) + .await + .expect("get stream"); + assert_eq!(loaded.object, stored); + assert_eq!( + collect_storage_stream(loaded.body) + .await + .expect("collect stream"), + b"streamed object".as_slice() + ); + + service.delete_object(&stored).await.expect("delete object"); + assert!(!service.object_exists(&stored).await.expect("object exists")); + let err = service + .get_object_stream(&stored) + .await + .expect_err("deleted object should be missing"); + assert!(matches!(err, StorageError::MissingBlob { .. })); +} + #[tokio::test] async fn delete_missing_object_succeeds() { let temp = TempDir::new().expect("temp dir"); diff --git a/crates/graphql-orm-storage/tests/provider_placeholders.rs b/crates/graphql-orm-storage/tests/provider_placeholders.rs index 7a0940cd..195c7fcf 100644 --- a/crates/graphql-orm-storage/tests/provider_placeholders.rs +++ b/crates/graphql-orm-storage/tests/provider_placeholders.rs @@ -2,7 +2,8 @@ use graphql_orm_storage::{AzureBlobStorageBackend, AzureBlobStorageConfig}; #[cfg(any(feature = "azure", feature = "s3"))] use graphql_orm_storage::{ - ObjectStorage, StorageBackend, StorageError, StorageNamespace, StoredObject, sha256_hex, + BlobStore, ObjectStorage, StorageBackend, StorageByteStream, StorageError, StorageNamespace, + StoredObject, sha256_hex, }; #[cfg(feature = "s3")] use graphql_orm_storage::{S3StorageBackend, S3StorageConfig}; @@ -27,6 +28,20 @@ async fn s3_placeholder_backend_returns_unsupported_errors() { let object = test_object(StorageBackend::S3); assert_eq!(backend.backend(), StorageBackend::S3); + assert_unsupported( + backend + .put_blob( + "objects/test", + StorageByteStream::from_bytes(b"bytes".to_vec()), + ) + .await, + "s3", + ); + assert_unsupported(backend.get_blob("objects/test").await, "s3"); + assert_unsupported(backend.blob_exists("objects/test").await, "s3"); + assert_unsupported(backend.head_blob("objects/test").await, "s3"); + assert_unsupported(backend.list_blobs("objects").await, "s3"); + assert_unsupported(backend.delete_blob("objects/test").await, "s3"); assert_unsupported( backend.put_object(object.clone(), b"bytes".to_vec()).await, "s3", @@ -52,6 +67,20 @@ async fn azure_placeholder_backend_returns_unsupported_errors() { let object = test_object(StorageBackend::AzureBlob); assert_eq!(backend.backend(), StorageBackend::AzureBlob); + assert_unsupported( + backend + .put_blob( + "objects/test", + StorageByteStream::from_bytes(b"bytes".to_vec()), + ) + .await, + "azure_blob", + ); + assert_unsupported(backend.get_blob("objects/test").await, "azure_blob"); + assert_unsupported(backend.blob_exists("objects/test").await, "azure_blob"); + assert_unsupported(backend.head_blob("objects/test").await, "azure_blob"); + assert_unsupported(backend.list_blobs("objects").await, "azure_blob"); + assert_unsupported(backend.delete_blob("objects/test").await, "azure_blob"); assert_unsupported( backend.put_object(object.clone(), b"bytes".to_vec()).await, "azure_blob", From 4648d8113c068861aadf39637c5329f503aaa925 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 00:37:09 +0000 Subject: [PATCH 005/108] Tighten full backup correctness --- crates/graphql-orm-backup/Cargo.toml | 4 - crates/graphql-orm-backup/src/backup.rs | 26 +++--- crates/graphql-orm-backup/src/database.rs | 27 ++++++ .../src/local_repository.rs | 7 ++ crates/graphql-orm-backup/src/manifest.rs | 92 ++++++++++++++++++- crates/graphql-orm-backup/src/object_index.rs | 16 ++++ crates/graphql-orm-backup/src/planner.rs | 6 ++ crates/graphql-orm-backup/src/repository.rs | 30 ++++++ crates/graphql-orm-backup/src/restore.rs | 8 ++ .../tests/full_backup_creation.rs | 21 +++++ 10 files changed, 220 insertions(+), 17 deletions(-) diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml index 33e40229..9a3cfb98 100644 --- a/crates/graphql-orm-backup/Cargo.toml +++ b/crates/graphql-orm-backup/Cargo.toml @@ -9,10 +9,6 @@ description = "Backup and restore orchestration primitives for graphql-orm appli [features] default = ["local"] local = [] -s3 = [] -azure = [] -dropbox = [] -smb = [] [dependencies] async-trait = "0.1" diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs index dc9f2ca1..f489ae65 100644 --- a/crates/graphql-orm-backup/src/backup.rs +++ b/crates/graphql-orm-backup/src/backup.rs @@ -14,6 +14,7 @@ pub const DATABASE_EXPORT_FORMAT: &str = "jsonl"; #[derive(Clone, Debug, Eq, PartialEq)] pub struct FullBackupRequest { pub snapshot_id: Uuid, + /// Snapshot creation time as UTC Unix seconds. pub created_at: i64, pub app_id: String, pub app_version: String, @@ -49,6 +50,13 @@ pub fn object_content_key(sha256_hex: &str) -> String { format!("objects/sha256/{shard_a}/{shard_b}/{sha256_hex}") } +/// Creates a full snapshot in the repository. +/// +/// # Errors +/// +/// Returns [`BackupError`] if planning fails, table serialization or +/// compression fails, object loading/checksum validation fails, or any +/// repository write fails. pub async fn create_full_backup( repository: &dyn BackupRepository, database: &dyn GraphqlOrmBackupAdapter, @@ -91,17 +99,7 @@ pub async fn create_full_backup( }); } - if repository.blob_exists(&content_key).await? { - let existing = repository.get_blob(&content_key).await?; - let existing_hash = sha256_hex(&existing); - if existing_hash != object.sha256_hex { - return Err(BackupError::ChecksumMismatch { - key: content_key, - expected: object.sha256_hex.clone(), - actual: existing_hash, - }); - } - } else { + if !repository.blob_exists(&content_key).await? { repository.put_blob(&content_key, bytes).await?; } @@ -143,6 +141,12 @@ pub async fn create_full_backup( Ok(FullBackupResult { manifest }) } +/// Writes a manifest as the final snapshot blob. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the checksum cannot be computed, the manifest +/// cannot be serialized, or the repository cannot write the blob. pub async fn write_manifest( repository: &dyn BackupRepository, manifest: &mut BackupSnapshotManifest, diff --git a/crates/graphql-orm-backup/src/database.rs b/crates/graphql-orm-backup/src/database.rs index f5594ffe..bc302823 100644 --- a/crates/graphql-orm-backup/src/database.rs +++ b/crates/graphql-orm-backup/src/database.rs @@ -6,21 +6,47 @@ use crate::{BackupError, RestoreContext}; #[async_trait] pub trait GraphqlOrmBackupAdapter: Send + Sync { + /// Returns backup-relevant schema metadata. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the adapter cannot read schema metadata. async fn schema_snapshot(&self) -> Result; + /// Exports all backup-enabled tables for a full snapshot. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the adapter cannot export table rows. async fn export_full(&self) -> Result, BackupError>; + /// Exports changed rows and tombstones since a parent snapshot. + /// + /// # Errors + /// + /// Returns [`BackupError`] if incremental export is unavailable or fails. async fn export_incremental( &self, parent_snapshot_id: Uuid, ) -> Result, BackupError>; + /// Restores a full table export. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the adapter cannot import the rows into the + /// target database. async fn restore_full( &self, export: Vec, context: RestoreContext, ) -> Result<(), BackupError>; + /// Restores incremental changes. + /// + /// # Errors + /// + /// Returns [`BackupError`] if incremental restore is unavailable or fails. async fn restore_incremental( &self, changes: Vec, @@ -62,5 +88,6 @@ pub struct BackupChangeExport { pub primary_key: String, pub action: BackupChangeAction, pub row: Option, + /// Change time as UTC Unix seconds. pub changed_at: i64, } diff --git a/crates/graphql-orm-backup/src/local_repository.rs b/crates/graphql-orm-backup/src/local_repository.rs index 1b239e0d..c824b83a 100644 --- a/crates/graphql-orm-backup/src/local_repository.rs +++ b/crates/graphql-orm-backup/src/local_repository.rs @@ -11,11 +11,18 @@ pub struct LocalBackupRepository { } impl LocalBackupRepository { + /// Creates a local repository rooted at a filesystem path. #[must_use] pub fn new(root: impl Into) -> Self { Self { root: root.into() } } + /// Opens an existing local repository root. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the path cannot be inspected or is not a + /// directory. pub async fn open_existing(root: impl Into) -> Result { let root = root.into(); let metadata = tokio::fs::metadata(&root) diff --git a/crates/graphql-orm-backup/src/manifest.rs b/crates/graphql-orm-backup/src/manifest.rs index 11704503..a4aaa724 100644 --- a/crates/graphql-orm-backup/src/manifest.rs +++ b/crates/graphql-orm-backup/src/manifest.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; +use serde::Serialize; use sha2::{Digest, Sha256}; use uuid::Uuid; @@ -7,6 +8,7 @@ use crate::{BackupError, BackupRepository}; pub const BACKUP_FORMAT_VERSION: u32 = 1; +#[non_exhaustive] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum BackupKind { Full, @@ -14,6 +16,7 @@ pub enum BackupKind { SyntheticFull, } +#[non_exhaustive] #[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum BackupCompression { #[default] @@ -26,6 +29,7 @@ pub struct BackupSnapshotManifest { pub format_version: u32, pub snapshot_id: Uuid, pub parent_snapshot_id: Option, + /// Snapshot creation time as UTC Unix seconds. pub created_at: i64, pub app_id: String, pub app_version: String, @@ -72,21 +76,37 @@ pub struct BackupTombstone { pub table_name: Option, pub primary_key: Option, pub object_id: Option, + /// Deletion time as UTC Unix seconds. pub deleted_at: i64, } +/// Computes the manifest checksum with the manifest's checksum field cleared. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the canonical checksum view cannot be serialized. pub fn manifest_checksum(manifest: &BackupSnapshotManifest) -> Result { - let mut canonical = manifest.clone(); - canonical.checksum.clear(); + let canonical = ChecksumManifestView::from(manifest); let bytes = serde_json::to_vec(&canonical)?; Ok(sha256_hex(&bytes)) } +/// Sets the checksum field on a manifest. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the manifest checksum cannot be computed. pub fn set_manifest_checksum(manifest: &mut BackupSnapshotManifest) -> Result<(), BackupError> { manifest.checksum = manifest_checksum(manifest)?; Ok(()) } +/// Verifies a manifest checksum. +/// +/// # Errors +/// +/// Returns [`BackupError::ChecksumMismatch`] if the checksum does not match, or +/// another [`BackupError`] if the canonical checksum cannot be computed. pub fn verify_manifest_checksum(manifest: &BackupSnapshotManifest) -> Result<(), BackupError> { let actual = manifest_checksum(manifest)?; if actual == manifest.checksum { @@ -100,6 +120,12 @@ pub fn verify_manifest_checksum(manifest: &BackupSnapshotManifest) -> Result<(), } } +/// Loads and verifies a single snapshot manifest. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the manifest blob is missing, cannot be +/// deserialized, or fails checksum verification. pub async fn load_manifest( repository: &dyn BackupRepository, snapshot_id: Uuid, @@ -111,6 +137,12 @@ pub async fn load_manifest( Ok(manifest) } +/// Loads a manifest and all of its parents in restore order. +/// +/// # Errors +/// +/// Returns [`BackupError`] if any manifest in the chain cannot be loaded, +/// verified, or if the resulting chain is invalid. pub async fn load_manifest_chain( repository: &dyn BackupRepository, snapshot_id: Uuid, @@ -136,6 +168,13 @@ pub async fn load_manifest_chain( Ok(chain) } +/// Validates manifest parent/child consistency. +/// +/// # Errors +/// +/// Returns [`BackupError::InvalidManifestChain`] when the chain is empty, +/// starts from a non-full root, contains duplicate snapshots, has broken parent +/// references, or mixes incompatible application/schema/backend metadata. pub fn validate_manifest_chain(chain: &[BackupSnapshotManifest]) -> Result<(), BackupError> { let Some(first) = chain.first() else { return Err(BackupError::InvalidManifestChain { @@ -212,10 +251,20 @@ pub fn validate_manifest_chain(chain: &[BackupSnapshotManifest]) -> Result<(), B Ok(()) } +/// Compresses a payload with zstd. +/// +/// # Errors +/// +/// Returns [`BackupError::Compression`] if zstd encoding fails. pub fn compress_payload(bytes: &[u8]) -> Result, BackupError> { zstd::stream::encode_all(bytes, 0).map_err(BackupError::compression) } +/// Decompresses a zstd payload. +/// +/// # Errors +/// +/// Returns [`BackupError::Compression`] if zstd decoding fails. pub fn decompress_payload(bytes: &[u8]) -> Result, BackupError> { zstd::stream::decode_all(bytes).map_err(BackupError::compression) } @@ -229,3 +278,42 @@ pub(crate) fn sha256_hex(bytes: &[u8]) -> String { fn manifest_key(snapshot_id: Uuid) -> String { format!("snapshots/{snapshot_id}/manifest.json") } + +#[derive(Serialize)] +struct ChecksumManifestView<'a> { + format_version: u32, + snapshot_id: Uuid, + parent_snapshot_id: Option, + created_at: i64, + app_id: &'a str, + app_version: &'a str, + graphql_orm_schema_version: &'a str, + graphql_orm_schema_hash: &'a str, + database_backend: &'a str, + backup_kind: &'a BackupKind, + database: &'a DatabaseBackupManifest, + objects: &'a [ObjectBackupEntry], + tombstones: &'a [BackupTombstone], + checksum: &'static str, +} + +impl<'a> From<&'a BackupSnapshotManifest> for ChecksumManifestView<'a> { + fn from(manifest: &'a BackupSnapshotManifest) -> Self { + Self { + format_version: manifest.format_version, + snapshot_id: manifest.snapshot_id, + parent_snapshot_id: manifest.parent_snapshot_id, + created_at: manifest.created_at, + app_id: &manifest.app_id, + app_version: &manifest.app_version, + graphql_orm_schema_version: &manifest.graphql_orm_schema_version, + graphql_orm_schema_hash: &manifest.graphql_orm_schema_hash, + database_backend: &manifest.database_backend, + backup_kind: &manifest.backup_kind, + database: &manifest.database, + objects: &manifest.objects, + tombstones: &manifest.tombstones, + checksum: "", + } + } +} diff --git a/crates/graphql-orm-backup/src/object_index.rs b/crates/graphql-orm-backup/src/object_index.rs index 5b25bdbd..f4ad862b 100644 --- a/crates/graphql-orm-backup/src/object_index.rs +++ b/crates/graphql-orm-backup/src/object_index.rs @@ -6,13 +6,29 @@ use crate::BackupError; #[async_trait] pub trait BackupObjectIndex: Send + Sync { + /// Lists all objects referenced by a full backup. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the object index cannot be queried. async fn list_objects_for_full_backup(&self) -> Result, BackupError>; + /// Lists objects newly referenced or changed since a parent snapshot. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the object index cannot be queried or + /// incremental discovery is unavailable. async fn list_objects_for_incremental_backup( &self, since_snapshot_id: Uuid, ) -> Result, BackupError>; + /// Loads the bytes for an object reference. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the object bytes cannot be loaded. async fn load_object(&self, object: &BackupObjectRef) -> Result; } diff --git a/crates/graphql-orm-backup/src/planner.rs b/crates/graphql-orm-backup/src/planner.rs index 2551471f..0486c323 100644 --- a/crates/graphql-orm-backup/src/planner.rs +++ b/crates/graphql-orm-backup/src/planner.rs @@ -10,6 +10,12 @@ pub struct FullBackupPlan { pub objects: Vec, } +/// Plans a full backup by collecting schema, table exports, and object refs. +/// +/// # Errors +/// +/// Returns [`BackupError`] if schema export, full row export, or object listing +/// fails. pub async fn plan_full_backup( database: &dyn GraphqlOrmBackupAdapter, objects: &dyn BackupObjectIndex, diff --git a/crates/graphql-orm-backup/src/repository.rs b/crates/graphql-orm-backup/src/repository.rs index 4851dd1e..d3be58fc 100644 --- a/crates/graphql-orm-backup/src/repository.rs +++ b/crates/graphql-orm-backup/src/repository.rs @@ -5,13 +5,43 @@ use crate::BackupError; #[async_trait] pub trait BackupRepository: Send + Sync { + /// Writes a blob at a repository key. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the key is invalid or the backend cannot + /// persist the blob. async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError>; + /// Reads a blob from a repository key. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the key is invalid, the blob is missing, or + /// the backend cannot read it. async fn get_blob(&self, key: &str) -> Result; + /// Checks whether a blob exists. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the key is invalid or the backend cannot + /// check metadata. async fn blob_exists(&self, key: &str) -> Result; + /// Lists blobs below a key prefix. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the prefix is invalid or the backend cannot + /// list blobs. async fn list_blobs(&self, prefix: &str) -> Result, BackupError>; + /// Deletes a blob if it exists. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the key is invalid or the backend cannot + /// delete the blob. async fn delete_blob(&self, key: &str) -> Result<(), BackupError>; } diff --git a/crates/graphql-orm-backup/src/restore.rs b/crates/graphql-orm-backup/src/restore.rs index d969ad0c..1f436dad 100644 --- a/crates/graphql-orm-backup/src/restore.rs +++ b/crates/graphql-orm-backup/src/restore.rs @@ -14,6 +14,7 @@ pub struct RestoreContext { } impl RestoreContext { + /// Builds the default empty-database restore context. #[must_use] pub fn empty_database() -> Self { Self { @@ -23,6 +24,7 @@ impl RestoreContext { } } + /// Builds a dry-run restore context. #[must_use] pub fn dry_run() -> Self { Self { @@ -33,6 +35,12 @@ impl RestoreContext { } } +/// Ensures an empty-target restore is only applied to an empty database. +/// +/// # Errors +/// +/// Returns [`BackupError::RestoreTargetNotEmpty`] when the context requires an +/// empty database and the target is not empty. pub fn ensure_empty_restore_target( target_is_empty: bool, context: &RestoreContext, diff --git a/crates/graphql-orm-backup/tests/full_backup_creation.rs b/crates/graphql-orm-backup/tests/full_backup_creation.rs index 28f2f536..30de5857 100644 --- a/crates/graphql-orm-backup/tests/full_backup_creation.rs +++ b/crates/graphql-orm-backup/tests/full_backup_creation.rs @@ -92,6 +92,11 @@ async fn create_full_backup_deduplicates_existing_object_blob() { assert_eq!(result.manifest.objects[0].content_key, object_key); assert!(!repository.write_order().contains(&object_key)); + assert_eq!( + repository.get_count(&object_key), + 0, + "existing content-addressed object blobs must not be re-read" + ); } #[tokio::test] @@ -222,6 +227,7 @@ fn object_id() -> Uuid { struct RecordingRepository { blobs: Arc>>, writes: Arc>>, + gets: Arc>>, } impl RecordingRepository { @@ -232,6 +238,15 @@ impl RecordingRepository { fn clear_write_order(&self) { self.writes.lock().expect("writes lock").clear(); } + + fn get_count(&self, key: &str) -> u64 { + self.gets + .lock() + .expect("gets lock") + .get(key) + .copied() + .unwrap_or_default() + } } #[async_trait] @@ -249,6 +264,12 @@ impl BackupRepository for RecordingRepository { } async fn get_blob(&self, key: &str) -> Result { + *self + .gets + .lock() + .expect("gets lock") + .entry(key.to_string()) + .or_default() += 1; self.blobs .lock() .expect("blobs lock") From 930aa0966646a8d2b0f7768008b5ce2e4aed1a08 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 00:40:00 +0000 Subject: [PATCH 006/108] Stabilize blob store API surface --- crates/graphql-orm-storage/AGENTS.md | 3 +- crates/graphql-orm-storage/Cargo.lock | 2 +- crates/graphql-orm-storage/Cargo.toml | 2 +- crates/graphql-orm-storage/README.md | 2 +- .../graphql-orm-storage/docs/agent-update.md | 8 +- crates/graphql-orm-storage/docs/blob-store.md | 36 +++ crates/graphql-orm-storage/src/azure.rs | 34 ++- crates/graphql-orm-storage/src/backend.rs | 2 + crates/graphql-orm-storage/src/blob.rs | 149 ++++++++++- crates/graphql-orm-storage/src/error.rs | 29 ++ crates/graphql-orm-storage/src/lib.rs | 4 +- crates/graphql-orm-storage/src/local.rs | 247 ++++++++++++++---- crates/graphql-orm-storage/src/s3.rs | 34 ++- crates/graphql-orm-storage/src/service.rs | 11 +- crates/graphql-orm-storage/tests/core.rs | 16 ++ .../graphql-orm-storage/tests/local_blob.rs | 140 +++++++++- .../tests/provider_placeholders.rs | 44 +++- 17 files changed, 688 insertions(+), 75 deletions(-) diff --git a/crates/graphql-orm-storage/AGENTS.md b/crates/graphql-orm-storage/AGENTS.md index 1e3eb9d5..7e0e0065 100644 --- a/crates/graphql-orm-storage/AGENTS.md +++ b/crates/graphql-orm-storage/AGENTS.md @@ -20,9 +20,10 @@ This crate is a reusable storage companion for applications that use `graphql-or ## Current Agent Handoff -- Current crate version is `0.2.0`. +- Current crate version is `0.3.0`. - The storage provider boundary is now the streaming `BlobStore` trait. - `ObjectStorage` extends `BlobStore`; custom providers must implement `BlobStore` first. +- `BlobStore` includes byte ranges, conditional writes, server-side copy, write options, and paged listing. - `StorageService` remains the high-level primary object API for generated object metadata. - `graphql-orm-backup` should adapt `BlobStore` directly for backup repository semantics; it should not use `StorageService`. - S3 and Azure Blob are still feature-gated unsupported placeholders. Do not add real SDK code without implementing the shared `BlobStore` provider layer first. diff --git a/crates/graphql-orm-storage/Cargo.lock b/crates/graphql-orm-storage/Cargo.lock index ce79c722..9fa4fc20 100644 --- a/crates/graphql-orm-storage/Cargo.lock +++ b/crates/graphql-orm-storage/Cargo.lock @@ -186,7 +186,7 @@ dependencies = [ [[package]] name = "graphql-orm-storage" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "bytes", diff --git a/crates/graphql-orm-storage/Cargo.toml b/crates/graphql-orm-storage/Cargo.toml index 7e9d5dfb..06d28164 100644 --- a/crates/graphql-orm-storage/Cargo.toml +++ b/crates/graphql-orm-storage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-storage" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm-storage" diff --git a/crates/graphql-orm-storage/README.md b/crates/graphql-orm-storage/README.md index 968e4ec3..cc47ef26 100644 --- a/crates/graphql-orm-storage/README.md +++ b/crates/graphql-orm-storage/README.md @@ -4,7 +4,7 @@ Provider-neutral object storage primitives for applications that use `graphql-or This crate stores bytes in an object backend and returns metadata that an application can persist in its own `graphql-orm` entity. It deliberately does not define application concepts such as collections, records, accessions, tenants, users, or media workflows. -Current crate version: `0.2.0`. +Current crate version: `0.3.0`. ## Current Status diff --git a/crates/graphql-orm-storage/docs/agent-update.md b/crates/graphql-orm-storage/docs/agent-update.md index 4417d55f..3192e24b 100644 --- a/crates/graphql-orm-storage/docs/agent-update.md +++ b/crates/graphql-orm-storage/docs/agent-update.md @@ -1,6 +1,6 @@ # Agent Update -This update summarizes the `0.2.0` storage-provider boundary for agents working +This update summarizes the `0.3.0` storage-provider boundary for agents working on `graphql-orm-storage` or downstream crates. ## What Changed @@ -8,6 +8,10 @@ on `graphql-orm-storage` or downstream crates. - Added the streaming `BlobStore` trait as the low-level provider abstraction. - Added `StorageByteStream`, `BlobBody`, `BlobMetadata`, and `BlobWriteOutcome`. +- Added `BlobPutOptions` for write metadata passthrough. +- Added `BlobListPage` for continuation-token listing. +- Added byte-range reads, conditional writes, and blob copy to `BlobStore`. +- Added retryable provider error taxonomy through `StorageError`. - Added `validate_blob_key` for consistent provider key validation. - Added `StorageService::put_object_stream` and `StorageService::get_object_stream`. @@ -44,7 +48,7 @@ pub struct BlobStoreBackupRepository { ## Still Pending -- Real S3 `BlobStore` provider. +- Real S3 `BlobStore` provider using the `0.3.0` trait surface. - Real Azure Blob `BlobStore` provider. - Backup adapter implementation after downstream crate alignment. - Any cloud SDK dependency decisions. diff --git a/crates/graphql-orm-storage/docs/blob-store.md b/crates/graphql-orm-storage/docs/blob-store.md index 37a4b08f..1e7298fe 100644 --- a/crates/graphql-orm-storage/docs/blob-store.md +++ b/crates/graphql-orm-storage/docs/blob-store.md @@ -31,20 +31,48 @@ pub trait BlobStore: Send + Sync { &self, key: &str, body: StorageByteStream, + options: BlobPutOptions, ) -> Result; + async fn put_blob_if_not_exists( + &self, + key: &str, + body: StorageByteStream, + options: BlobPutOptions, + ) -> Result, StorageError>; + async fn get_blob(&self, key: &str) -> Result; + async fn get_blob_range(&self, key: &str, range: Range) -> Result; + async fn blob_exists(&self, key: &str) -> Result; async fn head_blob(&self, key: &str) -> Result, StorageError>; + async fn list_blobs_page( + &self, + prefix: &str, + continuation: Option, + limit: usize, + ) -> Result; + async fn list_blobs(&self, prefix: &str) -> Result, StorageError>; + async fn copy_blob(&self, from: &str, to: &str) -> Result<(), StorageError>; + async fn delete_blob(&self, key: &str) -> Result<(), StorageError>; } ``` +`list_blobs` is a convenience method that drains `list_blobs_page`. +Provider implementations should make `list_blobs_page` the native listing path. + +`put_blob_if_not_exists` returns `Ok(None)` when the target key already exists. +It is the race-safe primitive for content-addressed deduplication. + +`copy_blob` may use provider-side copy and does not return a SHA-256 checksum. +Callers can use `head_blob` after copying when they need backend metadata. + ## Key Safety Blob keys are `/`-separated relative keys. `validate_blob_key` rejects: @@ -60,6 +88,14 @@ Blob keys are `/`-separated relative keys. `validate_blob_key` rejects: `list_blobs("")` is allowed and lists all blobs. +## Error Taxonomy + +`StorageError::Provider` includes a `retryable` flag for future network +providers. Callers can use `StorageError::is_retryable()` to decide whether a +failed operation is worth retrying. Local filesystem IO errors are treated as +retryable; invalid keys, missing blobs, unsupported backends, and failed +preconditions are permanent. + ## Relationship To ObjectStorage `ObjectStorage` builds on `BlobStore`. Provider implementations should implement diff --git a/crates/graphql-orm-storage/src/azure.rs b/crates/graphql-orm-storage/src/azure.rs index 22f618f9..28ae9358 100644 --- a/crates/graphql-orm-storage/src/azure.rs +++ b/crates/graphql-orm-storage/src/azure.rs @@ -3,8 +3,9 @@ use std::fmt; use async_trait::async_trait; use crate::{ - BlobBody, BlobMetadata, BlobStore, BlobWriteOutcome, ObjectStorage, StorageBackend, - StorageByteStream, StorageError, StorageObjectBody, StoredObject, unsupported_backend, + BlobBody, BlobListPage, BlobMetadata, BlobPutOptions, BlobStore, BlobWriteOutcome, + ObjectStorage, StorageBackend, StorageByteStream, StorageError, StorageObjectBody, + StoredObject, unsupported_backend, }; /// Configuration for a future Azure Blob Storage backend. @@ -75,14 +76,32 @@ impl BlobStore for AzureBlobStorageBackend { &self, _key: &str, _body: StorageByteStream, + _options: BlobPutOptions, ) -> Result { Err(unsupported_backend(StorageBackend::AzureBlob)) } + async fn put_blob_if_not_exists( + &self, + _key: &str, + _body: StorageByteStream, + _options: BlobPutOptions, + ) -> Result, StorageError> { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + async fn get_blob(&self, _key: &str) -> Result { Err(unsupported_backend(StorageBackend::AzureBlob)) } + async fn get_blob_range( + &self, + _key: &str, + _range: std::ops::Range, + ) -> Result { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + async fn blob_exists(&self, _key: &str) -> Result { Err(unsupported_backend(StorageBackend::AzureBlob)) } @@ -91,7 +110,16 @@ impl BlobStore for AzureBlobStorageBackend { Err(unsupported_backend(StorageBackend::AzureBlob)) } - async fn list_blobs(&self, _prefix: &str) -> Result, StorageError> { + async fn list_blobs_page( + &self, + _prefix: &str, + _continuation: Option, + _limit: usize, + ) -> Result { + Err(unsupported_backend(StorageBackend::AzureBlob)) + } + + async fn copy_blob(&self, _from: &str, _to: &str) -> Result<(), StorageError> { Err(unsupported_backend(StorageBackend::AzureBlob)) } diff --git a/crates/graphql-orm-storage/src/backend.rs b/crates/graphql-orm-storage/src/backend.rs index 88151080..5e32aa77 100644 --- a/crates/graphql-orm-storage/src/backend.rs +++ b/crates/graphql-orm-storage/src/backend.rs @@ -4,6 +4,7 @@ use crate::StorageError; /// Storage provider identifiers understood by this crate. #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] pub enum StorageBackend { /// Local filesystem object storage. Local, @@ -42,6 +43,7 @@ impl FromStr for StorageBackend { /// Logical storage namespace used as the first path segment of generated keys. #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] pub enum StorageNamespace { /// Original uploaded objects. Originals, diff --git a/crates/graphql-orm-storage/src/blob.rs b/crates/graphql-orm-storage/src/blob.rs index d372c521..691e2fb3 100644 --- a/crates/graphql-orm-storage/src/blob.rs +++ b/crates/graphql-orm-storage/src/blob.rs @@ -1,5 +1,6 @@ use std::{ fmt, + ops::Range, path::{Component, Path}, pin::Pin, }; @@ -100,6 +101,22 @@ pub struct BlobWriteOutcome { pub sha256_hex: String, } +/// Provider options for writing a blob. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BlobPutOptions { + /// MIME content type to pass through to providers that support it. + pub content_type: Option, +} + +/// One page of blob listing results. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BlobListPage { + /// Blob keys in this page. + pub keys: Vec, + /// Opaque continuation token for the next page. + pub next_continuation: Option, +} + /// Provider metadata for an existing blob. #[derive(Clone, Debug, PartialEq, Eq)] pub struct BlobMetadata { @@ -142,8 +159,28 @@ pub trait BlobStore: Send + Sync { &self, key: &str, body: StorageByteStream, + options: BlobPutOptions, ) -> Result; + /// Writes a blob only when the destination key does not exist. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the key is invalid or the provider cannot + /// conditionally write the blob. + async fn put_blob_if_not_exists( + &self, + key: &str, + body: StorageByteStream, + options: BlobPutOptions, + ) -> Result, StorageError> { + if self.blob_exists(key).await? { + return Ok(None); + } + + self.put_blob(key, body, options).await.map(Some) + } + /// Loads a blob stream. /// /// # Errors @@ -152,6 +189,29 @@ pub trait BlobStore: Send + Sync { /// load the blob. async fn get_blob(&self, key: &str) -> Result; + /// Loads a byte range from a blob stream. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the key or range is invalid, or when the + /// provider cannot load the blob. + async fn get_blob_range(&self, key: &str, range: Range) -> Result { + if range.end < range.start { + return Err(StorageError::PreconditionFailed { + key: key.to_string(), + condition: "range end is before range start".to_string(), + }); + } + + let length = range.end - range.start; + let blob = self.get_blob(key).await?; + Ok(BlobBody { + key: blob.key, + metadata: blob.metadata, + body: ranged_storage_stream(blob.body, range.start, length), + }) + } + /// Checks whether a blob exists. /// /// # Errors @@ -168,13 +228,53 @@ pub trait BlobStore: Send + Sync { /// load metadata. async fn head_blob(&self, key: &str) -> Result, StorageError>; + /// Lists one page of blob keys under a prefix. An empty prefix lists all blobs. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the prefix is invalid or the provider cannot + /// list blobs. + async fn list_blobs_page( + &self, + prefix: &str, + continuation: Option, + limit: usize, + ) -> Result; + /// Lists blob keys under a prefix. An empty prefix lists all blobs. /// /// # Errors /// /// Returns [`StorageError`] when the prefix is invalid or the provider cannot /// list blobs. - async fn list_blobs(&self, prefix: &str) -> Result, StorageError>; + async fn list_blobs(&self, prefix: &str) -> Result, StorageError> { + let mut keys = Vec::new(); + let mut continuation = None; + + loop { + let page = self.list_blobs_page(prefix, continuation, 1_000).await?; + keys.extend(page.keys); + continuation = page.next_continuation; + if continuation.is_none() { + break; + } + } + + Ok(keys) + } + + /// Copies a blob to another key. + /// + /// # Errors + /// + /// Returns [`StorageError`] when either key is invalid or the provider cannot + /// copy the blob. + async fn copy_blob(&self, from: &str, to: &str) -> Result<(), StorageError> { + let blob = self.get_blob(from).await?; + self.put_blob(to, blob.body, BlobPutOptions::default()) + .await?; + Ok(()) + } /// Deletes a blob. /// @@ -185,6 +285,53 @@ pub trait BlobStore: Send + Sync { async fn delete_blob(&self, key: &str) -> Result<(), StorageError>; } +fn ranged_storage_stream(body: StorageByteStream, skip: u64, length: u64) -> StorageByteStream { + let size_hint = body + .size_hint() + .map(|hint| hint.saturating_sub(skip).min(length)); + let stream = stream::try_unfold( + (body.into_inner(), skip, length), + |(mut inner, mut skip, mut remaining)| async move { + if remaining == 0 { + return Ok(None); + } + + while let Some(chunk) = inner.next().await { + let chunk = chunk?; + let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX); + + if skip >= chunk_len { + skip -= chunk_len; + continue; + } + + let start = + usize::try_from(skip).map_err(|_| StorageError::PreconditionFailed { + key: String::new(), + condition: "range start cannot fit in memory".to_string(), + })?; + let take_len = (chunk_len - skip).min(remaining); + let end = start + + usize::try_from(take_len).map_err(|_| StorageError::PreconditionFailed { + key: String::new(), + condition: "range length cannot fit in memory".to_string(), + })?; + let output = chunk.slice(start..end); + remaining -= take_len; + + return Ok(Some((output, (inner, 0, remaining)))); + } + + Ok(None) + }, + ); + + match size_hint { + Some(size_hint) => StorageByteStream::with_size_hint(Box::pin(stream), size_hint), + None => StorageByteStream::new(Box::pin(stream)), + } +} + /// Validates a provider-neutral blob key. /// /// # Errors diff --git a/crates/graphql-orm-storage/src/error.rs b/crates/graphql-orm-storage/src/error.rs index 926e7e46..a5e7cff1 100644 --- a/crates/graphql-orm-storage/src/error.rs +++ b/crates/graphql-orm-storage/src/error.rs @@ -7,6 +7,17 @@ pub enum StorageError { #[error("unsupported storage backend: {backend}")] UnsupportedBackend { backend: String }, + /// A provider operation failed. + #[error("storage provider error for {backend}: {message}")] + Provider { + /// Storage backend that returned the error. + backend: String, + /// Provider-specific error message. + message: String, + /// Whether retrying the operation may succeed. + retryable: bool, + }, + /// A storage key is empty, absolute, or contains unsafe path components. #[error("invalid storage key: {key}")] InvalidStorageKey { key: String }, @@ -15,6 +26,10 @@ pub enum StorageError { #[error("storage blob is missing: {key}")] MissingBlob { key: String }, + /// A conditional storage operation could not be applied. + #[error("storage precondition failed for {key}: {condition}")] + PreconditionFailed { key: String, condition: String }, + /// A local filesystem object path did not have a writable parent directory. #[error("local storage path has no parent: {path:?}")] MissingParent { path: PathBuf }, @@ -29,6 +44,20 @@ pub enum StorageError { } impl StorageError { + /// Returns whether retrying the failed operation may succeed. + #[must_use] + pub const fn is_retryable(&self) -> bool { + match self { + Self::Provider { retryable, .. } => *retryable, + Self::Io { .. } => true, + Self::UnsupportedBackend { .. } + | Self::InvalidStorageKey { .. } + | Self::MissingBlob { .. } + | Self::PreconditionFailed { .. } + | Self::MissingParent { .. } => false, + } + } + #[cfg(feature = "local")] pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { Self::Io { diff --git a/crates/graphql-orm-storage/src/lib.rs b/crates/graphql-orm-storage/src/lib.rs index bba8400f..fa34e47f 100644 --- a/crates/graphql-orm-storage/src/lib.rs +++ b/crates/graphql-orm-storage/src/lib.rs @@ -21,8 +21,8 @@ mod service; pub use azure::{AzureBlobStorageBackend, AzureBlobStorageConfig}; pub use backend::{StorageBackend, StorageNamespace, unsupported_backend}; pub use blob::{ - BlobBody, BlobMetadata, BlobStore, BlobWriteOutcome, BoxedStorageStream, StorageByteStream, - collect_storage_stream, validate_blob_key, + BlobBody, BlobListPage, BlobMetadata, BlobPutOptions, BlobStore, BlobWriteOutcome, + BoxedStorageStream, StorageByteStream, collect_storage_stream, validate_blob_key, }; pub use checksum::sha256_hex; pub use error::StorageError; diff --git a/crates/graphql-orm-storage/src/local.rs b/crates/graphql-orm-storage/src/local.rs index 23176d3f..6aace219 100644 --- a/crates/graphql-orm-storage/src/local.rs +++ b/crates/graphql-orm-storage/src/local.rs @@ -4,14 +4,14 @@ use async_trait::async_trait; use futures_util::StreamExt; use sha2::{Digest, Sha256}; use time::OffsetDateTime; -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, SeekFrom}; use tokio_util::io::ReaderStream; use uuid::Uuid; use crate::{ - BlobBody, BlobMetadata, BlobStore, BlobWriteOutcome, ObjectStorage, StorageBackend, - StorageByteStream, StorageError, StorageObjectBody, StoredObject, collect_storage_stream, - validate_blob_key, + BlobBody, BlobListPage, BlobMetadata, BlobPutOptions, BlobStore, BlobWriteOutcome, + ObjectStorage, StorageBackend, StorageByteStream, StorageError, StorageObjectBody, + StoredObject, collect_storage_stream, validate_blob_key, }; /// Local filesystem object storage backend. @@ -43,22 +43,11 @@ impl BlobStore for LocalStorageBackend { &self, key: &str, body: StorageByteStream, + _options: BlobPutOptions, ) -> Result { let path = self.path_for(key)?; - let parent = path - .parent() - .ok_or_else(|| StorageError::MissingParent { path: path.clone() })?; - tokio::fs::create_dir_all(parent) - .await - .map_err(|source| StorageError::io(parent, source))?; - - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| StorageError::InvalidStorageKey { - key: key.to_string(), - })?; - let temp_path = path.with_file_name(format!("{file_name}.{}.uploading", Uuid::new_v4())); + create_parent_dir(&path).await?; + let temp_path = temp_path_for(&path, key)?; let write_result = write_stream_to_temp(&temp_path, body).await; let outcome = match write_result { @@ -77,6 +66,40 @@ impl BlobStore for LocalStorageBackend { Ok(outcome) } + async fn put_blob_if_not_exists( + &self, + key: &str, + body: StorageByteStream, + _options: BlobPutOptions, + ) -> Result, StorageError> { + let path = self.path_for(key)?; + create_parent_dir(&path).await?; + let temp_path = temp_path_for(&path, key)?; + + let outcome = match write_stream_to_temp(&temp_path, body).await { + Ok(outcome) => outcome, + Err(err) => { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(err); + } + }; + + match tokio::fs::hard_link(&temp_path, &path).await { + Ok(()) => { + let _ = tokio::fs::remove_file(&temp_path).await; + Ok(Some(outcome)) + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + let _ = tokio::fs::remove_file(&temp_path).await; + Ok(None) + } + Err(source) => { + let _ = tokio::fs::remove_file(&temp_path).await; + Err(StorageError::io(&path, source)) + } + } + } + async fn get_blob(&self, key: &str) -> Result { let path = self.path_for(key)?; let file = match tokio::fs::File::open(&path).await { @@ -100,6 +123,43 @@ impl BlobStore for LocalStorageBackend { }) } + async fn get_blob_range( + &self, + key: &str, + range: std::ops::Range, + ) -> Result { + if range.end < range.start { + return Err(StorageError::PreconditionFailed { + key: key.to_string(), + condition: "range end is before range start".to_string(), + }); + } + + let path = self.path_for(key)?; + let mut file = match tokio::fs::File::open(&path).await { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(StorageError::MissingBlob { + key: key.to_string(), + }); + } + Err(source) => return Err(StorageError::io(&path, source)), + }; + file.seek(SeekFrom::Start(range.start)) + .await + .map_err(|source| StorageError::io(&path, source))?; + let length = range.end - range.start; + let stream_path = path.clone(); + let stream = ReaderStream::new(file.take(length)) + .map(move |chunk| chunk.map_err(|source| StorageError::io(&stream_path, source))); + + Ok(BlobBody { + key: key.to_string(), + metadata: self.head_blob(key).await?, + body: StorageByteStream::with_size_hint(Box::pin(stream), length), + }) + } + async fn blob_exists(&self, key: &str) -> Result { let path = self.path_for(key)?; match tokio::fs::metadata(&path).await { @@ -124,7 +184,108 @@ impl BlobStore for LocalStorageBackend { } } - async fn list_blobs(&self, prefix: &str) -> Result, StorageError> { + async fn list_blobs_page( + &self, + prefix: &str, + continuation: Option, + limit: usize, + ) -> Result { + if limit == 0 { + return Err(StorageError::PreconditionFailed { + key: prefix.to_string(), + condition: "list limit must be greater than zero".to_string(), + }); + } + + let keys = self.collect_blob_keys(prefix).await?; + let total_len = keys.len(); + let start_index = continuation + .as_deref() + .and_then(|token| keys.iter().position(|key| key == token)) + .map_or(0, |index| index + 1); + let page_keys: Vec<_> = keys.into_iter().skip(start_index).take(limit).collect(); + let next_continuation = if start_index + page_keys.len() < total_len { + page_keys.last().cloned() + } else { + None + }; + + Ok(BlobListPage { + keys: page_keys, + next_continuation, + }) + } + + async fn copy_blob(&self, from: &str, to: &str) -> Result<(), StorageError> { + let from_path = self.path_for(from)?; + let to_path = self.path_for(to)?; + create_parent_dir(&to_path).await?; + let temp_path = temp_path_for(&to_path, to)?; + + match tokio::fs::copy(&from_path, &temp_path).await { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(StorageError::MissingBlob { + key: from.to_string(), + }); + } + Err(source) => { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(StorageError::io(&from_path, source)); + } + } + + if let Err(source) = tokio::fs::rename(&temp_path, &to_path).await { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(StorageError::io(&to_path, source)); + } + + Ok(()) + } + + async fn delete_blob(&self, key: &str) -> Result<(), StorageError> { + let path = self.path_for(key)?; + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(StorageError::io(&path, source)), + } + } +} + +#[async_trait] +impl ObjectStorage for LocalStorageBackend { + async fn put_object( + &self, + object: StoredObject, + bytes: Vec, + ) -> Result { + self.put_blob( + &object.storage_key, + StorageByteStream::from_bytes(bytes), + BlobPutOptions::default(), + ) + .await?; + Ok(object) + } + + async fn get_object(&self, object: &StoredObject) -> Result { + let body = self.get_blob(&object.storage_key).await?; + let bytes = collect_storage_stream(body.body).await?; + Ok(StorageObjectBody { + object: object.clone(), + bytes: bytes.to_vec(), + }) + } + + async fn delete_object(&self, object: &StoredObject) -> Result<(), StorageError> { + self.delete_blob(&object.storage_key).await + } +} + +impl LocalStorageBackend { + async fn collect_blob_keys(&self, prefix: &str) -> Result, StorageError> { crate::blob::validate_blob_prefix(prefix)?; let start = if prefix.is_empty() { @@ -168,41 +329,25 @@ impl BlobStore for LocalStorageBackend { result.sort(); Ok(result) } - - async fn delete_blob(&self, key: &str) -> Result<(), StorageError> { - let path = self.path_for(key)?; - match tokio::fs::remove_file(&path).await { - Ok(()) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(source) => Err(StorageError::io(&path, source)), - } - } } -#[async_trait] -impl ObjectStorage for LocalStorageBackend { - async fn put_object( - &self, - object: StoredObject, - bytes: Vec, - ) -> Result { - self.put_blob(&object.storage_key, StorageByteStream::from_bytes(bytes)) - .await?; - Ok(object) - } - - async fn get_object(&self, object: &StoredObject) -> Result { - let body = self.get_blob(&object.storage_key).await?; - let bytes = collect_storage_stream(body.body).await?; - Ok(StorageObjectBody { - object: object.clone(), - bytes: bytes.to_vec(), - }) - } +async fn create_parent_dir(path: &Path) -> Result<(), StorageError> { + let parent = path + .parent() + .ok_or_else(|| StorageError::MissingParent { path: path.into() })?; + tokio::fs::create_dir_all(parent) + .await + .map_err(|source| StorageError::io(parent, source)) +} - async fn delete_object(&self, object: &StoredObject) -> Result<(), StorageError> { - self.delete_blob(&object.storage_key).await - } +fn temp_path_for(path: &Path, key: &str) -> Result { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| StorageError::InvalidStorageKey { + key: key.to_string(), + })?; + Ok(path.with_file_name(format!("{file_name}.{}.uploading", Uuid::new_v4()))) } async fn write_stream_to_temp( diff --git a/crates/graphql-orm-storage/src/s3.rs b/crates/graphql-orm-storage/src/s3.rs index a134f3bb..10c33c07 100644 --- a/crates/graphql-orm-storage/src/s3.rs +++ b/crates/graphql-orm-storage/src/s3.rs @@ -3,8 +3,9 @@ use std::fmt; use async_trait::async_trait; use crate::{ - BlobBody, BlobMetadata, BlobStore, BlobWriteOutcome, ObjectStorage, StorageBackend, - StorageByteStream, StorageError, StorageObjectBody, StoredObject, unsupported_backend, + BlobBody, BlobListPage, BlobMetadata, BlobPutOptions, BlobStore, BlobWriteOutcome, + ObjectStorage, StorageBackend, StorageByteStream, StorageError, StorageObjectBody, + StoredObject, unsupported_backend, }; /// Configuration for a future S3-compatible storage backend. @@ -75,14 +76,32 @@ impl BlobStore for S3StorageBackend { &self, _key: &str, _body: StorageByteStream, + _options: BlobPutOptions, ) -> Result { Err(unsupported_backend(StorageBackend::S3)) } + async fn put_blob_if_not_exists( + &self, + _key: &str, + _body: StorageByteStream, + _options: BlobPutOptions, + ) -> Result, StorageError> { + Err(unsupported_backend(StorageBackend::S3)) + } + async fn get_blob(&self, _key: &str) -> Result { Err(unsupported_backend(StorageBackend::S3)) } + async fn get_blob_range( + &self, + _key: &str, + _range: std::ops::Range, + ) -> Result { + Err(unsupported_backend(StorageBackend::S3)) + } + async fn blob_exists(&self, _key: &str) -> Result { Err(unsupported_backend(StorageBackend::S3)) } @@ -91,7 +110,16 @@ impl BlobStore for S3StorageBackend { Err(unsupported_backend(StorageBackend::S3)) } - async fn list_blobs(&self, _prefix: &str) -> Result, StorageError> { + async fn list_blobs_page( + &self, + _prefix: &str, + _continuation: Option, + _limit: usize, + ) -> Result { + Err(unsupported_backend(StorageBackend::S3)) + } + + async fn copy_blob(&self, _from: &str, _to: &str) -> Result<(), StorageError> { Err(unsupported_backend(StorageBackend::S3)) } diff --git a/crates/graphql-orm-storage/src/service.rs b/crates/graphql-orm-storage/src/service.rs index be39f464..e0e6c35f 100644 --- a/crates/graphql-orm-storage/src/service.rs +++ b/crates/graphql-orm-storage/src/service.rs @@ -5,9 +5,9 @@ use time::OffsetDateTime; use uuid::Uuid; use crate::{ - BlobMetadata, BlobStore, StorageBackend, StorageByteStream, StorageError, StorageObjectBody, - StorageObjectStream, StoragePutRequest, StoragePutStreamRequest, StoredObject, - build_storage_key, collect_storage_stream, file_extension, + BlobMetadata, BlobPutOptions, BlobStore, StorageBackend, StorageByteStream, StorageError, + StorageObjectBody, StorageObjectStream, StoragePutRequest, StoragePutStreamRequest, + StoredObject, build_storage_key, collect_storage_stream, file_extension, }; /// Provider implementation contract for object storage backends. @@ -86,9 +86,12 @@ impl StorageService { request: StoragePutStreamRequest, ) -> Result { let mut object = build_stream_stored_object(self.backend.backend(), &request); + let options = BlobPutOptions { + content_type: request.mime_type.clone(), + }; let outcome = self .backend - .put_blob(&object.storage_key, request.body) + .put_blob(&object.storage_key, request.body, options) .await?; object.size_bytes = outcome.size_bytes; object.sha256_hex = outcome.sha256_hex; diff --git a/crates/graphql-orm-storage/tests/core.rs b/crates/graphql-orm-storage/tests/core.rs index fe3c4c17..433954f0 100644 --- a/crates/graphql-orm-storage/tests/core.rs +++ b/crates/graphql-orm-storage/tests/core.rs @@ -92,3 +92,19 @@ fn unsupported_backend_uses_stable_backend_name() { StorageError::UnsupportedBackend { backend } if backend == "s3" )); } + +#[test] +fn retryability_is_explicit_for_provider_and_permanent_errors() { + let retryable = StorageError::Provider { + backend: "s3".to_string(), + message: "timeout".to_string(), + retryable: true, + }; + let permanent = StorageError::PreconditionFailed { + key: "objects/test".to_string(), + condition: "already exists".to_string(), + }; + + assert!(retryable.is_retryable()); + assert!(!permanent.is_retryable()); +} diff --git a/crates/graphql-orm-storage/tests/local_blob.rs b/crates/graphql-orm-storage/tests/local_blob.rs index aabf7556..fffb4677 100644 --- a/crates/graphql-orm-storage/tests/local_blob.rs +++ b/crates/graphql-orm-storage/tests/local_blob.rs @@ -1,7 +1,7 @@ use bytes::Bytes; use graphql_orm_storage::{ - BlobStore, LocalStorageBackend, StorageByteStream, StorageError, collect_storage_stream, - sha256_hex, + BlobPutOptions, BlobStore, LocalStorageBackend, StorageByteStream, StorageError, + collect_storage_stream, sha256_hex, }; use tempfile::TempDir; @@ -14,6 +14,7 @@ async fn local_blob_put_get_delete_round_trip() { .put_blob( "snapshots/a/manifest.json", StorageByteStream::from_bytes(Bytes::from_static(b"manifest")), + BlobPutOptions::default(), ) .await .expect("put blob"); @@ -74,6 +75,7 @@ async fn local_blob_head_and_exists_handle_present_and_missing_blobs() { .put_blob( "objects/sha256/aa/bb/hash", StorageByteStream::from_bytes(Bytes::from_static(b"object")), + BlobPutOptions::default(), ) .await .expect("put blob"); @@ -97,6 +99,7 @@ async fn local_blob_list_blobs_supports_empty_and_non_empty_prefixes() { .put_blob( "snapshots/a/manifest.json", StorageByteStream::from_bytes(Bytes::from_static(b"manifest")), + BlobPutOptions::default(), ) .await .expect("put manifest"); @@ -104,6 +107,7 @@ async fn local_blob_list_blobs_supports_empty_and_non_empty_prefixes() { .put_blob( "objects/sha256/aa/bb/hash", StorageByteStream::from_bytes(Bytes::from_static(b"object")), + BlobPutOptions::default(), ) .await .expect("put object"); @@ -128,6 +132,119 @@ async fn local_blob_list_blobs_supports_empty_and_non_empty_prefixes() { ); } +#[tokio::test] +async fn local_blob_paged_listing_returns_continuation_tokens() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + for key in ["prefix/a", "prefix/b", "prefix/c"] { + backend + .put_blob( + key, + StorageByteStream::from_bytes(Bytes::from_static(b"x")), + BlobPutOptions::default(), + ) + .await + .expect("put blob"); + } + + let first_page = backend + .list_blobs_page("prefix", None, 2) + .await + .expect("first page"); + assert_eq!( + first_page.keys, + vec!["prefix/a".to_string(), "prefix/b".to_string()] + ); + assert_eq!(first_page.next_continuation, Some("prefix/b".to_string())); + + let second_page = backend + .list_blobs_page("prefix", first_page.next_continuation, 2) + .await + .expect("second page"); + assert_eq!(second_page.keys, vec!["prefix/c".to_string()]); + assert_eq!(second_page.next_continuation, None); +} + +#[tokio::test] +async fn local_blob_range_reads_return_requested_bytes() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + backend + .put_blob( + "objects/ranged", + StorageByteStream::from_bytes(Bytes::from_static(b"0123456789")), + BlobPutOptions::default(), + ) + .await + .expect("put blob"); + + let body = backend + .get_blob_range("objects/ranged", 2..6) + .await + .expect("range read"); + let bytes = collect_storage_stream(body.body).await.expect("collect"); + assert_eq!(bytes, Bytes::from_static(b"2345")); +} + +#[tokio::test] +async fn local_blob_conditional_write_returns_none_when_key_exists() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + let first = backend + .put_blob_if_not_exists( + "objects/dedup", + StorageByteStream::from_bytes(Bytes::from_static(b"first")), + BlobPutOptions::default(), + ) + .await + .expect("first conditional write"); + assert!(first.is_some()); + + let second = backend + .put_blob_if_not_exists( + "objects/dedup", + StorageByteStream::from_bytes(Bytes::from_static(b"second")), + BlobPutOptions::default(), + ) + .await + .expect("second conditional write"); + assert_eq!(second, None); + + let body = backend.get_blob("objects/dedup").await.expect("get blob"); + let bytes = collect_storage_stream(body.body).await.expect("collect"); + assert_eq!(bytes, Bytes::from_static(b"first")); +} + +#[tokio::test] +async fn local_blob_copy_promotes_without_download_api() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + backend + .put_blob( + "temp/object", + StorageByteStream::from_bytes(Bytes::from_static(b"copy me")), + BlobPutOptions::default(), + ) + .await + .expect("put source"); + + backend + .copy_blob("temp/object", "originals/object") + .await + .expect("copy blob"); + + let body = backend + .get_blob("originals/object") + .await + .expect("get copied"); + let bytes = collect_storage_stream(body.body).await.expect("collect"); + assert_eq!(bytes, Bytes::from_static(b"copy me")); +} + #[tokio::test] async fn local_blob_delete_missing_succeeds() { let temp = TempDir::new().expect("temp dir"); @@ -159,13 +276,30 @@ async fn local_blob_rejects_invalid_keys_for_all_operations() { assert_invalid( backend - .put_blob("../escape", StorageByteStream::from_bytes(Bytes::new())) + .put_blob( + "../escape", + StorageByteStream::from_bytes(Bytes::new()), + BlobPutOptions::default(), + ) + .await, + ); + assert_invalid( + backend + .put_blob_if_not_exists( + "../escape", + StorageByteStream::from_bytes(Bytes::new()), + BlobPutOptions::default(), + ) .await, ); assert_invalid(backend.get_blob("../escape").await); + assert_invalid(backend.get_blob_range("../escape", 0..1).await); assert_invalid(backend.blob_exists("../escape").await); assert_invalid(backend.head_blob("../escape").await); assert_invalid(backend.list_blobs("../escape").await); + assert_invalid(backend.list_blobs_page("../escape", None, 100).await); + assert_invalid(backend.copy_blob("../escape", "objects/safe").await); + assert_invalid(backend.copy_blob("objects/safe", "../escape").await); assert_invalid(backend.delete_blob("../escape").await); } diff --git a/crates/graphql-orm-storage/tests/provider_placeholders.rs b/crates/graphql-orm-storage/tests/provider_placeholders.rs index 195c7fcf..615426b6 100644 --- a/crates/graphql-orm-storage/tests/provider_placeholders.rs +++ b/crates/graphql-orm-storage/tests/provider_placeholders.rs @@ -2,8 +2,8 @@ use graphql_orm_storage::{AzureBlobStorageBackend, AzureBlobStorageConfig}; #[cfg(any(feature = "azure", feature = "s3"))] use graphql_orm_storage::{ - BlobStore, ObjectStorage, StorageBackend, StorageByteStream, StorageError, StorageNamespace, - StoredObject, sha256_hex, + BlobPutOptions, BlobStore, ObjectStorage, StorageBackend, StorageByteStream, StorageError, + StorageNamespace, StoredObject, sha256_hex, }; #[cfg(feature = "s3")] use graphql_orm_storage::{S3StorageBackend, S3StorageConfig}; @@ -33,14 +33,31 @@ async fn s3_placeholder_backend_returns_unsupported_errors() { .put_blob( "objects/test", StorageByteStream::from_bytes(b"bytes".to_vec()), + BlobPutOptions::default(), + ) + .await, + "s3", + ); + assert_unsupported( + backend + .put_blob_if_not_exists( + "objects/test", + StorageByteStream::from_bytes(b"bytes".to_vec()), + BlobPutOptions::default(), ) .await, "s3", ); assert_unsupported(backend.get_blob("objects/test").await, "s3"); + assert_unsupported(backend.get_blob_range("objects/test", 0..1).await, "s3"); assert_unsupported(backend.blob_exists("objects/test").await, "s3"); assert_unsupported(backend.head_blob("objects/test").await, "s3"); assert_unsupported(backend.list_blobs("objects").await, "s3"); + assert_unsupported(backend.list_blobs_page("objects", None, 100).await, "s3"); + assert_unsupported( + backend.copy_blob("objects/test", "objects/copy").await, + "s3", + ); assert_unsupported(backend.delete_blob("objects/test").await, "s3"); assert_unsupported( backend.put_object(object.clone(), b"bytes".to_vec()).await, @@ -72,14 +89,37 @@ async fn azure_placeholder_backend_returns_unsupported_errors() { .put_blob( "objects/test", StorageByteStream::from_bytes(b"bytes".to_vec()), + BlobPutOptions::default(), + ) + .await, + "azure_blob", + ); + assert_unsupported( + backend + .put_blob_if_not_exists( + "objects/test", + StorageByteStream::from_bytes(b"bytes".to_vec()), + BlobPutOptions::default(), ) .await, "azure_blob", ); assert_unsupported(backend.get_blob("objects/test").await, "azure_blob"); + assert_unsupported( + backend.get_blob_range("objects/test", 0..1).await, + "azure_blob", + ); assert_unsupported(backend.blob_exists("objects/test").await, "azure_blob"); assert_unsupported(backend.head_blob("objects/test").await, "azure_blob"); assert_unsupported(backend.list_blobs("objects").await, "azure_blob"); + assert_unsupported( + backend.list_blobs_page("objects", None, 100).await, + "azure_blob", + ); + assert_unsupported( + backend.copy_blob("objects/test", "objects/copy").await, + "azure_blob", + ); assert_unsupported(backend.delete_blob("objects/test").await, "azure_blob"); assert_unsupported( backend.put_object(object.clone(), b"bytes".to_vec()).await, From 7104159be329b056f4a883fe572f763be0b2d2e4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 00:40:58 +0000 Subject: [PATCH 007/108] Add snapshot restore orchestration --- crates/graphql-orm-backup/src/backup.rs | 1 + crates/graphql-orm-backup/src/database.rs | 22 +- crates/graphql-orm-backup/src/lib.rs | 5 +- crates/graphql-orm-backup/src/manifest.rs | 2 + crates/graphql-orm-backup/src/restore.rs | 201 ++++++++++- .../graphql-orm-backup/tests/compression.rs | 1 + .../tests/local_repository_round_trip.rs | 1 + .../tests/manifest_chain.rs | 1 + .../tests/manifest_round_trip.rs | 1 + .../tests/restore_snapshot.rs | 324 ++++++++++++++++++ 10 files changed, 553 insertions(+), 6 deletions(-) create mode 100644 crates/graphql-orm-backup/tests/restore_snapshot.rs diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs index f489ae65..2773c6be 100644 --- a/crates/graphql-orm-backup/src/backup.rs +++ b/crates/graphql-orm-backup/src/backup.rs @@ -130,6 +130,7 @@ pub async fn create_full_backup( row_count, table_count: table_entries.len() as u64, tables: table_entries, + changes: Vec::new(), }, objects: object_entries, tombstones: Vec::new(), diff --git a/crates/graphql-orm-backup/src/database.rs b/crates/graphql-orm-backup/src/database.rs index bc302823..d1ebe32d 100644 --- a/crates/graphql-orm-backup/src/database.rs +++ b/crates/graphql-orm-backup/src/database.rs @@ -13,6 +13,20 @@ pub trait GraphqlOrmBackupAdapter: Send + Sync { /// Returns [`BackupError`] if the adapter cannot read schema metadata. async fn schema_snapshot(&self) -> Result; + /// Returns whether the restore target currently has no rows. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the adapter cannot determine target + /// emptiness. The default implementation returns + /// [`BackupError::UnsupportedOperation`] so restore adapters must opt in + /// explicitly. + async fn restore_target_is_empty(&self) -> Result { + Err(BackupError::UnsupportedOperation { + operation: "restore target emptiness check".to_string(), + }) + } + /// Exports all backup-enabled tables for a full snapshot. /// /// # Errors @@ -61,13 +75,13 @@ pub struct GraphqlOrmBackupSchema { pub schema_hash: String, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupTableExport { pub table_name: String, pub rows: Vec, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupRow { pub table_name: String, pub primary_key: String, @@ -75,14 +89,14 @@ pub struct BackupRow { pub values: serde_json::Map, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum BackupChangeAction { Create, Update, Delete, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupChangeExport { pub table_name: String, pub primary_key: String, diff --git a/crates/graphql-orm-backup/src/lib.rs b/crates/graphql-orm-backup/src/lib.rs index f5b1bd58..c3afb18a 100644 --- a/crates/graphql-orm-backup/src/lib.rs +++ b/crates/graphql-orm-backup/src/lib.rs @@ -36,5 +36,8 @@ pub use manifest::{ pub use object_index::{BackupObjectIndex, BackupObjectRef}; pub use planner::{FullBackupPlan, plan_full_backup}; pub use repository::BackupRepository; -pub use restore::{RestoreContext, RestoreMode, ensure_empty_restore_target}; +pub use restore::{ + RestoreContext, RestoreMode, RestoreObjectSink, RestoreResult, ensure_empty_restore_target, + restore_objects, restore_snapshot, +}; pub use verify::{verify_manifest_and_objects, verify_object_checksums}; diff --git a/crates/graphql-orm-backup/src/manifest.rs b/crates/graphql-orm-backup/src/manifest.rs index a4aaa724..5f4662cc 100644 --- a/crates/graphql-orm-backup/src/manifest.rs +++ b/crates/graphql-orm-backup/src/manifest.rs @@ -51,6 +51,8 @@ pub struct DatabaseBackupManifest { pub row_count: u64, pub table_count: u64, pub tables: Vec, + #[serde(default)] + pub changes: Vec, } #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] diff --git a/crates/graphql-orm-backup/src/restore.rs b/crates/graphql-orm-backup/src/restore.rs index 1f436dad..a4685ce7 100644 --- a/crates/graphql-orm-backup/src/restore.rs +++ b/crates/graphql-orm-backup/src/restore.rs @@ -1,4 +1,13 @@ -use crate::BackupError; +use async_trait::async_trait; +use bytes::Bytes; +use serde::de::DeserializeOwned; +use uuid::Uuid; + +use crate::{ + BackupChangeExport, BackupCompression, BackupError, BackupObjectRef, BackupRepository, + BackupSnapshotManifest, BackupTableExport, GraphqlOrmBackupAdapter, ObjectBackupEntry, + TableBackupEntry, decompress_payload, load_manifest_chain, verify_manifest_and_objects, +}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum RestoreMode { @@ -13,6 +22,29 @@ pub struct RestoreContext { pub disable_change_journal: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestoreResult { + pub manifest_chain_len: usize, + pub full_table_count: u64, + pub full_row_count: u64, + pub incremental_change_count: u64, +} + +#[async_trait] +pub trait RestoreObjectSink: Send + Sync { + /// Restores one object loaded from a backup repository. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the caller-supplied sink cannot persist the + /// object bytes. + async fn restore_object( + &self, + object: BackupObjectRef, + bytes: Bytes, + ) -> Result<(), BackupError>; +} + impl RestoreContext { /// Builds the default empty-database restore context. #[must_use] @@ -51,3 +83,170 @@ pub fn ensure_empty_restore_target( RestoreMode::DryRun => Ok(()), } } + +/// Restores a database snapshot chain through a `graphql-orm` backup adapter. +/// +/// In [`RestoreMode::DryRun`] this validates, verifies, downloads, decompresses, +/// and parses all database payloads without calling adapter restore methods. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the manifest chain is invalid, checksum +/// verification fails, target safety checks fail, payload parsing fails, or the +/// adapter restore call fails. +pub async fn restore_snapshot( + repository: &dyn BackupRepository, + database: &dyn GraphqlOrmBackupAdapter, + snapshot_id: Uuid, + context: RestoreContext, +) -> Result { + let chain = load_manifest_chain(repository, snapshot_id).await?; + for manifest in &chain { + verify_manifest_and_objects(repository, manifest).await?; + } + + let target_is_empty = match context.mode { + RestoreMode::DryRun => true, + RestoreMode::EmptyDatabase => database.restore_target_is_empty().await?, + }; + ensure_empty_restore_target(target_is_empty, &context)?; + + let full_manifest = chain + .first() + .ok_or_else(|| BackupError::InvalidManifestChain { + reason: "manifest chain is empty".to_string(), + })?; + let full_export = load_table_exports(repository, full_manifest).await?; + let full_table_count = full_export.len() as u64; + let full_row_count = full_export + .iter() + .map(|table| table.rows.len() as u64) + .sum::(); + + if !matches!(context.mode, RestoreMode::DryRun) { + database.restore_full(full_export, context.clone()).await?; + } + + let mut incremental_change_count = 0_u64; + for manifest in chain.iter().skip(1) { + let changes = load_change_exports(repository, manifest).await?; + incremental_change_count += changes.len() as u64; + if !matches!(context.mode, RestoreMode::DryRun) { + database + .restore_incremental(changes, context.clone()) + .await?; + } + } + + Ok(RestoreResult { + manifest_chain_len: chain.len(), + full_table_count, + full_row_count, + incremental_change_count, + }) +} + +/// Restores object blobs from a manifest through a caller-supplied sink. +/// +/// # Errors +/// +/// Returns [`BackupError`] if an object blob is missing, checksum verification +/// fails, or the sink rejects an object. +pub async fn restore_objects( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, + sink: &dyn RestoreObjectSink, +) -> Result<(), BackupError> { + for object in &manifest.objects { + let bytes = repository.get_blob(&object.content_key).await?; + let actual = crate::bytes_sha256_hex(&bytes); + if actual != object.sha256_hex { + return Err(BackupError::ChecksumMismatch { + key: object.content_key.clone(), + expected: object.sha256_hex.clone(), + actual, + }); + } + + sink.restore_object(object_ref_from_entry(object), bytes) + .await?; + } + + Ok(()) +} + +async fn load_table_exports( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, +) -> Result, BackupError> { + let mut exports = Vec::with_capacity(manifest.database.tables.len()); + for table in &manifest.database.tables { + let rows = load_jsonl_entries(repository, manifest, table).await?; + exports.push(BackupTableExport { + table_name: table.table_name.clone(), + rows, + }); + } + Ok(exports) +} + +async fn load_change_exports( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, +) -> Result, BackupError> { + let mut changes = Vec::new(); + for table in &manifest.database.changes { + changes.extend(load_jsonl_entries(repository, manifest, table).await?); + } + Ok(changes) +} + +async fn load_jsonl_entries( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, + entry: &TableBackupEntry, +) -> Result, BackupError> +where + T: DeserializeOwned, +{ + if manifest.database.export_format != crate::DATABASE_EXPORT_FORMAT { + return Err(BackupError::UnsupportedOperation { + operation: format!("database export format {}", manifest.database.export_format), + }); + } + + let stored = repository.get_blob(&entry.content_key).await?; + let payload = decode_payload(&stored, &manifest.database.compression)?; + parse_jsonl(&payload) +} + +fn decode_payload(bytes: &[u8], compression: &BackupCompression) -> Result, BackupError> { + match compression { + BackupCompression::None => Ok(bytes.to_vec()), + BackupCompression::Zstd => decompress_payload(bytes), + } +} + +fn parse_jsonl(payload: &[u8]) -> Result, BackupError> +where + T: DeserializeOwned, +{ + let mut entries = Vec::new(); + for line in payload.split(|byte| *byte == b'\n') { + if line.is_empty() { + continue; + } + entries.push(serde_json::from_slice(line)?); + } + Ok(entries) +} + +fn object_ref_from_entry(entry: &ObjectBackupEntry) -> BackupObjectRef { + BackupObjectRef { + object_id: entry.object_id, + storage_key: entry.storage_key.clone(), + sha256_hex: entry.sha256_hex.clone(), + size_bytes: entry.size_bytes, + mime_type: entry.mime_type.clone(), + } +} diff --git a/crates/graphql-orm-backup/tests/compression.rs b/crates/graphql-orm-backup/tests/compression.rs index 58fdab4a..e10a4407 100644 --- a/crates/graphql-orm-backup/tests/compression.rs +++ b/crates/graphql-orm-backup/tests/compression.rs @@ -151,6 +151,7 @@ fn manifest_with_table_hash(table_hash: String) -> BackupSnapshotManifest { content_key: "snapshots/snapshot/database/tables/users.jsonl.zst".to_string(), sha256_hex: table_hash, }], + changes: Vec::new(), }, objects: Vec::new(), tombstones: Vec::new(), diff --git a/crates/graphql-orm-backup/tests/local_repository_round_trip.rs b/crates/graphql-orm-backup/tests/local_repository_round_trip.rs index 8799eb15..67503966 100644 --- a/crates/graphql-orm-backup/tests/local_repository_round_trip.rs +++ b/crates/graphql-orm-backup/tests/local_repository_round_trip.rs @@ -265,6 +265,7 @@ fn sample_manifest_with_object_hash(object_hash: String) -> BackupSnapshotManife content_key: object_content_key(&table_hash), sha256_hex: table_hash, }], + changes: Vec::new(), }, objects: vec![ObjectBackupEntry { object_id: Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid"), diff --git a/crates/graphql-orm-backup/tests/manifest_chain.rs b/crates/graphql-orm-backup/tests/manifest_chain.rs index 8ab08fb5..21ee7f3a 100644 --- a/crates/graphql-orm-backup/tests/manifest_chain.rs +++ b/crates/graphql-orm-backup/tests/manifest_chain.rs @@ -176,6 +176,7 @@ fn sample_manifest( row_count: 0, table_count: 0, tables: Vec::new(), + changes: Vec::new(), }, objects: Vec::new(), tombstones: Vec::new(), diff --git a/crates/graphql-orm-backup/tests/manifest_round_trip.rs b/crates/graphql-orm-backup/tests/manifest_round_trip.rs index e0e171e2..d701c533 100644 --- a/crates/graphql-orm-backup/tests/manifest_round_trip.rs +++ b/crates/graphql-orm-backup/tests/manifest_round_trip.rs @@ -126,6 +126,7 @@ fn sample_manifest() -> BackupSnapshotManifest { content_key: "snapshots/snapshot/database/tables/storage.jsonl.zst".to_string(), sha256_hex: table_hash, }], + changes: Vec::new(), }, objects: vec![ObjectBackupEntry { object_id: object_id(), diff --git a/crates/graphql-orm-backup/tests/restore_snapshot.rs b/crates/graphql-orm-backup/tests/restore_snapshot.rs new file mode 100644 index 00000000..7abd98fd --- /dev/null +++ b/crates/graphql-orm-backup/tests/restore_snapshot.rs @@ -0,0 +1,324 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use bytes::Bytes; +use graphql_orm_backup::{ + BackupChangeExport, BackupError, BackupObjectIndex, BackupObjectRef, BackupRepository, + BackupRow, BackupTableExport, FullBackupRequest, GraphqlOrmBackupAdapter, + GraphqlOrmBackupSchema, LocalBackupRepository, RestoreContext, RestoreObjectSink, + bytes_sha256_hex, create_full_backup, object_content_key, restore_objects, restore_snapshot, +}; +use serde_json::{Map, Value}; +use tempfile::TempDir; +use uuid::Uuid; + +#[tokio::test] +async fn restore_snapshot_round_trips_full_backup_rows() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let rows = vec![ + backup_row("users", "1", &[("name", "Ada")]), + backup_row("users", "2", &[("name", "Grace")]), + ]; + let source = MockDatabase::with_tables(vec![BackupTableExport { + table_name: "users".to_string(), + rows: rows.clone(), + }]); + let objects = MockObjectIndex::default(); + + create_full_backup(&repository, &source, &objects, backup_request()) + .await + .expect("create full backup"); + + let target = MockDatabase::empty_restore_target(); + let result = restore_snapshot( + &repository, + &target, + snapshot_id(), + RestoreContext::empty_database(), + ) + .await + .expect("restore snapshot"); + + assert_eq!(result.manifest_chain_len, 1); + assert_eq!(result.full_table_count, 1); + assert_eq!(result.full_row_count, 2); + assert_eq!( + target.restored_full(), + vec![BackupTableExport { + table_name: "users".to_string(), + rows, + }] + ); +} + +#[tokio::test] +async fn restore_snapshot_dry_run_validates_and_parses_without_applying() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let source = MockDatabase::with_tables(vec![BackupTableExport { + table_name: "users".to_string(), + rows: vec![backup_row("users", "1", &[("name", "Ada")])], + }]); + let objects = MockObjectIndex::default(); + + create_full_backup(&repository, &source, &objects, backup_request()) + .await + .expect("create full backup"); + + let target = MockDatabase::non_empty_restore_target(); + let result = restore_snapshot( + &repository, + &target, + snapshot_id(), + RestoreContext::dry_run(), + ) + .await + .expect("dry-run restore"); + + assert_eq!(result.full_row_count, 1); + assert!(target.restored_full().is_empty()); +} + +#[tokio::test] +async fn restore_snapshot_refuses_non_empty_empty_database_target() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let source = MockDatabase::with_tables(vec![BackupTableExport { + table_name: "users".to_string(), + rows: Vec::new(), + }]); + let objects = MockObjectIndex::default(); + + create_full_backup(&repository, &source, &objects, backup_request()) + .await + .expect("create full backup"); + + let target = MockDatabase::non_empty_restore_target(); + let err = restore_snapshot( + &repository, + &target, + snapshot_id(), + RestoreContext::empty_database(), + ) + .await + .expect_err("non-empty target rejected"); + + assert!(matches!(err, BackupError::RestoreTargetNotEmpty)); + assert!(target.restored_full().is_empty()); +} + +#[tokio::test] +async fn restore_objects_loads_verified_object_bytes_into_sink() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let object_bytes = Bytes::from_static(b"object"); + let object_hash = bytes_sha256_hex(&object_bytes); + let source = MockDatabase::with_tables(vec![BackupTableExport { + table_name: "users".to_string(), + rows: Vec::new(), + }]); + let objects = MockObjectIndex { + objects: vec![BackupObjectRef { + object_id: object_id(), + storage_key: "objects/original.txt".to_string(), + sha256_hex: object_hash.clone(), + size_bytes: object_bytes.len() as u64, + mime_type: Some("text/plain".to_string()), + }], + bytes: vec![object_bytes.clone()], + }; + + let result = create_full_backup(&repository, &source, &objects, backup_request()) + .await + .expect("create full backup"); + assert!( + repository + .blob_exists(&object_content_key(&object_hash)) + .await + .expect("exists") + ); + + let sink = RecordingObjectSink::default(); + restore_objects(&repository, &result.manifest, &sink) + .await + .expect("restore objects"); + + assert_eq!( + sink.restored(), + vec![(objects.objects[0].clone(), object_bytes)] + ); +} + +fn backup_request() -> FullBackupRequest { + FullBackupRequest { + snapshot_id: snapshot_id(), + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + } +} + +fn backup_row(table_name: &str, primary_key: &str, values: &[(&str, &str)]) -> BackupRow { + let mut row_values = Map::new(); + for (key, value) in values { + row_values.insert((*key).to_string(), Value::String((*value).to_string())); + } + + BackupRow { + table_name: table_name.to_string(), + primary_key: primary_key.to_string(), + row_hash: bytes_sha256_hex(primary_key.as_bytes()), + values: row_values, + } +} + +fn snapshot_id() -> Uuid { + Uuid::parse_str("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb").expect("valid uuid") +} + +fn object_id() -> Uuid { + Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid") +} + +#[derive(Default)] +struct MockObjectIndex { + objects: Vec, + bytes: Vec, +} + +#[async_trait] +impl BackupObjectIndex for MockObjectIndex { + async fn list_objects_for_full_backup(&self) -> Result, BackupError> { + Ok(self.objects.clone()) + } + + async fn list_objects_for_incremental_backup( + &self, + _since_snapshot_id: Uuid, + ) -> Result, BackupError> { + Ok(Vec::new()) + } + + async fn load_object(&self, object: &BackupObjectRef) -> Result { + let index = self + .objects + .iter() + .position(|candidate| candidate.object_id == object.object_id) + .expect("object exists"); + Ok(self.bytes[index].clone()) + } +} + +#[derive(Clone)] +struct MockDatabase { + tables: Vec, + target_is_empty: bool, + restored_full: Arc>>, + restored_incremental: Arc>>, +} + +impl MockDatabase { + fn with_tables(tables: Vec) -> Self { + Self { + tables, + target_is_empty: true, + restored_full: Arc::default(), + restored_incremental: Arc::default(), + } + } + + fn empty_restore_target() -> Self { + Self::with_tables(Vec::new()) + } + + fn non_empty_restore_target() -> Self { + Self { + target_is_empty: false, + ..Self::with_tables(Vec::new()) + } + } + + fn restored_full(&self) -> Vec { + self.restored_full + .lock() + .expect("restored full lock") + .clone() + } +} + +#[async_trait] +impl GraphqlOrmBackupAdapter for MockDatabase { + async fn schema_snapshot(&self) -> Result { + Ok(GraphqlOrmBackupSchema { + backend: "sqlite".to_string(), + migration_version: "20260514000000".to_string(), + schema_hash: "schema-hash".to_string(), + }) + } + + async fn restore_target_is_empty(&self) -> Result { + Ok(self.target_is_empty) + } + + async fn export_full(&self) -> Result, BackupError> { + Ok(self.tables.clone()) + } + + async fn export_incremental( + &self, + _parent_snapshot_id: Uuid, + ) -> Result, BackupError> { + Ok(Vec::new()) + } + + async fn restore_full( + &self, + export: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + self.restored_full + .lock() + .expect("restored full lock") + .extend(export); + Ok(()) + } + + async fn restore_incremental( + &self, + changes: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + self.restored_incremental + .lock() + .expect("restored incremental lock") + .extend(changes); + Ok(()) + } +} + +#[derive(Default)] +struct RecordingObjectSink { + restored: Arc>>, +} + +impl RecordingObjectSink { + fn restored(&self) -> Vec<(BackupObjectRef, Bytes)> { + self.restored.lock().expect("restored lock").clone() + } +} + +#[async_trait] +impl RestoreObjectSink for RecordingObjectSink { + async fn restore_object( + &self, + object: BackupObjectRef, + bytes: Bytes, + ) -> Result<(), BackupError> { + self.restored + .lock() + .expect("restored lock") + .push((object, bytes)); + Ok(()) + } +} From bb34e83fe27f198d4be295fb33f4074375cdcdd2 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 00:41:58 +0000 Subject: [PATCH 008/108] Harden local blob maintenance --- crates/graphql-orm-storage/docs/blob-store.md | 4 + crates/graphql-orm-storage/docs/usage.md | 4 + crates/graphql-orm-storage/src/local.rs | 124 ++++++++++++++---- .../graphql-orm-storage/tests/local_blob.rs | 43 ++++++ 4 files changed, 150 insertions(+), 25 deletions(-) diff --git a/crates/graphql-orm-storage/docs/blob-store.md b/crates/graphql-orm-storage/docs/blob-store.md index 1e7298fe..e60684df 100644 --- a/crates/graphql-orm-storage/docs/blob-store.md +++ b/crates/graphql-orm-storage/docs/blob-store.md @@ -73,6 +73,10 @@ It is the race-safe primitive for content-addressed deduplication. `copy_blob` may use provider-side copy and does not return a SHA-256 checksum. Callers can use `head_blob` after copying when they need backend metadata. +The local provider writes through temporary `.uploading` files. Host +applications can call `LocalStorageBackend::sweep_temp_files` periodically to +remove stale temp files. + ## Key Safety Blob keys are `/`-separated relative keys. `validate_blob_key` rejects: diff --git a/crates/graphql-orm-storage/docs/usage.md b/crates/graphql-orm-storage/docs/usage.md index c7cb2300..8f720574 100644 --- a/crates/graphql-orm-storage/docs/usage.md +++ b/crates/graphql-orm-storage/docs/usage.md @@ -13,6 +13,10 @@ Use `StorageService` for primary object metadata workflows. Use `BlobStore` for low-level key-addressed blob operations that do not need generated object metadata. +When using `LocalStorageBackend`, interrupted writes can leave temporary +`*.uploading` files. Schedule `LocalStorageBackend::sweep_temp_files` from the +host application if long-running processes may be interrupted. + ## Dependency Default local filesystem support: diff --git a/crates/graphql-orm-storage/src/local.rs b/crates/graphql-orm-storage/src/local.rs index 6aace219..0840a2ef 100644 --- a/crates/graphql-orm-storage/src/local.rs +++ b/crates/graphql-orm-storage/src/local.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; use async_trait::async_trait; use futures_util::StreamExt; @@ -27,6 +30,45 @@ impl LocalStorageBackend { Self { root: root.into() } } + /// Removes stale temporary upload files under the local storage root. + /// + /// Files are considered temporary when their filename ends with + /// `.uploading`. Callers should schedule this periodically if the process + /// may be interrupted during writes. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the storage root cannot be walked or a + /// stale temp file cannot be removed. + pub async fn sweep_temp_files(&self, older_than: Duration) -> Result { + let now = SystemTime::now(); + let mut removed = 0; + let mut stack = vec![self.root.clone()]; + + while let Some(path) = stack.pop() { + let metadata = match tokio::fs::metadata(&path).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(source) => return Err(StorageError::io(&path, source)), + }; + + if metadata.is_file() { + if is_uploading_temp_file(&path) && is_older_than(&metadata, now, older_than) { + tokio::fs::remove_file(&path) + .await + .map_err(|source| StorageError::io(&path, source))?; + removed += 1; + } + continue; + } + + let entries = sorted_child_paths(&path).await?; + stack.extend(entries); + } + + Ok(removed) + } + fn path_for(&self, key: &str) -> Result { validate_blob_key(key)?; Ok(self.root.join(Path::new(key))) @@ -197,14 +239,11 @@ impl BlobStore for LocalStorageBackend { }); } - let keys = self.collect_blob_keys(prefix).await?; - let total_len = keys.len(); - let start_index = continuation - .as_deref() - .and_then(|token| keys.iter().position(|key| key == token)) - .map_or(0, |index| index + 1); - let page_keys: Vec<_> = keys.into_iter().skip(start_index).take(limit).collect(); - let next_continuation = if start_index + page_keys.len() < total_len { + let mut page_keys = self + .collect_blob_page(prefix, continuation.as_deref(), limit + 1) + .await?; + let next_continuation = if page_keys.len() > limit { + page_keys.truncate(limit); page_keys.last().cloned() } else { None @@ -285,7 +324,12 @@ impl ObjectStorage for LocalStorageBackend { } impl LocalStorageBackend { - async fn collect_blob_keys(&self, prefix: &str) -> Result, StorageError> { + async fn collect_blob_page( + &self, + prefix: &str, + continuation: Option<&str>, + limit: usize, + ) -> Result, StorageError> { crate::blob::validate_blob_prefix(prefix)?; let start = if prefix.is_empty() { @@ -294,10 +338,14 @@ impl LocalStorageBackend { self.root.join(prefix) }; - let mut result = Vec::new(); + let mut page = Vec::with_capacity(limit); let mut stack = vec![start]; while let Some(path) = stack.pop() { + if page.len() == limit { + break; + } + let metadata = match tokio::fs::metadata(&path).await { Ok(metadata) => metadata, Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, @@ -308,26 +356,26 @@ impl LocalStorageBackend { if is_uploading_temp_file(&path) { continue; } - if let Ok(relative) = path.strip_prefix(&self.root) { - result.push(relative.to_string_lossy().replace('\\', "/")); + if let Some(key) = self.key_for_path(&path) { + if continuation.is_some_and(|token| key.as_str() <= token) { + continue; + } + page.push(key); } continue; } - let mut entries = tokio::fs::read_dir(&path) - .await - .map_err(|source| StorageError::io(&path, source))?; - while let Some(entry) = entries - .next_entry() - .await - .map_err(|source| StorageError::io(&path, source))? - { - stack.push(entry.path()); - } + let entries = sorted_child_paths(&path).await?; + stack.extend(entries.into_iter().rev()); } - result.sort(); - Ok(result) + Ok(page) + } + + fn key_for_path(&self, path: &Path) -> Option { + path.strip_prefix(&self.root) + .ok() + .map(|relative| relative.to_string_lossy().replace('\\', "/")) } } @@ -385,3 +433,29 @@ fn is_uploading_temp_file(path: &Path) -> bool { .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".uploading")) } + +fn is_older_than(metadata: &std::fs::Metadata, now: SystemTime, older_than: Duration) -> bool { + metadata + .modified() + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age >= older_than) +} + +async fn sorted_child_paths(path: &Path) -> Result, StorageError> { + let mut entries = tokio::fs::read_dir(path) + .await + .map_err(|source| StorageError::io(path, source))?; + let mut paths = Vec::new(); + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|source| StorageError::io(path, source))? + { + paths.push(entry.path()); + } + + paths.sort(); + Ok(paths) +} diff --git a/crates/graphql-orm-storage/tests/local_blob.rs b/crates/graphql-orm-storage/tests/local_blob.rs index fffb4677..653eacd9 100644 --- a/crates/graphql-orm-storage/tests/local_blob.rs +++ b/crates/graphql-orm-storage/tests/local_blob.rs @@ -3,6 +3,7 @@ use graphql_orm_storage::{ BlobPutOptions, BlobStore, LocalStorageBackend, StorageByteStream, StorageError, collect_storage_stream, sha256_hex, }; +use std::time::Duration; use tempfile::TempDir; #[tokio::test] @@ -318,6 +319,48 @@ async fn local_blob_list_blobs_ignores_uploading_temp_files() { assert!(backend.list_blobs("").await.expect("list all").is_empty()); } +#[tokio::test] +async fn local_blob_sweep_temp_files_removes_stale_uploads() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + let temp_path = temp.path().join("snapshots/a/manifest.json.temp.uploading"); + tokio::fs::create_dir_all(temp_path.parent().expect("temp parent")) + .await + .expect("create parent"); + tokio::fs::write(&temp_path, b"partial") + .await + .expect("write temp"); + + let removed = backend + .sweep_temp_files(Duration::ZERO) + .await + .expect("sweep temp files"); + + assert_eq!(removed, 1); + assert!(!temp_path.exists()); +} + +#[tokio::test] +async fn local_blob_sweep_temp_files_keeps_recent_uploads() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + let temp_path = temp.path().join("snapshots/a/manifest.json.temp.uploading"); + tokio::fs::create_dir_all(temp_path.parent().expect("temp parent")) + .await + .expect("create parent"); + tokio::fs::write(&temp_path, b"partial") + .await + .expect("write temp"); + + let removed = backend + .sweep_temp_files(Duration::from_secs(86_400)) + .await + .expect("sweep temp files"); + + assert_eq!(removed, 0); + assert!(temp_path.exists()); +} + fn assert_invalid(result: Result) { assert!(matches!( result, From eb9ad7488c52061ed3d6b27cee6bdee8fa4289fd Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 00:44:42 +0000 Subject: [PATCH 009/108] Add incremental backups and compaction --- crates/graphql-orm-backup/src/backup.rs | 350 ++++++++++++++--- crates/graphql-orm-backup/src/lib.rs | 7 +- crates/graphql-orm-backup/src/restore.rs | 4 +- crates/graphql-orm-backup/src/verify.rs | 48 ++- .../tests/incremental_backup.rs | 359 ++++++++++++++++++ 5 files changed, 708 insertions(+), 60 deletions(-) create mode 100644 crates/graphql-orm-backup/tests/incremental_backup.rs diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs index 2773c6be..2b341f6a 100644 --- a/crates/graphql-orm-backup/src/backup.rs +++ b/crates/graphql-orm-backup/src/backup.rs @@ -1,12 +1,15 @@ +use std::collections::{BTreeMap, HashMap}; + use bytes::Bytes; use serde::Serialize; use uuid::Uuid; use crate::{ - BACKUP_FORMAT_VERSION, BackupError, BackupKind, BackupObjectIndex, BackupRepository, BackupRow, - BackupSnapshotManifest, BackupTableExport, DatabaseBackupManifest, GraphqlOrmBackupAdapter, - ObjectBackupEntry, TableBackupEntry, manifest::sha256_hex, plan_full_backup, - set_manifest_checksum, + BACKUP_FORMAT_VERSION, BackupChangeAction, BackupChangeExport, BackupError, BackupKind, + BackupObjectIndex, BackupRepository, BackupRow, BackupSnapshotManifest, BackupTableExport, + BackupTombstone, DatabaseBackupManifest, GraphqlOrmBackupAdapter, ObjectBackupEntry, + TableBackupEntry, load_manifest_chain, manifest::sha256_hex, plan_full_backup, + set_manifest_checksum, verify_manifest_and_objects, }; pub const DATABASE_EXPORT_FORMAT: &str = "jsonl"; @@ -25,6 +28,36 @@ pub struct FullBackupResult { pub manifest: BackupSnapshotManifest, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IncrementalBackupRequest { + pub snapshot_id: Uuid, + pub parent_snapshot_id: Uuid, + /// Snapshot creation time as UTC Unix seconds. + pub created_at: i64, + pub app_id: String, + pub app_version: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct IncrementalBackupResult { + pub manifest: BackupSnapshotManifest, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompactChainRequest { + pub snapshot_id: Uuid, + pub source_snapshot_id: Uuid, + /// Snapshot creation time as UTC Unix seconds. + pub created_at: i64, + pub app_id: String, + pub app_version: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CompactChainResult { + pub manifest: BackupSnapshotManifest, +} + #[must_use] pub fn snapshot_manifest_key(snapshot_id: Uuid) -> String { format!("snapshots/{snapshot_id}/manifest.json") @@ -86,33 +119,184 @@ pub async fn create_full_backup( }); } - let mut object_entries = Vec::with_capacity(plan.objects.len()); - for object in &plan.objects { - let bytes = objects.load_object(object).await?; - let actual = sha256_hex(&bytes); - let content_key = object_content_key(&object.sha256_hex); - if actual != object.sha256_hex { - return Err(BackupError::ChecksumMismatch { - key: content_key, - expected: object.sha256_hex.clone(), - actual, - }); + let object_entries = write_object_entries(repository, objects, &plan.objects).await?; + + let mut manifest = BackupSnapshotManifest { + format_version: BACKUP_FORMAT_VERSION, + snapshot_id: request.snapshot_id, + parent_snapshot_id: None, + created_at: request.created_at, + app_id: request.app_id, + app_version: request.app_version, + graphql_orm_schema_version: plan.schema.migration_version, + graphql_orm_schema_hash: plan.schema.schema_hash, + database_backend: plan.schema.backend, + backup_kind: BackupKind::Full, + database: DatabaseBackupManifest { + export_format: DATABASE_EXPORT_FORMAT.to_string(), + compression: crate::BackupCompression::Zstd, + row_count, + table_count: table_entries.len() as u64, + tables: table_entries, + changes: Vec::new(), + }, + objects: object_entries, + tombstones: Vec::new(), + checksum: String::new(), + }; + + write_manifest(repository, &mut manifest).await?; + + Ok(FullBackupResult { manifest }) +} + +/// Creates an incremental snapshot in the repository. +/// +/// # Errors +/// +/// Returns [`BackupError`] if schema lookup, incremental export, object +/// discovery/loading, payload serialization/compression, checksum validation, +/// or repository writes fail. +pub async fn create_incremental_backup( + repository: &dyn BackupRepository, + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, + request: IncrementalBackupRequest, +) -> Result { + let schema = database.schema_snapshot().await?; + let changes = database + .export_incremental(request.parent_snapshot_id) + .await?; + let object_refs = objects + .list_objects_for_incremental_backup(request.parent_snapshot_id) + .await?; + + let mut change_entries = Vec::new(); + let mut change_count = 0_u64; + let tombstones = changes_to_tombstones(&changes); + for group in group_changes_by_table(changes) { + let bytes = serialize_jsonl_entries(&group.changes)?; + let bytes = crate::compress_payload(&bytes)?; + let content_key = database_changes_key(request.snapshot_id, &group.table_name); + let sha256_hex = sha256_hex(&bytes); + repository + .put_blob(&content_key, Bytes::from(bytes)) + .await?; + + let table_change_count = group.changes.len() as u64; + change_count += table_change_count; + change_entries.push(TableBackupEntry { + table_name: group.table_name, + row_count: table_change_count, + content_key, + sha256_hex, + }); + } + + let object_entries = write_object_entries(repository, objects, &object_refs).await?; + + let mut manifest = BackupSnapshotManifest { + format_version: BACKUP_FORMAT_VERSION, + snapshot_id: request.snapshot_id, + parent_snapshot_id: Some(request.parent_snapshot_id), + created_at: request.created_at, + app_id: request.app_id, + app_version: request.app_version, + graphql_orm_schema_version: schema.migration_version, + graphql_orm_schema_hash: schema.schema_hash, + database_backend: schema.backend, + backup_kind: BackupKind::Incremental, + database: DatabaseBackupManifest { + export_format: DATABASE_EXPORT_FORMAT.to_string(), + compression: crate::BackupCompression::Zstd, + row_count: change_count, + table_count: 0, + tables: Vec::new(), + changes: change_entries, + }, + objects: object_entries, + tombstones, + checksum: String::new(), + }; + + write_manifest(repository, &mut manifest).await?; + + Ok(IncrementalBackupResult { manifest }) +} + +/// Compacts a full-plus-incremental chain into a synthetic full snapshot. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the source chain cannot be loaded or verified, +/// payloads cannot be parsed, or synthetic table/manifest blobs cannot be +/// written. +pub async fn compact_chain( + repository: &dyn BackupRepository, + request: CompactChainRequest, +) -> Result { + let chain = load_manifest_chain(repository, request.source_snapshot_id).await?; + for manifest in &chain { + verify_manifest_and_objects(repository, manifest).await?; + } + + let mut table_rows = BTreeMap::>::new(); + let full_manifest = chain + .first() + .ok_or_else(|| BackupError::InvalidManifestChain { + reason: "manifest chain is empty".to_string(), + })?; + for table in crate::restore::load_table_exports(repository, full_manifest).await? { + let rows = table_rows.entry(table.table_name).or_default(); + for row in table.rows { + rows.insert(row.primary_key.clone(), row); } + } - if !repository.blob_exists(&content_key).await? { - repository.put_blob(&content_key, bytes).await?; + for manifest in chain.iter().skip(1) { + for change in crate::restore::load_change_exports(repository, manifest).await? { + let rows = table_rows.entry(change.table_name.clone()).or_default(); + match change.action { + BackupChangeAction::Create | BackupChangeAction::Update => { + if let Some(row) = change.row { + rows.insert(change.primary_key, row); + } + } + BackupChangeAction::Delete => { + rows.remove(&change.primary_key); + } + } } + } - object_entries.push(ObjectBackupEntry { - object_id: object.object_id, - storage_key: object.storage_key.clone(), + let mut table_entries = Vec::with_capacity(table_rows.len()); + let mut row_count = 0_u64; + for (table_name, rows) in table_rows { + let rows = rows.into_values().collect::>(); + let bytes = serialize_jsonl_entries(&rows)?; + let bytes = crate::compress_payload(&bytes)?; + let content_key = database_table_key(request.snapshot_id, &table_name); + let sha256_hex = sha256_hex(&bytes); + repository + .put_blob(&content_key, Bytes::from(bytes)) + .await?; + + let table_row_count = rows.len() as u64; + row_count += table_row_count; + table_entries.push(TableBackupEntry { + table_name, + row_count: table_row_count, content_key, - sha256_hex: object.sha256_hex.clone(), - size_bytes: object.size_bytes, - mime_type: object.mime_type.clone(), + sha256_hex, }); } + let object_entries = compact_object_entries(&chain); + let latest = chain + .last() + .ok_or_else(|| BackupError::InvalidManifestChain { + reason: "manifest chain is empty".to_string(), + })?; let mut manifest = BackupSnapshotManifest { format_version: BACKUP_FORMAT_VERSION, snapshot_id: request.snapshot_id, @@ -120,10 +304,10 @@ pub async fn create_full_backup( created_at: request.created_at, app_id: request.app_id, app_version: request.app_version, - graphql_orm_schema_version: plan.schema.migration_version, - graphql_orm_schema_hash: plan.schema.schema_hash, - database_backend: plan.schema.backend, - backup_kind: BackupKind::Full, + graphql_orm_schema_version: latest.graphql_orm_schema_version.clone(), + graphql_orm_schema_hash: latest.graphql_orm_schema_hash.clone(), + database_backend: latest.database_backend.clone(), + backup_kind: BackupKind::SyntheticFull, database: DatabaseBackupManifest { export_format: DATABASE_EXPORT_FORMAT.to_string(), compression: crate::BackupCompression::Zstd, @@ -139,7 +323,7 @@ pub async fn create_full_backup( write_manifest(repository, &mut manifest).await?; - Ok(FullBackupResult { manifest }) + Ok(CompactChainResult { manifest }) } /// Writes a manifest as the final snapshot blob. @@ -168,30 +352,108 @@ pub fn bytes_sha256_hex(bytes: &[u8]) -> String { } fn serialize_table_export(table: &BackupTableExport) -> Result, BackupError> { + serialize_jsonl_entries(&table.rows) +} + +fn serialize_jsonl_entries(entries: &[T]) -> Result, BackupError> +where + T: Serialize, +{ let mut bytes = Vec::new(); - for row in &table.rows { - let serialized = SerializedBackupRow::from(row); - serde_json::to_writer(&mut bytes, &serialized)?; + for entry in entries { + serde_json::to_writer(&mut bytes, entry)?; bytes.push(b'\n'); } Ok(bytes) } -#[derive(Serialize)] -struct SerializedBackupRow<'a> { - table_name: &'a str, - primary_key: &'a str, - row_hash: &'a str, - values: &'a serde_json::Map, +async fn write_object_entries( + repository: &dyn BackupRepository, + objects: &dyn BackupObjectIndex, + object_refs: &[crate::BackupObjectRef], +) -> Result, BackupError> { + let mut object_entries = Vec::with_capacity(object_refs.len()); + for object in object_refs { + let bytes = objects.load_object(object).await?; + let actual = sha256_hex(&bytes); + let content_key = object_content_key(&object.sha256_hex); + if actual != object.sha256_hex { + return Err(BackupError::ChecksumMismatch { + key: content_key, + expected: object.sha256_hex.clone(), + actual, + }); + } + + if !repository.blob_exists(&content_key).await? { + repository.put_blob(&content_key, bytes).await?; + } + + object_entries.push(ObjectBackupEntry { + object_id: object.object_id, + storage_key: object.storage_key.clone(), + content_key, + sha256_hex: object.sha256_hex.clone(), + size_bytes: object.size_bytes, + mime_type: object.mime_type.clone(), + }); + } + Ok(object_entries) } -impl<'a> From<&'a BackupRow> for SerializedBackupRow<'a> { - fn from(row: &'a BackupRow) -> Self { - Self { - table_name: &row.table_name, - primary_key: &row.primary_key, - row_hash: &row.row_hash, - values: &row.values, +struct ChangeGroup { + table_name: String, + changes: Vec, +} + +fn group_changes_by_table(changes: Vec) -> Vec { + let mut groups = Vec::::new(); + for change in changes { + if let Some(group) = groups + .iter_mut() + .find(|group| group.table_name == change.table_name) + { + group.changes.push(change); + } else { + groups.push(ChangeGroup { + table_name: change.table_name.clone(), + changes: vec![change], + }); + } + } + groups +} + +fn changes_to_tombstones(changes: &[BackupChangeExport]) -> Vec { + changes + .iter() + .filter(|change| matches!(change.action, BackupChangeAction::Delete)) + .map(|change| BackupTombstone { + table_name: Some(change.table_name.clone()), + primary_key: Some(change.primary_key.clone()), + object_id: None, + deleted_at: change.changed_at, + }) + .collect() +} + +fn compact_object_entries(chain: &[BackupSnapshotManifest]) -> Vec { + let tombstoned_objects = chain + .iter() + .flat_map(|manifest| &manifest.tombstones) + .filter_map(|tombstone| tombstone.object_id) + .collect::>(); + let mut objects_by_id = HashMap::::new(); + + for manifest in chain { + for object in &manifest.objects { + if !tombstoned_objects.contains(&object.object_id) { + objects_by_id.insert(object.object_id, object.clone()); + } } } + + let mut objects = objects_by_id.into_values().collect::>(); + objects.sort_by_key(|object| object.object_id); + objects } diff --git a/crates/graphql-orm-backup/src/lib.rs b/crates/graphql-orm-backup/src/lib.rs index c3afb18a..e76bbda2 100644 --- a/crates/graphql-orm-backup/src/lib.rs +++ b/crates/graphql-orm-backup/src/lib.rs @@ -16,9 +16,10 @@ mod restore; mod verify; pub use backup::{ - DATABASE_EXPORT_FORMAT, FullBackupRequest, FullBackupResult, bytes_sha256_hex, - create_full_backup, database_changes_key, database_table_key, object_content_key, - snapshot_manifest_key, write_manifest, + CompactChainRequest, CompactChainResult, DATABASE_EXPORT_FORMAT, FullBackupRequest, + FullBackupResult, IncrementalBackupRequest, IncrementalBackupResult, bytes_sha256_hex, + compact_chain, create_full_backup, create_incremental_backup, database_changes_key, + database_table_key, object_content_key, snapshot_manifest_key, write_manifest, }; pub use database::{ BackupChangeAction, BackupChangeExport, BackupRow, BackupTableExport, GraphqlOrmBackupAdapter, diff --git a/crates/graphql-orm-backup/src/restore.rs b/crates/graphql-orm-backup/src/restore.rs index a4685ce7..5ddc9849 100644 --- a/crates/graphql-orm-backup/src/restore.rs +++ b/crates/graphql-orm-backup/src/restore.rs @@ -175,7 +175,7 @@ pub async fn restore_objects( Ok(()) } -async fn load_table_exports( +pub(crate) async fn load_table_exports( repository: &dyn BackupRepository, manifest: &BackupSnapshotManifest, ) -> Result, BackupError> { @@ -190,7 +190,7 @@ async fn load_table_exports( Ok(exports) } -async fn load_change_exports( +pub(crate) async fn load_change_exports( repository: &dyn BackupRepository, manifest: &BackupSnapshotManifest, ) -> Result, BackupError> { diff --git a/crates/graphql-orm-backup/src/verify.rs b/crates/graphql-orm-backup/src/verify.rs index e746e670..ba9961b2 100644 --- a/crates/graphql-orm-backup/src/verify.rs +++ b/crates/graphql-orm-backup/src/verify.rs @@ -1,8 +1,14 @@ use crate::{ - BackupError, BackupRepository, BackupSnapshotManifest, manifest::sha256_hex, + BackupError, BackupRepository, BackupSnapshotManifest, TableBackupEntry, manifest::sha256_hex, verify_manifest_checksum, }; +/// Verifies a manifest checksum and all referenced payload checksums. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the manifest checksum is invalid, a referenced +/// blob is missing, or a payload checksum does not match. pub async fn verify_manifest_and_objects( repository: &dyn BackupRepository, manifest: &BackupSnapshotManifest, @@ -11,6 +17,12 @@ pub async fn verify_manifest_and_objects( verify_object_checksums(repository, manifest).await } +/// Verifies object, table, and change payload checksums in a manifest. +/// +/// # Errors +/// +/// Returns [`BackupError`] if any referenced blob is missing or its checksum +/// does not match the manifest entry. pub async fn verify_object_checksums( repository: &dyn BackupRepository, manifest: &BackupSnapshotManifest, @@ -27,16 +39,30 @@ pub async fn verify_object_checksums( } } - for table in &manifest.database.tables { - let bytes = repository.get_blob(&table.content_key).await?; - let actual = sha256_hex(&bytes); - if actual != table.sha256_hex { - return Err(BackupError::ChecksumMismatch { - key: table.content_key.clone(), - expected: table.sha256_hex.clone(), - actual, - }); - } + for table in manifest + .database + .tables + .iter() + .chain(manifest.database.changes.iter()) + { + verify_entry_checksum(repository, table).await?; + } + + Ok(()) +} + +async fn verify_entry_checksum( + repository: &dyn BackupRepository, + entry: &TableBackupEntry, +) -> Result<(), BackupError> { + let bytes = repository.get_blob(&entry.content_key).await?; + let actual = sha256_hex(&bytes); + if actual != entry.sha256_hex { + return Err(BackupError::ChecksumMismatch { + key: entry.content_key.clone(), + expected: entry.sha256_hex.clone(), + actual, + }); } Ok(()) diff --git a/crates/graphql-orm-backup/tests/incremental_backup.rs b/crates/graphql-orm-backup/tests/incremental_backup.rs new file mode 100644 index 00000000..33084d03 --- /dev/null +++ b/crates/graphql-orm-backup/tests/incremental_backup.rs @@ -0,0 +1,359 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use graphql_orm_backup::{ + BackupChangeAction, BackupChangeExport, BackupError, BackupKind, BackupObjectIndex, + BackupObjectRef, BackupRepository, BackupRow, BackupTableExport, CompactChainRequest, + FullBackupRequest, GraphqlOrmBackupAdapter, GraphqlOrmBackupSchema, IncrementalBackupRequest, + LocalBackupRepository, RestoreContext, bytes_sha256_hex, compact_chain, create_full_backup, + create_incremental_backup, database_changes_key, load_manifest, restore_snapshot, +}; +use serde_json::{Map, Value}; +use tempfile::TempDir; +use uuid::Uuid; + +#[tokio::test] +async fn create_incremental_backup_writes_change_files_and_tombstones() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let changes = sample_changes(); + let database = MockDatabase::with_incremental(changes.clone()); + let objects = MockObjectIndex; + + let result = create_incremental_backup( + &repository, + &database, + &objects, + incremental_request(parent_id()), + ) + .await + .expect("create incremental backup"); + + assert_eq!(result.manifest.backup_kind, BackupKind::Incremental); + assert_eq!(result.manifest.parent_snapshot_id, Some(parent_id())); + assert_eq!(result.manifest.database.changes.len(), 1); + assert_eq!(result.manifest.database.row_count, changes.len() as u64); + assert_eq!(result.manifest.tombstones.len(), 1); + assert_eq!( + result.manifest.tombstones[0].primary_key.as_deref(), + Some("3") + ); + assert!( + repository + .blob_exists(&database_changes_key(incremental_id(), "users")) + .await + .expect("change file exists") + ); + assert_eq!(database.incremental_export_calls(), 1); +} + +#[tokio::test] +async fn restore_snapshot_applies_incremental_chain_after_full_snapshot() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let objects = MockObjectIndex; + let source = MockDatabase::with_full(vec![BackupTableExport { + table_name: "users".to_string(), + rows: vec![backup_row("users", "1", "Ada")], + }]); + create_full_backup(&repository, &source, &objects, full_request()) + .await + .expect("create full backup"); + + let incremental_source = MockDatabase::with_incremental(sample_changes()); + create_incremental_backup( + &repository, + &incremental_source, + &objects, + incremental_request(full_id()), + ) + .await + .expect("create incremental backup"); + + let target = MockDatabase::empty_restore_target(); + let result = restore_snapshot( + &repository, + &target, + incremental_id(), + RestoreContext::empty_database(), + ) + .await + .expect("restore chain"); + + assert_eq!(result.manifest_chain_len, 2); + assert_eq!(result.incremental_change_count, 3); + assert_eq!( + target.restored_full()[0].rows[0], + backup_row("users", "1", "Ada") + ); + assert_eq!(target.restored_incremental(), sample_changes()); +} + +#[tokio::test] +async fn compact_chain_writes_synthetic_full_snapshot() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let objects = MockObjectIndex; + let source = MockDatabase::with_full(vec![BackupTableExport { + table_name: "users".to_string(), + rows: vec![ + backup_row("users", "1", "Ada"), + backup_row("users", "3", "Delete Me"), + ], + }]); + create_full_backup(&repository, &source, &objects, full_request()) + .await + .expect("create full backup"); + + let incremental_source = MockDatabase::with_incremental(sample_changes()); + create_incremental_backup( + &repository, + &incremental_source, + &objects, + incremental_request(full_id()), + ) + .await + .expect("create incremental backup"); + + let result = compact_chain( + &repository, + CompactChainRequest { + snapshot_id: compacted_id(), + source_snapshot_id: incremental_id(), + created_at: 1_775_174_402, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + }, + ) + .await + .expect("compact chain"); + + assert_eq!(result.manifest.backup_kind, BackupKind::SyntheticFull); + assert_eq!(result.manifest.parent_snapshot_id, None); + assert_eq!(result.manifest.database.row_count, 2); + + let loaded = load_manifest(&repository, compacted_id()) + .await + .expect("load compacted manifest"); + assert_eq!(loaded.backup_kind, BackupKind::SyntheticFull); + + let target = MockDatabase::empty_restore_target(); + restore_snapshot( + &repository, + &target, + compacted_id(), + RestoreContext::empty_database(), + ) + .await + .expect("restore compacted snapshot"); + + let rows = &target.restored_full()[0].rows; + assert_eq!(rows.len(), 2); + assert!(rows.contains(&backup_row("users", "1", "Grace"))); + assert!(rows.contains(&backup_row("users", "2", "New"))); +} + +fn full_request() -> FullBackupRequest { + FullBackupRequest { + snapshot_id: full_id(), + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + } +} + +fn incremental_request(parent_snapshot_id: Uuid) -> IncrementalBackupRequest { + IncrementalBackupRequest { + snapshot_id: incremental_id(), + parent_snapshot_id, + created_at: 1_775_174_401, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + } +} + +fn sample_changes() -> Vec { + vec![ + BackupChangeExport { + table_name: "users".to_string(), + primary_key: "1".to_string(), + action: BackupChangeAction::Update, + row: Some(backup_row("users", "1", "Grace")), + changed_at: 1_775_174_401, + }, + BackupChangeExport { + table_name: "users".to_string(), + primary_key: "2".to_string(), + action: BackupChangeAction::Create, + row: Some(backup_row("users", "2", "New")), + changed_at: 1_775_174_401, + }, + BackupChangeExport { + table_name: "users".to_string(), + primary_key: "3".to_string(), + action: BackupChangeAction::Delete, + row: None, + changed_at: 1_775_174_401, + }, + ] +} + +fn backup_row(table_name: &str, primary_key: &str, name: &str) -> BackupRow { + let mut values = Map::new(); + values.insert("name".to_string(), Value::String(name.to_string())); + + BackupRow { + table_name: table_name.to_string(), + primary_key: primary_key.to_string(), + row_hash: bytes_sha256_hex(name.as_bytes()), + values, + } +} + +fn full_id() -> Uuid { + Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid") +} + +fn parent_id() -> Uuid { + Uuid::parse_str("99999999-9999-4999-9999-999999999999").expect("valid uuid") +} + +fn incremental_id() -> Uuid { + Uuid::parse_str("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb").expect("valid uuid") +} + +fn compacted_id() -> Uuid { + Uuid::parse_str("cccccccc-cccc-4ccc-cccc-cccccccccccc").expect("valid uuid") +} + +struct MockObjectIndex; + +#[async_trait] +impl BackupObjectIndex for MockObjectIndex { + async fn list_objects_for_full_backup(&self) -> Result, BackupError> { + Ok(Vec::new()) + } + + async fn list_objects_for_incremental_backup( + &self, + _since_snapshot_id: Uuid, + ) -> Result, BackupError> { + Ok(Vec::new()) + } + + async fn load_object(&self, _object: &BackupObjectRef) -> Result { + Err(BackupError::MissingBlob { + key: "mock object".to_string(), + }) + } +} + +#[derive(Clone)] +struct MockDatabase { + full_tables: Vec, + incremental_changes: Vec, + incremental_export_calls: Arc>, + restored_full: Arc>>, + restored_incremental: Arc>>, +} + +impl MockDatabase { + fn with_full(full_tables: Vec) -> Self { + Self { + full_tables, + incremental_changes: Vec::new(), + incremental_export_calls: Arc::default(), + restored_full: Arc::default(), + restored_incremental: Arc::default(), + } + } + + fn with_incremental(incremental_changes: Vec) -> Self { + Self { + full_tables: Vec::new(), + incremental_changes, + incremental_export_calls: Arc::default(), + restored_full: Arc::default(), + restored_incremental: Arc::default(), + } + } + + fn empty_restore_target() -> Self { + Self::with_full(Vec::new()) + } + + fn incremental_export_calls(&self) -> u64 { + *self + .incremental_export_calls + .lock() + .expect("incremental calls lock") + } + + fn restored_full(&self) -> Vec { + self.restored_full + .lock() + .expect("restored full lock") + .clone() + } + + fn restored_incremental(&self) -> Vec { + self.restored_incremental + .lock() + .expect("restored incremental lock") + .clone() + } +} + +#[async_trait] +impl GraphqlOrmBackupAdapter for MockDatabase { + async fn schema_snapshot(&self) -> Result { + Ok(GraphqlOrmBackupSchema { + backend: "sqlite".to_string(), + migration_version: "20260514000000".to_string(), + schema_hash: "schema-hash".to_string(), + }) + } + + async fn restore_target_is_empty(&self) -> Result { + Ok(true) + } + + async fn export_full(&self) -> Result, BackupError> { + Ok(self.full_tables.clone()) + } + + async fn export_incremental( + &self, + _parent_snapshot_id: Uuid, + ) -> Result, BackupError> { + *self + .incremental_export_calls + .lock() + .expect("incremental calls lock") += 1; + Ok(self.incremental_changes.clone()) + } + + async fn restore_full( + &self, + export: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + self.restored_full + .lock() + .expect("restored full lock") + .extend(export); + Ok(()) + } + + async fn restore_incremental( + &self, + changes: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + self.restored_incremental + .lock() + .expect("restored incremental lock") + .extend(changes); + Ok(()) + } +} From eeb5ddf24d233020ee080a03892384599df49318 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 00:49:28 +0000 Subject: [PATCH 010/108] Add repository locking and pruning --- crates/graphql-orm-backup/Cargo.lock | 65 ++++++ crates/graphql-orm-backup/Cargo.toml | 1 + crates/graphql-orm-backup/src/backup.rs | 205 ++++++++++++++--- crates/graphql-orm-backup/src/error.rs | 3 + crates/graphql-orm-backup/src/lib.rs | 19 +- .../src/local_repository.rs | 31 ++- crates/graphql-orm-backup/src/lock.rs | 89 ++++++++ crates/graphql-orm-backup/src/prune.rs | 149 ++++++++++++ crates/graphql-orm-backup/src/repository.rs | 17 ++ crates/graphql-orm-backup/src/verify.rs | 100 ++++++-- .../tests/full_backup_creation.rs | 7 +- .../tests/operational_safety.rs | 214 ++++++++++++++++++ 12 files changed, 843 insertions(+), 57 deletions(-) create mode 100644 crates/graphql-orm-backup/src/lock.rs create mode 100644 crates/graphql-orm-backup/src/prune.rs create mode 100644 crates/graphql-orm-backup/tests/operational_safety.rs diff --git a/crates/graphql-orm-backup/Cargo.lock b/crates/graphql-orm-backup/Cargo.lock index 17da72c2..be5253c0 100644 --- a/crates/graphql-orm-backup/Cargo.lock +++ b/crates/graphql-orm-backup/Cargo.lock @@ -127,12 +127,71 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -145,8 +204,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", ] @@ -192,6 +256,7 @@ version = "0.1.0" dependencies = [ "async-trait", "bytes", + "futures", "serde", "serde_json", "sha2", diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml index 9a3cfb98..af9d7283 100644 --- a/crates/graphql-orm-backup/Cargo.toml +++ b/crates/graphql-orm-backup/Cargo.toml @@ -13,6 +13,7 @@ local = [] [dependencies] async-trait = "0.1" bytes = "1" +futures = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs index 2b341f6a..6e0facd7 100644 --- a/crates/graphql-orm-backup/src/backup.rs +++ b/crates/graphql-orm-backup/src/backup.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, HashMap}; use bytes::Bytes; +use futures::{StreamExt, TryStreamExt, stream}; use serde::Serialize; use uuid::Uuid; @@ -8,11 +9,27 @@ use crate::{ BACKUP_FORMAT_VERSION, BackupChangeAction, BackupChangeExport, BackupError, BackupKind, BackupObjectIndex, BackupRepository, BackupRow, BackupSnapshotManifest, BackupTableExport, BackupTombstone, DatabaseBackupManifest, GraphqlOrmBackupAdapter, ObjectBackupEntry, - TableBackupEntry, load_manifest_chain, manifest::sha256_hex, plan_full_backup, - set_manifest_checksum, verify_manifest_and_objects, + RepositoryLock, RepositoryLockOptions, TableBackupEntry, load_manifest_chain, + manifest::sha256_hex, plan_full_backup, set_manifest_checksum, verify_manifest_and_objects, }; pub const DATABASE_EXPORT_FORMAT: &str = "jsonl"; +pub const DEFAULT_OBJECT_CONCURRENCY: usize = 8; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupExecutionOptions { + pub object_concurrency: usize, + pub lock: RepositoryLockOptions, +} + +impl Default for BackupExecutionOptions { + fn default() -> Self { + Self { + object_concurrency: DEFAULT_OBJECT_CONCURRENCY, + lock: RepositoryLockOptions::default(), + } + } +} #[derive(Clone, Debug, Eq, PartialEq)] pub struct FullBackupRequest { @@ -95,6 +112,42 @@ pub async fn create_full_backup( database: &dyn GraphqlOrmBackupAdapter, objects: &dyn BackupObjectIndex, request: FullBackupRequest, +) -> Result { + create_full_backup_with_options( + repository, + database, + objects, + request, + &BackupExecutionOptions::default(), + ) + .await +} + +/// Creates a full snapshot with explicit execution options. +/// +/// # Errors +/// +/// Returns [`BackupError`] if planning fails, table serialization or +/// compression fails, object loading/checksum validation fails, locking fails, +/// or any repository write fails. +pub async fn create_full_backup_with_options( + repository: &dyn BackupRepository, + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, + request: FullBackupRequest, + options: &BackupExecutionOptions, +) -> Result { + let lock = RepositoryLock::acquire(repository, &options.lock).await?; + let result = create_full_backup_inner(repository, database, objects, request, options).await; + release_lock(repository, lock, result).await +} + +async fn create_full_backup_inner( + repository: &dyn BackupRepository, + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, + request: FullBackupRequest, + options: &BackupExecutionOptions, ) -> Result { let plan = plan_full_backup(database, objects).await?; @@ -119,7 +172,13 @@ pub async fn create_full_backup( }); } - let object_entries = write_object_entries(repository, objects, &plan.objects).await?; + let object_entries = write_object_entries( + repository, + objects, + &plan.objects, + options.object_concurrency, + ) + .await?; let mut manifest = BackupSnapshotManifest { format_version: BACKUP_FORMAT_VERSION, @@ -162,6 +221,43 @@ pub async fn create_incremental_backup( database: &dyn GraphqlOrmBackupAdapter, objects: &dyn BackupObjectIndex, request: IncrementalBackupRequest, +) -> Result { + create_incremental_backup_with_options( + repository, + database, + objects, + request, + &BackupExecutionOptions::default(), + ) + .await +} + +/// Creates an incremental snapshot with explicit execution options. +/// +/// # Errors +/// +/// Returns [`BackupError`] if schema lookup, incremental export, object +/// discovery/loading, payload serialization/compression, checksum validation, +/// locking, or repository writes fail. +pub async fn create_incremental_backup_with_options( + repository: &dyn BackupRepository, + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, + request: IncrementalBackupRequest, + options: &BackupExecutionOptions, +) -> Result { + let lock = RepositoryLock::acquire(repository, &options.lock).await?; + let result = + create_incremental_backup_inner(repository, database, objects, request, options).await; + release_lock(repository, lock, result).await +} + +async fn create_incremental_backup_inner( + repository: &dyn BackupRepository, + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, + request: IncrementalBackupRequest, + options: &BackupExecutionOptions, ) -> Result { let schema = database.schema_snapshot().await?; let changes = database @@ -193,7 +289,13 @@ pub async fn create_incremental_backup( }); } - let object_entries = write_object_entries(repository, objects, &object_refs).await?; + let object_entries = write_object_entries( + repository, + objects, + &object_refs, + options.object_concurrency, + ) + .await?; let mut manifest = BackupSnapshotManifest { format_version: BACKUP_FORMAT_VERSION, @@ -234,6 +336,30 @@ pub async fn create_incremental_backup( pub async fn compact_chain( repository: &dyn BackupRepository, request: CompactChainRequest, +) -> Result { + compact_chain_with_options(repository, request, &BackupExecutionOptions::default()).await +} + +/// Compacts a chain with explicit execution options. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the source chain cannot be loaded or verified, +/// locking fails, payloads cannot be parsed, or synthetic table/manifest blobs +/// cannot be written. +pub async fn compact_chain_with_options( + repository: &dyn BackupRepository, + request: CompactChainRequest, + options: &BackupExecutionOptions, +) -> Result { + let lock = RepositoryLock::acquire(repository, &options.lock).await?; + let result = compact_chain_inner(repository, request).await; + release_lock(repository, lock, result).await +} + +async fn compact_chain_inner( + repository: &dyn BackupRepository, + request: CompactChainRequest, ) -> Result { let chain = load_manifest_chain(repository, request.source_snapshot_id).await?; for manifest in &chain { @@ -371,36 +497,59 @@ async fn write_object_entries( repository: &dyn BackupRepository, objects: &dyn BackupObjectIndex, object_refs: &[crate::BackupObjectRef], + object_concurrency: usize, ) -> Result, BackupError> { - let mut object_entries = Vec::with_capacity(object_refs.len()); - for object in object_refs { - let bytes = objects.load_object(object).await?; - let actual = sha256_hex(&bytes); - let content_key = object_content_key(&object.sha256_hex); - if actual != object.sha256_hex { - return Err(BackupError::ChecksumMismatch { - key: content_key, - expected: object.sha256_hex.clone(), - actual, - }); - } + let concurrency = object_concurrency.max(1); + let mut object_entries = stream::iter(object_refs.iter().enumerate()) + .map(|(index, object)| async move { + let bytes = objects.load_object(object).await?; + let actual = sha256_hex(&bytes); + let content_key = object_content_key(&object.sha256_hex); + if actual != object.sha256_hex { + return Err(BackupError::ChecksumMismatch { + key: content_key, + expected: object.sha256_hex.clone(), + actual, + }); + } - if !repository.blob_exists(&content_key).await? { - repository.put_blob(&content_key, bytes).await?; - } + if !repository.blob_exists(&content_key).await? { + repository.put_blob(&content_key, bytes).await?; + } - object_entries.push(ObjectBackupEntry { - object_id: object.object_id, - storage_key: object.storage_key.clone(), - content_key, - sha256_hex: object.sha256_hex.clone(), - size_bytes: object.size_bytes, - mime_type: object.mime_type.clone(), - }); - } + Ok(( + index, + ObjectBackupEntry { + object_id: object.object_id, + storage_key: object.storage_key.clone(), + content_key, + sha256_hex: object.sha256_hex.clone(), + size_bytes: object.size_bytes, + mime_type: object.mime_type.clone(), + }, + )) + }) + .buffer_unordered(concurrency) + .try_collect::>() + .await?; + object_entries.sort_by_key(|(index, _)| *index); + let object_entries = object_entries.into_iter().map(|(_, entry)| entry).collect(); Ok(object_entries) } +async fn release_lock( + repository: &dyn BackupRepository, + lock: RepositoryLock, + result: Result, +) -> Result { + let release_result = lock.release(repository).await; + match (result, release_result) { + (Ok(value), Ok(())) => Ok(value), + (Err(err), _) => Err(err), + (Ok(_), Err(err)) => Err(err), + } +} + struct ChangeGroup { table_name: String, changes: Vec, diff --git a/crates/graphql-orm-backup/src/error.rs b/crates/graphql-orm-backup/src/error.rs index c7796097..6ed4df63 100644 --- a/crates/graphql-orm-backup/src/error.rs +++ b/crates/graphql-orm-backup/src/error.rs @@ -27,6 +27,9 @@ pub enum BackupError { #[error("invalid manifest chain: {reason}")] InvalidManifestChain { reason: String }, + #[error("backup repository is locked by {lock_key}")] + RepositoryLocked { lock_key: String }, + #[error("backup payload compression error")] Compression { #[source] diff --git a/crates/graphql-orm-backup/src/lib.rs b/crates/graphql-orm-backup/src/lib.rs index e76bbda2..7b503def 100644 --- a/crates/graphql-orm-backup/src/lib.rs +++ b/crates/graphql-orm-backup/src/lib.rs @@ -8,18 +8,22 @@ mod database; mod error; #[cfg(feature = "local")] mod local_repository; +mod lock; mod manifest; mod object_index; mod planner; +mod prune; mod repository; mod restore; mod verify; pub use backup::{ - CompactChainRequest, CompactChainResult, DATABASE_EXPORT_FORMAT, FullBackupRequest, - FullBackupResult, IncrementalBackupRequest, IncrementalBackupResult, bytes_sha256_hex, - compact_chain, create_full_backup, create_incremental_backup, database_changes_key, - database_table_key, object_content_key, snapshot_manifest_key, write_manifest, + BackupExecutionOptions, CompactChainRequest, CompactChainResult, DATABASE_EXPORT_FORMAT, + DEFAULT_OBJECT_CONCURRENCY, FullBackupRequest, FullBackupResult, IncrementalBackupRequest, + IncrementalBackupResult, bytes_sha256_hex, compact_chain, compact_chain_with_options, + create_full_backup, create_full_backup_with_options, create_incremental_backup, + create_incremental_backup_with_options, database_changes_key, database_table_key, + object_content_key, snapshot_manifest_key, write_manifest, }; pub use database::{ BackupChangeAction, BackupChangeExport, BackupRow, BackupTableExport, GraphqlOrmBackupAdapter, @@ -28,6 +32,7 @@ pub use database::{ pub use error::BackupError; #[cfg(feature = "local")] pub use local_repository::LocalBackupRepository; +pub use lock::{DEFAULT_LOCK_STALE_AFTER_SECONDS, RepositoryLock, RepositoryLockOptions}; pub use manifest::{ BACKUP_FORMAT_VERSION, BackupCompression, BackupKind, BackupSnapshotManifest, BackupTombstone, DatabaseBackupManifest, ObjectBackupEntry, TableBackupEntry, compress_payload, @@ -36,9 +41,13 @@ pub use manifest::{ }; pub use object_index::{BackupObjectIndex, BackupObjectRef}; pub use planner::{FullBackupPlan, plan_full_backup}; +pub use prune::{KeepPolicy, PruneResult, prune}; pub use repository::BackupRepository; pub use restore::{ RestoreContext, RestoreMode, RestoreObjectSink, RestoreResult, ensure_empty_restore_target, restore_objects, restore_snapshot, }; -pub use verify::{verify_manifest_and_objects, verify_object_checksums}; +pub use verify::{ + VerificationOptions, verify_manifest_and_objects, verify_manifest_and_objects_with_options, + verify_object_checksums, verify_object_checksums_with_options, +}; diff --git a/crates/graphql-orm-backup/src/local_repository.rs b/crates/graphql-orm-backup/src/local_repository.rs index c824b83a..6f6201b5 100644 --- a/crates/graphql-orm-backup/src/local_repository.rs +++ b/crates/graphql-orm-backup/src/local_repository.rs @@ -1,4 +1,7 @@ -use std::path::{Component, Path, PathBuf}; +use std::{ + io::Write, + path::{Component, Path, PathBuf}, +}; use async_trait::async_trait; use bytes::Bytes; @@ -64,6 +67,32 @@ impl BackupRepository for LocalBackupRepository { Ok(()) } + async fn put_blob_if_absent(&self, key: &str, body: Bytes) -> Result { + let path = self.path_for(key)?; + let parent = path + .parent() + .ok_or_else(|| BackupError::InvalidRepositoryKey { + key: key.to_string(), + })?; + tokio::fs::create_dir_all(parent) + .await + .map_err(|source| BackupError::io(parent, source))?; + + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut file) => { + file.write_all(&body) + .map_err(|source| BackupError::io(&path, source))?; + Ok(true) + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), + Err(source) => Err(BackupError::io(&path, source)), + } + } + async fn get_blob(&self, key: &str) -> Result { let path = self.path_for(key)?; match tokio::fs::read(&path).await { diff --git a/crates/graphql-orm-backup/src/lock.rs b/crates/graphql-orm-backup/src/lock.rs new file mode 100644 index 00000000..c3e5e1bf --- /dev/null +++ b/crates/graphql-orm-backup/src/lock.rs @@ -0,0 +1,89 @@ +use bytes::Bytes; + +use crate::{BackupError, BackupRepository}; + +pub const DEFAULT_LOCK_STALE_AFTER_SECONDS: i64 = 3_600; +const REPOSITORY_LOCK_KEY: &str = "locks/repository.lock"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RepositoryLockOptions { + pub stale_after_seconds: i64, +} + +impl Default for RepositoryLockOptions { + fn default() -> Self { + Self { + stale_after_seconds: DEFAULT_LOCK_STALE_AFTER_SECONDS, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RepositoryLock { + key: String, +} + +impl RepositoryLock { + /// Acquires the repository writer lock. + /// + /// # Errors + /// + /// Returns [`BackupError::RepositoryLocked`] if a non-stale lock exists, or + /// another [`BackupError`] if the repository cannot be read or written. + pub async fn acquire( + repository: &dyn BackupRepository, + options: &RepositoryLockOptions, + ) -> Result { + let now = unix_seconds(); + let body = Bytes::from(now.to_string()); + if repository + .put_blob_if_absent(REPOSITORY_LOCK_KEY, body.clone()) + .await? + { + return Ok(Self { + key: REPOSITORY_LOCK_KEY.to_string(), + }); + } + + let existing = repository.get_blob(REPOSITORY_LOCK_KEY).await?; + let locked_at = std::str::from_utf8(&existing) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(now); + if now.saturating_sub(locked_at) <= options.stale_after_seconds { + return Err(BackupError::RepositoryLocked { + lock_key: REPOSITORY_LOCK_KEY.to_string(), + }); + } + + repository.delete_blob(REPOSITORY_LOCK_KEY).await?; + if repository + .put_blob_if_absent(REPOSITORY_LOCK_KEY, body) + .await? + { + Ok(Self { + key: REPOSITORY_LOCK_KEY.to_string(), + }) + } else { + Err(BackupError::RepositoryLocked { + lock_key: REPOSITORY_LOCK_KEY.to_string(), + }) + } + } + + /// Releases the repository writer lock. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the repository cannot delete the lock blob. + pub async fn release(self, repository: &dyn BackupRepository) -> Result<(), BackupError> { + repository.delete_blob(&self.key).await + } +} + +fn unix_seconds() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default() +} diff --git a/crates/graphql-orm-backup/src/prune.rs b/crates/graphql-orm-backup/src/prune.rs new file mode 100644 index 00000000..16d759ba --- /dev/null +++ b/crates/graphql-orm-backup/src/prune.rs @@ -0,0 +1,149 @@ +use std::collections::HashSet; + +use uuid::Uuid; + +use crate::{ + BackupError, BackupRepository, BackupSnapshotManifest, RepositoryLock, RepositoryLockOptions, + load_manifest, load_manifest_chain, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KeepPolicy { + pub keep_last: usize, + pub lock: RepositoryLockOptions, +} + +impl Default for KeepPolicy { + fn default() -> Self { + Self { + keep_last: 1, + lock: RepositoryLockOptions::default(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PruneResult { + pub retained_snapshots: usize, + pub deleted_snapshots: usize, + pub deleted_blobs: usize, +} + +/// Prunes expired snapshots and unreferenced object blobs. +/// +/// # Errors +/// +/// Returns [`BackupError`] if repository listing, manifest loading, locking, or +/// blob deletion fails. +pub async fn prune( + repository: &dyn BackupRepository, + keep_policy: &KeepPolicy, +) -> Result { + let lock = RepositoryLock::acquire(repository, &keep_policy.lock).await?; + let result = prune_inner(repository, keep_policy).await; + let release_result = lock.release(repository).await; + match (result, release_result) { + (Ok(result), Ok(())) => Ok(result), + (Err(err), _) => Err(err), + (Ok(_), Err(err)) => Err(err), + } +} + +async fn prune_inner( + repository: &dyn BackupRepository, + keep_policy: &KeepPolicy, +) -> Result { + let mut manifests = load_all_manifests(repository).await?; + manifests.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| right.snapshot_id.cmp(&left.snapshot_id)) + }); + + let selected = manifests + .iter() + .take(keep_policy.keep_last) + .map(|manifest| manifest.snapshot_id) + .collect::>(); + + let mut retained_ids = HashSet::new(); + let mut reachable_content_keys = HashSet::new(); + for snapshot_id in selected { + let chain = load_manifest_chain(repository, snapshot_id).await?; + for manifest in chain { + retained_ids.insert(manifest.snapshot_id); + collect_reachable_keys(&manifest, &mut reachable_content_keys); + } + } + + let all_ids = manifests + .iter() + .map(|manifest| manifest.snapshot_id) + .collect::>(); + let expired_ids = all_ids + .difference(&retained_ids) + .copied() + .collect::>(); + + let mut deleted_blobs = 0_usize; + for snapshot_id in &expired_ids { + for key in repository + .list_blobs(&format!("snapshots/{snapshot_id}")) + .await? + { + repository.delete_blob(&key).await?; + deleted_blobs += 1; + } + } + + for key in repository.list_blobs("objects/sha256").await? { + if !reachable_content_keys.contains(&key) { + repository.delete_blob(&key).await?; + deleted_blobs += 1; + } + } + + Ok(PruneResult { + retained_snapshots: retained_ids.len(), + deleted_snapshots: expired_ids.len(), + deleted_blobs, + }) +} + +async fn load_all_manifests( + repository: &dyn BackupRepository, +) -> Result, BackupError> { + let mut manifests = Vec::new(); + for key in repository.list_blobs("snapshots").await? { + let Some(snapshot_id) = snapshot_id_from_manifest_key(&key) else { + continue; + }; + manifests.push(load_manifest(repository, snapshot_id).await?); + } + Ok(manifests) +} + +fn snapshot_id_from_manifest_key(key: &str) -> Option { + let rest = key.strip_prefix("snapshots/")?; + let snapshot_id = rest.strip_suffix("/manifest.json")?; + if snapshot_id.contains('/') { + return None; + } + Uuid::parse_str(snapshot_id).ok() +} + +fn collect_reachable_keys( + manifest: &BackupSnapshotManifest, + reachable_content_keys: &mut HashSet, +) { + for table in &manifest.database.tables { + reachable_content_keys.insert(table.content_key.clone()); + } + for change in &manifest.database.changes { + reachable_content_keys.insert(change.content_key.clone()); + } + for object in &manifest.objects { + reachable_content_keys.insert(object.content_key.clone()); + } +} diff --git a/crates/graphql-orm-backup/src/repository.rs b/crates/graphql-orm-backup/src/repository.rs index d3be58fc..46856f40 100644 --- a/crates/graphql-orm-backup/src/repository.rs +++ b/crates/graphql-orm-backup/src/repository.rs @@ -13,6 +13,23 @@ pub trait BackupRepository: Send + Sync { /// persist the blob. async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError>; + /// Writes a blob only when no blob exists at the key. + /// + /// Returns `true` when the blob was written and `false` when the key + /// already existed. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the key is invalid or the backend cannot + /// perform the conditional write. + async fn put_blob_if_absent(&self, key: &str, body: Bytes) -> Result { + if self.blob_exists(key).await? { + return Ok(false); + } + self.put_blob(key, body).await?; + Ok(true) + } + /// Reads a blob from a repository key. /// /// # Errors diff --git a/crates/graphql-orm-backup/src/verify.rs b/crates/graphql-orm-backup/src/verify.rs index ba9961b2..2ec808ea 100644 --- a/crates/graphql-orm-backup/src/verify.rs +++ b/crates/graphql-orm-backup/src/verify.rs @@ -1,7 +1,21 @@ use crate::{ - BackupError, BackupRepository, BackupSnapshotManifest, TableBackupEntry, manifest::sha256_hex, - verify_manifest_checksum, + BackupError, BackupRepository, BackupSnapshotManifest, DEFAULT_OBJECT_CONCURRENCY, + ObjectBackupEntry, TableBackupEntry, manifest::sha256_hex, verify_manifest_checksum, }; +use futures::{StreamExt, TryStreamExt, stream}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerificationOptions { + pub blob_concurrency: usize, +} + +impl Default for VerificationOptions { + fn default() -> Self { + Self { + blob_concurrency: DEFAULT_OBJECT_CONCURRENCY, + } + } +} /// Verifies a manifest checksum and all referenced payload checksums. /// @@ -17,6 +31,21 @@ pub async fn verify_manifest_and_objects( verify_object_checksums(repository, manifest).await } +/// Verifies a manifest checksum and payload checksums with explicit options. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the manifest checksum is invalid, a referenced +/// blob is missing, or a payload checksum does not match. +pub async fn verify_manifest_and_objects_with_options( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, + options: &VerificationOptions, +) -> Result<(), BackupError> { + verify_manifest_checksum(manifest)?; + verify_object_checksums_with_options(repository, manifest, options).await +} + /// Verifies object, table, and change payload checksums in a manifest. /// /// # Errors @@ -27,25 +56,56 @@ pub async fn verify_object_checksums( repository: &dyn BackupRepository, manifest: &BackupSnapshotManifest, ) -> Result<(), BackupError> { - for object in &manifest.objects { - let bytes = repository.get_blob(&object.content_key).await?; - let actual = sha256_hex(&bytes); - if actual != object.sha256_hex { - return Err(BackupError::ChecksumMismatch { - key: object.content_key.clone(), - expected: object.sha256_hex.clone(), - actual, - }); - } - } + verify_object_checksums_with_options(repository, manifest, &VerificationOptions::default()) + .await +} - for table in manifest - .database - .tables - .iter() - .chain(manifest.database.changes.iter()) - { - verify_entry_checksum(repository, table).await?; +/// Verifies object, table, and change payload checksums with explicit options. +/// +/// # Errors +/// +/// Returns [`BackupError`] if any referenced blob is missing or its checksum +/// does not match the manifest entry. +pub async fn verify_object_checksums_with_options( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, + options: &VerificationOptions, +) -> Result<(), BackupError> { + let concurrency = options.blob_concurrency.max(1); + + stream::iter(&manifest.objects) + .map(|object| verify_object_checksum(repository, object)) + .buffer_unordered(concurrency) + .try_collect::>() + .await?; + + stream::iter( + manifest + .database + .tables + .iter() + .chain(manifest.database.changes.iter()), + ) + .map(|entry| verify_entry_checksum(repository, entry)) + .buffer_unordered(concurrency) + .try_collect::>() + .await?; + + Ok(()) +} + +async fn verify_object_checksum( + repository: &dyn BackupRepository, + object: &ObjectBackupEntry, +) -> Result<(), BackupError> { + let bytes = repository.get_blob(&object.content_key).await?; + let actual = sha256_hex(&bytes); + if actual != object.sha256_hex { + return Err(BackupError::ChecksumMismatch { + key: object.content_key.clone(), + expected: object.sha256_hex.clone(), + actual, + }); } Ok(()) diff --git a/crates/graphql-orm-backup/tests/full_backup_creation.rs b/crates/graphql-orm-backup/tests/full_backup_creation.rs index 30de5857..5f6fd1b1 100644 --- a/crates/graphql-orm-backup/tests/full_backup_creation.rs +++ b/crates/graphql-orm-backup/tests/full_backup_creation.rs @@ -177,9 +177,10 @@ async fn create_full_backup_writes_manifest_after_payloads() { .expect("create full backup"); let writes = repository.write_order(); - assert_eq!(writes.len(), 2); - assert_eq!(writes[0], database_table_key(snapshot_id(), "users")); - assert_eq!(writes[1], snapshot_manifest_key(snapshot_id())); + assert_eq!(writes.len(), 3); + assert_eq!(writes[0], "locks/repository.lock"); + assert_eq!(writes[1], database_table_key(snapshot_id(), "users")); + assert_eq!(writes[2], snapshot_manifest_key(snapshot_id())); } fn backup_request() -> FullBackupRequest { diff --git a/crates/graphql-orm-backup/tests/operational_safety.rs b/crates/graphql-orm-backup/tests/operational_safety.rs new file mode 100644 index 00000000..58772985 --- /dev/null +++ b/crates/graphql-orm-backup/tests/operational_safety.rs @@ -0,0 +1,214 @@ +use async_trait::async_trait; +use bytes::Bytes; +use graphql_orm_backup::{ + BackupChangeExport, BackupError, BackupObjectIndex, BackupObjectRef, BackupRepository, + BackupTableExport, FullBackupRequest, GraphqlOrmBackupAdapter, GraphqlOrmBackupSchema, + KeepPolicy, LocalBackupRepository, RestoreContext, bytes_sha256_hex, create_full_backup, + object_content_key, prune, snapshot_manifest_key, +}; +use tempfile::TempDir; +use uuid::Uuid; + +#[tokio::test] +async fn create_full_backup_refuses_active_repository_lock() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + repository + .put_blob( + "locks/repository.lock", + Bytes::from(unix_seconds().to_string()), + ) + .await + .expect("write lock"); + + let database = MockDatabase; + let objects = MockObjectIndex::default(); + let err = create_full_backup( + &repository, + &database, + &objects, + backup_request(first_id(), 1), + ) + .await + .expect_err("active lock rejected"); + + assert!(matches!(err, BackupError::RepositoryLocked { .. })); +} + +#[tokio::test] +async fn prune_deletes_expired_snapshots_and_unreferenced_objects() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let database = MockDatabase; + + let first_object = Bytes::from_static(b"first object"); + let first_hash = bytes_sha256_hex(&first_object); + let first_objects = MockObjectIndex::new(first_hash.clone(), first_object); + create_full_backup( + &repository, + &database, + &first_objects, + backup_request(first_id(), 1), + ) + .await + .expect("first backup"); + + let second_object = Bytes::from_static(b"second object"); + let second_hash = bytes_sha256_hex(&second_object); + let second_objects = MockObjectIndex::new(second_hash.clone(), second_object); + create_full_backup( + &repository, + &database, + &second_objects, + backup_request(second_id(), 2), + ) + .await + .expect("second backup"); + + let result = prune( + &repository, + &KeepPolicy { + keep_last: 1, + ..KeepPolicy::default() + }, + ) + .await + .expect("prune repository"); + + assert_eq!(result.retained_snapshots, 1); + assert_eq!(result.deleted_snapshots, 1); + assert!( + !repository + .blob_exists(&snapshot_manifest_key(first_id())) + .await + .expect("first manifest exists check") + ); + assert!( + repository + .blob_exists(&snapshot_manifest_key(second_id())) + .await + .expect("second manifest exists check") + ); + assert!( + !repository + .blob_exists(&object_content_key(&first_hash)) + .await + .expect("first object exists check") + ); + assert!( + repository + .blob_exists(&object_content_key(&second_hash)) + .await + .expect("second object exists check") + ); +} + +fn backup_request(snapshot_id: Uuid, created_at: i64) -> FullBackupRequest { + FullBackupRequest { + snapshot_id, + created_at, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + } +} + +fn first_id() -> Uuid { + Uuid::parse_str("aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa").expect("valid uuid") +} + +fn second_id() -> Uuid { + Uuid::parse_str("bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb").expect("valid uuid") +} + +fn object_id() -> Uuid { + Uuid::parse_str("cccccccc-cccc-4ccc-cccc-cccccccccccc").expect("valid uuid") +} + +fn unix_seconds() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default() +} + +struct MockDatabase; + +#[async_trait] +impl GraphqlOrmBackupAdapter for MockDatabase { + async fn schema_snapshot(&self) -> Result { + Ok(GraphqlOrmBackupSchema { + backend: "sqlite".to_string(), + migration_version: "20260514000000".to_string(), + schema_hash: "schema-hash".to_string(), + }) + } + + async fn export_full(&self) -> Result, BackupError> { + Ok(vec![BackupTableExport { + table_name: "users".to_string(), + rows: Vec::new(), + }]) + } + + async fn export_incremental( + &self, + _parent_snapshot_id: Uuid, + ) -> Result, BackupError> { + Ok(Vec::new()) + } + + async fn restore_full( + &self, + _export: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + Ok(()) + } + + async fn restore_incremental( + &self, + _changes: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + Ok(()) + } +} + +#[derive(Default)] +struct MockObjectIndex { + object: Option, + bytes: Bytes, +} + +impl MockObjectIndex { + fn new(hash: String, bytes: Bytes) -> Self { + Self { + object: Some(BackupObjectRef { + object_id: object_id(), + storage_key: format!("objects/{hash}.txt"), + sha256_hex: hash, + size_bytes: bytes.len() as u64, + mime_type: Some("text/plain".to_string()), + }), + bytes, + } + } +} + +#[async_trait] +impl BackupObjectIndex for MockObjectIndex { + async fn list_objects_for_full_backup(&self) -> Result, BackupError> { + Ok(self.object.iter().cloned().collect()) + } + + async fn list_objects_for_incremental_backup( + &self, + _since_snapshot_id: Uuid, + ) -> Result, BackupError> { + Ok(Vec::new()) + } + + async fn load_object(&self, _object: &BackupObjectRef) -> Result { + Ok(self.bytes.clone()) + } +} From 4364f1d51f8b2c646e301b68da900ff435caf417 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 00:49:49 +0000 Subject: [PATCH 011/108] Implement S3 blob backend --- crates/graphql-orm-storage/AGENTS.md | 5 +- crates/graphql-orm-storage/Cargo.lock | 2181 +++++++++++++++-- crates/graphql-orm-storage/Cargo.toml | 4 +- crates/graphql-orm-storage/README.md | 11 +- .../graphql-orm-storage/docs/agent-update.md | 7 +- .../graphql-orm-storage/docs/architecture.md | 4 +- crates/graphql-orm-storage/docs/plan.md | 4 +- .../docs/provider-roadmap.md | 11 +- crates/graphql-orm-storage/docs/usage.md | 14 +- crates/graphql-orm-storage/src/s3.rs | 580 ++++- .../tests/provider_placeholders.rs | 41 +- .../tests/s3_integration.rs | 101 + 12 files changed, 2674 insertions(+), 289 deletions(-) create mode 100644 crates/graphql-orm-storage/tests/s3_integration.rs diff --git a/crates/graphql-orm-storage/AGENTS.md b/crates/graphql-orm-storage/AGENTS.md index 7e0e0065..0c7a6fb1 100644 --- a/crates/graphql-orm-storage/AGENTS.md +++ b/crates/graphql-orm-storage/AGENTS.md @@ -15,7 +15,7 @@ This crate is a reusable storage companion for applications that use `graphql-or - Prefer traits and small adapters over application-specific coupling. - Keep provider-specific code behind feature flags. - Local filesystem support is the baseline provider. -- S3 and Azure Blob support should be explicit feature-gated work; placeholder paths must return clear unsupported errors until implemented. +- S3 and Azure Blob support should be explicit feature-gated work; Azure placeholder paths must return clear unsupported errors until implemented. - Add tests for path safety, checksums, key generation, and provider round trips. ## Current Agent Handoff @@ -26,5 +26,6 @@ This crate is a reusable storage companion for applications that use `graphql-or - `BlobStore` includes byte ranges, conditional writes, server-side copy, write options, and paged listing. - `StorageService` remains the high-level primary object API for generated object metadata. - `graphql-orm-backup` should adapt `BlobStore` directly for backup repository semantics; it should not use `StorageService`. -- S3 and Azure Blob are still feature-gated unsupported placeholders. Do not add real SDK code without implementing the shared `BlobStore` provider layer first. +- S3 is implemented behind the `s3` feature through the shared `BlobStore` provider layer. +- Azure Blob is still a feature-gated unsupported placeholder. Do not add real Azure SDK code without implementing the shared `BlobStore` provider layer first. - See `docs/agent-update.md`, `docs/blob-store.md`, `docs/streaming.md`, and `docs/backup-integration.md` before making provider or backup-facing changes. diff --git a/crates/graphql-orm-storage/Cargo.lock b/crates/graphql-orm-storage/Cargo.lock index 9fa4fc20..815c0d85 100644 --- a/crates/graphql-orm-storage/Cargo.lock +++ b/crates/graphql-orm-storage/Cargo.lock @@ -2,12 +2,27 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -20,351 +35,1736 @@ dependencies = [ ] [[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "block-buffer" -version = "0.10.4" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "bumpalo" -version = "3.20.2" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "bytes" -version = "1.11.1" +name = "aws-credential-types" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "aws-lc-rs" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "aws-lc-sys" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ - "libc", + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "aws-runtime" +version = "1.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "6c9b9de216a988dd54b754a82a7660cfe14cee4f6782ae4524470972fa0ccb39" dependencies = [ - "generic-array", - "typenum", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "aws-sdk-s3" +version = "1.137.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "c2dd7213994e2ff9382ff100403b78c30d1b74cdfcd8fa9d0d1dc3a94a5c4874" dependencies = [ - "powerfmt", - "serde_core", + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.2", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", ] [[package]] -name = "digest" -version = "0.10.7" +name = "aws-sigv4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" dependencies = [ - "block-buffer", - "crypto-common", + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.2", + "p256", + "percent-encoding", + "sha2 0.11.0", + "subtle", + "time", + "tracing", + "zeroize", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "aws-smithy-async" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] [[package]] -name = "errno" -version = "0.3.14" +name = "aws-smithy-checksums" +version = "0.64.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "e9e8e65f4f81fcccdeb6c3eca2af17ac21d421a1786a26a394aecf421d616d3a" dependencies = [ - "libc", - "windows-sys", + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1", + "sha2 0.11.0", + "tracing", ] [[package]] -name = "fastrand" -version = "2.4.1" +name = "aws-smithy-eventstream" +version = "0.60.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] [[package]] -name = "foldhash" -version = "0.1.5" +name = "aws-smithy-http" +version = "0.63.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] [[package]] -name = "futures-core" -version = "0.3.32" +name = "aws-smithy-http-client" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.15", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.10.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.41", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "aws-smithy-json" +version = "0.62.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" dependencies = [ - "proc-macro2", - "quote", - "syn", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "aws-smithy-observability" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] [[package]] -name = "futures-task" -version = "0.3.32" +name = "aws-smithy-runtime" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] [[package]] -name = "futures-util" -version = "0.3.32" +name = "aws-smithy-runtime-api" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" dependencies = [ - "futures-core", - "futures-macro", - "futures-task", + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.2", "pin-project-lite", - "slab", + "tokio", + "tracing", + "zeroize", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" dependencies = [ - "typenum", - "version_check", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "aws-smithy-schema" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.2", ] [[package]] -name = "graphql-orm-storage" -version = "0.3.0" +name = "aws-smithy-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" dependencies = [ - "async-trait", + "base64-simd", "bytes", + "bytes-utils", "futures-core", - "futures-util", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", "serde", - "sha2", - "tempfile", - "thiserror", "time", "tokio", "tokio-util", - "uuid", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "aws-smithy-xml" +version = "0.60.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" dependencies = [ - "foldhash", + "xmlparser", ] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "aws-types" +version = "1.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] [[package]] -name = "heck" -version = "0.5.0" +name = "base16ct" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] -name = "id-arena" -version = "2.3.0" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "indexmap" -version = "2.14.0" +name = "base64-simd" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "outref", + "vsimd", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "base64ct" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] -name = "js-sys" -version = "0.3.98" +name = "bitflags" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", + "generic-array", ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "block-buffer" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] [[package]] -name = "libc" -version = "0.2.186" +name = "bumpalo" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "log" -version = "0.4.29" +name = "bytes-utils" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] [[package]] -name = "memchr" -version = "2.8.0" +name = "cc" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] [[package]] -name = "num-conv" -version = "0.2.1" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "once_cell" -version = "1.21.4" +name = "cmake" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "cmov" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] -name = "powerfmt" -version = "0.2.0" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "graphql-orm-storage" +version = "0.3.0" +dependencies = [ + "async-trait", + "aws-credential-types", + "aws-sdk-s3", + "bytes", + "futures-core", + "futures-util", + "serde", + "sha2 0.10.9", + "tempfile", + "thiserror", + "time", + "tokio", + "tokio-util", + "uuid", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[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", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.2", + "hyper 1.10.1", + "hyper-util", + "rustls 0.23.41", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "hyper 1.10.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.4", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[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]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "prettyplease" -version = "0.2.37" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "schannel" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "unicode-ident", + "windows-sys 0.61.2", ] [[package]] -name = "quote" -version = "1.0.45" +name = "sct" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "proc-macro2", + "ring", + "untrusted", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "sec1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] [[package]] -name = "rustix" -version = "1.1.4" +name = "security-framework" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "errno", + "core-foundation", + "core-foundation-sys", "libc", - "linux-raw-sys", - "windows-sys", + "security-framework-sys", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "security-framework-sys" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] name = "semver" @@ -415,6 +1815,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -422,8 +1833,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", ] [[package]] @@ -432,6 +1870,60 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -443,6 +1935,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -450,10 +1953,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -506,6 +2009,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -513,8 +2026,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "socket2 0.6.4", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -528,6 +2045,26 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.41", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -541,6 +2078,65 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.0" @@ -559,13 +2155,37 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -577,6 +2197,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -680,6 +2321,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -689,6 +2339,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -783,6 +2497,101 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/graphql-orm-storage/Cargo.toml b/crates/graphql-orm-storage/Cargo.toml index 06d28164..92d2d117 100644 --- a/crates/graphql-orm-storage/Cargo.toml +++ b/crates/graphql-orm-storage/Cargo.toml @@ -9,11 +9,13 @@ description = "Provider-neutral object storage primitives for graphql-orm applic [features] default = ["local"] local = ["dep:tokio", "dep:tokio-util"] -s3 = [] +s3 = ["dep:aws-credential-types", "dep:aws-sdk-s3", "dep:tokio", "dep:tokio-util"] azure = [] [dependencies] async-trait = "0.1" +aws-credential-types = { version = "1.2.14", optional = true } +aws-sdk-s3 = { version = "1.137.0", features = ["behavior-version-latest"], optional = true } bytes = "1" futures-core = "0.3" futures-util = "0.3" diff --git a/crates/graphql-orm-storage/README.md b/crates/graphql-orm-storage/README.md index cc47ef26..157f5797 100644 --- a/crates/graphql-orm-storage/README.md +++ b/crates/graphql-orm-storage/README.md @@ -11,7 +11,8 @@ Current crate version: `0.3.0`. - Local filesystem backend implemented. - Streaming `BlobStore` abstraction implemented. - Stable object metadata and key generation implemented. -- S3 and Azure Blob expose explicit unsupported placeholder backends behind feature flags for later provider work. +- S3-compatible backend implemented behind the `s3` feature. +- Azure Blob exposes an explicit unsupported placeholder backend behind the `azure` feature. ## Design Rule @@ -22,7 +23,7 @@ The core crate does not provide default GraphQL resolvers. Upload, download, del ## Cargo Features - `local`: enabled by default; provides `LocalStorageBackend`. -- `s3`: provides `S3StorageBackend` and `S3StorageConfig` placeholders that return an unsupported-backend error until real S3-compatible storage is implemented. +- `s3`: provides `S3StorageBackend` and `S3StorageConfig` backed by `aws-sdk-s3`; supports S3-compatible providers such as MinIO with path-style addressing. - `azure`: provides `AzureBlobStorageBackend` and `AzureBlobStorageConfig` placeholders that return an unsupported-backend error until real Azure Blob Storage is implemented. For detailed integration guidance, see [docs/usage.md](docs/usage.md). @@ -108,9 +109,13 @@ Only the file extension is copied from the original filename. The original filen ## Provider Roadmap 1. Local filesystem -2. S3-compatible object storage implemented as `BlobStore` first, then `ObjectStorage` +2. S3-compatible object storage 3. Azure Blob Storage implemented as `BlobStore` first, then `ObjectStorage` +S3 integration tests are opt-in. Set `S3_TEST_ENDPOINT` and `S3_TEST_BUCKET`, +plus optional `S3_TEST_REGION`, `S3_TEST_ACCESS_KEY`, `S3_TEST_SECRET_KEY`, and +`S3_TEST_PATH_STYLE`, to run them against MinIO or another S3-compatible service. + Backup repositories such as Dropbox and SMB belong in `graphql-orm-backup`, not this crate. ## Verification diff --git a/crates/graphql-orm-storage/docs/agent-update.md b/crates/graphql-orm-storage/docs/agent-update.md index 3192e24b..41df5e96 100644 --- a/crates/graphql-orm-storage/docs/agent-update.md +++ b/crates/graphql-orm-storage/docs/agent-update.md @@ -17,7 +17,8 @@ on `graphql-orm-storage` or downstream crates. `StorageService::get_object_stream`. - Buffered object APIs still exist and delegate through the streaming layer. - `LocalStorageBackend` now implements `BlobStore` and `ObjectStorage`. -- S3 and Azure Blob placeholders now implement `BlobStore` and still return +- S3 now implements `BlobStore` and `ObjectStorage` behind the `s3` feature. +- Azure Blob remains a placeholder that implements `BlobStore` and still returns `UnsupportedBackend`. ## Provider Guidance @@ -48,8 +49,6 @@ pub struct BlobStoreBackupRepository { ## Still Pending -- Real S3 `BlobStore` provider using the `0.3.0` trait surface. - Real Azure Blob `BlobStore` provider. - Backup adapter implementation after downstream crate alignment. -- Any cloud SDK dependency decisions. -- Provider integration tests that require external services. +- Provider integration tests that require external services beyond opt-in S3. diff --git a/crates/graphql-orm-storage/docs/architecture.md b/crates/graphql-orm-storage/docs/architecture.md index 9f546265..26780c75 100644 --- a/crates/graphql-orm-storage/docs/architecture.md +++ b/crates/graphql-orm-storage/docs/architecture.md @@ -96,8 +96,8 @@ The crate uses `StorageError` through `thiserror`. Application code can convert Provider-specific code should live behind cargo features: - `local`: default, implemented now -- `s3`: reserved -- `azure`: reserved +- `s3`: implemented with `aws-sdk-s3` +- `azure`: reserved placeholder Provider implementations must satisfy the same `ObjectStorage` trait. Provider implementations should implement `BlobStore` first, then expose diff --git a/crates/graphql-orm-storage/docs/plan.md b/crates/graphql-orm-storage/docs/plan.md index 71f69d34..5df763e3 100644 --- a/crates/graphql-orm-storage/docs/plan.md +++ b/crates/graphql-orm-storage/docs/plan.md @@ -11,7 +11,8 @@ Create a reusable object storage crate for applications that use `graphql-orm`. - Provider-neutral object storage trait. - Storage service that generates object IDs, keys, sizes, hashes, and timestamps. - Local filesystem backend. -- Feature placeholders for S3 and Azure Blob. +- S3-compatible backend behind the `s3` feature. +- Feature placeholder for Azure Blob. - Tests for key generation, checksum generation, local round trips, and path safety. ## What This Crate Must Not Provide @@ -54,7 +55,6 @@ Applications should also own GraphQL resolvers and route handlers. A future opti ## Future Work -- Add S3-compatible provider behind the `s3` feature, implemented as `BlobStore` first. - Add Azure Blob provider behind the `azure` feature, implemented as `BlobStore` first. - Add a `graphql-orm-backup` adapter that wraps `BlobStore` as a backup repository. - Add optional server-side encryption hooks if applications need provider-managed keys. diff --git a/crates/graphql-orm-storage/docs/provider-roadmap.md b/crates/graphql-orm-storage/docs/provider-roadmap.md index c60354c8..97cceb48 100644 --- a/crates/graphql-orm-storage/docs/provider-roadmap.md +++ b/crates/graphql-orm-storage/docs/provider-roadmap.md @@ -14,11 +14,11 @@ Acceptance criteria: ## Phase 2: S3-Compatible Storage -Add behind the `s3` feature. +Implemented behind the `s3` feature. -Implement S3 as a `BlobStore` first. The high-level `ObjectStorage` behavior -should delegate to the same S3 blob operations so backup integrations can reuse -the provider through a future adapter. +S3 is implemented as a `BlobStore` first. The high-level `ObjectStorage` +behavior delegates to the same S3 blob operations so backup integrations can +reuse the provider through a future adapter. Expected configuration: @@ -32,6 +32,9 @@ Expected configuration: The implementation must use the same `storage_key` values as local storage. +Integration tests are opt-in through `S3_TEST_ENDPOINT` and `S3_TEST_BUCKET`. +Use the optional `S3_TEST_PATH_STYLE` setting for MinIO compatibility. + ## Phase 3: Azure Blob Storage Add behind the `azure` feature. diff --git a/crates/graphql-orm-storage/docs/usage.md b/crates/graphql-orm-storage/docs/usage.md index 8f720574..cded21de 100644 --- a/crates/graphql-orm-storage/docs/usage.md +++ b/crates/graphql-orm-storage/docs/usage.md @@ -26,7 +26,7 @@ Default local filesystem support: graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm-storage" } ``` -Provider-placeholder-only builds: +Provider-specific builds: ```toml [dependencies] @@ -195,12 +195,16 @@ Generated keys use: | Feature | Status | Public API | | --- | --- | --- | | `local` | Implemented and enabled by default | `LocalStorageBackend` | -| `s3` | Placeholder only | `S3StorageBackend`, `S3StorageConfig` | +| `s3` | Implemented with `aws-sdk-s3` | `S3StorageBackend`, `S3StorageConfig` | | `azure` | Placeholder only | `AzureBlobStorageBackend`, `AzureBlobStorageConfig` | -The S3 and Azure placeholder backends are intentionally explicit. They expose -the planned configuration shape but return `StorageError::UnsupportedBackend` -for put, get, and delete operations until real provider implementations land. +The S3 backend supports S3-compatible services such as MinIO and respects the +`path_style` configuration flag. S3 integration tests run only when +`S3_TEST_ENDPOINT` and `S3_TEST_BUCKET` are set. + +The Azure placeholder backend is intentionally explicit. It exposes the planned +configuration shape but returns `StorageError::UnsupportedBackend` until a real +Azure provider implementation lands. ## GraphQL Boundary diff --git a/crates/graphql-orm-storage/src/s3.rs b/crates/graphql-orm-storage/src/s3.rs index 10c33c07..1edb9304 100644 --- a/crates/graphql-orm-storage/src/s3.rs +++ b/crates/graphql-orm-storage/src/s3.rs @@ -1,14 +1,27 @@ use std::fmt; use async_trait::async_trait; +use aws_credential_types::Credentials; +use aws_sdk_s3::{ + Client, + config::{Builder as S3ConfigBuilder, Region}, + primitives::ByteStream, + types::{CompletedMultipartUpload, CompletedPart}, +}; +use bytes::{Bytes, BytesMut}; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use tokio_util::io::ReaderStream; use crate::{ BlobBody, BlobListPage, BlobMetadata, BlobPutOptions, BlobStore, BlobWriteOutcome, ObjectStorage, StorageBackend, StorageByteStream, StorageError, StorageObjectBody, - StoredObject, unsupported_backend, + StoredObject, collect_storage_stream, validate_blob_key, }; -/// Configuration for a future S3-compatible storage backend. +const MULTIPART_PART_SIZE: usize = 8 * 1024 * 1024; + +/// Configuration for an S3-compatible storage backend. #[derive(Clone, PartialEq, Eq)] pub struct S3StorageConfig { /// S3-compatible endpoint URL. @@ -42,21 +55,35 @@ impl fmt::Debug for S3StorageConfig { } } -/// Placeholder S3-compatible storage backend. -/// -/// This type exposes the planned provider shape behind the `s3` feature, but -/// object operations return [`StorageError::UnsupportedBackend`] until real S3 -/// support is implemented. +/// S3-compatible storage backend. #[derive(Clone, Debug)] pub struct S3StorageBackend { config: S3StorageConfig, + client: Client, } impl S3StorageBackend { - /// Creates a new unsupported S3-compatible backend placeholder. + /// Creates a new S3-compatible backend. #[must_use] pub fn new(config: S3StorageConfig) -> Self { - Self { config } + let credentials = Credentials::new( + config.access_key_id.clone(), + config.secret_access_key.clone(), + None, + None, + "graphql-orm-storage", + ); + let s3_config = S3ConfigBuilder::new() + .region(Region::new(config.region.clone())) + .credentials_provider(credentials) + .endpoint_url(config.endpoint_url.clone()) + .force_path_style(config.path_style) + .build(); + + Self { + config, + client: Client::from_conf(s3_config), + } } /// Returns the backend configuration. @@ -64,6 +91,222 @@ impl S3StorageBackend { pub const fn config(&self) -> &S3StorageConfig { &self.config } + + fn provider_key(&self, key: &str) -> Result { + validate_blob_key(key)?; + let Some(prefix) = self.config.key_prefix.as_deref() else { + return Ok(key.to_string()); + }; + let prefix = prefix.trim_matches('/'); + if prefix.is_empty() { + return Ok(key.to_string()); + } + validate_blob_key(prefix)?; + Ok(format!("{prefix}/{key}")) + } + + fn strip_provider_prefix(&self, provider_key: &str) -> String { + let Some(prefix) = self.config.key_prefix.as_deref() else { + return provider_key.to_string(); + }; + let prefix = prefix.trim_matches('/'); + provider_key + .strip_prefix(prefix) + .and_then(|key| key.strip_prefix('/')) + .unwrap_or(provider_key) + .to_string() + } + + async fn put_blob_inner( + &self, + key: &str, + body: StorageByteStream, + options: BlobPutOptions, + if_not_exists: bool, + ) -> Result, StorageError> { + let provider_key = self.provider_key(key)?; + let mut stream = body.into_inner(); + let mut buffer = BytesMut::new(); + let mut hasher = Sha256::new(); + let mut size_bytes = 0_u64; + let mut multipart = None; + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + size_bytes = size_bytes.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + hasher.update(&chunk); + buffer.extend_from_slice(&chunk); + + while buffer.len() >= MULTIPART_PART_SIZE { + let state = match multipart.as_mut() { + Some(state) => state, + None => { + multipart = Some( + self.create_multipart_upload(&provider_key, &options) + .await?, + ); + multipart.as_mut().expect("multipart state exists") + } + }; + let part = buffer.split_to(MULTIPART_PART_SIZE).freeze(); + self.upload_part(&provider_key, state, part).await?; + } + } + + let outcome = BlobWriteOutcome { + size_bytes, + sha256_hex: format!("{:x}", hasher.finalize()), + }; + + match multipart { + Some(mut state) => { + if !buffer.is_empty() { + self.upload_part(&provider_key, &mut state, buffer.freeze()) + .await?; + } + + match self + .complete_multipart_upload(&provider_key, state, if_not_exists) + .await + { + Ok(()) => Ok(Some(outcome)), + Err(err) if if_not_exists && is_precondition_error(&err) => Ok(None), + Err(err) => Err(err), + } + } + None => { + let bytes = buffer.freeze(); + match self + .put_single_blob(&provider_key, bytes, options, if_not_exists) + .await + { + Ok(()) => Ok(Some(outcome)), + Err(err) if if_not_exists && is_precondition_error(&err) => Ok(None), + Err(err) => Err(err), + } + } + } + } + + async fn put_single_blob( + &self, + provider_key: &str, + bytes: Bytes, + options: BlobPutOptions, + if_not_exists: bool, + ) -> Result<(), StorageError> { + let mut request = self + .client + .put_object() + .bucket(&self.config.bucket) + .key(provider_key) + .body(ByteStream::from(bytes)); + + if let Some(content_type) = options.content_type { + request = request.content_type(content_type); + } + if if_not_exists { + request = request.if_none_match("*"); + } + + request.send().await.map_err(map_s3_error)?; + Ok(()) + } + + async fn create_multipart_upload( + &self, + provider_key: &str, + options: &BlobPutOptions, + ) -> Result { + let mut request = self + .client + .create_multipart_upload() + .bucket(&self.config.bucket) + .key(provider_key); + + if let Some(content_type) = options.content_type.as_deref() { + request = request.content_type(content_type); + } + + let output = request.send().await.map_err(map_s3_error)?; + let upload_id = output.upload_id().ok_or_else(|| StorageError::Provider { + backend: StorageBackend::S3.as_str().to_string(), + message: "S3 multipart upload did not return an upload id".to_string(), + retryable: true, + })?; + + Ok(MultipartUploadState { + upload_id: upload_id.to_string(), + next_part_number: 1, + completed_parts: Vec::new(), + }) + } + + async fn upload_part( + &self, + provider_key: &str, + state: &mut MultipartUploadState, + bytes: Bytes, + ) -> Result<(), StorageError> { + let part_number = state.next_part_number; + let output = self + .client + .upload_part() + .bucket(&self.config.bucket) + .key(provider_key) + .upload_id(&state.upload_id) + .part_number(part_number) + .body(ByteStream::from(bytes)) + .send() + .await + .map_err(map_s3_error)?; + + let completed_part = CompletedPart::builder() + .set_e_tag(output.e_tag().map(ToString::to_string)) + .part_number(part_number) + .build(); + state.completed_parts.push(completed_part); + state.next_part_number += 1; + Ok(()) + } + + async fn complete_multipart_upload( + &self, + provider_key: &str, + state: MultipartUploadState, + if_not_exists: bool, + ) -> Result<(), StorageError> { + let upload_id = state.upload_id.clone(); + let completed = CompletedMultipartUpload::builder() + .set_parts(Some(state.completed_parts)) + .build(); + let mut request = self + .client + .complete_multipart_upload() + .bucket(&self.config.bucket) + .key(provider_key) + .upload_id(&upload_id) + .multipart_upload(completed); + + if if_not_exists { + request = request.if_none_match("*"); + } + + match request.send().await.map_err(map_s3_error) { + Ok(_) => Ok(()), + Err(err) => { + let _ = self + .client + .abort_multipart_upload() + .bucket(&self.config.bucket) + .key(provider_key) + .upload_id(upload_id) + .send() + .await; + Err(err) + } + } + } } #[async_trait] @@ -74,57 +317,246 @@ impl BlobStore for S3StorageBackend { async fn put_blob( &self, - _key: &str, - _body: StorageByteStream, - _options: BlobPutOptions, + key: &str, + body: StorageByteStream, + options: BlobPutOptions, ) -> Result { - Err(unsupported_backend(StorageBackend::S3)) + self.put_blob_inner(key, body, options, false) + .await? + .ok_or_else(|| StorageError::PreconditionFailed { + key: key.to_string(), + condition: "blob unexpectedly already exists".to_string(), + }) } async fn put_blob_if_not_exists( &self, - _key: &str, - _body: StorageByteStream, - _options: BlobPutOptions, + key: &str, + body: StorageByteStream, + options: BlobPutOptions, ) -> Result, StorageError> { - Err(unsupported_backend(StorageBackend::S3)) + self.put_blob_inner(key, body, options, true).await } - async fn get_blob(&self, _key: &str) -> Result { - Err(unsupported_backend(StorageBackend::S3)) + async fn get_blob(&self, key: &str) -> Result { + let provider_key = self.provider_key(key)?; + let output = match self + .client + .get_object() + .bucket(&self.config.bucket) + .key(&provider_key) + .send() + .await + { + Ok(output) => output, + Err(err) if is_missing_error(&err) => { + return Err(StorageError::MissingBlob { + key: key.to_string(), + }); + } + Err(err) => return Err(map_s3_error(err)), + }; + + let metadata = BlobMetadata { + key: key.to_string(), + size_bytes: output + .content_length() + .and_then(|size| u64::try_from(size).ok()), + sha256_hex: None, + etag: output.e_tag().map(ToString::to_string), + last_modified: None, + }; + let stream_key = key.to_string(); + let stream = ReaderStream::new(output.body.into_async_read()).map(move |chunk| { + chunk.map_err(|source| StorageError::Provider { + backend: StorageBackend::S3.as_str().to_string(), + message: format!("S3 object stream failed for {stream_key}: {source}"), + retryable: true, + }) + }); + + Ok(BlobBody { + key: key.to_string(), + metadata: Some(metadata), + body: StorageByteStream::new(Box::pin(stream)), + }) } async fn get_blob_range( &self, - _key: &str, - _range: std::ops::Range, + key: &str, + range: std::ops::Range, ) -> Result { - Err(unsupported_backend(StorageBackend::S3)) + if range.end < range.start { + return Err(StorageError::PreconditionFailed { + key: key.to_string(), + condition: "range end is before range start".to_string(), + }); + } + if range.start == range.end { + return Ok(BlobBody { + key: key.to_string(), + metadata: self.head_blob(key).await?, + body: StorageByteStream::from_bytes(Bytes::new()), + }); + } + + let provider_key = self.provider_key(key)?; + let range_header = format!("bytes={}-{}", range.start, range.end - 1); + let output = match self + .client + .get_object() + .bucket(&self.config.bucket) + .key(&provider_key) + .range(range_header) + .send() + .await + { + Ok(output) => output, + Err(err) if is_missing_error(&err) => { + return Err(StorageError::MissingBlob { + key: key.to_string(), + }); + } + Err(err) => return Err(map_s3_error(err)), + }; + + let metadata = BlobMetadata { + key: key.to_string(), + size_bytes: output + .content_length() + .and_then(|size| u64::try_from(size).ok()), + sha256_hex: None, + etag: output.e_tag().map(ToString::to_string), + last_modified: None, + }; + let stream_key = key.to_string(); + let stream = ReaderStream::new(output.body.into_async_read()).map(move |chunk| { + chunk.map_err(|source| StorageError::Provider { + backend: StorageBackend::S3.as_str().to_string(), + message: format!("S3 object range stream failed for {stream_key}: {source}"), + retryable: true, + }) + }); + + Ok(BlobBody { + key: key.to_string(), + metadata: Some(metadata), + body: StorageByteStream::with_size_hint(Box::pin(stream), range.end - range.start), + }) } - async fn blob_exists(&self, _key: &str) -> Result { - Err(unsupported_backend(StorageBackend::S3)) + async fn blob_exists(&self, key: &str) -> Result { + Ok(self.head_blob(key).await?.is_some()) } - async fn head_blob(&self, _key: &str) -> Result, StorageError> { - Err(unsupported_backend(StorageBackend::S3)) + async fn head_blob(&self, key: &str) -> Result, StorageError> { + let provider_key = self.provider_key(key)?; + let output = match self + .client + .head_object() + .bucket(&self.config.bucket) + .key(&provider_key) + .send() + .await + { + Ok(output) => output, + Err(err) if is_missing_error(&err) => return Ok(None), + Err(err) => return Err(map_s3_error(err)), + }; + + Ok(Some(BlobMetadata { + key: key.to_string(), + size_bytes: output + .content_length() + .and_then(|size| u64::try_from(size).ok()), + sha256_hex: None, + etag: output.e_tag().map(ToString::to_string), + last_modified: None, + })) } async fn list_blobs_page( &self, - _prefix: &str, - _continuation: Option, - _limit: usize, + prefix: &str, + continuation: Option, + limit: usize, ) -> Result { - Err(unsupported_backend(StorageBackend::S3)) + if limit == 0 { + return Err(StorageError::PreconditionFailed { + key: prefix.to_string(), + condition: "list limit must be greater than zero".to_string(), + }); + } + + let provider_prefix = if prefix.is_empty() { + self.config + .key_prefix + .as_deref() + .map(|prefix| prefix.trim_matches('/')) + .unwrap_or_default() + .to_string() + } else { + self.provider_key(prefix)? + }; + + let output = self + .client + .list_objects_v2() + .bucket(&self.config.bucket) + .prefix(provider_prefix) + .set_continuation_token(continuation) + .max_keys(i32::try_from(limit).unwrap_or(i32::MAX)) + .send() + .await + .map_err(map_s3_error)?; + + let keys = output + .contents() + .iter() + .filter_map(|object| object.key()) + .map(|key| self.strip_provider_prefix(key)) + .filter(|key| !key.is_empty()) + .collect(); + + Ok(BlobListPage { + keys, + next_continuation: output.next_continuation_token().map(ToString::to_string), + }) } - async fn copy_blob(&self, _from: &str, _to: &str) -> Result<(), StorageError> { - Err(unsupported_backend(StorageBackend::S3)) + async fn copy_blob(&self, from: &str, to: &str) -> Result<(), StorageError> { + let from_key = self.provider_key(from)?; + let to_key = self.provider_key(to)?; + let copy_source = format!("{}/{}", self.config.bucket, from_key); + + match self + .client + .copy_object() + .bucket(&self.config.bucket) + .key(to_key) + .copy_source(copy_source) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) if is_missing_error(&err) => Err(StorageError::MissingBlob { + key: from.to_string(), + }), + Err(err) => Err(map_s3_error(err)), + } } - async fn delete_blob(&self, _key: &str) -> Result<(), StorageError> { - Err(unsupported_backend(StorageBackend::S3)) + async fn delete_blob(&self, key: &str) -> Result<(), StorageError> { + let provider_key = self.provider_key(key)?; + self.client + .delete_object() + .bucket(&self.config.bucket) + .key(provider_key) + .send() + .await + .map_err(map_s3_error)?; + Ok(()) } } @@ -132,17 +564,83 @@ impl BlobStore for S3StorageBackend { impl ObjectStorage for S3StorageBackend { async fn put_object( &self, - _object: StoredObject, - _bytes: Vec, + object: StoredObject, + bytes: Vec, ) -> Result { - Err(unsupported_backend(StorageBackend::S3)) + self.put_blob( + &object.storage_key, + StorageByteStream::from_bytes(bytes), + BlobPutOptions { + content_type: object.mime_type.clone(), + }, + ) + .await?; + Ok(object) } - async fn get_object(&self, _object: &StoredObject) -> Result { - Err(unsupported_backend(StorageBackend::S3)) + async fn get_object(&self, object: &StoredObject) -> Result { + let body = self.get_blob(&object.storage_key).await?; + let bytes = collect_storage_stream(body.body).await?; + Ok(StorageObjectBody { + object: object.clone(), + bytes: bytes.to_vec(), + }) } - async fn delete_object(&self, _object: &StoredObject) -> Result<(), StorageError> { - Err(unsupported_backend(StorageBackend::S3)) + async fn delete_object(&self, object: &StoredObject) -> Result<(), StorageError> { + self.delete_blob(&object.storage_key).await + } +} + +#[derive(Debug)] +struct MultipartUploadState { + upload_id: String, + next_part_number: i32, + completed_parts: Vec, +} + +fn is_missing_error(err: &aws_sdk_s3::error::SdkError) -> bool +where + E: fmt::Debug, +{ + let error = format!("{err:?}"); + error.contains("NoSuchKey") + || error.contains("NotFound") + || error.contains("Not Found") + || error.contains("status: 404") + || error.contains("code: 404") +} + +fn is_precondition_error(err: &StorageError) -> bool { + let message = err.to_string(); + message.contains("PreconditionFailed") + || message.contains("Precondition Failed") + || message.contains("status: 412") + || message.contains("code: 412") +} + +fn map_s3_error(err: aws_sdk_s3::error::SdkError) -> StorageError +where + E: fmt::Debug, +{ + let message = format!("{err:?}"); + let retryable = is_retryable_s3_message(&message); + StorageError::Provider { + backend: StorageBackend::S3.as_str().to_string(), + message, + retryable, } } + +fn is_retryable_s3_message(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("timeout") + || lower.contains("timed out") + || lower.contains("dispatch") + || lower.contains("connection") + || lower.contains("throttl") + || lower.contains("slowdown") + || lower.contains("temporarily") + || lower.contains("status: 5") + || lower.contains("code: 5") +} diff --git a/crates/graphql-orm-storage/tests/provider_placeholders.rs b/crates/graphql-orm-storage/tests/provider_placeholders.rs index 615426b6..b6c8ef6b 100644 --- a/crates/graphql-orm-storage/tests/provider_placeholders.rs +++ b/crates/graphql-orm-storage/tests/provider_placeholders.rs @@ -23,48 +23,11 @@ fn s3_debug_output_redacts_secret_access_key() { #[cfg(feature = "s3")] #[tokio::test] -async fn s3_placeholder_backend_returns_unsupported_errors() { +async fn s3_backend_exposes_config_and_backend_without_leaking_secrets() { let backend = S3StorageBackend::new(s3_config()); - let object = test_object(StorageBackend::S3); assert_eq!(backend.backend(), StorageBackend::S3); - assert_unsupported( - backend - .put_blob( - "objects/test", - StorageByteStream::from_bytes(b"bytes".to_vec()), - BlobPutOptions::default(), - ) - .await, - "s3", - ); - assert_unsupported( - backend - .put_blob_if_not_exists( - "objects/test", - StorageByteStream::from_bytes(b"bytes".to_vec()), - BlobPutOptions::default(), - ) - .await, - "s3", - ); - assert_unsupported(backend.get_blob("objects/test").await, "s3"); - assert_unsupported(backend.get_blob_range("objects/test", 0..1).await, "s3"); - assert_unsupported(backend.blob_exists("objects/test").await, "s3"); - assert_unsupported(backend.head_blob("objects/test").await, "s3"); - assert_unsupported(backend.list_blobs("objects").await, "s3"); - assert_unsupported(backend.list_blobs_page("objects", None, 100).await, "s3"); - assert_unsupported( - backend.copy_blob("objects/test", "objects/copy").await, - "s3", - ); - assert_unsupported(backend.delete_blob("objects/test").await, "s3"); - assert_unsupported( - backend.put_object(object.clone(), b"bytes".to_vec()).await, - "s3", - ); - assert_unsupported(backend.get_object(&object).await, "s3"); - assert_unsupported(backend.delete_object(&object).await, "s3"); + assert_eq!(backend.config().bucket, "objects"); } #[cfg(feature = "azure")] diff --git a/crates/graphql-orm-storage/tests/s3_integration.rs b/crates/graphql-orm-storage/tests/s3_integration.rs new file mode 100644 index 00000000..aacfd0ae --- /dev/null +++ b/crates/graphql-orm-storage/tests/s3_integration.rs @@ -0,0 +1,101 @@ +#![cfg(feature = "s3")] + +use bytes::Bytes; +use graphql_orm_storage::{ + BlobPutOptions, BlobStore, S3StorageBackend, S3StorageConfig, StorageByteStream, + collect_storage_stream, sha256_hex, +}; +use uuid::Uuid; + +#[tokio::test] +async fn s3_blob_store_round_trip_when_env_is_configured() { + let Some(backend) = backend_from_env() else { + return; + }; + + let key = "objects/test.txt"; + let copy_key = "objects/test-copy.txt"; + + let outcome = backend + .put_blob( + key, + StorageByteStream::from_bytes(Bytes::from_static(b"hello s3 storage")), + BlobPutOptions { + content_type: Some("text/plain".to_string()), + }, + ) + .await + .expect("put object"); + assert_eq!(outcome.size_bytes, 16); + assert_eq!(outcome.sha256_hex, sha256_hex(b"hello s3 storage")); + + assert!(backend.blob_exists(key).await.expect("exists")); + + let metadata = backend + .head_blob(key) + .await + .expect("head") + .expect("metadata"); + assert_eq!(metadata.key, key); + assert_eq!(metadata.size_bytes, Some(16)); + + let body = backend.get_blob(key).await.expect("get object"); + let bytes = collect_storage_stream(body.body).await.expect("collect"); + assert_eq!(bytes, Bytes::from_static(b"hello s3 storage")); + + let ranged = backend + .get_blob_range(key, 6..8) + .await + .expect("range object"); + let ranged_bytes = collect_storage_stream(ranged.body) + .await + .expect("collect range"); + assert_eq!(ranged_bytes, Bytes::from_static(b"s3")); + + let conditional = backend + .put_blob_if_not_exists( + key, + StorageByteStream::from_bytes(Bytes::from_static(b"replacement")), + BlobPutOptions::default(), + ) + .await + .expect("conditional write"); + assert_eq!(conditional, None); + + backend.copy_blob(key, copy_key).await.expect("copy object"); + let page = backend + .list_blobs_page("objects", None, 1) + .await + .expect("list page"); + assert_eq!(page.keys.len(), 1); + assert!(page.next_continuation.is_some()); + + backend.delete_blob(key).await.expect("delete object"); + backend + .delete_blob(copy_key) + .await + .expect("delete copied object"); +} + +fn backend_from_env() -> Option { + let endpoint_url = std::env::var("S3_TEST_ENDPOINT").ok()?; + let bucket = std::env::var("S3_TEST_BUCKET").ok()?; + let region = std::env::var("S3_TEST_REGION").unwrap_or_else(|_| "us-east-1".to_string()); + let access_key_id = + std::env::var("S3_TEST_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".to_string()); + let secret_access_key = + std::env::var("S3_TEST_SECRET_KEY").unwrap_or_else(|_| "minioadmin".to_string()); + let path_style = std::env::var("S3_TEST_PATH_STYLE") + .map(|value| value != "false") + .unwrap_or(true); + + Some(S3StorageBackend::new(S3StorageConfig { + endpoint_url, + region, + bucket, + key_prefix: Some(format!("graphql-orm-storage-tests/{}", Uuid::new_v4())), + access_key_id, + secret_access_key, + path_style, + })) +} From 8ee7bc7ed4402d047f6c0273caa4a9ec1aa46cf4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 00:56:50 +0000 Subject: [PATCH 012/108] Expand backup crate documentation --- crates/graphql-orm-backup/README.md | 196 +++++++++++------- crates/graphql-orm-backup/docs/README.md | 16 ++ .../graphql-orm-backup/docs/architecture.md | 57 +++-- .../docs/restore-semantics.md | 23 +- .../docs/snapshot-format.md | 33 ++- crates/graphql-orm-backup/docs/usage.md | 154 ++++++++++++-- crates/graphql-orm-backup/src/backup.rs | 25 +++ crates/graphql-orm-backup/src/database.rs | 22 ++ crates/graphql-orm-backup/src/lib.rs | 64 +++++- .../src/local_repository.rs | 1 + crates/graphql-orm-backup/src/lock.rs | 3 + crates/graphql-orm-backup/src/manifest.rs | 44 ++++ crates/graphql-orm-backup/src/object_index.rs | 7 + crates/graphql-orm-backup/src/prune.rs | 7 + crates/graphql-orm-backup/src/repository.rs | 1 + crates/graphql-orm-backup/src/restore.rs | 13 ++ crates/graphql-orm-backup/src/verify.rs | 2 + 17 files changed, 550 insertions(+), 118 deletions(-) create mode 100644 crates/graphql-orm-backup/docs/README.md diff --git a/crates/graphql-orm-backup/README.md b/crates/graphql-orm-backup/README.md index 81177493..19ac5875 100644 --- a/crates/graphql-orm-backup/README.md +++ b/crates/graphql-orm-backup/README.md @@ -1,110 +1,152 @@ # graphql-orm-backup -Backup and restore orchestration primitives for applications that use `graphql-orm`. - -This crate coordinates database export/import adapters, stored-object indexes, backup repositories, snapshot manifests, verification, and restore planning. It does not own application auth, UI, scheduling, or domain-specific workflow behavior. - -## Current Status - -- Snapshot manifest types implemented. -- Manifest checksum support implemented. -- Backup repository trait implemented. -- Local filesystem backup repository implemented. -- Object index and database adapter contracts implemented. -- Full backup planner skeleton implemented. -- Full snapshot creation implemented through `create_full_backup`. -- Verification helpers implemented. -- Manifest chain loading and validation implemented. -- Table payloads are zstd-compressed JSON Lines. -- Mounted SMB paths are supported through `LocalBackupRepository` filesystem semantics and `open_existing` root validation. -- Restore safety context implemented for empty-target restores. +`graphql-orm-backup` provides backup repository, snapshot manifest, verification, restore, +incremental backup, and compaction orchestration for applications that use `graphql-orm`. + +The crate deliberately stays outside application policy and storage metadata decisions. Host +applications provide adapters for database export/import and stored-object lookup; this crate owns +backup layout, checksums, repository writes, restore ordering, and operational safety. + +## Highlights + +- full snapshot creation through `create_full_backup` +- incremental snapshot creation through `create_incremental_backup` +- restore orchestration through `restore_snapshot` +- object rehydration through caller-supplied `RestoreObjectSink` +- manifest-chain loading and validation +- zstd-compressed JSON Lines table and change payloads +- content-addressed object blobs keyed by SHA-256 +- local filesystem repository with path traversal protection +- mounted SMB support through local filesystem semantics and `LocalBackupRepository::open_existing` +- bounded concurrent object writes and checksum verification +- advisory repository writer lock for backup, compaction, and pruning operations +- synthetic-full compaction through `compact_chain` +- retention pruning through `prune` + +## Install + +```toml +[dependencies] +graphql-orm-backup = { git = "https://github.com/Dastari/graphql-orm-backup" } +``` -`graphql-orm` still needs to provide stable logical export/import and change-journal APIs before complete database backup/restore can be implemented. +The default `local` feature enables `LocalBackupRepository`. -## Documentation +```toml +[dependencies] +graphql-orm-backup = { + git = "https://github.com/Dastari/graphql-orm-backup", + default-features = false +} +``` -- [Architecture](docs/architecture.md) -- [Usage guide](docs/usage.md) -- [Snapshot format](docs/snapshot-format.md) -- [Restore semantics](docs/restore-semantics.md) -- [Provider roadmap](docs/provider-roadmap.md) -- [Cloud provider direction](docs/cloud-provider-direction.md) -- [SMB mounted repository guidance](docs/smb.md) -- [graphql-orm integration brief](docs/graphql-orm-agent-brief.md) +Use `default-features = false` when providing only custom repository implementations. -## Design Rule +## Snapshot Layout -Backups are manifest-based and content-addressed. +Backups are manifest-based and content-addressed: ```text snapshots/{snapshot_id}/manifest.json snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst snapshots/{snapshot_id}/database/changes/{table_name}.jsonl.zst objects/sha256/{first_two}/{next_two}/{sha256} +locks/repository.lock ``` -The manifest is written last. Its checksum excludes its own `checksum` field. -Table payloads are written as zstd-compressed JSON Lines and table checksums -cover the stored compressed bytes. +Table and change payloads are zstd-compressed JSON Lines. Manifest table/change checksums cover the +stored compressed bytes. Object blobs are deduplicated by SHA-256 content key. -## Backup Repository Example +## Quick Full Backup Example ```rust -use bytes::Bytes; -use graphql_orm_backup::{BackupRepository, LocalBackupRepository}; +use graphql_orm_backup::{ + BackupObjectIndex, FullBackupRequest, GraphqlOrmBackupAdapter, + LocalBackupRepository, create_full_backup, +}; +use uuid::Uuid; -# async fn example() -> Result<(), graphql_orm_backup::BackupError> { -let repository = LocalBackupRepository::new("./backup"); -repository - .put_blob("snapshots/example/manifest.json", Bytes::from_static(b"{}")) +async fn run_backup( + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, +) -> Result<(), graphql_orm_backup::BackupError> { + let repository = LocalBackupRepository::new("./backups"); + + let result = create_full_backup( + &repository, + database, + objects, + FullBackupRequest { + snapshot_id: Uuid::new_v4(), + created_at: 1_775_174_400, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + }, + ) .await?; -# Ok(()) -# } -``` -## Full Backup Creation + println!("created snapshot {}", result.manifest.snapshot_id); + Ok(()) +} +``` -`create_full_backup` coordinates a database adapter, stored-object index, and -backup repository. It writes table payloads and content-addressed object blobs, -then writes the snapshot manifest last. +## Restore Example ```rust use graphql_orm_backup::{ - FullBackupRequest, LocalBackupRepository, create_full_backup, + BackupRepository, GraphqlOrmBackupAdapter, RestoreContext, restore_snapshot, }; use uuid::Uuid; -# async fn example( -# database: &dyn graphql_orm_backup::GraphqlOrmBackupAdapter, -# objects: &dyn graphql_orm_backup::BackupObjectIndex, -# ) -> Result<(), graphql_orm_backup::BackupError> { -let repository = LocalBackupRepository::new("./backups"); -let result = create_full_backup( - &repository, - database, - objects, - FullBackupRequest { - snapshot_id: Uuid::new_v4(), - created_at: 1_775_174_400, - app_id: "example-app".to_string(), - app_version: "0.1.0".to_string(), - }, -) -.await?; - -println!("created snapshot {}", result.manifest.snapshot_id); -# Ok(()) -# } +async fn restore_database( + repository: &dyn BackupRepository, + database: &dyn GraphqlOrmBackupAdapter, + snapshot_id: Uuid, +) -> Result<(), graphql_orm_backup::BackupError> { + restore_snapshot( + repository, + database, + snapshot_id, + RestoreContext::empty_database(), + ) + .await?; + + Ok(()) +} ``` -## Restore Policy +`RestoreMode::DryRun` validates manifests, checksums, decompression, and JSONL parsing without +calling adapter import methods. + +## Adapter Boundaries + +- `GraphqlOrmBackupAdapter` handles schema metadata, full row export, incremental row export, and + full/incremental row restore. +- `BackupObjectIndex` lists and loads application object bytes referenced by snapshots. +- `BackupRepository` stores backup blobs and manifests. +- `RestoreObjectSink` receives object bytes when applications rehydrate their primary object store. + +The crate does not own authentication, authorization, application transactions, scheduling, audit +events, object metadata persistence, or cloud credentials. + +## Documentation + +- [Documentation index](docs/README.md) +- [Usage guide](docs/usage.md) +- [Architecture](docs/architecture.md) +- [Snapshot format](docs/snapshot-format.md) +- [Restore semantics](docs/restore-semantics.md) +- [Provider roadmap](docs/provider-roadmap.md) +- [Cloud provider direction](docs/cloud-provider-direction.md) +- [SMB mounted repository guidance](docs/smb.md) +- [graphql-orm integration brief](docs/graphql-orm-agent-brief.md) -The first supported restore mode is restore into an empty database and empty object store. In-place replacement is future work. +## Status -## Provider Roadmap +Full backups, restore orchestration, incremental backups, manifest-chain validation, synthetic-full +compaction, local repository support, locking, and pruning are implemented. -1. Local filesystem -2. S3 -3. Azure Blob -4. SMB through mounted filesystem path -5. Dropbox +The remaining major integration item is the future `graphql-orm-storage::BlobStore` adapter path, +which should replace duplicated cloud/local blob-provider code once the storage crate exposes its +stable low-level blob trait. Client-side encryption and content-defined chunking are intentionally +out of scope for the current crate. diff --git a/crates/graphql-orm-backup/docs/README.md b/crates/graphql-orm-backup/docs/README.md new file mode 100644 index 00000000..a1679ce8 --- /dev/null +++ b/crates/graphql-orm-backup/docs/README.md @@ -0,0 +1,16 @@ +# Documentation + +This directory contains the project documentation that is too detailed for the repository front +page. + +- [Usage guide](usage.md) +- [Architecture](architecture.md) +- [Snapshot format](snapshot-format.md) +- [Restore semantics](restore-semantics.md) +- [Provider roadmap](provider-roadmap.md) +- [Cloud provider direction](cloud-provider-direction.md) +- [SMB mounted repository guidance](smb.md) +- [Implementation plan](plan.md) +- [graphql-orm integration brief](graphql-orm-agent-brief.md) + +The root [README](../README.md) is the best starting point if you are new to the project. diff --git a/crates/graphql-orm-backup/docs/architecture.md b/crates/graphql-orm-backup/docs/architecture.md index 9372e81c..172097b2 100644 --- a/crates/graphql-orm-backup/docs/architecture.md +++ b/crates/graphql-orm-backup/docs/architecture.md @@ -28,27 +28,52 @@ ## Incremental Backup Flow -Incremental backup is blocked on graphql-orm change journal support. - -Expected flow: +`create_incremental_backup` implements the repository-side incremental snapshot flow. It depends on +the application or future `graphql-orm` runtime adapter returning reliable changes from +`GraphqlOrmBackupAdapter::export_incremental`. 1. Load parent snapshot marker. -2. Ask graphql-orm for changed rows and tombstones since the parent. +2. Ask the adapter for changed rows and deletes since the parent. 3. Ask object index for newly referenced or changed objects. -4. Write change files and objects. -5. Write incremental manifest with `parent_snapshot_id`. +4. Serialize changes as JSON Lines. +5. Compress change payloads with zstd, checksum the stored compressed bytes, and write them. +6. Write object blobs by content-addressed key if missing. +7. Emit delete tombstones for `BackupChangeAction::Delete`. +8. Write an incremental manifest with `parent_snapshot_id`. ## Restore Flow +`restore_snapshot` implements database restore orchestration. + 1. Load selected manifest. -2. Load and verify parent manifest chain. +2. Load and validate parent manifest chain. 3. Verify manifest checksums. -4. Verify object and table checksums. -5. Confirm target database/object store is empty. -6. Run migrations to compatible schema. -7. Import full snapshot rows in dependency order. -8. Apply incremental snapshots in order. -9. Restore objects. -10. Verify restored row counts and checksums. - -Only the safety scaffolding exists until graphql-orm import/export APIs are finalized. +4. Verify object, table, and change payload checksums. +5. Confirm target database is empty for `RestoreMode::EmptyDatabase`. +6. Download, decompress, and parse full table payloads. +7. Call `GraphqlOrmBackupAdapter::restore_full`. +8. Download, decompress, and parse incremental change payloads in chain order. +9. Call `GraphqlOrmBackupAdapter::restore_incremental` for each incremental manifest. + +`RestoreMode::DryRun` performs steps 1-6 and incremental parsing without calling restore methods. +Stored object rehydration is explicit through `restore_objects` and a caller-supplied +`RestoreObjectSink`. + +## Compaction Flow + +`compact_chain` folds a full-plus-incremental chain into a `SyntheticFull` snapshot. + +1. Load and verify the source manifest chain. +2. Parse the full table payloads into in-memory row maps keyed by primary key. +3. Apply incremental create/update/delete records in chain order. +4. Write the resulting tables as compressed JSON Lines under the new snapshot id. +5. Carry forward non-tombstoned object entries. +6. Write a new synthetic-full manifest. + +## Operational Safety + +- `create_full_backup`, `create_incremental_backup`, `compact_chain`, and `prune` acquire the + repository advisory lock. +- Object blob writes and checksum verification use bounded concurrency with configurable limits. +- `prune` retains manifest chains selected by `KeepPolicy` and removes expired snapshot blobs plus + unreferenced content-addressed object blobs. diff --git a/crates/graphql-orm-backup/docs/restore-semantics.md b/crates/graphql-orm-backup/docs/restore-semantics.md index 443d783e..c7824a81 100644 --- a/crates/graphql-orm-backup/docs/restore-semantics.md +++ b/crates/graphql-orm-backup/docs/restore-semantics.md @@ -1,14 +1,23 @@ # Restore Semantics -## Initial Supported Mode +## Supported Modes -Only restore into an empty target is supported initially. +The default applying mode restores into an empty target: ```rust RestoreMode::EmptyDatabase ``` -In-place restore and replacement are future work. +Dry-run restore is also supported: + +```rust +RestoreMode::DryRun +``` + +Dry run loads and validates the manifest chain, verifies payload checksums, decompresses table and +change payloads, and parses JSON Lines without calling database restore methods. + +In-place restore and replacement remain future work. ## Restore Context @@ -31,6 +40,7 @@ The default empty restore context disables application policies and change journ - Verify object checksums before final success. - Verify table payload checksums against the stored compressed bytes. - Decompress table payloads only after checksum verification. +- Refuse `EmptyDatabase` restore when the database adapter reports a non-empty target. - Preserve primary keys. - Preserve created and updated timestamps where entities define them. - Restore rows in dependency order. @@ -38,6 +48,13 @@ The default empty restore context disables application policies and change journ - Do not run normal GraphQL row policies during restore. - Refuse non-empty target databases in `EmptyDatabase` mode. +## Object Restore + +Database restore and object restore are separate. `restore_snapshot` restores table/change payloads +through `GraphqlOrmBackupAdapter`. `restore_objects` loads verified object blobs and passes them to +a caller-supplied `RestoreObjectSink` so applications can choose how to rehydrate their primary +object store. + ## Future Replacement Mode Future in-place restore must require: diff --git a/crates/graphql-orm-backup/docs/snapshot-format.md b/crates/graphql-orm-backup/docs/snapshot-format.md index c6d2f5ed..69e1e5b2 100644 --- a/crates/graphql-orm-backup/docs/snapshot-format.md +++ b/crates/graphql-orm-backup/docs/snapshot-format.md @@ -7,6 +7,7 @@ snapshots/{snapshot_id}/manifest.json snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst snapshots/{snapshot_id}/database/changes/{table_name}.jsonl.zst objects/sha256/{first_two}/{next_two}/{sha256} +locks/repository.lock ``` ## Manifest @@ -21,6 +22,7 @@ The manifest records: - database backend - backup kind - database table export entries +- database change export entries - database payload compression - object entries - tombstones @@ -38,7 +40,7 @@ objects/sha256/ab/cd/abcdef... This allows dedupe across snapshots and providers. -## Database Blobs +## Database Table Blobs The table export payload is JSON Lines compressed with zstd. Each decompressed line is one serialized backup row and ends with `\n`. @@ -61,3 +63,32 @@ The manifest records: Table entry checksums are computed over the stored compressed bytes, not the decompressed JSON Lines payload. This lets repository verification validate the exact bytes stored in the backup repository. + +## Database Change Blobs + +Incremental snapshots store change payloads under: + +```text +snapshots/{snapshot_id}/database/changes/{table_name}.jsonl.zst +``` + +Each decompressed line is a serialized `BackupChangeExport`. Delete changes also +produce manifest tombstones. + +## Synthetic Full Snapshots + +`compact_chain` writes a `SyntheticFull` manifest by replaying a full snapshot +and its incremental change payloads into a new full table payload set. The +synthetic full has no parent snapshot id. + +## Repository Lock + +Writer operations use an advisory lock blob: + +```text +locks/repository.lock +``` + +The lock is created with repository conditional-write semantics and is removed +when the writer completes. Stale lock handling is controlled by +`RepositoryLockOptions`. diff --git a/crates/graphql-orm-backup/docs/usage.md b/crates/graphql-orm-backup/docs/usage.md index 9fd58e22..756d200a 100644 --- a/crates/graphql-orm-backup/docs/usage.md +++ b/crates/graphql-orm-backup/docs/usage.md @@ -3,7 +3,7 @@ `graphql-orm-backup` is an orchestration crate. It does not connect directly to an application database or object store. Host applications provide small adapter implementations, and the crate handles snapshot layout, checksums, repository -writes, verification, and restore safety scaffolding. +writes, restore orchestration, compaction, pruning, and verification. ## Core Concepts @@ -16,6 +16,8 @@ writes, verification, and restore safety scaffolding. - `BackupSnapshotManifest`: durable record of a snapshot's database files, object files, checksums, schema hash, and application metadata. - `RestoreContext`: explicit restore mode and safety flags. +- `RestoreObjectSink`: application hook for rehydrating object bytes. +- `KeepPolicy`: retention rule used by `prune`. ## Creating A Full Backup @@ -76,9 +78,12 @@ For full backups, implement: hash. - `export_full`: return table exports in the order they should be written. -Restore and incremental methods are present in the trait so the public contract -can evolve in place, but full restore and true incremental backup are not yet -implemented by this crate. +For restore and incremental backups, implement: + +- `restore_target_is_empty`: report whether `RestoreMode::EmptyDatabase` is safe. +- `export_incremental`: return create/update/delete changes since a parent snapshot. +- `restore_full`: import full table exports. +- `restore_incremental`: apply incremental changes in manifest-chain order. ## Object Index Responsibilities @@ -94,6 +99,126 @@ For full backups, implement: `create_full_backup` verifies the loaded bytes against the declared SHA-256 before the object is referenced in the manifest. +## Incremental Backup + +`create_incremental_backup` writes compressed change files and an incremental +manifest linked to a parent snapshot. + +```rust +use graphql_orm_backup::{ + BackupObjectIndex, GraphqlOrmBackupAdapter, IncrementalBackupRequest, + LocalBackupRepository, create_incremental_backup, +}; +use uuid::Uuid; + +async fn run_incremental( + database: &dyn GraphqlOrmBackupAdapter, + objects: &dyn BackupObjectIndex, + parent_snapshot_id: Uuid, +) -> Result<(), graphql_orm_backup::BackupError> { + let repository = LocalBackupRepository::new("./backups"); + + create_incremental_backup( + &repository, + database, + objects, + IncrementalBackupRequest { + snapshot_id: Uuid::new_v4(), + parent_snapshot_id, + created_at: 1_775_174_401, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + }, + ) + .await?; + + Ok(()) +} +``` + +Delete changes emit manifest tombstones. The quality of an incremental backup +depends on the adapter’s change journal or equivalent application-side change +tracking. + +## Restore + +`restore_snapshot` loads and validates the manifest chain, verifies payload +checksums, parses compressed JSON Lines, and calls the database adapter. + +```rust +use graphql_orm_backup::{ + BackupRepository, GraphqlOrmBackupAdapter, RestoreContext, restore_snapshot, +}; +use uuid::Uuid; + +async fn restore( + repository: &dyn BackupRepository, + database: &dyn GraphqlOrmBackupAdapter, + snapshot_id: Uuid, +) -> Result<(), graphql_orm_backup::BackupError> { + restore_snapshot( + repository, + database, + snapshot_id, + RestoreContext::empty_database(), + ) + .await?; + + Ok(()) +} +``` + +Use `RestoreContext::dry_run()` to validate and parse a snapshot chain without +calling adapter restore methods. + +Object rehydration is separate: + +```rust +use graphql_orm_backup::{ + BackupRepository, BackupSnapshotManifest, RestoreObjectSink, restore_objects, +}; + +async fn restore_object_store( + repository: &dyn BackupRepository, + manifest: &BackupSnapshotManifest, + sink: &dyn RestoreObjectSink, +) -> Result<(), graphql_orm_backup::BackupError> { + restore_objects(repository, manifest, sink).await +} +``` + +## Compaction And Retention + +`compact_chain` folds a full-plus-incremental chain into a synthetic full +snapshot: + +```rust +use graphql_orm_backup::{CompactChainRequest, BackupRepository, compact_chain}; +use uuid::Uuid; + +async fn compact( + repository: &dyn BackupRepository, + source_snapshot_id: Uuid, +) -> Result<(), graphql_orm_backup::BackupError> { + compact_chain( + repository, + CompactChainRequest { + snapshot_id: Uuid::new_v4(), + source_snapshot_id, + created_at: 1_775_174_402, + app_id: "example-app".to_string(), + app_version: "0.1.0".to_string(), + }, + ) + .await?; + + Ok(()) +} +``` + +`prune` retains the newest manifest chains selected by `KeepPolicy` and deletes +expired snapshot blobs plus unreferenced content-addressed object blobs. + ## Repository Layout Full backups use this layout: @@ -101,7 +226,9 @@ Full backups use this layout: ```text snapshots/{snapshot_id}/manifest.json snapshots/{snapshot_id}/database/tables/{table_name}.jsonl.zst +snapshots/{snapshot_id}/database/changes/{table_name}.jsonl.zst objects/sha256/{first_two}/{next_two}/{sha256} +locks/repository.lock ``` Table payloads are zstd-compressed JSON Lines. Manifest table checksums cover @@ -128,20 +255,7 @@ Verification checks: - manifest checksum - object blob checksums - table export blob checksums +- incremental change blob checksums -## Restore Status - -The crate currently provides restore safety scaffolding only: - -- `RestoreContext::empty_database` -- `RestoreContext::dry_run` -- `ensure_empty_restore_target` - -Full restore depends on stable `graphql-orm` row import, dependency ordering, -restore context, and policy/journal bypass APIs. - -## Incremental Backup Status - -Incremental backup is intentionally deferred. It depends on a reliable -`graphql-orm` change journal with row updates, deletes/tombstones, transaction -ordering, and object-change discovery semantics. +`VerificationOptions` lets callers tune bounded checksum verification +concurrency. diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs index 6e0facd7..88e4b78e 100644 --- a/crates/graphql-orm-backup/src/backup.rs +++ b/crates/graphql-orm-backup/src/backup.rs @@ -14,11 +14,15 @@ use crate::{ }; pub const DATABASE_EXPORT_FORMAT: &str = "jsonl"; +/// Default number of concurrent object/payload operations. pub const DEFAULT_OBJECT_CONCURRENCY: usize = 8; +/// Runtime tuning options for backup, incremental, and compaction operations. #[derive(Clone, Debug, Eq, PartialEq)] pub struct BackupExecutionOptions { + /// Maximum number of concurrent object writes/checks. pub object_concurrency: usize, + /// Advisory repository lock settings. pub lock: RepositoryLockOptions, } @@ -31,50 +35,71 @@ impl Default for BackupExecutionOptions { } } +/// Request metadata for a full backup. #[derive(Clone, Debug, Eq, PartialEq)] pub struct FullBackupRequest { + /// Snapshot id to write. pub snapshot_id: Uuid, /// Snapshot creation time as UTC Unix seconds. pub created_at: i64, + /// Stable application identifier. pub app_id: String, + /// Application version that produced the snapshot. pub app_version: String, } +/// Result returned after writing a full backup. #[derive(Clone, Debug, PartialEq)] pub struct FullBackupResult { + /// Manifest written for the full backup. pub manifest: BackupSnapshotManifest, } +/// Request metadata for an incremental backup. #[derive(Clone, Debug, Eq, PartialEq)] pub struct IncrementalBackupRequest { + /// Snapshot id to write. pub snapshot_id: Uuid, + /// Parent full, synthetic-full, or incremental snapshot id. pub parent_snapshot_id: Uuid, /// Snapshot creation time as UTC Unix seconds. pub created_at: i64, + /// Stable application identifier. pub app_id: String, + /// Application version that produced the snapshot. pub app_version: String, } +/// Result returned after writing an incremental backup. #[derive(Clone, Debug, PartialEq)] pub struct IncrementalBackupResult { + /// Manifest written for the incremental backup. pub manifest: BackupSnapshotManifest, } +/// Request metadata for synthetic-full compaction. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CompactChainRequest { + /// Synthetic-full snapshot id to write. pub snapshot_id: Uuid, + /// Source snapshot id whose chain should be compacted. pub source_snapshot_id: Uuid, /// Snapshot creation time as UTC Unix seconds. pub created_at: i64, + /// Stable application identifier. pub app_id: String, + /// Application version that produced the synthetic full. pub app_version: String, } +/// Result returned after synthetic-full compaction. #[derive(Clone, Debug, PartialEq)] pub struct CompactChainResult { + /// Manifest written for the synthetic-full snapshot. pub manifest: BackupSnapshotManifest, } +/// Returns the manifest key for a snapshot id. #[must_use] pub fn snapshot_manifest_key(snapshot_id: Uuid) -> String { format!("snapshots/{snapshot_id}/manifest.json") diff --git a/crates/graphql-orm-backup/src/database.rs b/crates/graphql-orm-backup/src/database.rs index d1ebe32d..a7336a44 100644 --- a/crates/graphql-orm-backup/src/database.rs +++ b/crates/graphql-orm-backup/src/database.rs @@ -5,6 +5,7 @@ use uuid::Uuid; use crate::{BackupError, RestoreContext}; #[async_trait] +/// Database integration contract used by backup and restore operations. pub trait GraphqlOrmBackupAdapter: Send + Sync { /// Returns backup-relevant schema metadata. /// @@ -68,39 +69,60 @@ pub trait GraphqlOrmBackupAdapter: Send + Sync { ) -> Result<(), BackupError>; } +/// Schema metadata captured in backup manifests. #[derive(Clone, Debug, Eq, PartialEq)] pub struct GraphqlOrmBackupSchema { + /// Database backend identifier, such as `sqlite` or `postgres`. pub backend: String, + /// Application/ORM migration version. pub migration_version: String, + /// Stable schema hash. pub schema_hash: String, } +/// Full export for one table. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupTableExport { + /// Table name. pub table_name: String, + /// Rows exported for this table. pub rows: Vec, } +/// Logical database row used in table and change payloads. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupRow { + /// Table name. pub table_name: String, + /// Stable primary-key string. pub primary_key: String, + /// Adapter-provided row hash. pub row_hash: String, + /// JSON-compatible row values. pub values: serde_json::Map, } +/// Incremental change action. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum BackupChangeAction { + /// New row. Create, + /// Existing row updated. Update, + /// Row deleted. Delete, } +/// One incremental row change. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupChangeExport { + /// Table name. pub table_name: String, + /// Stable primary-key string. pub primary_key: String, + /// Change action. pub action: BackupChangeAction, + /// Row body for create/update changes. pub row: Option, /// Change time as UTC Unix seconds. pub changed_at: i64, diff --git a/crates/graphql-orm-backup/src/lib.rs b/crates/graphql-orm-backup/src/lib.rs index 7b503def..9761fc17 100644 --- a/crates/graphql-orm-backup/src/lib.rs +++ b/crates/graphql-orm-backup/src/lib.rs @@ -1,7 +1,69 @@ -//! Backup and restore orchestration primitives for graphql-orm applications. +//! Backup and restore orchestration primitives for `graphql-orm` applications. //! //! This crate coordinates database export/import adapters, stored object indexes, //! backup repositories, snapshot manifests, verification, and restore planning. +//! +//! `graphql-orm-backup` does not own application authorization, scheduling, +//! cloud credentials, or primary object metadata. Applications provide small +//! adapter implementations and this crate handles repository layout, checksums, +//! compressed payloads, manifest chains, restore orchestration, compaction, +//! locking, and pruning. +//! +//! # Full Backup +//! +//! ```no_run +//! use graphql_orm_backup::{ +//! BackupObjectIndex, FullBackupRequest, GraphqlOrmBackupAdapter, +//! LocalBackupRepository, create_full_backup, +//! }; +//! use uuid::Uuid; +//! +//! # async fn example( +//! # database: &dyn GraphqlOrmBackupAdapter, +//! # objects: &dyn BackupObjectIndex, +//! # ) -> Result<(), graphql_orm_backup::BackupError> { +//! let repository = LocalBackupRepository::new("./backups"); +//! let result = create_full_backup( +//! &repository, +//! database, +//! objects, +//! FullBackupRequest { +//! snapshot_id: Uuid::new_v4(), +//! created_at: 1_775_174_400, +//! app_id: "example-app".to_string(), +//! app_version: "0.1.0".to_string(), +//! }, +//! ) +//! .await?; +//! +//! println!("created snapshot {}", result.manifest.snapshot_id); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Restore +//! +//! ```no_run +//! use graphql_orm_backup::{ +//! BackupRepository, GraphqlOrmBackupAdapter, RestoreContext, restore_snapshot, +//! }; +//! use uuid::Uuid; +//! +//! # async fn example( +//! # repository: &dyn BackupRepository, +//! # database: &dyn GraphqlOrmBackupAdapter, +//! # snapshot_id: Uuid, +//! # ) -> Result<(), graphql_orm_backup::BackupError> { +//! restore_snapshot( +//! repository, +//! database, +//! snapshot_id, +//! RestoreContext::empty_database(), +//! ) +//! .await?; +//! # Ok(()) +//! # } +//! ``` mod backup; mod database; diff --git a/crates/graphql-orm-backup/src/local_repository.rs b/crates/graphql-orm-backup/src/local_repository.rs index 6f6201b5..9fa81d7c 100644 --- a/crates/graphql-orm-backup/src/local_repository.rs +++ b/crates/graphql-orm-backup/src/local_repository.rs @@ -9,6 +9,7 @@ use bytes::Bytes; use crate::{BackupError, BackupRepository}; #[derive(Clone, Debug)] +/// Local filesystem implementation of [`BackupRepository`]. pub struct LocalBackupRepository { root: PathBuf, } diff --git a/crates/graphql-orm-backup/src/lock.rs b/crates/graphql-orm-backup/src/lock.rs index c3e5e1bf..450a8d21 100644 --- a/crates/graphql-orm-backup/src/lock.rs +++ b/crates/graphql-orm-backup/src/lock.rs @@ -6,7 +6,9 @@ pub const DEFAULT_LOCK_STALE_AFTER_SECONDS: i64 = 3_600; const REPOSITORY_LOCK_KEY: &str = "locks/repository.lock"; #[derive(Clone, Debug, Eq, PartialEq)] +/// Advisory repository lock settings. pub struct RepositoryLockOptions { + /// Age in seconds after which a lock blob is considered stale. pub stale_after_seconds: i64, } @@ -19,6 +21,7 @@ impl Default for RepositoryLockOptions { } #[derive(Clone, Debug, Eq, PartialEq)] +/// Acquired advisory repository lock. pub struct RepositoryLock { key: String, } diff --git a/crates/graphql-orm-backup/src/manifest.rs b/crates/graphql-orm-backup/src/manifest.rs index 5f4662cc..ebe35589 100644 --- a/crates/graphql-orm-backup/src/manifest.rs +++ b/crates/graphql-orm-backup/src/manifest.rs @@ -8,75 +8,119 @@ use crate::{BackupError, BackupRepository}; pub const BACKUP_FORMAT_VERSION: u32 = 1; +/// Snapshot type stored in a manifest. #[non_exhaustive] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum BackupKind { + /// Complete table/object snapshot. Full, + /// Changes relative to a parent snapshot. Incremental, + /// Full snapshot synthesized by compacting a chain. SyntheticFull, } +/// Compression applied to database payload blobs. #[non_exhaustive] #[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum BackupCompression { + /// Payload is stored without compression. #[default] None, + /// Payload is zstd-compressed. Zstd, } +/// Durable manifest describing one backup snapshot. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupSnapshotManifest { + /// Manifest format version. pub format_version: u32, + /// Snapshot id. pub snapshot_id: Uuid, + /// Parent snapshot id for incrementals. pub parent_snapshot_id: Option, /// Snapshot creation time as UTC Unix seconds. pub created_at: i64, + /// Stable application identifier. pub app_id: String, + /// Application version that produced the snapshot. pub app_version: String, + /// GraphQL ORM migration/schema version. pub graphql_orm_schema_version: String, + /// Stable hash of the GraphQL ORM schema snapshot. pub graphql_orm_schema_hash: String, + /// Database backend identifier. pub database_backend: String, + /// Snapshot kind. pub backup_kind: BackupKind, + /// Database payload metadata. pub database: DatabaseBackupManifest, + /// Stored object payload metadata. pub objects: Vec, + /// Delete tombstones included in the snapshot. pub tombstones: Vec, + /// Manifest checksum. The checksum field itself is cleared while hashing. pub checksum: String, } +/// Database payload metadata stored in a snapshot manifest. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct DatabaseBackupManifest { + /// Logical payload format, currently `jsonl`. pub export_format: String, + /// Compression applied to table/change payload blobs. #[serde(default)] pub compression: BackupCompression, + /// Total full rows or incremental changes represented by this manifest. pub row_count: u64, + /// Number of full table exports. pub table_count: u64, + /// Full table payload entries. pub tables: Vec, + /// Incremental change payload entries. #[serde(default)] pub changes: Vec, } +/// Metadata for a table or change payload blob. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TableBackupEntry { + /// Table name represented by this payload. pub table_name: String, + /// Number of rows or changes in this payload. pub row_count: u64, + /// Repository content key. pub content_key: String, + /// SHA-256 checksum of the stored payload bytes. pub sha256_hex: String, } +/// Metadata for a content-addressed stored object blob. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct ObjectBackupEntry { + /// Application object id. pub object_id: Uuid, + /// Original application storage key. pub storage_key: String, + /// Repository content key. pub content_key: String, + /// SHA-256 checksum of the object bytes. pub sha256_hex: String, + /// Object size in bytes. pub size_bytes: u64, + /// Optional MIME type. pub mime_type: Option, } +/// Delete tombstone emitted by incremental snapshots. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupTombstone { + /// Deleted table name, when the tombstone refers to a database row. pub table_name: Option, + /// Deleted primary key, when the tombstone refers to a database row. pub primary_key: Option, + /// Deleted object id, when the tombstone refers to a stored object. pub object_id: Option, /// Deletion time as UTC Unix seconds. pub deleted_at: i64, diff --git a/crates/graphql-orm-backup/src/object_index.rs b/crates/graphql-orm-backup/src/object_index.rs index f4ad862b..1539589c 100644 --- a/crates/graphql-orm-backup/src/object_index.rs +++ b/crates/graphql-orm-backup/src/object_index.rs @@ -5,6 +5,7 @@ use uuid::Uuid; use crate::BackupError; #[async_trait] +/// Application object lookup contract used by backup operations. pub trait BackupObjectIndex: Send + Sync { /// Lists all objects referenced by a full backup. /// @@ -32,11 +33,17 @@ pub trait BackupObjectIndex: Send + Sync { async fn load_object(&self, object: &BackupObjectRef) -> Result; } +/// Object metadata returned by an application object index. #[derive(Clone, Debug, Eq, PartialEq)] pub struct BackupObjectRef { + /// Application object id. pub object_id: Uuid, + /// Original application storage key. pub storage_key: String, + /// Expected SHA-256 checksum of the object bytes. pub sha256_hex: String, + /// Object size in bytes. pub size_bytes: u64, + /// Optional MIME type. pub mime_type: Option, } diff --git a/crates/graphql-orm-backup/src/prune.rs b/crates/graphql-orm-backup/src/prune.rs index 16d759ba..70a6ae40 100644 --- a/crates/graphql-orm-backup/src/prune.rs +++ b/crates/graphql-orm-backup/src/prune.rs @@ -8,8 +8,11 @@ use crate::{ }; #[derive(Clone, Debug, Eq, PartialEq)] +/// Retention policy for repository pruning. pub struct KeepPolicy { + /// Number of newest manifest chains to retain. pub keep_last: usize, + /// Advisory repository lock settings. pub lock: RepositoryLockOptions, } @@ -23,9 +26,13 @@ impl Default for KeepPolicy { } #[derive(Clone, Debug, Eq, PartialEq)] +/// Summary returned by `prune`. pub struct PruneResult { + /// Number of retained snapshot manifests. pub retained_snapshots: usize, + /// Number of expired snapshot manifests. pub deleted_snapshots: usize, + /// Number of blobs deleted. pub deleted_blobs: usize, } diff --git a/crates/graphql-orm-backup/src/repository.rs b/crates/graphql-orm-backup/src/repository.rs index 46856f40..7a4ce2a0 100644 --- a/crates/graphql-orm-backup/src/repository.rs +++ b/crates/graphql-orm-backup/src/repository.rs @@ -4,6 +4,7 @@ use bytes::Bytes; use crate::BackupError; #[async_trait] +/// Key-addressed backup repository. pub trait BackupRepository: Send + Sync { /// Writes a blob at a repository key. /// diff --git a/crates/graphql-orm-backup/src/restore.rs b/crates/graphql-orm-backup/src/restore.rs index 5ddc9849..33e04eac 100644 --- a/crates/graphql-orm-backup/src/restore.rs +++ b/crates/graphql-orm-backup/src/restore.rs @@ -10,27 +10,40 @@ use crate::{ }; #[derive(Clone, Debug, Eq, PartialEq)] +/// Restore execution mode. pub enum RestoreMode { + /// Apply restore only if the target is empty. EmptyDatabase, + /// Validate and parse without applying database changes. DryRun, } #[derive(Clone, Debug, Eq, PartialEq)] +/// Restore behavior flags passed to database adapters. pub struct RestoreContext { + /// Restore mode. pub mode: RestoreMode, + /// Whether application policies should be disabled during restore. pub disable_policies: bool, + /// Whether change journaling should be disabled during restore. pub disable_change_journal: bool, } #[derive(Clone, Debug, Eq, PartialEq)] +/// Summary returned by `restore_snapshot`. pub struct RestoreResult { + /// Number of manifests in the restored chain. pub manifest_chain_len: usize, + /// Number of full table payloads parsed. pub full_table_count: u64, + /// Number of full rows parsed. pub full_row_count: u64, + /// Number of incremental changes parsed. pub incremental_change_count: u64, } #[async_trait] +/// Sink used by `restore_objects` to rehydrate application object stores. pub trait RestoreObjectSink: Send + Sync { /// Restores one object loaded from a backup repository. /// diff --git a/crates/graphql-orm-backup/src/verify.rs b/crates/graphql-orm-backup/src/verify.rs index 2ec808ea..eb48c909 100644 --- a/crates/graphql-orm-backup/src/verify.rs +++ b/crates/graphql-orm-backup/src/verify.rs @@ -5,7 +5,9 @@ use crate::{ use futures::{StreamExt, TryStreamExt, stream}; #[derive(Clone, Debug, Eq, PartialEq)] +/// Verification concurrency settings. pub struct VerificationOptions { + /// Maximum number of concurrent blob checksum reads. pub blob_concurrency: usize, } From 1b1356663d7dadd50b36338d42a5b87204722f05 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 00:59:24 +0000 Subject: [PATCH 013/108] Refresh storage documentation --- crates/graphql-orm-storage/README.md | 174 +++++++++++------- crates/graphql-orm-storage/docs/README.md | 18 ++ .../graphql-orm-storage/docs/architecture.md | 19 +- .../docs/backup-integration.md | 13 +- .../graphql-orm-storage/docs/development.md | 66 +++++++ .../docs/digitise-extraction-notes.md | 34 ---- crates/graphql-orm-storage/docs/plan.md | 93 +++++----- .../graphql-orm-storage/docs/release-notes.md | 48 +++++ crates/graphql-orm-storage/src/error.rs | 29 ++- crates/graphql-orm-storage/src/lib.rs | 30 ++- 10 files changed, 358 insertions(+), 166 deletions(-) create mode 100644 crates/graphql-orm-storage/docs/README.md create mode 100644 crates/graphql-orm-storage/docs/development.md delete mode 100644 crates/graphql-orm-storage/docs/digitise-extraction-notes.md create mode 100644 crates/graphql-orm-storage/docs/release-notes.md diff --git a/crates/graphql-orm-storage/README.md b/crates/graphql-orm-storage/README.md index 157f5797..82c19d2b 100644 --- a/crates/graphql-orm-storage/README.md +++ b/crates/graphql-orm-storage/README.md @@ -1,35 +1,56 @@ # graphql-orm-storage -Provider-neutral object storage primitives for applications that use `graphql-orm`. - -This crate stores bytes in an object backend and returns metadata that an application can persist in its own `graphql-orm` entity. It deliberately does not define application concepts such as collections, records, accessions, tenants, users, or media workflows. - -Current crate version: `0.3.0`. - -## Current Status - -- Local filesystem backend implemented. -- Streaming `BlobStore` abstraction implemented. -- Stable object metadata and key generation implemented. -- S3-compatible backend implemented behind the `s3` feature. -- Azure Blob exposes an explicit unsupported placeholder backend behind the `azure` feature. - -## Design Rule +`graphql-orm-storage` provides provider-neutral object storage primitives for +applications that use `graphql-orm`. + +It stores bytes in object backends and returns metadata that host applications +can persist in their own `graphql-orm` entities. It does not define application +tables, authorization, upload routes, download routes, GraphQL resolvers, or +domain workflows. + +## Highlights + +- streaming `BlobStore` trait for low-level key-addressed blob storage +- high-level `StorageService` that generates object IDs, sharded keys, byte + counts, SHA-256 checksums, and timestamps +- buffered and streaming object APIs +- local filesystem backend enabled by default +- S3-compatible backend behind the `s3` feature, including MinIO-compatible + path-style configuration +- Azure Blob placeholder behind the `azure` feature that returns explicit + `UnsupportedBackend` errors until implemented +- strict key validation for path safety across providers +- byte-range reads, conditional writes, provider-side copy hooks, and paged + listing for cloud-provider compatibility +- retry-aware provider error taxonomy through `StorageError::is_retryable` + +## Install + +Default local filesystem support: + +```toml +[dependencies] +graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm-storage" } +``` -Do not store file bytes in the application database. Store bytes in an object backend and persist only metadata in the database. +S3-compatible storage without the default local backend: -The core crate does not provide default GraphQL resolvers. Upload, download, delete, and metadata mutation resolvers need host-application authorization and row-policy logic. Future GraphQL helpers should require the application to inject an explicit access-policy adapter. +```toml +[dependencies] +graphql-orm-storage = { + git = "https://github.com/Dastari/graphql-orm-storage", + default-features = false, + features = ["s3"], +} +``` -## Cargo Features +Available provider features: -- `local`: enabled by default; provides `LocalStorageBackend`. -- `s3`: provides `S3StorageBackend` and `S3StorageConfig` backed by `aws-sdk-s3`; supports S3-compatible providers such as MinIO with path-style addressing. -- `azure`: provides `AzureBlobStorageBackend` and `AzureBlobStorageConfig` placeholders that return an unsupported-backend error until real Azure Blob Storage is implemented. +- `local` - enabled by default; provides `LocalStorageBackend` +- `s3` - provides `S3StorageBackend` and `S3StorageConfig` +- `azure` - provides unsupported placeholder types for future Azure Blob work -For detailed integration guidance, see [docs/usage.md](docs/usage.md). -For the lower-level blob abstraction, see [docs/blob-store.md](docs/blob-store.md). -For streaming object APIs, see [docs/streaming.md](docs/streaming.md). -For backup integration guidance, see [docs/backup-integration.md](docs/backup-integration.md). +## Quick Local Example ```rust use std::sync::Arc; @@ -50,7 +71,7 @@ let stored = service }) .await?; -// Persist this metadata in your application's graphql-orm entity. +// Persist this metadata in the host application's graphql-orm entity. let object_id = stored.object_id; let storage_key = stored.storage_key; let sha256_hex = stored.sha256_hex; @@ -58,18 +79,50 @@ let sha256_hex = stored.sha256_hex; # } ``` -## Suggested graphql-orm Entity Shape +## S3-Compatible Example + +```rust +use std::sync::Arc; + +use graphql_orm_storage::{ + S3StorageBackend, S3StorageConfig, StorageNamespace, StoragePutRequest, + StorageService, +}; + +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +let backend = S3StorageBackend::new(S3StorageConfig { + endpoint_url: "http://127.0.0.1:9000".to_string(), + region: "us-east-1".to_string(), + bucket: "objects".to_string(), + key_prefix: Some("app-storage".to_string()), + access_key_id: "minioadmin".to_string(), + secret_access_key: "minioadmin".to_string(), + path_style: true, +}); + +let service = StorageService::new(Arc::new(backend)); +let stored = service + .put_object(StoragePutRequest { + namespace: StorageNamespace::Originals, + file_name: Some("artifact.bin".to_string()), + mime_type: Some("application/octet-stream".to_string()), + bytes: b"bytes".to_vec(), + }) + .await?; +# let _ = stored; +# Ok(()) +# } +``` + +## Storage Metadata -Applications should own their metadata entity so they can attach their own tenant, collection, user, or workflow fields. +Applications own their metadata entity so they can attach application-specific +fields and policies. ```rust -#[derive(GraphQLEntity, GraphQLRelations, GraphQLOperations, async_graphql::SimpleObject)] -#[graphql_entity( - table = "storage", - plural = "StorageItems", - default_sort = "created_at DESC" -)] -pub struct Storage { +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity(table = "storage_objects", plural = "StorageObjects")] +pub struct StorageObjectRow { #[primary_key] pub id: graphql_orm::uuid::Uuid, @@ -90,42 +143,25 @@ pub struct Storage { } ``` -## Object Keys - -Default object keys use this format: - -```text -{namespace}/{uuid[0..2]}/{uuid[2..4]}/{uuid}.{extension} -``` - -Example: - -```text -originals/6c/57/6c57a6cc-09e6-4a7f-a320-2f2bde4cfd86.jpg -``` +The crate deliberately stores file bytes in object storage, not in database +rows. -Only the file extension is copied from the original filename. The original filename is never used as an object path. +## Documentation -## Provider Roadmap +- [Documentation index](docs/README.md) +- [Usage guide](docs/usage.md) +- [BlobStore API](docs/blob-store.md) +- [Streaming APIs](docs/streaming.md) +- [Architecture and crate boundaries](docs/architecture.md) +- [Provider roadmap](docs/provider-roadmap.md) +- [Backup integration guidance](docs/backup-integration.md) +- [Development and test commands](docs/development.md) +- [Release notes](docs/release-notes.md) -1. Local filesystem -2. S3-compatible object storage -3. Azure Blob Storage implemented as `BlobStore` first, then `ObjectStorage` +## Status -S3 integration tests are opt-in. Set `S3_TEST_ENDPOINT` and `S3_TEST_BUCKET`, -plus optional `S3_TEST_REGION`, `S3_TEST_ACCESS_KEY`, `S3_TEST_SECRET_KEY`, and -`S3_TEST_PATH_STYLE`, to run them against MinIO or another S3-compatible service. - -Backup repositories such as Dropbox and SMB belong in `graphql-orm-backup`, not this crate. - -## Verification +Current crate version: `0.3.0`. -```bash -cargo fmt --check -cargo test -cargo test --all-features -cargo test --no-default-features -cargo check --features s3,azure --no-default-features -cargo clippy --all-features --all-targets -- -D warnings -cargo clippy --no-default-features --lib -- -D warnings -``` +Local filesystem and S3-compatible storage are implemented. Azure Blob remains +an explicit placeholder. Provider integration tests that require external +services are opt-in. diff --git a/crates/graphql-orm-storage/docs/README.md b/crates/graphql-orm-storage/docs/README.md new file mode 100644 index 00000000..0d87c8fc --- /dev/null +++ b/crates/graphql-orm-storage/docs/README.md @@ -0,0 +1,18 @@ +# Documentation + +This directory contains the project documentation that is too detailed for the +repository front page. + +- [Usage guide](usage.md) +- [BlobStore API](blob-store.md) +- [Streaming APIs](streaming.md) +- [Architecture and crate boundaries](architecture.md) +- [Provider roadmap](provider-roadmap.md) +- [Backup integration guidance](backup-integration.md) +- [Agent update](agent-update.md) +- [Implementation plan](plan.md) +- [Release notes](release-notes.md) +- [Development and tests](development.md) + +The root [README](../README.md) is the best starting point if you are new to +the project. diff --git a/crates/graphql-orm-storage/docs/architecture.md b/crates/graphql-orm-storage/docs/architecture.md index 26780c75..c2fa3cdf 100644 --- a/crates/graphql-orm-storage/docs/architecture.md +++ b/crates/graphql-orm-storage/docs/architecture.md @@ -2,7 +2,9 @@ ## Boundary -`graphql-orm-storage` owns object bytes and object locators. It does not own database rows. This keeps the crate usable by any application that wants to persist storage metadata differently. +`graphql-orm-storage` owns object bytes and object locators. It does not own +database rows. This keeps the crate usable by any application that wants to +persist storage metadata differently. ## BlobStore Boundary @@ -21,12 +23,13 @@ a `BlobStore` adapter, not through `StorageService`. The core crate should not provide default GraphQL upload, download, delete, or metadata mutation resolvers. -Reason: storage authorization is application-specific. Digitise currently combines: +Reason: storage authorization is application-specific. Host applications often +combine: - `graphql-orm` read/write policy names on metadata entities - application row-policy checks -- collection membership checks -- platform-admin bypass rules +- tenant, project, or ownership checks +- administrator bypass rules - route-level bearer-token validation for file download - route-level upload checks before bytes are accepted @@ -62,7 +65,7 @@ pub trait StorageAccessPolicy: Send + Sync { The host app should still own: - the `graphql-orm` storage metadata entity -- policy names such as `storage.read` and `storage.manage` +- application-specific policy names - row ownership checks - upload/download HTTP routes or GraphQL mutation wrappers - audit logging @@ -99,6 +102,6 @@ Provider-specific code should live behind cargo features: - `s3`: implemented with `aws-sdk-s3` - `azure`: reserved placeholder -Provider implementations must satisfy the same `ObjectStorage` trait. -Provider implementations should implement `BlobStore` first, then expose -`ObjectStorage` behavior on top of it. +Provider implementations must satisfy the same `BlobStore` trait. High-level +`ObjectStorage` behavior should delegate to the provider's `BlobStore` +implementation. diff --git a/crates/graphql-orm-storage/docs/backup-integration.md b/crates/graphql-orm-storage/docs/backup-integration.md index 1b221108..45325e45 100644 --- a/crates/graphql-orm-storage/docs/backup-integration.md +++ b/crates/graphql-orm-storage/docs/backup-integration.md @@ -37,10 +37,12 @@ pub struct BlobStoreBackupRepository { Mapping: -- `BackupRepository::put_blob` calls `BlobStore::put_blob` +- `BackupRepository::put_blob` calls `BlobStore::put_blob` with backup-owned + write options - `BackupRepository::get_blob` collects or streams `BlobStore::get_blob` - `BackupRepository::blob_exists` calls `BlobStore::blob_exists` -- `BackupRepository::list_blobs` calls `BlobStore::list_blobs` +- `BackupRepository::list_blobs` calls `BlobStore::list_blobs_page` or + `BlobStore::list_blobs` - `BackupRepository::delete_blob` calls `BlobStore::delete_blob` The adapter should apply and strip its configured repository prefix @@ -48,9 +50,10 @@ consistently. ## Provider Ownership -S3-compatible and Azure Blob SDK integrations should live in this crate as -`BlobStore` implementations. `graphql-orm-backup` should adapt them instead of -duplicating cloud SDK code. +S3-compatible storage lives in this crate as a `BlobStore` implementation. +Future Azure Blob SDK integration should also live in this crate. +`graphql-orm-backup` should adapt these providers instead of duplicating cloud +SDK code. Dropbox remains backup-specific and is not a primary object storage provider for this crate. diff --git a/crates/graphql-orm-storage/docs/development.md b/crates/graphql-orm-storage/docs/development.md new file mode 100644 index 00000000..a9dcae70 --- /dev/null +++ b/crates/graphql-orm-storage/docs/development.md @@ -0,0 +1,66 @@ +# Development + +This repository is a single Rust crate. + +## Common Checks + +Run the default provider tests: + +```bash +cargo test +``` + +Run the full feature matrix: + +```bash +cargo fmt --check +cargo test --all-features +cargo test --no-default-features +cargo test --features s3,azure --no-default-features +cargo check --features s3,azure --no-default-features +cargo clippy --all-features --all-targets -- -D warnings +cargo clippy --no-default-features --lib -- -D warnings +``` + +Build docs with warnings denied: + +```bash +RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps +``` + +## S3 Integration Tests + +S3 integration tests are opt-in. They compile with the `s3` feature but return +without touching the network unless `S3_TEST_ENDPOINT` and `S3_TEST_BUCKET` are +set. + +Example MinIO environment: + +```bash +S3_TEST_ENDPOINT=http://127.0.0.1:9000 \ +S3_TEST_BUCKET=graphql-orm-storage-test \ +S3_TEST_REGION=us-east-1 \ +S3_TEST_ACCESS_KEY=minioadmin \ +S3_TEST_SECRET_KEY=minioadmin \ +S3_TEST_PATH_STYLE=true \ +cargo test --features s3 --no-default-features --test s3_integration +``` + +Use a dedicated throwaway bucket or prefix. The test writes and deletes objects +under a generated prefix. + +## Documentation + +The root `README.md` should stay short. Long-form material belongs in `docs/` +and should be linked from the README or `docs/README.md`. + +Public Rust APIs should have rustdoc comments. Public fallible functions should +include a `# Errors` section. + +## Versioning + +When public APIs or documentation examples change, update: + +- `Cargo.toml` +- `Cargo.lock` +- README/docs snippets that show a concrete crate version diff --git a/crates/graphql-orm-storage/docs/digitise-extraction-notes.md b/crates/graphql-orm-storage/docs/digitise-extraction-notes.md deleted file mode 100644 index a48c9103..00000000 --- a/crates/graphql-orm-storage/docs/digitise-extraction-notes.md +++ /dev/null @@ -1,34 +0,0 @@ -# Digitise Extraction Notes - -Digitise currently has storage code in: - -- `/home/toby/digitse/src/storage/mod.rs` -- `/home/toby/digitse/src/storage/local.rs` -- `/home/toby/digitse/src/media/mod.rs` -- `/home/toby/digitse/src/domain/entities/media.rs` - -The generic pieces extracted into this crate are: - -- storage backend enum -- namespace enum -- stored object metadata -- object storage trait -- storage service wrapper -- key generation -- local filesystem backend -- checksum generation - -The Digitise-specific pieces intentionally left out are: - -- `Storage` and `Media` entity definitions -- collection ownership -- upload authorization -- download authorization -- content classification -- MIME sniffing -- thumbnail generation -- document preview generation -- audit events -- AI analysis hooks - -Digitise adoption should replace its local storage module with this crate, then keep `MediaService` as the application service that classifies content, writes `Storage` rows, creates optional `Media` rows, and queues derivative work. diff --git a/crates/graphql-orm-storage/docs/plan.md b/crates/graphql-orm-storage/docs/plan.md index 5df763e3..bdf90480 100644 --- a/crates/graphql-orm-storage/docs/plan.md +++ b/crates/graphql-orm-storage/docs/plan.md @@ -1,60 +1,67 @@ -# graphql-orm-storage Implementation Plan +# Project Plan -## Goal +`graphql-orm-storage` is the reusable byte-storage companion crate for +`graphql-orm` applications. The crate owns object and blob storage concerns +only. Host applications remain responsible for authorization, GraphQL schema +design, metadata entities, routing, audit behavior, and workflow-specific +policy. -Create a reusable object storage crate for applications that use `graphql-orm`. The crate owns byte storage concerns only. Applications remain responsible for authorization, domain ownership, GraphQL entities, upload routes, download routes, and workflow-specific behavior. - -## What This Crate Provides +## Implemented In 0.3.0 - Provider-neutral object metadata. -- Provider-neutral streaming blob storage trait. -- Provider-neutral object storage trait. -- Storage service that generates object IDs, keys, sizes, hashes, and timestamps. +- Provider-neutral `StorageBackend` and `StorageNamespace` enums. +- Streaming `BlobStore` trait with: + - streaming writes and reads + - byte-range reads + - conditional writes for content-addressed deduplication + - provider-side copy hook + - paged listing + - existence and metadata checks +- Buffered and streaming `StorageService` object APIs. - Local filesystem backend. - S3-compatible backend behind the `s3` feature. -- Feature placeholder for Azure Blob. -- Tests for key generation, checksum generation, local round trips, and path safety. - -## What This Crate Must Not Provide - -- Application auth or policy checks. -- Default GraphQL upload/download resolvers. -- Digitise collection, record, accession, media, or tenant assumptions. -- Database entities that force one application schema. -- File blobs in database rows. -- Backup repository providers such as Dropbox or SMB. +- Azure Blob placeholder behind the `azure` feature. +- SHA-256 checksum helpers. +- Safe sharded storage-key generation. +- Strict key validation for local and cloud providers. +- Public documentation and rustdocs for the crate boundary. -## Initial Implementation +## Crate Boundaries -1. Define `StorageBackend` and `StorageNamespace`. -2. Define `StoragePutRequest`, `StoredObject`, and `StorageObjectBody`. -3. Define `ObjectStorage`. -4. Define `StorageService`. -5. Implement SHA-256 checksums. -6. Implement safe sharded object key generation. -7. Implement `LocalStorageBackend`. -8. Add tests for the local backend and key safety. -9. Add `BlobStore` as the shared low-level provider abstraction. -10. Add streaming object APIs while preserving buffered object APIs. +This crate must not provide: -## Integration Pattern For Applications +- application auth or policy checks +- default GraphQL upload/download/delete resolvers +- application-specific collection, record, media, tenant, or policy assumptions +- database entities that force one application schema +- file bytes stored in database rows +- backup repository providers such as Dropbox or SMB -Applications should call `StorageService::put_object`, then persist the returned `StoredObject` fields into their own `graphql-orm` entity. +Applications should persist returned `StoredObject` metadata in their own +`graphql-orm` entities. -If database insertion fails after object storage succeeds, application code should delete the stored object or enqueue an orphan cleanup job. This crate deliberately does not know the application transaction boundary. +## Application Integration Pattern -Applications should also own GraphQL resolvers and route handlers. A future optional GraphQL helper must be authorization-adapter driven and must not expose generic upload/download operations without host-provided access checks. +1. Validate upload requests in the host application. +2. Apply application-specific authorization before accepting bytes. +3. Call `StorageService::put_object` or `StorageService::put_object_stream`. +4. Persist the returned `StoredObject` fields in the application database. +5. If database insertion fails after storage succeeds, delete the stored object + or enqueue orphan cleanup in the host application. +6. On downloads, load metadata first, authorize it, then call + `StorageService::get_object` or `StorageService::get_object_stream`. -## Expected Output From A Storage Agent +## Backup Integration Pattern -- A compilable crate under `/home/toby/graphql-orm-storage`. -- Public API documented in `README.md`. -- Local backend tests passing with `cargo test`. -- Provider roadmap documented. -- Notes explaining what Digitise must change to consume this crate. +`graphql-orm-backup` should adapt `BlobStore` directly instead of using +`StorageService`. Backup repositories use manifest, table, change-payload, and +content-addressed object keys; those keys should not be forced through primary +object metadata or generated storage namespaces. ## Future Work -- Add Azure Blob provider behind the `azure` feature, implemented as `BlobStore` first. -- Add a `graphql-orm-backup` adapter that wraps `BlobStore` as a backup repository. -- Add optional server-side encryption hooks if applications need provider-managed keys. +- Implement Azure Blob as a real `BlobStore` provider. +- Add a `graphql-orm-backup` adapter that wraps `BlobStore` as a backup + repository. +- Add optional provider-managed encryption configuration if primary object + storage applications need it. diff --git a/crates/graphql-orm-storage/docs/release-notes.md b/crates/graphql-orm-storage/docs/release-notes.md new file mode 100644 index 00000000..bf4a23c0 --- /dev/null +++ b/crates/graphql-orm-storage/docs/release-notes.md @@ -0,0 +1,48 @@ +# Release Notes + +This page records user-facing changes for recent `graphql-orm-storage` +releases. + +## 0.3.0 + +Provider API stabilization and S3 implementation. + +- Bumped `graphql-orm-storage` to `0.3.0`. +- Added retry-aware provider errors through `StorageError::Provider` and + `StorageError::is_retryable`. +- Added `StorageError::PreconditionFailed` for conditional and range/listing + precondition failures. +- Added `BlobPutOptions` for provider write metadata such as content type. +- Added `BlobListPage` and `BlobStore::list_blobs_page` for continuation-token + listing. +- Added `BlobStore::get_blob_range`, `put_blob_if_not_exists`, and `copy_blob`. +- Marked `StorageBackend` and `StorageNamespace` as `#[non_exhaustive]`. +- Added `LocalStorageBackend::sweep_temp_files` for stale `.uploading` cleanup. +- Reworked local paged listing to walk incrementally instead of collecting the + whole tree for each page. +- Implemented S3-compatible storage behind the `s3` feature using + `aws-sdk-s3`. +- Added opt-in S3 integration tests controlled by `S3_TEST_ENDPOINT` and + `S3_TEST_BUCKET`. +- Kept Azure Blob as an explicit unsupported placeholder. + +## 0.2.0 + +Streaming blob storage foundation. + +- Added the streaming `BlobStore` trait. +- Added `StorageByteStream`, `BlobBody`, `BlobMetadata`, and + `BlobWriteOutcome`. +- Rebuilt local storage on top of `BlobStore`. +- Added streaming object APIs while preserving buffered `StorageService` + methods. +- Added backup integration guidance to adapt `BlobStore` directly. + +## 0.1.0 + +Initial baseline. + +- Added provider-neutral object metadata. +- Added local filesystem storage. +- Added key generation, checksum helpers, and path-safety validation. +- Added unsupported S3 and Azure Blob placeholder APIs. diff --git a/crates/graphql-orm-storage/src/error.rs b/crates/graphql-orm-storage/src/error.rs index a5e7cff1..4aff1dc2 100644 --- a/crates/graphql-orm-storage/src/error.rs +++ b/crates/graphql-orm-storage/src/error.rs @@ -5,7 +5,10 @@ use std::path::PathBuf; pub enum StorageError { /// The selected provider is known but is not implemented by this build. #[error("unsupported storage backend: {backend}")] - UnsupportedBackend { backend: String }, + UnsupportedBackend { + /// Stable backend name. + backend: String, + }, /// A provider operation failed. #[error("storage provider error for {backend}: {message}")] @@ -20,24 +23,40 @@ pub enum StorageError { /// A storage key is empty, absolute, or contains unsafe path components. #[error("invalid storage key: {key}")] - InvalidStorageKey { key: String }, + InvalidStorageKey { + /// Rejected storage key. + key: String, + }, /// A requested blob is missing from the storage backend. #[error("storage blob is missing: {key}")] - MissingBlob { key: String }, + MissingBlob { + /// Requested storage key. + key: String, + }, /// A conditional storage operation could not be applied. #[error("storage precondition failed for {key}: {condition}")] - PreconditionFailed { key: String, condition: String }, + PreconditionFailed { + /// Storage key involved in the failed precondition. + key: String, + /// Human-readable precondition description. + condition: String, + }, /// A local filesystem object path did not have a writable parent directory. #[error("local storage path has no parent: {path:?}")] - MissingParent { path: PathBuf }, + MissingParent { + /// Local path that had no usable parent directory. + path: PathBuf, + }, /// A filesystem operation failed. #[error("storage io error at {path:?}")] Io { + /// Local path involved in the failed filesystem operation. path: PathBuf, + /// Original filesystem error. #[source] source: std::io::Error, }, diff --git a/crates/graphql-orm-storage/src/lib.rs b/crates/graphql-orm-storage/src/lib.rs index fa34e47f..73c2be0c 100644 --- a/crates/graphql-orm-storage/src/lib.rs +++ b/crates/graphql-orm-storage/src/lib.rs @@ -1,7 +1,33 @@ -//! Provider-neutral object storage primitives for applications using graphql-orm. +#![warn(missing_docs)] + +//! Provider-neutral object storage primitives for applications using +//! `graphql-orm`. //! //! This crate stores file bytes in an object backend and returns metadata that an -//! application can persist in its own graphql-orm entity. +//! application can persist in its own `graphql-orm` entity. +//! +//! # Boundaries +//! +//! `graphql-orm-storage` does not provide GraphQL upload/download resolvers, +//! application authorization, database entities, MIME sniffing, derivative +//! generation, or file bytes stored in database rows. +//! +//! Use [`StorageService`] for primary object workflows that need generated +//! object metadata. Use [`BlobStore`] for lower-level key-addressed blob +//! operations, such as backup repository adapters. +//! +//! # Example +//! +//! ``` +//! use graphql_orm_storage::{StorageByteStream, sha256_hex, validate_blob_key}; +//! +//! validate_blob_key("objects/sha256/aa/bb/hash")?; +//! assert_eq!(sha256_hex(b"bytes").len(), 64); +//! +//! let stream = StorageByteStream::from_bytes(b"bytes".to_vec()); +//! assert_eq!(stream.size_hint(), Some(5)); +//! # Ok::<(), graphql_orm_storage::StorageError>(()) +//! ``` #[cfg(feature = "azure")] mod azure; From 26330779e4e3da5de94d6ff83ba0f5e80fa92af0 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 01:07:26 +0000 Subject: [PATCH 014/108] Use storage blob store for backup repositories --- crates/graphql-orm-backup/Cargo.lock | 83 ++++++++ crates/graphql-orm-backup/Cargo.toml | 19 +- crates/graphql-orm-backup/README.md | 11 +- .../docs/cloud-provider-direction.md | 10 +- .../docs/provider-roadmap.md | 15 +- crates/graphql-orm-backup/docs/usage.md | 22 +++ crates/graphql-orm-backup/src/backup.rs | 21 +-- crates/graphql-orm-backup/src/error.rs | 24 ++- crates/graphql-orm-backup/src/lib.rs | 12 +- .../src/local_repository.rs | 177 ++---------------- crates/graphql-orm-backup/src/repository.rs | 127 +++++++++++++ 11 files changed, 324 insertions(+), 197 deletions(-) diff --git a/crates/graphql-orm-backup/Cargo.lock b/crates/graphql-orm-backup/Cargo.lock index be5253c0..2feb28b5 100644 --- a/crates/graphql-orm-backup/Cargo.lock +++ b/crates/graphql-orm-backup/Cargo.lock @@ -83,6 +83,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + [[package]] name = "digest" version = "0.10.7" @@ -257,6 +266,7 @@ dependencies = [ "async-trait", "bytes", "futures", + "graphql-orm-storage", "serde", "serde_json", "sha2", @@ -267,6 +277,23 @@ dependencies = [ "zstd", ] +[[package]] +name = "graphql-orm-storage" +version = "0.3.0" +dependencies = [ + "async-trait", + "bytes", + "futures-core", + "futures-util", + "serde", + "sha2", + "thiserror", + "time", + "tokio", + "tokio-util", + "uuid", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -364,6 +391,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -382,6 +415,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -557,12 +596,43 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "pin-project-lite", "tokio-macros", ] @@ -578,6 +648,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "typenum" version = "1.20.0" diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml index af9d7283..f3a71ebd 100644 --- a/crates/graphql-orm-backup/Cargo.toml +++ b/crates/graphql-orm-backup/Cargo.toml @@ -8,17 +8,17 @@ description = "Backup and restore orchestration primitives for graphql-orm appli [features] default = ["local"] -local = [] +local = ["graphql-orm-storage/local"] [dependencies] async-trait = "0.1" bytes = "1" futures = "0.3" +graphql-orm-storage = { version = "0.3.0", path = "../graphql-orm-storage", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" thiserror = "2" -tokio = { version = "1", features = ["fs"] } uuid = { version = "1", features = ["serde", "v4"] } zstd = "0.13" @@ -30,3 +30,18 @@ tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] } name = "local_repository_round_trip" path = "tests/local_repository_round_trip.rs" required-features = ["local"] + +[[test]] +name = "incremental_backup" +path = "tests/incremental_backup.rs" +required-features = ["local"] + +[[test]] +name = "operational_safety" +path = "tests/operational_safety.rs" +required-features = ["local"] + +[[test]] +name = "restore_snapshot" +path = "tests/restore_snapshot.rs" +required-features = ["local"] diff --git a/crates/graphql-orm-backup/README.md b/crates/graphql-orm-backup/README.md index 19ac5875..78fd883a 100644 --- a/crates/graphql-orm-backup/README.md +++ b/crates/graphql-orm-backup/README.md @@ -17,6 +17,7 @@ backup layout, checksums, repository writes, restore ordering, and operational s - zstd-compressed JSON Lines table and change payloads - content-addressed object blobs keyed by SHA-256 - local filesystem repository with path traversal protection +- `graphql-orm-storage::BlobStore` repository adapter for shared local/S3 provider code - mounted SMB support through local filesystem semantics and `LocalBackupRepository::open_existing` - bounded concurrent object writes and checksum verification - advisory repository writer lock for backup, compaction, and pruning operations @@ -146,7 +147,9 @@ events, object metadata persistence, or cloud credentials. Full backups, restore orchestration, incremental backups, manifest-chain validation, synthetic-full compaction, local repository support, locking, and pruning are implemented. -The remaining major integration item is the future `graphql-orm-storage::BlobStore` adapter path, -which should replace duplicated cloud/local blob-provider code once the storage crate exposes its -stable low-level blob trait. Client-side encryption and content-defined chunking are intentionally -out of scope for the current crate. +Provider code is shared through `graphql-orm-storage::BlobStore`. `LocalBackupRepository` is a thin +wrapper over the storage crate's local blob backend, and `BlobStoreBackupRepository` can adapt any +storage blob provider, including S3-compatible storage from `graphql-orm-storage`. + +Client-side encryption and content-defined chunking are intentionally out of scope for the current +crate. diff --git a/crates/graphql-orm-backup/docs/cloud-provider-direction.md b/crates/graphql-orm-backup/docs/cloud-provider-direction.md index 202eada5..8b807344 100644 --- a/crates/graphql-orm-backup/docs/cloud-provider-direction.md +++ b/crates/graphql-orm-backup/docs/cloud-provider-direction.md @@ -15,10 +15,9 @@ Backup repositories and primary object storage have different semantics: - backup repositories need list operations for prefixes - primary object storage should not inherit backup manifest semantics -## Future Adapter +## Adapter -Once `graphql-orm-storage::BlobStore` exists, add an optional adapter in this -crate: +`graphql-orm-backup` now exposes `BlobStoreBackupRepository`: ```rust pub struct BlobStoreBackupRepository { @@ -30,6 +29,7 @@ pub struct BlobStoreBackupRepository { Mapping: - `BackupRepository::put_blob` calls `BlobStore::put_blob` +- `BackupRepository::put_blob_if_absent` calls `BlobStore::put_blob_if_not_exists` - `BackupRepository::get_blob` collects the blob stream into `bytes::Bytes` - `BackupRepository::blob_exists` calls `BlobStore::blob_exists` - `BackupRepository::list_blobs` calls `BlobStore::list_blobs` @@ -46,5 +46,5 @@ The adapter must apply and strip its configured repository prefix consistently. ## Current Rule -Do not add direct AWS or Azure SDK dependencies to this crate until the shared -`BlobStore` path has been implemented or explicitly rejected. +Do not add direct AWS or Azure SDK dependencies to this crate. Cloud provider SDK +integration belongs in `graphql-orm-storage` as `BlobStore` implementations. diff --git a/crates/graphql-orm-backup/docs/provider-roadmap.md b/crates/graphql-orm-backup/docs/provider-roadmap.md index f1c50390..3c1bbe45 100644 --- a/crates/graphql-orm-backup/docs/provider-roadmap.md +++ b/crates/graphql-orm-backup/docs/provider-roadmap.md @@ -13,12 +13,11 @@ Acceptance criteria: ## Phase 2: S3 -Do not implement direct AWS SDK integration in this crate yet. +Do not implement direct AWS SDK integration in this crate. -`graphql-orm-storage` should first expose a shared lower-level streaming -`BlobStore` abstraction. `graphql-orm-backup` should then adapt that abstraction -to `BackupRepository` so primary object storage and backup repositories can -share S3-compatible provider code without sharing higher-level semantics. +`graphql-orm-backup` adapts `graphql-orm-storage::BlobStore` through +`BlobStoreBackupRepository`, so S3-compatible provider code should live in +`graphql-orm-storage`. Expected configuration: @@ -31,9 +30,9 @@ Expected configuration: ## Phase 3: Azure Blob -Do not implement direct Azure SDK integration in this crate yet. Azure Blob -should follow the same future `graphql-orm-storage::BlobStore` adapter path as -S3 once the shared abstraction exists. +Do not implement direct Azure SDK integration in this crate. Azure Blob should +follow the same `graphql-orm-storage::BlobStore` adapter path as S3 once the +storage crate provides a real Azure Blob implementation. Expected configuration: diff --git a/crates/graphql-orm-backup/docs/usage.md b/crates/graphql-orm-backup/docs/usage.md index 756d200a..72dcefc1 100644 --- a/crates/graphql-orm-backup/docs/usage.md +++ b/crates/graphql-orm-backup/docs/usage.md @@ -8,6 +8,8 @@ writes, restore orchestration, compaction, pruning, and verification. ## Core Concepts - `BackupRepository`: destination for backup blobs and manifests. +- `BlobStoreBackupRepository`: adapter from `graphql-orm-storage::BlobStore` to + `BackupRepository`. - `LocalBackupRepository`: filesystem implementation of `BackupRepository`. - `GraphqlOrmBackupAdapter`: interim database export/import contract until the final `graphql-orm` runtime backup API lands. @@ -234,6 +236,26 @@ locks/repository.lock Table payloads are zstd-compressed JSON Lines. Manifest table checksums cover the stored compressed bytes. +## BlobStore Adapter + +Use `BlobStoreBackupRepository` when an application already has a +`graphql-orm-storage::BlobStore` provider: + +```rust +use std::sync::Arc; + +use graphql_orm_backup::BlobStoreBackupRepository; +use graphql_orm_storage::BlobStore; + +fn backup_repository(store: Arc) -> BlobStoreBackupRepository { + BlobStoreBackupRepository::new(store) +} +``` + +`LocalBackupRepository` is built on top of the storage crate's local +`BlobStore`. S3-compatible backup repositories should also use this adapter with +the storage crate's S3 backend rather than adding AWS SDK code here. + ## Verification Use `verify_manifest_and_objects` to validate a completed snapshot manifest diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs index 88e4b78e..58537351 100644 --- a/crates/graphql-orm-backup/src/backup.rs +++ b/crates/graphql-orm-backup/src/backup.rs @@ -1,4 +1,7 @@ -use std::collections::{BTreeMap, HashMap}; +use std::{ + collections::{BTreeMap, HashMap}, + io::Write, +}; use bytes::Bytes; use futures::{StreamExt, TryStreamExt, stream}; @@ -180,7 +183,6 @@ async fn create_full_backup_inner( let mut row_count = 0_u64; for table in &plan.tables { let bytes = serialize_table_export(table)?; - let bytes = crate::compress_payload(&bytes)?; let content_key = database_table_key(request.snapshot_id, &table.table_name); let sha256_hex = sha256_hex(&bytes); repository @@ -297,7 +299,6 @@ async fn create_incremental_backup_inner( let tombstones = changes_to_tombstones(&changes); for group in group_changes_by_table(changes) { let bytes = serialize_jsonl_entries(&group.changes)?; - let bytes = crate::compress_payload(&bytes)?; let content_key = database_changes_key(request.snapshot_id, &group.table_name); let sha256_hex = sha256_hex(&bytes); repository @@ -425,7 +426,6 @@ async fn compact_chain_inner( for (table_name, rows) in table_rows { let rows = rows.into_values().collect::>(); let bytes = serialize_jsonl_entries(&rows)?; - let bytes = crate::compress_payload(&bytes)?; let content_key = database_table_key(request.snapshot_id, &table_name); let sha256_hex = sha256_hex(&bytes); repository @@ -510,12 +510,13 @@ fn serialize_jsonl_entries(entries: &[T]) -> Result, BackupError> where T: Serialize, { - let mut bytes = Vec::new(); + let mut encoder = + zstd::stream::Encoder::new(Vec::new(), 0).map_err(BackupError::compression)?; for entry in entries { - serde_json::to_writer(&mut bytes, entry)?; - bytes.push(b'\n'); + serde_json::to_writer(&mut encoder, entry)?; + encoder.write_all(b"\n").map_err(BackupError::compression)?; } - Ok(bytes) + encoder.finish().map_err(BackupError::compression) } async fn write_object_entries( @@ -538,9 +539,7 @@ async fn write_object_entries( }); } - if !repository.blob_exists(&content_key).await? { - repository.put_blob(&content_key, bytes).await?; - } + repository.put_blob_if_absent(&content_key, bytes).await?; Ok(( index, diff --git a/crates/graphql-orm-backup/src/error.rs b/crates/graphql-orm-backup/src/error.rs index 6ed4df63..189cc3c8 100644 --- a/crates/graphql-orm-backup/src/error.rs +++ b/crates/graphql-orm-backup/src/error.rs @@ -36,6 +36,12 @@ pub enum BackupError { source: std::io::Error, }, + #[error("backup storage error")] + Storage { + #[source] + source: graphql_orm_storage::StorageError, + }, + #[error("unsupported operation: {operation}")] UnsupportedOperation { operation: String }, @@ -54,12 +60,20 @@ impl BackupError { pub(crate) fn compression(source: std::io::Error) -> Self { Self::Compression { source } } +} - #[cfg(feature = "local")] - pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { - Self::Io { - path: path.into(), - source, +impl From for BackupError { + fn from(source: graphql_orm_storage::StorageError) -> Self { + match source { + graphql_orm_storage::StorageError::UnsupportedBackend { backend } => { + Self::UnsupportedProvider { provider: backend } + } + graphql_orm_storage::StorageError::InvalidStorageKey { key } => { + Self::InvalidRepositoryKey { key } + } + graphql_orm_storage::StorageError::MissingBlob { key } => Self::MissingBlob { key }, + graphql_orm_storage::StorageError::Io { path, source } => Self::Io { path, source }, + source => Self::Storage { source }, } } } diff --git a/crates/graphql-orm-backup/src/lib.rs b/crates/graphql-orm-backup/src/lib.rs index 9761fc17..e25bad6c 100644 --- a/crates/graphql-orm-backup/src/lib.rs +++ b/crates/graphql-orm-backup/src/lib.rs @@ -8,23 +8,27 @@ //! adapter implementations and this crate handles repository layout, checksums, //! compressed payloads, manifest chains, restore orchestration, compaction, //! locking, and pruning. +//! [`BlobStoreBackupRepository`] adapts `graphql-orm-storage` +//! [`graphql_orm_storage::BlobStore`] implementations so local and cloud +//! provider code can be shared without routing backup keys through primary +//! object metadata. //! //! # Full Backup //! //! ```no_run //! use graphql_orm_backup::{ //! BackupObjectIndex, FullBackupRequest, GraphqlOrmBackupAdapter, -//! LocalBackupRepository, create_full_backup, +//! BackupRepository, create_full_backup, //! }; //! use uuid::Uuid; //! //! # async fn example( +//! # repository: &dyn BackupRepository, //! # database: &dyn GraphqlOrmBackupAdapter, //! # objects: &dyn BackupObjectIndex, //! # ) -> Result<(), graphql_orm_backup::BackupError> { -//! let repository = LocalBackupRepository::new("./backups"); //! let result = create_full_backup( -//! &repository, +//! repository, //! database, //! objects, //! FullBackupRequest { @@ -104,7 +108,7 @@ pub use manifest::{ pub use object_index::{BackupObjectIndex, BackupObjectRef}; pub use planner::{FullBackupPlan, plan_full_backup}; pub use prune::{KeepPolicy, PruneResult, prune}; -pub use repository::BackupRepository; +pub use repository::{BackupRepository, BlobStoreBackupRepository}; pub use restore::{ RestoreContext, RestoreMode, RestoreObjectSink, RestoreResult, ensure_empty_restore_target, restore_objects, restore_snapshot, diff --git a/crates/graphql-orm-backup/src/local_repository.rs b/crates/graphql-orm-backup/src/local_repository.rs index 9fa81d7c..b2e69109 100644 --- a/crates/graphql-orm-backup/src/local_repository.rs +++ b/crates/graphql-orm-backup/src/local_repository.rs @@ -1,24 +1,24 @@ -use std::{ - io::Write, - path::{Component, Path, PathBuf}, -}; +use std::{path::PathBuf, sync::Arc}; use async_trait::async_trait; use bytes::Bytes; +use graphql_orm_storage::LocalStorageBackend; -use crate::{BackupError, BackupRepository}; +use crate::{BackupError, BackupRepository, BlobStoreBackupRepository}; -#[derive(Clone, Debug)] /// Local filesystem implementation of [`BackupRepository`]. +#[derive(Clone, Debug)] pub struct LocalBackupRepository { - root: PathBuf, + inner: BlobStoreBackupRepository, } impl LocalBackupRepository { /// Creates a local repository rooted at a filesystem path. #[must_use] pub fn new(root: impl Into) -> Self { - Self { root: root.into() } + Self { + inner: BlobStoreBackupRepository::new(Arc::new(LocalStorageBackend::new(root))), + } } /// Opens an existing local repository root. @@ -29,180 +29,41 @@ impl LocalBackupRepository { /// directory. pub async fn open_existing(root: impl Into) -> Result { let root = root.into(); - let metadata = tokio::fs::metadata(&root) - .await - .map_err(|source| BackupError::io(&root, source))?; + let metadata = std::fs::metadata(&root).map_err(|source| BackupError::Io { + path: root.clone(), + source, + })?; if !metadata.is_dir() { return Err(BackupError::InvalidRepositoryRoot { path: root }); } - Ok(Self { root }) - } - - fn path_for(&self, key: &str) -> Result { - validate_repository_key(key)?; - Ok(self.root.join(Path::new(key))) + Ok(Self::new(root)) } } #[async_trait] impl BackupRepository for LocalBackupRepository { async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError> { - let path = self.path_for(key)?; - let parent = path - .parent() - .ok_or_else(|| BackupError::InvalidRepositoryKey { - key: key.to_string(), - })?; - tokio::fs::create_dir_all(parent) - .await - .map_err(|source| BackupError::io(parent, source))?; - - let temp_path = path.with_extension("uploading"); - tokio::fs::write(&temp_path, body) - .await - .map_err(|source| BackupError::io(&temp_path, source))?; - tokio::fs::rename(&temp_path, &path) - .await - .map_err(|source| BackupError::io(&path, source))?; - Ok(()) + self.inner.put_blob(key, body).await } async fn put_blob_if_absent(&self, key: &str, body: Bytes) -> Result { - let path = self.path_for(key)?; - let parent = path - .parent() - .ok_or_else(|| BackupError::InvalidRepositoryKey { - key: key.to_string(), - })?; - tokio::fs::create_dir_all(parent) - .await - .map_err(|source| BackupError::io(parent, source))?; - - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - { - Ok(mut file) => { - file.write_all(&body) - .map_err(|source| BackupError::io(&path, source))?; - Ok(true) - } - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), - Err(source) => Err(BackupError::io(&path, source)), - } + self.inner.put_blob_if_absent(key, body).await } async fn get_blob(&self, key: &str) -> Result { - let path = self.path_for(key)?; - match tokio::fs::read(&path).await { - Ok(bytes) => Ok(Bytes::from(bytes)), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - Err(BackupError::MissingBlob { - key: key.to_string(), - }) - } - Err(source) => Err(BackupError::io(&path, source)), - } + self.inner.get_blob(key).await } async fn blob_exists(&self, key: &str) -> Result { - let path = self.path_for(key)?; - match tokio::fs::metadata(&path).await { - Ok(metadata) => Ok(metadata.is_file()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(source) => Err(BackupError::io(&path, source)), - } + self.inner.blob_exists(key).await } async fn list_blobs(&self, prefix: &str) -> Result, BackupError> { - validate_repository_prefix(prefix)?; - - let start = if prefix.is_empty() { - self.root.clone() - } else { - self.root.join(prefix) - }; - - let mut result = Vec::new(); - let mut stack = vec![start]; - - while let Some(path) = stack.pop() { - let metadata = match tokio::fs::metadata(&path).await { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, - Err(source) => return Err(BackupError::io(&path, source)), - }; - - if metadata.is_file() { - if let Ok(relative) = path.strip_prefix(&self.root) { - result.push(relative.to_string_lossy().replace('\\', "/")); - } - continue; - } - - let mut entries = tokio::fs::read_dir(&path) - .await - .map_err(|source| BackupError::io(&path, source))?; - while let Some(entry) = entries - .next_entry() - .await - .map_err(|source| BackupError::io(&path, source))? - { - stack.push(entry.path()); - } - } - - result.sort(); - Ok(result) + self.inner.list_blobs(prefix).await } async fn delete_blob(&self, key: &str) -> Result<(), BackupError> { - let path = self.path_for(key)?; - match tokio::fs::remove_file(&path).await { - Ok(()) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(source) => Err(BackupError::io(&path, source)), - } - } -} - -fn validate_repository_key(key: &str) -> Result<(), BackupError> { - if key.is_empty() - || key.contains('\\') - || key.contains('\0') - || key - .split('/') - .any(|component| component.is_empty() || component == "." || component == "..") - { - return Err(BackupError::InvalidRepositoryKey { - key: key.to_string(), - }); - } - - let path = Path::new(key); - if path.is_absolute() { - return Err(BackupError::InvalidRepositoryKey { - key: key.to_string(), - }); + self.inner.delete_blob(key).await } - - for component in path.components() { - if !matches!(component, Component::Normal(_)) { - return Err(BackupError::InvalidRepositoryKey { - key: key.to_string(), - }); - } - } - - Ok(()) -} - -fn validate_repository_prefix(prefix: &str) -> Result<(), BackupError> { - if prefix.is_empty() { - return Ok(()); - } - - validate_repository_key(prefix) } diff --git a/crates/graphql-orm-backup/src/repository.rs b/crates/graphql-orm-backup/src/repository.rs index 7a4ce2a0..487eea5e 100644 --- a/crates/graphql-orm-backup/src/repository.rs +++ b/crates/graphql-orm-backup/src/repository.rs @@ -1,5 +1,10 @@ +use std::sync::Arc; + use async_trait::async_trait; use bytes::Bytes; +use graphql_orm_storage::{ + BlobPutOptions, BlobStore, StorageByteStream, collect_storage_stream, validate_blob_key, +}; use crate::BackupError; @@ -63,3 +68,125 @@ pub trait BackupRepository: Send + Sync { /// delete the blob. async fn delete_blob(&self, key: &str) -> Result<(), BackupError>; } + +/// [`BackupRepository`] adapter over a `graphql-orm-storage` [`BlobStore`]. +#[derive(Clone)] +pub struct BlobStoreBackupRepository { + store: Arc, + prefix: Option, +} + +impl BlobStoreBackupRepository { + /// Creates an adapter without a repository prefix. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { + store, + prefix: None, + } + } + + /// Creates an adapter rooted under a blob-store prefix. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the prefix is not a safe blob key. + pub fn with_prefix( + store: Arc, + prefix: impl Into, + ) -> Result { + let prefix = prefix.into(); + validate_blob_key(&prefix)?; + Ok(Self { + store, + prefix: Some(prefix), + }) + } + + /// Returns the wrapped blob store. + #[must_use] + pub fn store(&self) -> Arc { + Arc::clone(&self.store) + } + + fn apply_prefix(&self, key: &str) -> String { + match &self.prefix { + Some(prefix) => format!("{prefix}/{key}"), + None => key.to_string(), + } + } + + fn apply_prefix_to_list(&self, prefix: &str) -> String { + match (&self.prefix, prefix.is_empty()) { + (Some(repository_prefix), true) => repository_prefix.clone(), + (Some(repository_prefix), false) => format!("{repository_prefix}/{prefix}"), + (None, _) => prefix.to_string(), + } + } + + fn strip_prefix(&self, key: String) -> String { + let Some(prefix) = &self.prefix else { + return key; + }; + key.strip_prefix(prefix) + .and_then(|rest| rest.strip_prefix('/')) + .unwrap_or(&key) + .to_string() + } +} + +impl std::fmt::Debug for BlobStoreBackupRepository { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BlobStoreBackupRepository") + .field("prefix", &self.prefix) + .finish_non_exhaustive() + } +} + +#[async_trait] +impl BackupRepository for BlobStoreBackupRepository { + async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError> { + self.store + .put_blob( + &self.apply_prefix(key), + StorageByteStream::from_bytes(body), + BlobPutOptions::default(), + ) + .await?; + Ok(()) + } + + async fn put_blob_if_absent(&self, key: &str, body: Bytes) -> Result { + let outcome = self + .store + .put_blob_if_not_exists( + &self.apply_prefix(key), + StorageByteStream::from_bytes(body), + BlobPutOptions::default(), + ) + .await?; + Ok(outcome.is_some()) + } + + async fn get_blob(&self, key: &str) -> Result { + let body = self.store.get_blob(&self.apply_prefix(key)).await?; + Ok(collect_storage_stream(body.body).await?) + } + + async fn blob_exists(&self, key: &str) -> Result { + Ok(self.store.blob_exists(&self.apply_prefix(key)).await?) + } + + async fn list_blobs(&self, prefix: &str) -> Result, BackupError> { + let keys = self + .store + .list_blobs(&self.apply_prefix_to_list(prefix)) + .await?; + Ok(keys.into_iter().map(|key| self.strip_prefix(key)).collect()) + } + + async fn delete_blob(&self, key: &str) -> Result<(), BackupError> { + Ok(self.store.delete_blob(&self.apply_prefix(key)).await?) + } +} From c6578163e7c70e01407280c492cf88c1073a377d Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 02:57:13 +0000 Subject: [PATCH 015/108] Expose ranged object streams --- crates/graphql-orm-storage/docs/streaming.md | 25 +++++++++++++++++++ crates/graphql-orm-storage/src/service.rs | 22 +++++++++++++++- .../tests/local_round_trip.rs | 12 +++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/graphql-orm-storage/docs/streaming.md b/crates/graphql-orm-storage/docs/streaming.md index afb71549..b0cab485 100644 --- a/crates/graphql-orm-storage/docs/streaming.md +++ b/crates/graphql-orm-storage/docs/streaming.md @@ -66,6 +66,31 @@ let bytes = collect_storage_stream(loaded.body).await?; Applications can use the stream directly instead of collecting it. +## Load A Range + +```rust +# use std::sync::Arc; +# use graphql_orm_storage::{ +# LocalStorageBackend, StorageByteStream, StorageNamespace, +# StoragePutStreamRequest, StorageService, collect_storage_stream, +# }; +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +# let service = StorageService::new(Arc::new(LocalStorageBackend::new("./data/storage"))); +# let stored = service.put_object_stream(StoragePutStreamRequest { +# namespace: StorageNamespace::Originals, +# file_name: Some("artifact.bin".to_string()), +# mime_type: Some("application/octet-stream".to_string()), +# body: StorageByteStream::from_bytes(b"streamed bytes".to_vec()), +# }).await?; +let loaded = service.get_object_range_stream(&stored, 0..8).await?; +let bytes = collect_storage_stream(loaded.body).await?; +# Ok(()) +# } +``` + +Range reads delegate to the configured provider when it has a native ranged read +path. + ## Buffered Compatibility The original buffered APIs remain available and delegate through the streaming diff --git a/crates/graphql-orm-storage/src/service.rs b/crates/graphql-orm-storage/src/service.rs index e0e6c35f..108700ba 100644 --- a/crates/graphql-orm-storage/src/service.rs +++ b/crates/graphql-orm-storage/src/service.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{ops::Range, sync::Arc}; use async_trait::async_trait; use time::OffsetDateTime; @@ -131,6 +131,26 @@ impl StorageService { }) } + /// Loads a byte range as a streaming object body for existing metadata. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the backend cannot load the object range. + pub async fn get_object_range_stream( + &self, + object: &StoredObject, + range: Range, + ) -> Result { + let body = self + .backend + .get_blob_range(&object.storage_key, range) + .await?; + Ok(StorageObjectStream { + object: object.clone(), + body: body.body, + }) + } + /// Deletes an object from the configured backend. /// /// # Errors diff --git a/crates/graphql-orm-storage/tests/local_round_trip.rs b/crates/graphql-orm-storage/tests/local_round_trip.rs index 7522f135..8b0f70ec 100644 --- a/crates/graphql-orm-storage/tests/local_round_trip.rs +++ b/crates/graphql-orm-storage/tests/local_round_trip.rs @@ -89,6 +89,18 @@ async fn local_put_get_stream_round_trip_preserves_bytes_and_metadata() { b"streamed object".as_slice() ); + let ranged = service + .get_object_range_stream(&stored, 2..10) + .await + .expect("get range stream"); + assert_eq!(ranged.object, stored); + assert_eq!( + collect_storage_stream(ranged.body) + .await + .expect("collect range stream"), + b"reamed o".as_slice() + ); + service.delete_object(&stored).await.expect("delete object"); assert!(!service.object_exists(&stored).await.expect("object exists")); let err = service From b805bf663e020a9e74d5a9579a7655d668bdf06c Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 2 Jul 2026 03:28:33 +0000 Subject: [PATCH 016/108] Bump backup crate to 0.2.0 --- crates/graphql-orm-backup/Cargo.lock | 2 +- crates/graphql-orm-backup/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/graphql-orm-backup/Cargo.lock b/crates/graphql-orm-backup/Cargo.lock index 2feb28b5..b94d955a 100644 --- a/crates/graphql-orm-backup/Cargo.lock +++ b/crates/graphql-orm-backup/Cargo.lock @@ -261,7 +261,7 @@ dependencies = [ [[package]] name = "graphql-orm-backup" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "bytes", diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml index f3a71ebd..8f62fc94 100644 --- a/crates/graphql-orm-backup/Cargo.toml +++ b/crates/graphql-orm-backup/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-backup" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm-backup" From 26d8934ad33f6467ad40ef51ed251b2048346349 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 6 Jul 2026 19:55:05 +1000 Subject: [PATCH 017/108] Add streaming object recording support --- crates/graphql-orm-storage/AGENTS.md | 3 +- crates/graphql-orm-storage/Cargo.lock | 3 +- crates/graphql-orm-storage/Cargo.toml | 10 +- crates/graphql-orm-storage/README.md | 5 +- crates/graphql-orm-storage/docs/README.md | 1 + .../graphql-orm-storage/docs/agent-update.md | 4 +- .../graphql-orm-storage/docs/architecture.md | 5 + crates/graphql-orm-storage/docs/plan.md | 4 +- .../docs/recording-streams.md | 147 ++++++++ .../graphql-orm-storage/docs/release-notes.md | 16 + crates/graphql-orm-storage/src/lib.rs | 8 +- crates/graphql-orm-storage/src/local.rs | 342 +++++++++++++++++- .../src/streaming_object.rs | 197 ++++++++++ .../tests/local_streaming_object.rs | 218 +++++++++++ 14 files changed, 953 insertions(+), 10 deletions(-) create mode 100644 crates/graphql-orm-storage/docs/recording-streams.md create mode 100644 crates/graphql-orm-storage/src/streaming_object.rs create mode 100644 crates/graphql-orm-storage/tests/local_streaming_object.rs diff --git a/crates/graphql-orm-storage/AGENTS.md b/crates/graphql-orm-storage/AGENTS.md index 0c7a6fb1..348fbdba 100644 --- a/crates/graphql-orm-storage/AGENTS.md +++ b/crates/graphql-orm-storage/AGENTS.md @@ -20,11 +20,12 @@ This crate is a reusable storage companion for applications that use `graphql-or ## Current Agent Handoff -- Current crate version is `0.3.0`. +- Current crate version is `0.4.0`. - The storage provider boundary is now the streaming `BlobStore` trait. - `ObjectStorage` extends `BlobStore`; custom providers must implement `BlobStore` first. - `BlobStore` includes byte ranges, conditional writes, server-side copy, write options, and paged listing. - `StorageService` remains the high-level primary object API for generated object metadata. +- `StreamingObjectStore` supports bucket/key large-object streaming, multipart writes, range reads, metadata, listing, and retention deletion. - `graphql-orm-backup` should adapt `BlobStore` directly for backup repository semantics; it should not use `StorageService`. - S3 is implemented behind the `s3` feature through the shared `BlobStore` provider layer. - Azure Blob is still a feature-gated unsupported placeholder. Do not add real Azure SDK code without implementing the shared `BlobStore` provider layer first. diff --git a/crates/graphql-orm-storage/Cargo.lock b/crates/graphql-orm-storage/Cargo.lock index 815c0d85..2927b08a 100644 --- a/crates/graphql-orm-storage/Cargo.lock +++ b/crates/graphql-orm-storage/Cargo.lock @@ -888,7 +888,7 @@ dependencies = [ [[package]] name = "graphql-orm-storage" -version = "0.3.0" +version = "0.4.0" dependencies = [ "async-trait", "aws-credential-types", @@ -897,6 +897,7 @@ dependencies = [ "futures-core", "futures-util", "serde", + "serde_json", "sha2 0.10.9", "tempfile", "thiserror", diff --git a/crates/graphql-orm-storage/Cargo.toml b/crates/graphql-orm-storage/Cargo.toml index 92d2d117..26adf114 100644 --- a/crates/graphql-orm-storage/Cargo.toml +++ b/crates/graphql-orm-storage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-storage" -version = "0.3.0" +version = "0.4.0" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm-storage" @@ -8,7 +8,7 @@ description = "Provider-neutral object storage primitives for graphql-orm applic [features] default = ["local"] -local = ["dep:tokio", "dep:tokio-util"] +local = ["dep:serde_json", "dep:tokio", "dep:tokio-util"] s3 = ["dep:aws-credential-types", "dep:aws-sdk-s3", "dep:tokio", "dep:tokio-util"] azure = [] @@ -20,6 +20,7 @@ bytes = "1" futures-core = "0.3" futures-util = "0.3" serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", optional = true } sha2 = "0.10" thiserror = "2" time = { version = "0.3", features = ["serde"] } @@ -40,3 +41,8 @@ required-features = ["local"] name = "local_blob" path = "tests/local_blob.rs" required-features = ["local"] + +[[test]] +name = "local_streaming_object" +path = "tests/local_streaming_object.rs" +required-features = ["local"] diff --git a/crates/graphql-orm-storage/README.md b/crates/graphql-orm-storage/README.md index 82c19d2b..2faed3fc 100644 --- a/crates/graphql-orm-storage/README.md +++ b/crates/graphql-orm-storage/README.md @@ -14,6 +14,7 @@ domain workflows. - high-level `StorageService` that generates object IDs, sharded keys, byte counts, SHA-256 checksums, and timestamps - buffered and streaming object APIs +- bucket/key streaming object APIs for large recordings and HTTP range playback - local filesystem backend enabled by default - S3-compatible backend behind the `s3` feature, including MinIO-compatible path-style configuration @@ -22,6 +23,7 @@ domain workflows. - strict key validation for path safety across providers - byte-range reads, conditional writes, provider-side copy hooks, and paged listing for cloud-provider compatibility +- multipart local recording writes with atomic finalize and abort cleanup - retry-aware provider error taxonomy through `StorageError::is_retryable` ## Install @@ -152,6 +154,7 @@ rows. - [Usage guide](docs/usage.md) - [BlobStore API](docs/blob-store.md) - [Streaming APIs](docs/streaming.md) +- [Recording and large-object streams](docs/recording-streams.md) - [Architecture and crate boundaries](docs/architecture.md) - [Provider roadmap](docs/provider-roadmap.md) - [Backup integration guidance](docs/backup-integration.md) @@ -160,7 +163,7 @@ rows. ## Status -Current crate version: `0.3.0`. +Current crate version: `0.4.0`. Local filesystem and S3-compatible storage are implemented. Azure Blob remains an explicit placeholder. Provider integration tests that require external diff --git a/crates/graphql-orm-storage/docs/README.md b/crates/graphql-orm-storage/docs/README.md index 0d87c8fc..92f0929a 100644 --- a/crates/graphql-orm-storage/docs/README.md +++ b/crates/graphql-orm-storage/docs/README.md @@ -6,6 +6,7 @@ repository front page. - [Usage guide](usage.md) - [BlobStore API](blob-store.md) - [Streaming APIs](streaming.md) +- [Recording and large-object streams](recording-streams.md) - [Architecture and crate boundaries](architecture.md) - [Provider roadmap](provider-roadmap.md) - [Backup integration guidance](backup-integration.md) diff --git a/crates/graphql-orm-storage/docs/agent-update.md b/crates/graphql-orm-storage/docs/agent-update.md index 41df5e96..9004ab44 100644 --- a/crates/graphql-orm-storage/docs/agent-update.md +++ b/crates/graphql-orm-storage/docs/agent-update.md @@ -1,6 +1,6 @@ # Agent Update -This update summarizes the `0.3.0` storage-provider boundary for agents working +This update summarizes the `0.4.0` storage-provider boundary for agents working on `graphql-orm-storage` or downstream crates. ## What Changed @@ -17,6 +17,8 @@ on `graphql-orm-storage` or downstream crates. `StorageService::get_object_stream`. - Buffered object APIs still exist and delegate through the streaming layer. - `LocalStorageBackend` now implements `BlobStore` and `ObjectStorage`. +- `LocalStorageBackend` implements `StreamingObjectStore` for local large-object + streaming and recording-style workloads. - S3 now implements `BlobStore` and `ObjectStorage` behind the `s3` feature. - Azure Blob remains a placeholder that implements `BlobStore` and still returns `UnsupportedBackend`. diff --git a/crates/graphql-orm-storage/docs/architecture.md b/crates/graphql-orm-storage/docs/architecture.md index c2fa3cdf..ea0da681 100644 --- a/crates/graphql-orm-storage/docs/architecture.md +++ b/crates/graphql-orm-storage/docs/architecture.md @@ -16,6 +16,11 @@ metadata, listing, and delete operations. IDs, namespaces, storage keys, byte counts, checksums, and timestamps before applications persist metadata in their own database rows. +`StreamingObjectStore` is the bucket/key workflow for large objects such as +recordings. It supports multipart writes, atomic visibility after completion, +caller metadata, range reads, listing, and retention deletion without requiring +the caller to buffer a full object in memory. + `graphql-orm-backup` should reuse future cloud provider implementations through a `BlobStore` adapter, not through `StorageService`. diff --git a/crates/graphql-orm-storage/docs/plan.md b/crates/graphql-orm-storage/docs/plan.md index bdf90480..87419bff 100644 --- a/crates/graphql-orm-storage/docs/plan.md +++ b/crates/graphql-orm-storage/docs/plan.md @@ -6,7 +6,7 @@ only. Host applications remain responsible for authorization, GraphQL schema design, metadata entities, routing, audit behavior, and workflow-specific policy. -## Implemented In 0.3.0 +## Implemented In 0.4.0 - Provider-neutral object metadata. - Provider-neutral `StorageBackend` and `StorageNamespace` enums. @@ -18,6 +18,7 @@ policy. - paged listing - existence and metadata checks - Buffered and streaming `StorageService` object APIs. +- Bucket/key `StreamingObjectStore` APIs for large recording-style objects. - Local filesystem backend. - S3-compatible backend behind the `s3` feature. - Azure Blob placeholder behind the `azure` feature. @@ -25,6 +26,7 @@ policy. - Safe sharded storage-key generation. - Strict key validation for local and cloud providers. - Public documentation and rustdocs for the crate boundary. +- Local multipart object writes with atomic completion and abort cleanup. ## Crate Boundaries diff --git a/crates/graphql-orm-storage/docs/recording-streams.md b/crates/graphql-orm-storage/docs/recording-streams.md new file mode 100644 index 00000000..f8f71f01 --- /dev/null +++ b/crates/graphql-orm-storage/docs/recording-streams.md @@ -0,0 +1,147 @@ +# Recording And Large-Object Streams + +`StreamingObjectStore` provides a bucket/key API for recording-style workloads +that need incremental writes, atomic completion, metadata, and HTTP range +playback. + +The API is generic. It does not know about any RMM domain model, user model, +tenant model, or authorization system. + +## API Shape + +```rust +#[async_trait::async_trait] +pub trait StreamingObjectStore: Send + Sync { + async fn put_object_stream( + &self, + bucket: &str, + key: &str, + content_type: Option, + metadata: ObjectMetadata, + stream: StorageByteStream, + ) -> Result; + + async fn create_multipart_object( + &self, + bucket: &str, + key: &str, + content_type: Option, + metadata: ObjectMetadata, + ) -> Result; + + async fn get_object_range( + &self, + bucket: &str, + key: &str, + range: std::ops::Range, + ) -> Result; + + async fn get_object_metadata( + &self, + bucket: &str, + key: &str, + ) -> Result; + + async fn list_objects( + &self, + bucket: &str, + prefix: &str, + ) -> Result, StorageError>; + + async fn delete_object(&self, bucket: &str, key: &str) -> Result<(), StorageError>; +} +``` + +`MultipartWriter::complete` makes the object visible. Until completion succeeds, +local objects do not appear in `list_objects` and `get_object_metadata` returns +missing. + +## Multipart Recording Write + +```rust +use bytes::Bytes; +use graphql_orm_storage::{LocalStorageBackend, ObjectMetadata, StreamingObjectStore}; + +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +let backend = LocalStorageBackend::new("./data/storage"); +let metadata = ObjectMetadata::from([ + ("tenant_id".to_string(), "tenant-1".to_string()), + ("device_id".to_string(), "device-1".to_string()), +]); + +let mut writer = backend + .create_multipart_object( + "recordings", + "sessions/session-1/video.webm", + Some("video/webm".to_string()), + metadata, + ) + .await?; + +writer.write_chunk(Bytes::from_static(b"chunk-1")).await?; +writer.write_chunk(Bytes::from_static(b"chunk-2")).await?; + +let object = writer.complete().await?; +assert_eq!(object.size_bytes, 14); +# Ok(()) +# } +``` + +Call `abort` when the host application rejects or cancels a recording: + +```rust +# use graphql_orm_storage::{LocalStorageBackend, ObjectMetadata, StreamingObjectStore}; +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +# let backend = LocalStorageBackend::new("./data/storage"); +let writer = backend + .create_multipart_object("recordings", "sessions/cancelled/video.webm", None, ObjectMetadata::new()) + .await?; +writer.abort().await?; +# Ok(()) +# } +``` + +## HTTP Range Playback + +`get_object_range` accepts an exclusive Rust range and returns range metadata. +For an HTTP `Content-Range` header, subtract one from the exclusive end: + +```rust +# use graphql_orm_storage::{LocalStorageBackend, StreamingObjectStore}; +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +# let backend = LocalStorageBackend::new("./data/storage"); +let body = backend + .get_object_range("recordings", "sessions/session-1/video.webm", 0..1024) + .await?; + +let header = format!( + "bytes {}-{}/{}", + body.range.start, + body.range.end.saturating_sub(1), + body.range.total_size +); +# let _ = header; +# Ok(()) +# } +``` + +The returned `body.body` is a `StorageByteStream`; route handlers can stream it +directly to the HTTP response. + +## graphql-orm And agql-auth Integration + +Host applications should keep authorization and persistence outside this crate: + +1. Use `agql-auth` or application policy code to authorize the recording + session, playback request, or retention deletion. +2. Persist `ObjectInfo` fields in an application-owned `graphql-orm` entity. +3. Store authorization-related values such as tenant, device, user, session, or + policy scope in application rows and, when useful, in `ObjectMetadata`. +4. On playback, load the metadata row first, authorize it, then call + `get_object_range`. +5. On retention deletion, authorize and select rows in the application, then + call `delete_object`. + +The storage crate does not depend on `graphql-orm` or `agql-auth`; it exposes +the storage primitives those layers can call after they have made policy +decisions. diff --git a/crates/graphql-orm-storage/docs/release-notes.md b/crates/graphql-orm-storage/docs/release-notes.md index bf4a23c0..79ef9c06 100644 --- a/crates/graphql-orm-storage/docs/release-notes.md +++ b/crates/graphql-orm-storage/docs/release-notes.md @@ -3,6 +3,22 @@ This page records user-facing changes for recent `graphql-orm-storage` releases. +## 0.4.0 + +Large-object streaming support for recording-style workloads. + +- Bumped `graphql-orm-storage` to `0.4.0`. +- Added `StreamingObjectStore` for bucket/key objects. +- Added `ObjectInfo`, `ObjectMetadata`, `ObjectRangeBody`, and + `ObjectContentRange`. +- Added `MultipartWriter` and `BoxedMultipartWriter` for incremental writes. +- Implemented local multipart writes with temp files and atomic visibility on + completion. +- Added range reads for HTTP playback use cases. +- Added local object sidecar metadata persistence, listing, and deletion. +- Added tests for large streamed writes, range reads, abort cleanup, metadata, + listing visibility, and retention deletion. + ## 0.3.0 Provider API stabilization and S3 implementation. diff --git a/crates/graphql-orm-storage/src/lib.rs b/crates/graphql-orm-storage/src/lib.rs index 73c2be0c..0045f64a 100644 --- a/crates/graphql-orm-storage/src/lib.rs +++ b/crates/graphql-orm-storage/src/lib.rs @@ -13,7 +13,8 @@ //! generation, or file bytes stored in database rows. //! //! Use [`StorageService`] for primary object workflows that need generated -//! object metadata. Use [`BlobStore`] for lower-level key-addressed blob +//! object metadata. Use [`StreamingObjectStore`] for bucket/key workloads such +//! as large recordings. Use [`BlobStore`] for lower-level key-addressed blob //! operations, such as backup repository adapters. //! //! # Example @@ -42,6 +43,7 @@ mod object; #[cfg(feature = "s3")] mod s3; mod service; +mod streaming_object; #[cfg(feature = "azure")] pub use azure::{AzureBlobStorageBackend, AzureBlobStorageConfig}; @@ -62,3 +64,7 @@ pub use object::{ #[cfg(feature = "s3")] pub use s3::{S3StorageBackend, S3StorageConfig}; pub use service::{ObjectStorage, StorageService}; +pub use streaming_object::{ + BoxedMultipartWriter, MultipartWriter, ObjectContentRange, ObjectInfo, ObjectMetadata, + ObjectRangeBody, StreamingObjectStore, validate_object_bucket, +}; diff --git a/crates/graphql-orm-storage/src/local.rs b/crates/graphql-orm-storage/src/local.rs index 0840a2ef..3f12ead5 100644 --- a/crates/graphql-orm-storage/src/local.rs +++ b/crates/graphql-orm-storage/src/local.rs @@ -4,6 +4,7 @@ use std::{ }; use async_trait::async_trait; +use bytes::Bytes; use futures_util::StreamExt; use sha2::{Digest, Sha256}; use time::OffsetDateTime; @@ -13,10 +14,15 @@ use uuid::Uuid; use crate::{ BlobBody, BlobListPage, BlobMetadata, BlobPutOptions, BlobStore, BlobWriteOutcome, - ObjectStorage, StorageBackend, StorageByteStream, StorageError, StorageObjectBody, - StoredObject, collect_storage_stream, validate_blob_key, + BoxedMultipartWriter, MultipartWriter, ObjectContentRange, ObjectInfo, ObjectMetadata, + ObjectRangeBody, ObjectStorage, StorageBackend, StorageByteStream, StorageError, + StorageObjectBody, StoredObject, StreamingObjectStore, collect_storage_stream, + validate_blob_key, validate_object_bucket, }; +const INTERNAL_STORAGE_DIR: &str = ".graphql-orm-storage"; +const OBJECT_METADATA_DIR: &str = "object-metadata"; + /// Local filesystem object storage backend. #[derive(Clone, Debug)] pub struct LocalStorageBackend { @@ -73,6 +79,30 @@ impl LocalStorageBackend { validate_blob_key(key)?; Ok(self.root.join(Path::new(key))) } + + fn object_path_for(&self, bucket: &str, key: &str) -> Result { + validate_object_bucket(bucket)?; + validate_blob_key(key)?; + Ok(self.root.join(bucket).join(Path::new(key))) + } + + fn object_metadata_path_for(&self, bucket: &str, key: &str) -> Result { + validate_object_bucket(bucket)?; + validate_blob_key(key)?; + let path = self + .root + .join(INTERNAL_STORAGE_DIR) + .join(OBJECT_METADATA_DIR) + .join(bucket) + .join(Path::new(key)); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| StorageError::InvalidStorageKey { + key: key.to_string(), + })?; + Ok(path.with_file_name(format!("{file_name}.metadata.json"))) + } } #[async_trait] @@ -323,6 +353,287 @@ impl ObjectStorage for LocalStorageBackend { } } +#[async_trait] +impl StreamingObjectStore for LocalStorageBackend { + async fn put_object_stream( + &self, + bucket: &str, + key: &str, + content_type: Option, + metadata: ObjectMetadata, + stream: StorageByteStream, + ) -> Result { + let mut writer = self + .create_multipart_object(bucket, key, content_type, metadata) + .await?; + let mut stream = stream.into_inner(); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + if let Err(err) = writer.write_chunk(bytes).await { + let _ = writer.abort().await; + return Err(err); + } + } + Err(err) => { + let _ = writer.abort().await; + return Err(err); + } + } + } + + writer.complete().await + } + + async fn create_multipart_object( + &self, + bucket: &str, + key: &str, + content_type: Option, + metadata: ObjectMetadata, + ) -> Result { + let object_path = self.object_path_for(bucket, key)?; + let metadata_path = self.object_metadata_path_for(bucket, key)?; + create_parent_dir(&object_path).await?; + create_parent_dir(&metadata_path).await?; + let temp_path = temp_path_for(&object_path, key)?; + let metadata_temp_path = temp_path_for(&metadata_path, key)?; + let file = tokio::fs::File::create(&temp_path) + .await + .map_err(|source| StorageError::io(&temp_path, source))?; + + Ok(Box::new(LocalMultipartWriter { + bucket: bucket.to_string(), + key: key.to_string(), + content_type, + metadata, + object_path, + temp_path, + metadata_path, + metadata_temp_path, + file, + hasher: Sha256::new(), + size_bytes: 0, + })) + } + + async fn get_object_range( + &self, + bucket: &str, + key: &str, + range: std::ops::Range, + ) -> Result { + if range.end < range.start { + return Err(StorageError::PreconditionFailed { + key: format!("{bucket}/{key}"), + condition: "range end is before range start".to_string(), + }); + } + + let object = self.get_object_metadata(bucket, key).await?; + if range.start > object.size_bytes { + return Err(StorageError::PreconditionFailed { + key: format!("{bucket}/{key}"), + condition: "range start is beyond object size".to_string(), + }); + } + let end = range.end.min(object.size_bytes); + let length = end.saturating_sub(range.start); + let object_path = self.object_path_for(bucket, key)?; + let mut file = match tokio::fs::File::open(&object_path).await { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(StorageError::MissingBlob { + key: format!("{bucket}/{key}"), + }); + } + Err(source) => return Err(StorageError::io(&object_path, source)), + }; + file.seek(SeekFrom::Start(range.start)) + .await + .map_err(|source| StorageError::io(&object_path, source))?; + let stream_path = object_path.clone(); + let stream = ReaderStream::new(file.take(length)) + .map(move |chunk| chunk.map_err(|source| StorageError::io(&stream_path, source))); + + let total_size = object.size_bytes; + Ok(ObjectRangeBody { + object, + range: ObjectContentRange { + start: range.start, + end, + total_size, + }, + content_length: length, + body: StorageByteStream::with_size_hint(Box::pin(stream), length), + }) + } + + async fn get_object_metadata( + &self, + bucket: &str, + key: &str, + ) -> Result { + let metadata_path = self.object_metadata_path_for(bucket, key)?; + let bytes = match tokio::fs::read(&metadata_path).await { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(StorageError::MissingBlob { + key: format!("{bucket}/{key}"), + }); + } + Err(source) => return Err(StorageError::io(&metadata_path, source)), + }; + serde_json::from_slice(&bytes).map_err(|source| StorageError::Provider { + backend: StorageBackend::Local.as_str().to_string(), + message: format!("local object metadata is invalid: {source}"), + retryable: false, + }) + } + + async fn list_objects( + &self, + bucket: &str, + prefix: &str, + ) -> Result, StorageError> { + validate_object_bucket(bucket)?; + crate::blob::validate_blob_prefix(prefix)?; + let start = if prefix.is_empty() { + self.root.join(bucket) + } else { + self.root.join(bucket).join(prefix) + }; + let mut objects = Vec::new(); + let mut stack = vec![start]; + + while let Some(path) = stack.pop() { + let metadata = match tokio::fs::metadata(&path).await { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(source) => return Err(StorageError::io(&path, source)), + }; + + if metadata.is_file() { + if is_uploading_temp_file(&path) { + continue; + } + let Some(key) = self.key_for_bucket_path(bucket, &path) else { + continue; + }; + if !key.starts_with(prefix) { + continue; + } + if let Ok(info) = self.get_object_metadata(bucket, &key).await { + objects.push(info); + } + continue; + } + + let entries = sorted_child_paths(&path).await?; + stack.extend(entries.into_iter().rev()); + } + + objects.sort_by(|left, right| left.key.cmp(&right.key)); + Ok(objects) + } + + async fn delete_object(&self, bucket: &str, key: &str) -> Result<(), StorageError> { + let object_path = self.object_path_for(bucket, key)?; + let metadata_path = self.object_metadata_path_for(bucket, key)?; + + match tokio::fs::remove_file(&object_path).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(StorageError::io(&object_path, source)), + } + match tokio::fs::remove_file(&metadata_path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(StorageError::io(&metadata_path, source)), + } + } +} + +struct LocalMultipartWriter { + bucket: String, + key: String, + content_type: Option, + metadata: ObjectMetadata, + object_path: PathBuf, + temp_path: PathBuf, + metadata_path: PathBuf, + metadata_temp_path: PathBuf, + file: tokio::fs::File, + hasher: Sha256, + size_bytes: u64, +} + +#[async_trait] +impl MultipartWriter for LocalMultipartWriter { + async fn write_chunk(&mut self, bytes: Bytes) -> Result<(), StorageError> { + self.size_bytes = self + .size_bytes + .saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); + self.hasher.update(&bytes); + self.file + .write_all(&bytes) + .await + .map_err(|source| StorageError::io(&self.temp_path, source)) + } + + async fn complete(mut self: Box) -> Result { + self.file + .flush() + .await + .map_err(|source| StorageError::io(&self.temp_path, source))?; + + let sha256_hex = format!("{:x}", self.hasher.finalize()); + let object = ObjectInfo { + bucket: self.bucket.clone(), + key: self.key.clone(), + content_type: self.content_type.clone(), + metadata: self.metadata.clone(), + size_bytes: self.size_bytes, + sha256_hex: sha256_hex.clone(), + etag: Some(sha256_hex), + last_modified: OffsetDateTime::now_utc(), + }; + + write_object_metadata(&self.metadata_temp_path, &object).await?; + + if let Err(source) = tokio::fs::rename(&self.temp_path, &self.object_path).await { + let _ = tokio::fs::remove_file(&self.temp_path).await; + let _ = tokio::fs::remove_file(&self.metadata_temp_path).await; + return Err(StorageError::io(&self.object_path, source)); + } + if let Err(source) = tokio::fs::rename(&self.metadata_temp_path, &self.metadata_path).await + { + let _ = tokio::fs::remove_file(&self.metadata_temp_path).await; + return Err(StorageError::io(&self.metadata_path, source)); + } + + Ok(object) + } + + async fn abort(self: Box) -> Result<(), StorageError> { + drop(self.file); + let temp_result = tokio::fs::remove_file(&self.temp_path).await; + let metadata_result = tokio::fs::remove_file(&self.metadata_temp_path).await; + + match temp_result { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(StorageError::io(&self.temp_path, source)), + } + match metadata_result { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(StorageError::io(&self.metadata_temp_path, source)), + } + } +} + impl LocalStorageBackend { async fn collect_blob_page( &self, @@ -345,6 +656,9 @@ impl LocalStorageBackend { if page.len() == limit { break; } + if is_internal_storage_path(&self.root, &path) { + continue; + } let metadata = match tokio::fs::metadata(&path).await { Ok(metadata) => metadata, @@ -377,6 +691,12 @@ impl LocalStorageBackend { .ok() .map(|relative| relative.to_string_lossy().replace('\\', "/")) } + + fn key_for_bucket_path(&self, bucket: &str, path: &Path) -> Option { + path.strip_prefix(self.root.join(bucket)) + .ok() + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + } } async fn create_parent_dir(path: &Path) -> Result<(), StorageError> { @@ -428,12 +748,30 @@ async fn write_stream_to_temp( }) } +async fn write_object_metadata(path: &Path, object: &ObjectInfo) -> Result<(), StorageError> { + let bytes = serde_json::to_vec(object).map_err(|source| StorageError::Provider { + backend: StorageBackend::Local.as_str().to_string(), + message: format!("local object metadata could not be serialized: {source}"), + retryable: false, + })?; + tokio::fs::write(path, bytes) + .await + .map_err(|source| StorageError::io(path, source)) +} + fn is_uploading_temp_file(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".uploading")) } +fn is_internal_storage_path(root: &Path, path: &Path) -> bool { + path.strip_prefix(root) + .ok() + .and_then(|relative| relative.components().next()) + .is_some_and(|component| component.as_os_str() == INTERNAL_STORAGE_DIR) +} + fn is_older_than(metadata: &std::fs::Metadata, now: SystemTime, older_than: Duration) -> bool { metadata .modified() diff --git a/crates/graphql-orm-storage/src/streaming_object.rs b/crates/graphql-orm-storage/src/streaming_object.rs new file mode 100644 index 00000000..b24725bd --- /dev/null +++ b/crates/graphql-orm-storage/src/streaming_object.rs @@ -0,0 +1,197 @@ +use std::{collections::BTreeMap, ops::Range}; + +use async_trait::async_trait; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +use crate::{StorageByteStream, StorageError}; + +/// Application-provided object metadata values. +pub type ObjectMetadata = BTreeMap; + +/// Metadata for a completed bucket/key object. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectInfo { + /// Logical storage bucket. + pub bucket: String, + /// Object key inside the bucket. + pub key: String, + /// Provider content type, when supplied. + pub content_type: Option, + /// Application metadata stored with the object. + pub metadata: ObjectMetadata, + /// Object size in bytes. + pub size_bytes: u64, + /// Lowercase hexadecimal SHA-256 checksum for the object bytes. + pub sha256_hex: String, + /// Provider ETag or equivalent checksum, when available. + pub etag: Option, + /// UTC completion timestamp. + pub last_modified: OffsetDateTime, +} + +/// Byte range metadata for a partial object response. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ObjectContentRange { + /// Inclusive range start. + pub start: u64, + /// Exclusive range end. + pub end: u64, + /// Total object size in bytes. + pub total_size: u64, +} + +impl ObjectContentRange { + /// Returns the number of bytes in this range. + #[must_use] + pub const fn len(&self) -> u64 { + self.end - self.start + } + + /// Returns whether the range is empty. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.start == self.end + } +} + +/// Streaming response for an object byte range. +#[derive(Debug)] +pub struct ObjectRangeBody { + /// Completed object metadata. + pub object: ObjectInfo, + /// Returned range metadata. + pub range: ObjectContentRange, + /// Number of bytes in the returned body. + pub content_length: u64, + /// Streaming range body. + pub body: StorageByteStream, +} + +/// Boxed multipart object writer. +pub type BoxedMultipartWriter = Box; + +/// Incremental object writer for large uploads. +#[async_trait] +pub trait MultipartWriter: Send { + /// Appends one byte chunk to the in-progress object. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the provider cannot write the chunk. + async fn write_chunk(&mut self, bytes: Bytes) -> Result<(), StorageError>; + + /// Finalizes the object and makes it visible to readers and listings. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the provider cannot atomically finalize the + /// object. + async fn complete(self: Box) -> Result; + + /// Aborts the in-progress object and removes temporary state. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the provider cannot remove temporary + /// state. + async fn abort(self: Box) -> Result<(), StorageError>; +} + +/// Bucket/key streaming object API for large media or recording workloads. +#[async_trait] +pub trait StreamingObjectStore: Send + Sync { + /// Stores an object from a byte stream. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the bucket/key is invalid or the provider + /// cannot store the stream. + async fn put_object_stream( + &self, + bucket: &str, + key: &str, + content_type: Option, + metadata: ObjectMetadata, + stream: StorageByteStream, + ) -> Result; + + /// Creates a multipart writer for an object. + /// + /// The object is not visible to readers or listings until + /// [`MultipartWriter::complete`] succeeds. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the bucket/key is invalid or the provider + /// cannot create the writer. + async fn create_multipart_object( + &self, + bucket: &str, + key: &str, + content_type: Option, + metadata: ObjectMetadata, + ) -> Result; + + /// Loads a byte range from an object. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the bucket/key or range is invalid, the + /// object is missing, or the provider cannot stream the range. + async fn get_object_range( + &self, + bucket: &str, + key: &str, + range: Range, + ) -> Result; + + /// Loads completed object metadata. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the bucket/key is invalid, the object is + /// missing, or the provider cannot load metadata. + async fn get_object_metadata( + &self, + bucket: &str, + key: &str, + ) -> Result; + + /// Lists completed objects in a bucket whose keys start with `prefix`. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the bucket/prefix is invalid or the + /// provider cannot list objects. + async fn list_objects( + &self, + bucket: &str, + prefix: &str, + ) -> Result, StorageError>; + + /// Deletes a completed object and its metadata. + /// + /// # Errors + /// + /// Returns [`StorageError`] when the bucket/key is invalid or the provider + /// cannot delete object state. + async fn delete_object(&self, bucket: &str, key: &str) -> Result<(), StorageError>; +} + +/// Validates a logical object bucket name. +/// +/// # Errors +/// +/// Returns [`StorageError::InvalidStorageKey`] when the bucket name is empty or +/// path-like. +pub fn validate_object_bucket(bucket: &str) -> Result<(), StorageError> { + crate::validate_blob_key(bucket)?; + if bucket.contains('/') { + return Err(StorageError::InvalidStorageKey { + key: bucket.to_string(), + }); + } + Ok(()) +} diff --git a/crates/graphql-orm-storage/tests/local_streaming_object.rs b/crates/graphql-orm-storage/tests/local_streaming_object.rs new file mode 100644 index 00000000..133ac4ce --- /dev/null +++ b/crates/graphql-orm-storage/tests/local_streaming_object.rs @@ -0,0 +1,218 @@ +use std::collections::BTreeMap; + +use bytes::Bytes; +use futures_util::stream; +use graphql_orm_storage::{ + LocalStorageBackend, ObjectMetadata, StorageByteStream, StorageError, StreamingObjectStore, + collect_storage_stream, sha256_hex, +}; +use tempfile::TempDir; + +#[tokio::test] +async fn local_streaming_object_large_streamed_write_preserves_metadata() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + let metadata = recording_metadata(); + let chunks = (0..128).map(|index| { + let byte = u8::try_from(index % 251).expect("byte"); + Ok(Bytes::from(vec![byte; 64 * 1024])) + }); + let stream = StorageByteStream::new(Box::pin(stream::iter(chunks))); + + let object = backend + .put_object_stream( + "recordings", + "sessions/alpha/video.webm", + Some("video/webm".to_string()), + metadata.clone(), + stream, + ) + .await + .expect("put stream"); + + assert_eq!(object.bucket, "recordings"); + assert_eq!(object.key, "sessions/alpha/video.webm"); + assert_eq!(object.content_type.as_deref(), Some("video/webm")); + assert_eq!(object.metadata, metadata); + assert_eq!(object.size_bytes, 8 * 1024 * 1024); + + let loaded = backend + .get_object_metadata("recordings", "sessions/alpha/video.webm") + .await + .expect("metadata"); + assert_eq!(loaded, object); +} + +#[tokio::test] +async fn local_streaming_object_range_reads_support_http_playback() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + backend + .put_object_stream( + "recordings", + "sessions/beta/video.webm", + Some("video/webm".to_string()), + ObjectMetadata::new(), + StorageByteStream::from_bytes(Bytes::from_static(b"0123456789abcdef")), + ) + .await + .expect("put stream"); + + let range = backend + .get_object_range("recordings", "sessions/beta/video.webm", 4..10) + .await + .expect("range"); + assert_eq!(range.range.start, 4); + assert_eq!(range.range.end, 10); + assert_eq!(range.range.total_size, 16); + assert_eq!(range.content_length, 6); + + let bytes = collect_storage_stream(range.body).await.expect("collect"); + assert_eq!(bytes, Bytes::from_static(b"456789")); +} + +#[tokio::test] +async fn local_streaming_object_multipart_abort_cleans_temporary_state() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + let mut writer = backend + .create_multipart_object( + "recordings", + "sessions/gamma/video.webm", + Some("video/webm".to_string()), + ObjectMetadata::new(), + ) + .await + .expect("create writer"); + + writer + .write_chunk(Bytes::from_static(b"partial")) + .await + .expect("write chunk"); + writer.abort().await.expect("abort"); + + let err = backend + .get_object_metadata("recordings", "sessions/gamma/video.webm") + .await + .expect_err("aborted object must be missing"); + assert!(matches!(err, StorageError::MissingBlob { .. })); + + let listed = backend + .list_objects("recordings", "sessions/gamma") + .await + .expect("list"); + assert!(listed.is_empty()); +} + +#[tokio::test] +async fn local_streaming_object_multipart_complete_is_atomic_for_listing() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + let mut writer = backend + .create_multipart_object( + "recordings", + "sessions/delta/video.webm", + Some("video/webm".to_string()), + recording_metadata(), + ) + .await + .expect("create writer"); + + writer + .write_chunk(Bytes::from_static(b"chunk-1-")) + .await + .expect("write chunk"); + + assert!( + backend + .list_objects("recordings", "sessions/delta") + .await + .expect("list before complete") + .is_empty() + ); + + writer + .write_chunk(Bytes::from_static(b"chunk-2")) + .await + .expect("write chunk"); + let completed = writer.complete().await.expect("complete"); + + assert_eq!(completed.size_bytes, 15); + assert_eq!(completed.sha256_hex, sha256_hex(b"chunk-1-chunk-2")); + + let listed = backend + .list_objects("recordings", "sessions/delta") + .await + .expect("list after complete"); + assert_eq!(listed, vec![completed]); +} + +#[tokio::test] +async fn local_streaming_object_delete_removes_completed_object_and_metadata() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + backend + .put_object_stream( + "recordings", + "retention/expired.webm", + Some("video/webm".to_string()), + recording_metadata(), + StorageByteStream::from_bytes(Bytes::from_static(b"expired")), + ) + .await + .expect("put stream"); + + backend + .delete_object("recordings", "retention/expired.webm") + .await + .expect("delete"); + backend + .delete_object("recordings", "retention/expired.webm") + .await + .expect("delete missing"); + + let err = backend + .get_object_metadata("recordings", "retention/expired.webm") + .await + .expect_err("metadata deleted"); + assert!(matches!(err, StorageError::MissingBlob { .. })); + assert!( + backend + .list_objects("recordings", "retention") + .await + .expect("list") + .is_empty() + ); +} + +#[tokio::test] +async fn local_streaming_object_internal_metadata_does_not_leak_into_blob_listing() { + let temp = TempDir::new().expect("temp dir"); + let backend = LocalStorageBackend::new(temp.path()); + + backend + .put_object_stream( + "recordings", + "sessions/epsilon/video.webm", + Some("video/webm".to_string()), + ObjectMetadata::new(), + StorageByteStream::from_bytes(Bytes::from_static(b"video")), + ) + .await + .expect("put stream"); + + let blobs = graphql_orm_storage::BlobStore::list_blobs(&backend, "") + .await + .expect("list blobs"); + assert_eq!(blobs, vec!["recordings/sessions/epsilon/video.webm"]); +} + +fn recording_metadata() -> BTreeMap { + BTreeMap::from([ + ("tenant_id".to_string(), "tenant-1".to_string()), + ("device_id".to_string(), "device-1".to_string()), + ("auth_scope".to_string(), "recordings.read".to_string()), + ]) +} From e619cb889fb8da247a26b14b397fc3aada2f4109 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sun, 12 Jul 2026 06:42:37 +1000 Subject: [PATCH 018/108] Add graphql-orm runtime adapters, snapshot deletion, and blob-store restore sink - Optional orm feature: OrmBackupAdapter bridges GraphqlOrmBackupRuntime to the GraphqlOrmBackupAdapter contract (schema snapshots, consistent full export, empty-target detection, full restore, replace-existing clearing) - OrmBackupObjectIndex derives a BackupObjectIndex from one object metadata table plus the application's primary BlobStore - BlobStoreRestoreObjectSink rehydrates a blob store at original storage keys - delete_snapshot removes one snapshot under the writer lock with object GC - BackupError::Database carries adapter failures with operation context - Verification and object writes iterate owned pairs so futures stay Send - Reference sibling crates by git URL so the crate resolves from GitHub Co-Authored-By: Claude Fable 5 --- crates/graphql-orm-backup/CHANGELOG.md | 43 + crates/graphql-orm-backup/Cargo.lock | 2230 +++++++++++++++-- crates/graphql-orm-backup/Cargo.toml | 8 +- crates/graphql-orm-backup/README.md | 13 +- crates/graphql-orm-backup/docs/usage.md | 69 + crates/graphql-orm-backup/src/backup.rs | 8 +- crates/graphql-orm-backup/src/error.rs | 3 + crates/graphql-orm-backup/src/lib.rs | 10 +- crates/graphql-orm-backup/src/orm.rs | 561 +++++ crates/graphql-orm-backup/src/prune.rs | 89 +- crates/graphql-orm-backup/src/restore.rs | 38 + crates/graphql-orm-backup/src/verify.rs | 67 +- .../tests/operational_safety.rs | 71 +- 13 files changed, 2931 insertions(+), 279 deletions(-) create mode 100644 crates/graphql-orm-backup/CHANGELOG.md create mode 100644 crates/graphql-orm-backup/src/orm.rs diff --git a/crates/graphql-orm-backup/CHANGELOG.md b/crates/graphql-orm-backup/CHANGELOG.md new file mode 100644 index 00000000..c1a60d09 --- /dev/null +++ b/crates/graphql-orm-backup/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +## 0.3.0 + +- Added the optional `orm` feature with a generic [`OrmBackupAdapter`] that + bridges the `graphql-orm` `GraphqlOrmBackupRuntime` implementation on + `Database` to this crate's `GraphqlOrmBackupAdapter` contract: schema + snapshots, consistent full export, empty-target detection, and full restore. + Incremental export/restore report `UnsupportedOperation` until a + change-journal integration lands. +- Added `OrmBackupAdapter::clear_restore_target` so hosts can replace an + existing database before an empty-database restore. PostgreSQL clears with + one `TRUNCATE ... CASCADE`; SQLite suspends `PRAGMA foreign_keys` on a + dedicated connection around a child-first delete transaction because + `RESTRICT` foreign keys are enforced immediately on both backends. +- Added `OrmBackupObjectIndex` (also behind `orm`), a `BackupObjectIndex` over + one backup-enabled object metadata table plus the application's primary + `BlobStore`. Hosts supply table and column names; rows without a valid + recorded SHA-256 are hashed from the loaded blob bytes at listing time. +- Added `BlobStoreRestoreObjectSink`, a `RestoreObjectSink` that writes object + bytes back to a `graphql-orm-storage` `BlobStore` at each object's original + storage key. +- Added `delete_snapshot` and `DeleteSnapshotResult`: deletes one snapshot + under the repository writer lock, refuses when another manifest depends on + it, and removes object blobs no remaining snapshot references. +- Added `BackupError::Database` for database adapter failures with an + operation-context message. +- Manifest and object verification plus backup object writes now iterate owned + key/checksum pairs so the returned futures stay fully `Send`-generalizable + inside async GraphQL resolvers. + +## 0.2.0 + +- Replaced the crate-local filesystem repository internals with + `graphql-orm-storage` blob stores; added `BlobStoreBackupRepository` so any + storage blob provider can back a repository. +- Expanded crate documentation. + +## 0.1.0 + +- Initial release: full backups, compressed snapshots and manifest chains, + restore orchestration, incremental backups and synthetic-full compaction, + repository locking, and retention pruning. diff --git a/crates/graphql-orm-backup/Cargo.lock b/crates/graphql-orm-backup/Cargo.lock index b94d955a..09ebfb17 100644 --- a/crates/graphql-orm-backup/Cargo.lock +++ b/crates/graphql-orm-backup/Cargo.lock @@ -3,10 +3,126 @@ version = 4 [[package]] -name = "anyhow" -version = "1.0.102" +name = "Inflector" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ascii_utils" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" + +[[package]] +name = "async-graphql" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1057a9f7ccf2404d94571dec3451ade1cb524790df6f1ada0d19c2a49f6b0f40" +dependencies = [ + "async-graphql-derive", + "async-graphql-parser", + "async-graphql-value", + "async-io", + "async-trait", + "asynk-strim", + "base64", + "bytes", + "fast_chemail", + "fnv", + "futures-channel", + "futures-util", + "handlebars", + "http", + "indexmap", + "lru", + "mime", + "multer", + "num-traits", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "static_assertions_next", + "tempfile", + "thiserror", + "uuid", +] + +[[package]] +name = "async-graphql-derive" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e6cbeadc8515e66450fba0985ce722192e28443697799988265d86304d7cc68" +dependencies = [ + "Inflector", + "async-graphql-parser", + "darling 0.23.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "strum", + "syn", + "thiserror", +] + +[[package]] +name = "async-graphql-parser" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64ef70f77a1c689111e52076da1cd18f91834bcb847de0a9171f83624b07fbf" +dependencies = [ + "async-graphql-value", + "pest", + "serde", + "serde_json", +] + +[[package]] +name = "async-graphql-value" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" +dependencies = [ + "bytes", + "indexmap", + "serde", + "serde_json", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] [[package]] name = "async-trait" @@ -19,11 +135,51 @@ dependencies = [ "syn", ] +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -36,21 +192,30 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] [[package]] name = "cc" -version = "1.2.62" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -64,6 +229,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -73,6 +262,36 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -83,6 +302,86 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -92,6 +391,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -99,7 +429,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", ] [[package]] @@ -115,7 +482,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fast_chemail" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495a39d30d624c2caabe6312bfead73e7717692b44e0b32df168c275a2e8e9e4" +dependencies = [ + "ascii_utils", ] [[package]] @@ -130,12 +528,44 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.32" @@ -178,6 +608,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -185,7 +626,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] -name = "futures-macro" +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" @@ -236,36 +687,50 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "r-efi 5.3.0", - "wasip2", + "wasi", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", + "r-efi", +] + +[[package]] +name = "graphql-orm" +version = "0.6.0" +source = "git+https://github.com/Dastari/graphql-orm#25e5aaf2f051e9c955d243e51a8e05a574e495a6" +dependencies = [ + "async-graphql", + "futures", + "graphql-orm-macros", + "serde", + "serde_json", + "sqlx", + "tokio", + "tokio-stream", + "uuid", ] [[package]] name = "graphql-orm-backup" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "bytes", "futures", + "graphql-orm", "graphql-orm-storage", "serde", "serde_json", @@ -277,15 +742,28 @@ dependencies = [ "zstd", ] +[[package]] +name = "graphql-orm-macros" +version = "0.6.0" +source = "git+https://github.com/Dastari/graphql-orm#25e5aaf2f051e9c955d243e51a8e05a574e495a6" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "graphql-orm-storage" -version = "0.3.0" +version = "0.4.0" +source = "git+https://github.com/Dastari/graphql-orm-storage#3c92c96bdb9f4d7b48481f2a5038c7ba73dde81c" dependencies = [ "async-trait", "bytes", "futures-core", "futures-util", "serde", + "serde_json", "sha2", "thiserror", "time", @@ -294,13 +772,42 @@ dependencies = [ "uuid", ] +[[package]] +name = "handlebars" +version = "6.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26569a2763497b7bd3fbd19374b774ea6038c5293678771259cd534d49740ff" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -309,6 +816,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -316,10 +832,168 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "id-arena" -version = "2.3.0" +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[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 = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] [[package]] name = "indexmap" @@ -341,31 +1015,33 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -373,23 +1049,136 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "md-5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] [[package]] name = "num-conv" @@ -398,169 +1187,835 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] -name = "once_cell" -version = "1.21.4" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "num-iter" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] [[package]] -name = "pkg-config" -version = "0.3.33" +name = "num-modular" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" [[package]] -name = "powerfmt" -version = "0.2.0" +name = "num-order" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "proc-macro2", - "syn", + "autocfg", + "libm", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "unicode-ident", + "lock_api", + "parking_lot_core", ] [[package]] -name = "quote" -version = "1.0.45" +name = "parking_lot_core" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "proc-macro2", + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] [[package]] -name = "r-efi" -version = "6.0.0" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "rustix" -version = "1.1.4" +name = "pest" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "memchr", + "ucd-trie", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "pest_derive" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] [[package]] -name = "semver" -version = "1.0.28" +name = "pest_generator" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "serde" -version = "1.0.228" +name = "pest_meta" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" dependencies = [ - "serde_core", - "serde_derive", + "pest", ] [[package]] -name = "serde_core" +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +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]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions_next" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" dependencies = [ - "serde_derive", + "unicode-bidi", + "unicode-normalization", + "unicode-properties", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "serde_json" -version = "1.0.149" +name = "strum" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "strum_macros", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "strum_macros" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "shlex" -version = "1.3.0" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "slab" -version = "0.4.12" +name = "syn" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] [[package]] -name = "syn" -version = "2.0.117" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn", ] [[package]] @@ -570,10 +2025,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -626,6 +2081,31 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -633,8 +2113,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -648,6 +2132,18 @@ dependencies = [ "syn", ] +[[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", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -661,11 +2157,85 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" @@ -674,23 +2244,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -698,28 +2313,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "wasite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -730,9 +2339,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -740,9 +2349,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -753,45 +2362,39 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "webpki-roots" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "leb128fmt", - "wasmparser", + "webpki-roots 1.0.8", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "webpki-roots" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "rustls-pki-types", ] [[package]] -name = "wasmparser" -version = "0.244.0" +name = "whoami" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", + "libredox", + "wasite", ] [[package]] @@ -800,6 +2403,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -810,97 +2431,242 @@ dependencies = [ ] [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "wit-bindgen-rust-macro", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "zerocopy" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ - "anyhow", - "heck", - "wit-parser", + "zerocopy-derive", ] [[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +name = "zerocopy-derive" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", + "proc-macro2", + "quote", "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", ] [[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ - "anyhow", - "prettyplease", "proc-macro2", "quote", "syn", - "wit-bindgen-core", - "wit-bindgen-rust", + "synstructure", ] [[package]] -name = "wit-component" -version = "0.244.0" +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "displaydoc", + "yoke", + "zerofrom", ] [[package]] -name = "wit-parser" -version = "0.244.0" +name = "zerovec" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml index 8f62fc94..87930d3f 100644 --- a/crates/graphql-orm-backup/Cargo.toml +++ b/crates/graphql-orm-backup/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-backup" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm-backup" @@ -9,12 +9,16 @@ description = "Backup and restore orchestration primitives for graphql-orm appli [features] default = ["local"] local = ["graphql-orm-storage/local"] +# Requires the host application to enable exactly one graphql-orm backend +# feature (sqlite or postgres). +orm = ["dep:graphql-orm"] [dependencies] async-trait = "0.1" bytes = "1" futures = "0.3" -graphql-orm-storage = { version = "0.3.0", path = "../graphql-orm-storage", default-features = false } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm", optional = true, default-features = false } +graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm-storage", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/crates/graphql-orm-backup/README.md b/crates/graphql-orm-backup/README.md index 78fd883a..76bdf6d1 100644 --- a/crates/graphql-orm-backup/README.md +++ b/crates/graphql-orm-backup/README.md @@ -13,6 +13,10 @@ backup layout, checksums, repository writes, restore ordering, and operational s - incremental snapshot creation through `create_incremental_backup` - restore orchestration through `restore_snapshot` - object rehydration through caller-supplied `RestoreObjectSink` +- `BlobStoreRestoreObjectSink` for rehydrating a `graphql-orm-storage` blob store in place +- optional `orm` feature with ready-made `graphql-orm` runtime adapters + (`OrmBackupAdapter`, `OrmBackupObjectIndex`) including replace-existing + restore-target clearing - manifest-chain loading and validation - zstd-compressed JSON Lines table and change payloads - content-addressed object blobs keyed by SHA-256 @@ -23,6 +27,7 @@ backup layout, checksums, repository writes, restore ordering, and operational s - advisory repository writer lock for backup, compaction, and pruning operations - synthetic-full compaction through `compact_chain` - retention pruning through `prune` +- single-snapshot deletion with object garbage collection through `delete_snapshot` ## Install @@ -43,6 +48,10 @@ graphql-orm-backup = { Use `default-features = false` when providing only custom repository implementations. +Enable the `orm` feature for the ready-made `graphql-orm` runtime adapters. The +host application must also enable exactly one `graphql-orm` backend feature +(`sqlite` or `postgres`). + ## Snapshot Layout Backups are manifest-based and content-addressed: @@ -145,7 +154,9 @@ events, object metadata persistence, or cloud credentials. ## Status Full backups, restore orchestration, incremental backups, manifest-chain validation, synthetic-full -compaction, local repository support, locking, and pruning are implemented. +compaction, local repository support, locking, pruning, and single-snapshot deletion are +implemented. The optional `orm` feature ships ready-made `graphql-orm` runtime adapters so hosts +only supply entity metadata and object-table column names. Provider code is shared through `graphql-orm-storage::BlobStore`. `LocalBackupRepository` is a thin wrapper over the storage crate's local blob backend, and `BlobStoreBackupRepository` can adapt any diff --git a/crates/graphql-orm-backup/docs/usage.md b/crates/graphql-orm-backup/docs/usage.md index 72dcefc1..45d01ccd 100644 --- a/crates/graphql-orm-backup/docs/usage.md +++ b/crates/graphql-orm-backup/docs/usage.md @@ -101,6 +101,56 @@ For full backups, implement: `create_full_backup` verifies the loaded bytes against the declared SHA-256 before the object is referenced in the manifest. +## graphql-orm Runtime Adapters (`orm` feature) + +The optional `orm` feature ships adapters over the `GraphqlOrmBackupRuntime` +implementation on `graphql_orm::db::Database`, so hosts only supply entity +metadata and object-table column names: + +```rust +use std::sync::Arc; + +use graphql_orm::db::Database; +use graphql_orm::graphql::orm::EntityMetadata; +use graphql_orm_backup::{OrmBackupAdapter, OrmBackupObjectIndex, OrmObjectIndexColumns}; +use graphql_orm_storage::BlobStore; + +fn adapters( + database: Arc, + entities: Vec<&'static EntityMetadata>, + store: Arc, +) -> (OrmBackupAdapter, OrmBackupObjectIndex) { + let adapter = OrmBackupAdapter::new(database.clone(), entities.clone()); + let objects = OrmBackupObjectIndex::new( + database, + entities, + OrmObjectIndexColumns { + table_name: "stored_objects".to_string(), + object_id_column: "object_id".to_string(), + storage_key_column: "storage_key".to_string(), + sha256_hex_column: "sha256_hex".to_string(), + size_bytes_column: "size_bytes".to_string(), + mime_type_column: Some("mime_type".to_string()), + }, + store, + ); + (adapter, objects) +} +``` + +The entity list must match the list used for migrations so exports and restores +cover every application-owned table. + +`OrmBackupAdapter::current_schema_snapshot` exposes the current schema hash for +manifest compatibility checks, and `OrmBackupAdapter::clear_restore_target` +empties every backup-enabled table so a replace-existing restore can run +through the standard empty-database path. Incremental export and restore return +`UnsupportedOperation` until a change-journal integration lands. + +`BlobStoreRestoreObjectSink` (available without the `orm` feature) writes +restored object bytes back to a `graphql-orm-storage` `BlobStore` at each +object's original storage key. + ## Incremental Backup `create_incremental_backup` writes compressed change files and an incremental @@ -221,6 +271,25 @@ async fn compact( `prune` retains the newest manifest chains selected by `KeepPolicy` and deletes expired snapshot blobs plus unreferenced content-addressed object blobs. +`delete_snapshot` removes one snapshot under the repository writer lock. It +refuses to delete a snapshot that is the parent of another manifest and +garbage-collects object blobs no remaining snapshot references: + +```rust +use graphql_orm_backup::{BackupRepository, RepositoryLockOptions, delete_snapshot}; +use uuid::Uuid; + +async fn delete( + repository: &dyn BackupRepository, + snapshot_id: Uuid, +) -> Result<(), graphql_orm_backup::BackupError> { + let result = + delete_snapshot(repository, snapshot_id, &RepositoryLockOptions::default()).await?; + println!("deleted {} blobs", result.deleted_blobs); + Ok(()) +} +``` + ## Repository Layout Full backups use this layout: diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs index 58537351..2efe95b2 100644 --- a/crates/graphql-orm-backup/src/backup.rs +++ b/crates/graphql-orm-backup/src/backup.rs @@ -526,9 +526,13 @@ async fn write_object_entries( object_concurrency: usize, ) -> Result, BackupError> { let concurrency = object_concurrency.max(1); - let mut object_entries = stream::iter(object_refs.iter().enumerate()) + // Owned refs keep the stream free of higher-ranked borrows, which + // otherwise break `Send` future inference in async resolvers that await + // backup creation. + let owned_refs = object_refs.to_vec(); + let mut object_entries = stream::iter(owned_refs.into_iter().enumerate()) .map(|(index, object)| async move { - let bytes = objects.load_object(object).await?; + let bytes = objects.load_object(&object).await?; let actual = sha256_hex(&bytes); let content_key = object_content_key(&object.sha256_hex); if actual != object.sha256_hex { diff --git a/crates/graphql-orm-backup/src/error.rs b/crates/graphql-orm-backup/src/error.rs index 189cc3c8..ae34505f 100644 --- a/crates/graphql-orm-backup/src/error.rs +++ b/crates/graphql-orm-backup/src/error.rs @@ -45,6 +45,9 @@ pub enum BackupError { #[error("unsupported operation: {operation}")] UnsupportedOperation { operation: String }, + #[error("database adapter error: {message}")] + Database { message: String }, + #[error("serialization error")] Serialization(#[from] serde_json::Error), diff --git a/crates/graphql-orm-backup/src/lib.rs b/crates/graphql-orm-backup/src/lib.rs index e25bad6c..4a971e86 100644 --- a/crates/graphql-orm-backup/src/lib.rs +++ b/crates/graphql-orm-backup/src/lib.rs @@ -77,6 +77,8 @@ mod local_repository; mod lock; mod manifest; mod object_index; +#[cfg(feature = "orm")] +mod orm; mod planner; mod prune; mod repository; @@ -106,12 +108,14 @@ pub use manifest::{ set_manifest_checksum, validate_manifest_chain, verify_manifest_checksum, }; pub use object_index::{BackupObjectIndex, BackupObjectRef}; +#[cfg(feature = "orm")] +pub use orm::{OrmBackupAdapter, OrmBackupObjectIndex, OrmObjectIndexColumns}; pub use planner::{FullBackupPlan, plan_full_backup}; -pub use prune::{KeepPolicy, PruneResult, prune}; +pub use prune::{DeleteSnapshotResult, KeepPolicy, PruneResult, delete_snapshot, prune}; pub use repository::{BackupRepository, BlobStoreBackupRepository}; pub use restore::{ - RestoreContext, RestoreMode, RestoreObjectSink, RestoreResult, ensure_empty_restore_target, - restore_objects, restore_snapshot, + BlobStoreRestoreObjectSink, RestoreContext, RestoreMode, RestoreObjectSink, RestoreResult, + ensure_empty_restore_target, restore_objects, restore_snapshot, }; pub use verify::{ VerificationOptions, verify_manifest_and_objects, verify_manifest_and_objects_with_options, diff --git a/crates/graphql-orm-backup/src/orm.rs b/crates/graphql-orm-backup/src/orm.rs new file mode 100644 index 00000000..aa83100d --- /dev/null +++ b/crates/graphql-orm-backup/src/orm.rs @@ -0,0 +1,561 @@ +//! `graphql-orm` runtime integration behind the `orm` feature. +//! +//! [`OrmBackupAdapter`] bridges the [`graphql_orm::graphql::orm::GraphqlOrmBackupRuntime`] +//! implementation on [`graphql_orm::db::Database`] to this crate's +//! [`GraphqlOrmBackupAdapter`] contract, and [`OrmBackupObjectIndex`] derives a +//! [`BackupObjectIndex`] from one backup-enabled table that records stored +//! object metadata. Host applications supply entity metadata and column names; +//! this module stays free of application domain assumptions. +//! +//! The `orm` feature requires the host application to enable exactly one +//! `graphql-orm` backend feature (`sqlite` or `postgres`). + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use graphql_orm::db::Database; +use graphql_orm::graphql::orm::{ + BackupRow as OrmBackupRow, BackupValue, EntityBackupDescriptor, EntityMetadata, + GraphqlOrmBackupRuntime, GraphqlOrmSchemaSnapshot, RestoreContext as OrmRestoreContext, + RestoreMode as OrmRestoreMode, +}; +use graphql_orm::sqlx::Row as _; +use graphql_orm_storage::{BlobStore, collect_storage_stream}; +use uuid::Uuid; + +use crate::{ + BackupChangeExport, BackupError, BackupObjectIndex, BackupObjectRef, BackupRow, + BackupTableExport, GraphqlOrmBackupAdapter, GraphqlOrmBackupSchema, RestoreContext, + RestoreMode, bytes_sha256_hex, +}; + +/// [`GraphqlOrmBackupAdapter`] over a `graphql-orm` [`Database`]. +/// +/// Full export and restore delegate to the `GraphqlOrmBackupRuntime` +/// implementation shipped with `graphql-orm`. Incremental export and restore +/// are unsupported until a change-journal integration lands. +pub struct OrmBackupAdapter { + database: Arc, + entities: Vec<&'static EntityMetadata>, + migration_version: Option, +} + +impl OrmBackupAdapter { + /// Creates an adapter over a database and its backup-enabled entities. + /// + /// The entity list must match the list used for migrations so exports and + /// restores cover every application-owned table. + #[must_use] + pub fn new(database: Arc, entities: Vec<&'static EntityMetadata>) -> Self { + Self { + database, + entities, + migration_version: None, + } + } + + /// Overrides the migration version recorded in schema snapshots. + /// + /// Without an override the adapter reads the latest applied migration + /// version from the database schema manager. + #[must_use] + pub fn with_migration_version(mut self, migration_version: impl Into) -> Self { + self.migration_version = Some(migration_version.into()); + self + } + + /// Returns the current `graphql-orm` schema snapshot for the configured + /// entities. + /// + /// Hosts should compare a manifest's schema hash against this snapshot + /// before destructive restore steps. + /// + /// # Errors + /// + /// Returns [`BackupError`] if the migration version cannot be read. + pub async fn current_schema_snapshot(&self) -> Result { + let migration_version = self.resolve_migration_version().await?; + Ok(GraphqlOrmBackupRuntime::schema_snapshot( + self.database.as_ref(), + migration_version, + &self.entities, + )) + } + + /// Deletes all rows from every backup-enabled table so an + /// empty-database restore can run against a previously used database. + /// + /// `RESTRICT` foreign keys are enforced immediately on both backends, so + /// PostgreSQL clears with one `TRUNCATE ... CASCADE` statement and SQLite + /// suspends `PRAGMA foreign_keys` on a dedicated connection around a + /// child-first delete transaction, mirroring the `graphql-orm` migration + /// executor. Callers own any additional safety checks such as manifest + /// verification and schema compatibility. + /// + /// # Errors + /// + /// Returns [`BackupError`] if any clear statement or the transaction + /// fails. + pub async fn clear_restore_target(&self) -> Result<(), BackupError> { + let mut descriptors = self.descriptors(); + descriptors.sort_by(|left, right| { + right + .restore_order + .cmp(&left.restore_order) + .then_with(|| right.table_name.cmp(&left.table_name)) + }); + let backend = graphql_orm::graphql::orm::current_backend(); + + if backend == graphql_orm::graphql::orm::DatabaseBackend::Sqlite { + self.clear_restore_target_without_foreign_keys(&descriptors) + .await + } else { + let tables = descriptors + .iter() + .map(|descriptor| quote_identifier(&descriptor.table_name)) + .collect::>() + .join(", "); + graphql_orm::sqlx::query(&format!("TRUNCATE TABLE {tables} CASCADE")) + .execute(self.database.pool()) + .await + .map_err(database_error("clear restore target"))?; + Ok(()) + } + } + + async fn clear_restore_target_without_foreign_keys( + &self, + descriptors: &[EntityBackupDescriptor], + ) -> Result<(), BackupError> { + use graphql_orm::sqlx::Connection as _; + + let mut connection = self + .database + .pool() + .acquire() + .await + .map_err(database_error("clear restore target"))?; + graphql_orm::sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *connection) + .await + .map_err(database_error("suspend foreign keys"))?; + + let cleared = async { + let mut tx = connection + .begin() + .await + .map_err(database_error("clear restore target"))?; + for descriptor in descriptors { + let sql = format!("DELETE FROM {}", quote_identifier(&descriptor.table_name)); + graphql_orm::sqlx::query(&sql) + .execute(&mut *tx) + .await + .map_err(database_error(&format!( + "clear restore target table {}", + descriptor.table_name + )))?; + } + tx.commit() + .await + .map_err(database_error("clear restore target commit")) + } + .await; + + let reenabled = graphql_orm::sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut *connection) + .await + .map_err(database_error("re-enable foreign keys")); + if reenabled.is_err() { + // Never return a connection with foreign keys disabled to the pool. + let _ = connection.detach().close().await; + } + + cleared?; + reenabled?; + Ok(()) + } + + fn descriptors(&self) -> Vec { + self.database.list_backup_entities(&self.entities) + } + + async fn resolve_migration_version(&self) -> Result { + if let Some(migration_version) = &self.migration_version { + return Ok(migration_version.clone()); + } + Ok(self + .database + .schema() + .current_version() + .await + .map_err(database_error("read migration version"))? + .unwrap_or_else(|| "unversioned".to_string())) + } +} + +#[async_trait] +impl GraphqlOrmBackupAdapter for OrmBackupAdapter { + async fn schema_snapshot(&self) -> Result { + let snapshot = self.current_schema_snapshot().await?; + Ok(GraphqlOrmBackupSchema { + backend: snapshot.backend, + migration_version: snapshot.migration_version, + schema_hash: snapshot.schema_hash, + }) + } + + async fn restore_target_is_empty(&self) -> Result { + for descriptor in self.descriptors() { + let sql = format!( + "SELECT COUNT(*) AS row_count FROM {}", + quote_identifier(&descriptor.table_name) + ); + let row = graphql_orm::sqlx::query(&sql) + .fetch_one(self.database.pool()) + .await + .map_err(database_error(&format!( + "count rows in table {}", + descriptor.table_name + )))?; + let row_count: i64 = row + .try_get("row_count") + .map_err(database_error("decode row count"))?; + if row_count != 0 { + return Ok(false); + } + } + Ok(true) + } + + async fn export_full(&self) -> Result, BackupError> { + let mut descriptors = self.descriptors(); + descriptors.sort_by(|left, right| { + left.export_order + .cmp(&right.export_order) + .then_with(|| left.table_name.cmp(&right.table_name)) + }); + + let mut snapshot = self + .database + .begin_consistent_snapshot() + .await + .map_err(database_error("begin export snapshot"))?; + let mut exports = Vec::with_capacity(descriptors.len()); + for descriptor in &descriptors { + let rows = self + .database + .export_table_rows(&mut snapshot, descriptor) + .await + .map_err(database_error(&format!( + "export table {}", + descriptor.table_name + )))?; + exports.push(BackupTableExport { + table_name: descriptor.table_name.clone(), + rows: rows + .into_iter() + .map(crate_row_from_orm) + .collect::, _>>()?, + }); + } + Ok(exports) + } + + async fn export_incremental( + &self, + _parent_snapshot_id: Uuid, + ) -> Result, BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "incremental export requires a graphql-orm change journal integration" + .to_string(), + }) + } + + async fn restore_full( + &self, + export: Vec, + context: RestoreContext, + ) -> Result<(), BackupError> { + let context = orm_restore_context(&context)?; + let snapshot = self.current_schema_snapshot().await?; + let mut rows_by_table = BTreeMap::new(); + for table in export { + rows_by_table.insert( + table.table_name.clone(), + table + .rows + .into_iter() + .map(orm_row_from_crate) + .collect::, _>>()?, + ); + } + self.database + .restore_backup_rows(&snapshot, &snapshot, &rows_by_table, &context) + .await + .map_err(database_error("restore rows"))?; + Ok(()) + } + + async fn restore_incremental( + &self, + _changes: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "incremental restore requires a graphql-orm change journal integration" + .to_string(), + }) + } +} + +/// Column names an [`OrmBackupObjectIndex`] reads from an object metadata +/// table. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OrmObjectIndexColumns { + /// Backup-enabled table that records stored object metadata. + pub table_name: String, + /// UUID column identifying each object. + pub object_id_column: String, + /// Column holding the provider-neutral storage key. + pub storage_key_column: String, + /// Column holding the lowercase hex SHA-256 of the object bytes. + pub sha256_hex_column: String, + /// Column holding the object size in bytes. + pub size_bytes_column: String, + /// Optional column holding the object MIME type. + pub mime_type_column: Option, +} + +/// [`BackupObjectIndex`] over one `graphql-orm` object metadata table plus the +/// application's primary [`BlobStore`]. +/// +/// Rows whose checksum column does not contain a valid SHA-256 (for example +/// when a hash backfill queue has not caught up) are hashed from the loaded +/// blob bytes at listing time. +pub struct OrmBackupObjectIndex { + database: Arc, + entities: Vec<&'static EntityMetadata>, + columns: OrmObjectIndexColumns, + store: Arc, +} + +impl OrmBackupObjectIndex { + /// Creates an object index over a metadata table and blob store. + #[must_use] + pub fn new( + database: Arc, + entities: Vec<&'static EntityMetadata>, + columns: OrmObjectIndexColumns, + store: Arc, + ) -> Self { + Self { + database, + entities, + columns, + store, + } + } + + fn object_table_descriptor(&self) -> Result { + self.database + .list_backup_entities(&self.entities) + .into_iter() + .find(|descriptor| descriptor.table_name == self.columns.table_name) + .ok_or_else(|| BackupError::Database { + message: format!( + "object index table {} is not a backup-enabled entity", + self.columns.table_name + ), + }) + } + + async fn object_ref_from_row( + &self, + row: &OrmBackupRow, + ) -> Result { + let object_id = row_uuid(row, &self.columns.object_id_column)?; + let storage_key = row_string(row, &self.columns.storage_key_column)?; + let mime_type = match &self.columns.mime_type_column { + Some(column) => row_optional_string(row, column), + None => None, + }; + + let recorded_sha256 = row_optional_string(row, &self.columns.sha256_hex_column); + let recorded_size = row_optional_i64(row, &self.columns.size_bytes_column); + let (sha256_hex, size_bytes) = match (recorded_sha256, recorded_size) { + (Some(sha256_hex), Some(size_bytes)) + if is_valid_sha256_hex(&sha256_hex) && size_bytes >= 0 => + { + (sha256_hex.to_ascii_lowercase(), size_bytes as u64) + } + _ => { + let bytes = load_blob(self.store.as_ref(), &storage_key).await?; + (bytes_sha256_hex(&bytes), bytes.len() as u64) + } + }; + + Ok(BackupObjectRef { + object_id, + storage_key, + sha256_hex, + size_bytes, + mime_type, + }) + } +} + +#[async_trait] +impl BackupObjectIndex for OrmBackupObjectIndex { + async fn list_objects_for_full_backup(&self) -> Result, BackupError> { + let descriptor = self.object_table_descriptor()?; + let mut snapshot = self + .database + .begin_consistent_snapshot() + .await + .map_err(database_error("begin object index snapshot"))?; + let rows = self + .database + .export_table_rows(&mut snapshot, &descriptor) + .await + .map_err(database_error(&format!( + "export object index table {}", + descriptor.table_name + )))?; + drop(snapshot); + + let mut objects = Vec::with_capacity(rows.len()); + for row in &rows { + objects.push(self.object_ref_from_row(row).await?); + } + Ok(objects) + } + + async fn list_objects_for_incremental_backup( + &self, + _since_snapshot_id: Uuid, + ) -> Result, BackupError> { + Err(BackupError::UnsupportedOperation { + operation: + "incremental object discovery requires a graphql-orm change journal integration" + .to_string(), + }) + } + + async fn load_object(&self, object: &BackupObjectRef) -> Result { + load_blob(self.store.as_ref(), &object.storage_key).await + } +} + +async fn load_blob(store: &dyn BlobStore, storage_key: &str) -> Result { + let body = store.get_blob(storage_key).await?; + Ok(collect_storage_stream(body.body).await?) +} + +fn crate_row_from_orm(row: OrmBackupRow) -> Result { + let mut values = serde_json::Map::new(); + for (column, value) in row.values { + values.insert(column, serde_json::to_value(&value)?); + } + Ok(BackupRow { + table_name: row.table_name, + primary_key: row.primary_key, + row_hash: row.row_hash, + values, + }) +} + +fn orm_row_from_crate(row: BackupRow) -> Result { + let mut values = BTreeMap::new(); + for (column, value) in row.values { + values.insert(column, serde_json::from_value::(value)?); + } + Ok(OrmBackupRow { + table_name: row.table_name, + primary_key: row.primary_key, + row_hash: row.row_hash, + values, + }) +} + +fn orm_restore_context(context: &RestoreContext) -> Result { + let mode = match context.mode { + RestoreMode::EmptyDatabase => OrmRestoreMode::EmptyDatabase, + RestoreMode::DryRun => OrmRestoreMode::DryRun, + }; + Ok(OrmRestoreContext { + mode, + disable_policies: context.disable_policies, + disable_change_journal: context.disable_change_journal, + }) +} + +fn row_value<'a>(row: &'a OrmBackupRow, column: &str) -> Result<&'a BackupValue, BackupError> { + row.values.get(column).ok_or_else(|| BackupError::Database { + message: format!( + "object index row {} in table {} is missing column {}", + row.primary_key, row.table_name, column + ), + }) +} + +fn row_uuid(row: &OrmBackupRow, column: &str) -> Result { + match row_value(row, column)? { + BackupValue::Uuid(value) => Ok(*value), + BackupValue::String(value) => { + Uuid::parse_str(value).map_err(|error| BackupError::Database { + message: format!( + "object index column {column} in table {} is not a uuid: {error}", + row.table_name + ), + }) + } + other => Err(BackupError::Database { + message: format!( + "object index column {column} in table {} has unsupported uuid value {other:?}", + row.table_name + ), + }), + } +} + +fn row_string(row: &OrmBackupRow, column: &str) -> Result { + match row_value(row, column)? { + BackupValue::String(value) => Ok(value.clone()), + other => Err(BackupError::Database { + message: format!( + "object index column {column} in table {} has unsupported string value {other:?}", + row.table_name + ), + }), + } +} + +fn row_optional_string(row: &OrmBackupRow, column: &str) -> Option { + match row.values.get(column) { + Some(BackupValue::String(value)) if !value.trim().is_empty() => Some(value.clone()), + _ => None, + } +} + +fn row_optional_i64(row: &OrmBackupRow, column: &str) -> Option { + match row.values.get(column) { + Some(BackupValue::Integer(value)) => Some(*value), + _ => None, + } +} + +fn is_valid_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn database_error(operation: &str) -> impl Fn(graphql_orm::Error) -> BackupError + '_ { + move |error| BackupError::Database { + message: format!("{operation}: {error}"), + } +} + +fn quote_identifier(identifier: &str) -> String { + format!("\"{}\"", identifier.replace('"', "\"\"")) +} diff --git a/crates/graphql-orm-backup/src/prune.rs b/crates/graphql-orm-backup/src/prune.rs index 70a6ae40..f46aa6ab 100644 --- a/crates/graphql-orm-backup/src/prune.rs +++ b/crates/graphql-orm-backup/src/prune.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::{ BackupError, BackupRepository, BackupSnapshotManifest, RepositoryLock, RepositoryLockOptions, - load_manifest, load_manifest_chain, + load_manifest, load_manifest_chain, snapshot_manifest_key, }; #[derive(Clone, Debug, Eq, PartialEq)] @@ -118,6 +118,93 @@ async fn prune_inner( }) } +#[derive(Clone, Debug, Eq, PartialEq)] +/// Summary returned by `delete_snapshot`. +pub struct DeleteSnapshotResult { + /// Number of blobs deleted, including unreferenced object blobs. + pub deleted_blobs: usize, + /// Number of snapshot manifests remaining after deletion. + pub retained_snapshots: usize, +} + +/// Deletes one snapshot and any object blobs no other snapshot references. +/// +/// Snapshots that are the parent of another manifest cannot be deleted; +/// dependent snapshots must be deleted or compacted first. +/// +/// # Errors +/// +/// Returns [`BackupError`] if the snapshot is missing, another manifest depends +/// on it, or repository listing, locking, or blob deletion fails. +pub async fn delete_snapshot( + repository: &dyn BackupRepository, + snapshot_id: Uuid, + lock_options: &RepositoryLockOptions, +) -> Result { + let lock = RepositoryLock::acquire(repository, lock_options).await?; + let result = delete_snapshot_inner(repository, snapshot_id).await; + let release_result = lock.release(repository).await; + match (result, release_result) { + (Ok(result), Ok(())) => Ok(result), + (Err(err), _) => Err(err), + (Ok(_), Err(err)) => Err(err), + } +} + +async fn delete_snapshot_inner( + repository: &dyn BackupRepository, + snapshot_id: Uuid, +) -> Result { + let manifests = load_all_manifests(repository).await?; + if !manifests + .iter() + .any(|manifest| manifest.snapshot_id == snapshot_id) + { + return Err(BackupError::MissingBlob { + key: snapshot_manifest_key(snapshot_id), + }); + } + if let Some(child) = manifests + .iter() + .find(|manifest| manifest.parent_snapshot_id == Some(snapshot_id)) + { + return Err(BackupError::InvalidManifestChain { + reason: format!( + "snapshot {snapshot_id} is the parent of snapshot {}; delete or compact dependent snapshots first", + child.snapshot_id + ), + }); + } + + let mut deleted_blobs = 0_usize; + for key in repository + .list_blobs(&format!("snapshots/{snapshot_id}")) + .await? + { + repository.delete_blob(&key).await?; + deleted_blobs += 1; + } + + let mut reachable_content_keys = HashSet::new(); + for manifest in manifests + .iter() + .filter(|manifest| manifest.snapshot_id != snapshot_id) + { + collect_reachable_keys(manifest, &mut reachable_content_keys); + } + for key in repository.list_blobs("objects/sha256").await? { + if !reachable_content_keys.contains(&key) { + repository.delete_blob(&key).await?; + deleted_blobs += 1; + } + } + + Ok(DeleteSnapshotResult { + deleted_blobs, + retained_snapshots: manifests.len() - 1, + }) +} + async fn load_all_manifests( repository: &dyn BackupRepository, ) -> Result, BackupError> { diff --git a/crates/graphql-orm-backup/src/restore.rs b/crates/graphql-orm-backup/src/restore.rs index 33e04eac..c80be68f 100644 --- a/crates/graphql-orm-backup/src/restore.rs +++ b/crates/graphql-orm-backup/src/restore.rs @@ -1,5 +1,8 @@ +use std::sync::Arc; + use async_trait::async_trait; use bytes::Bytes; +use graphql_orm_storage::{BlobPutOptions, BlobStore, StorageByteStream}; use serde::de::DeserializeOwned; use uuid::Uuid; @@ -58,6 +61,41 @@ pub trait RestoreObjectSink: Send + Sync { ) -> Result<(), BackupError>; } +/// [`RestoreObjectSink`] that writes object bytes back to a +/// `graphql-orm-storage` [`BlobStore`] at each object's original storage key. +#[derive(Clone)] +pub struct BlobStoreRestoreObjectSink { + store: Arc, +} + +impl BlobStoreRestoreObjectSink { + /// Creates a sink over the application's primary object blob store. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl RestoreObjectSink for BlobStoreRestoreObjectSink { + async fn restore_object( + &self, + object: BackupObjectRef, + bytes: Bytes, + ) -> Result<(), BackupError> { + self.store + .put_blob( + &object.storage_key, + StorageByteStream::from_bytes(bytes), + BlobPutOptions { + content_type: object.mime_type.clone(), + }, + ) + .await?; + Ok(()) + } +} + impl RestoreContext { /// Builds the default empty-database restore context. #[must_use] diff --git a/crates/graphql-orm-backup/src/verify.rs b/crates/graphql-orm-backup/src/verify.rs index eb48c909..0e42f935 100644 --- a/crates/graphql-orm-backup/src/verify.rs +++ b/crates/graphql-orm-backup/src/verify.rs @@ -1,6 +1,6 @@ use crate::{ BackupError, BackupRepository, BackupSnapshotManifest, DEFAULT_OBJECT_CONCURRENCY, - ObjectBackupEntry, TableBackupEntry, manifest::sha256_hex, verify_manifest_checksum, + manifest::sha256_hex, verify_manifest_checksum, }; use futures::{StreamExt, TryStreamExt, stream}; @@ -75,54 +75,49 @@ pub async fn verify_object_checksums_with_options( ) -> Result<(), BackupError> { let concurrency = options.blob_concurrency.max(1); - stream::iter(&manifest.objects) - .map(|object| verify_object_checksum(repository, object)) + // Owned (key, checksum) pairs keep the streams free of higher-ranked + // borrows, which otherwise break `Send` future inference in async + // resolvers that await this function. + let mut object_checks = Vec::with_capacity(manifest.objects.len()); + for object in &manifest.objects { + object_checks.push((object.content_key.clone(), object.sha256_hex.clone())); + } + stream::iter(object_checks) + .map(|(content_key, sha256)| verify_blob_checksum(repository, content_key, sha256)) .buffer_unordered(concurrency) .try_collect::>() .await?; - stream::iter( - manifest - .database - .tables - .iter() - .chain(manifest.database.changes.iter()), - ) - .map(|entry| verify_entry_checksum(repository, entry)) - .buffer_unordered(concurrency) - .try_collect::>() - .await?; - - Ok(()) -} - -async fn verify_object_checksum( - repository: &dyn BackupRepository, - object: &ObjectBackupEntry, -) -> Result<(), BackupError> { - let bytes = repository.get_blob(&object.content_key).await?; - let actual = sha256_hex(&bytes); - if actual != object.sha256_hex { - return Err(BackupError::ChecksumMismatch { - key: object.content_key.clone(), - expected: object.sha256_hex.clone(), - actual, - }); + let mut entry_checks = + Vec::with_capacity(manifest.database.tables.len() + manifest.database.changes.len()); + for entry in manifest + .database + .tables + .iter() + .chain(manifest.database.changes.iter()) + { + entry_checks.push((entry.content_key.clone(), entry.sha256_hex.clone())); } + stream::iter(entry_checks) + .map(|(content_key, sha256)| verify_blob_checksum(repository, content_key, sha256)) + .buffer_unordered(concurrency) + .try_collect::>() + .await?; Ok(()) } -async fn verify_entry_checksum( +async fn verify_blob_checksum( repository: &dyn BackupRepository, - entry: &TableBackupEntry, + content_key: String, + expected_sha256_hex: String, ) -> Result<(), BackupError> { - let bytes = repository.get_blob(&entry.content_key).await?; + let bytes = repository.get_blob(&content_key).await?; let actual = sha256_hex(&bytes); - if actual != entry.sha256_hex { + if actual != expected_sha256_hex { return Err(BackupError::ChecksumMismatch { - key: entry.content_key.clone(), - expected: entry.sha256_hex.clone(), + key: content_key, + expected: expected_sha256_hex, actual, }); } diff --git a/crates/graphql-orm-backup/tests/operational_safety.rs b/crates/graphql-orm-backup/tests/operational_safety.rs index 58772985..ed798978 100644 --- a/crates/graphql-orm-backup/tests/operational_safety.rs +++ b/crates/graphql-orm-backup/tests/operational_safety.rs @@ -3,8 +3,8 @@ use bytes::Bytes; use graphql_orm_backup::{ BackupChangeExport, BackupError, BackupObjectIndex, BackupObjectRef, BackupRepository, BackupTableExport, FullBackupRequest, GraphqlOrmBackupAdapter, GraphqlOrmBackupSchema, - KeepPolicy, LocalBackupRepository, RestoreContext, bytes_sha256_hex, create_full_backup, - object_content_key, prune, snapshot_manifest_key, + KeepPolicy, LocalBackupRepository, RepositoryLockOptions, RestoreContext, bytes_sha256_hex, + create_full_backup, delete_snapshot, object_content_key, prune, snapshot_manifest_key, }; use tempfile::TempDir; use uuid::Uuid; @@ -103,6 +103,73 @@ async fn prune_deletes_expired_snapshots_and_unreferenced_objects() { ); } +#[tokio::test] +async fn delete_snapshot_removes_snapshot_and_unreferenced_objects() { + let temp = TempDir::new().expect("temp dir"); + let repository = LocalBackupRepository::new(temp.path()); + let database = MockDatabase; + + let first_object = Bytes::from_static(b"first object"); + let first_hash = bytes_sha256_hex(&first_object); + let first_objects = MockObjectIndex::new(first_hash.clone(), first_object); + create_full_backup( + &repository, + &database, + &first_objects, + backup_request(first_id(), 1), + ) + .await + .expect("first backup"); + + let second_object = Bytes::from_static(b"second object"); + let second_hash = bytes_sha256_hex(&second_object); + let second_objects = MockObjectIndex::new(second_hash.clone(), second_object); + create_full_backup( + &repository, + &database, + &second_objects, + backup_request(second_id(), 2), + ) + .await + .expect("second backup"); + + let result = delete_snapshot(&repository, first_id(), &RepositoryLockOptions::default()) + .await + .expect("delete first snapshot"); + assert_eq!(result.retained_snapshots, 1); + assert!(result.deleted_blobs >= 2); + + assert!( + !repository + .blob_exists(&snapshot_manifest_key(first_id())) + .await + .expect("first manifest exists check") + ); + assert!( + repository + .blob_exists(&snapshot_manifest_key(second_id())) + .await + .expect("second manifest exists check") + ); + assert!( + !repository + .blob_exists(&object_content_key(&first_hash)) + .await + .expect("first object exists check") + ); + assert!( + repository + .blob_exists(&object_content_key(&second_hash)) + .await + .expect("second object exists check") + ); + + let missing = delete_snapshot(&repository, first_id(), &RepositoryLockOptions::default()) + .await + .expect_err("deleting a missing snapshot fails"); + assert!(matches!(missing, BackupError::MissingBlob { .. })); +} + fn backup_request(snapshot_id: Uuid, created_at: i64) -> FullBackupRequest { FullBackupRequest { snapshot_id, From 8d08bf92f84611d0dffd3b58244fd394f3f1e4cf Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sun, 12 Jul 2026 06:53:23 +1000 Subject: [PATCH 019/108] Add adapter-level column backup policy overrides Hosts can exclude or redact columns whose database types the graphql-orm export cannot round-trip yet (for example PostGIS geometry) without editing entity metadata, which would change migration-planning inputs. Co-Authored-By: Claude Fable 5 --- crates/graphql-orm-backup/CHANGELOG.md | 5 ++ crates/graphql-orm-backup/src/lib.rs | 2 + crates/graphql-orm-backup/src/orm.rs | 68 +++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/crates/graphql-orm-backup/CHANGELOG.md b/crates/graphql-orm-backup/CHANGELOG.md index c1a60d09..c4595b4d 100644 --- a/crates/graphql-orm-backup/CHANGELOG.md +++ b/crates/graphql-orm-backup/CHANGELOG.md @@ -8,6 +8,11 @@ snapshots, consistent full export, empty-target detection, and full restore. Incremental export/restore report `UnsupportedOperation` until a change-journal integration lands. +- Added `OrmBackupAdapter::with_column_backup_policy` so hosts can exclude or + redact columns whose database types the export cannot round-trip yet (for + example PostGIS geometry) without editing entity metadata, which would + change migration-planning inputs. `ColumnBackupPolicy` is re-exported behind + the `orm` feature. - Added `OrmBackupAdapter::clear_restore_target` so hosts can replace an existing database before an empty-database restore. PostgreSQL clears with one `TRUNCATE ... CASCADE`; SQLite suspends `PRAGMA foreign_keys` on a diff --git a/crates/graphql-orm-backup/src/lib.rs b/crates/graphql-orm-backup/src/lib.rs index 4a971e86..508d6591 100644 --- a/crates/graphql-orm-backup/src/lib.rs +++ b/crates/graphql-orm-backup/src/lib.rs @@ -98,6 +98,8 @@ pub use database::{ GraphqlOrmBackupSchema, }; pub use error::BackupError; +#[cfg(feature = "orm")] +pub use graphql_orm::graphql::orm::ColumnBackupPolicy; #[cfg(feature = "local")] pub use local_repository::LocalBackupRepository; pub use lock::{DEFAULT_LOCK_STALE_AFTER_SECONDS, RepositoryLock, RepositoryLockOptions}; diff --git a/crates/graphql-orm-backup/src/orm.rs b/crates/graphql-orm-backup/src/orm.rs index aa83100d..572b93d1 100644 --- a/crates/graphql-orm-backup/src/orm.rs +++ b/crates/graphql-orm-backup/src/orm.rs @@ -17,9 +17,9 @@ use async_trait::async_trait; use bytes::Bytes; use graphql_orm::db::Database; use graphql_orm::graphql::orm::{ - BackupRow as OrmBackupRow, BackupValue, EntityBackupDescriptor, EntityMetadata, - GraphqlOrmBackupRuntime, GraphqlOrmSchemaSnapshot, RestoreContext as OrmRestoreContext, - RestoreMode as OrmRestoreMode, + BackupRow as OrmBackupRow, BackupValue, ColumnBackupPolicy, EntityBackupDescriptor, + EntityMetadata, GraphqlOrmBackupRuntime, GraphqlOrmSchemaSnapshot, + RestoreContext as OrmRestoreContext, RestoreMode as OrmRestoreMode, }; use graphql_orm::sqlx::Row as _; use graphql_orm_storage::{BlobStore, collect_storage_stream}; @@ -40,6 +40,13 @@ pub struct OrmBackupAdapter { database: Arc, entities: Vec<&'static EntityMetadata>, migration_version: Option, + column_policy_overrides: Vec, +} + +struct ColumnPolicyOverride { + table_name: String, + column_name: String, + policy: ColumnBackupPolicy, } impl OrmBackupAdapter { @@ -53,6 +60,7 @@ impl OrmBackupAdapter { database, entities, migration_version: None, + column_policy_overrides: Vec::new(), } } @@ -66,8 +74,33 @@ impl OrmBackupAdapter { self } + /// Overrides one column's backup policy at the adapter level. + /// + /// Use this to exclude or redact columns whose database types the + /// `graphql-orm` export cannot round-trip yet (for example PostGIS + /// geometry) without editing entity metadata, which would change + /// migration-planning inputs. Overrides apply to export, restore + /// validation, and restore imports; the schema hash keeps using the + /// unmodified entity metadata, so the same overrides must be configured + /// when a snapshot is created and when it is restored. + #[must_use] + pub fn with_column_backup_policy( + mut self, + table_name: impl Into, + column_name: impl Into, + policy: ColumnBackupPolicy, + ) -> Self { + self.column_policy_overrides.push(ColumnPolicyOverride { + table_name: table_name.into(), + column_name: column_name.into(), + policy, + }); + self + } + /// Returns the current `graphql-orm` schema snapshot for the configured - /// entities. + /// entities, with column policy overrides applied to its entity + /// descriptors. /// /// Hosts should compare a manifest's schema hash against this snapshot /// before destructive restore steps. @@ -77,11 +110,30 @@ impl OrmBackupAdapter { /// Returns [`BackupError`] if the migration version cannot be read. pub async fn current_schema_snapshot(&self) -> Result { let migration_version = self.resolve_migration_version().await?; - Ok(GraphqlOrmBackupRuntime::schema_snapshot( + let mut snapshot = GraphqlOrmBackupRuntime::schema_snapshot( self.database.as_ref(), migration_version, &self.entities, - )) + ); + self.apply_column_policy_overrides(&mut snapshot.entities); + Ok(snapshot) + } + + fn apply_column_policy_overrides(&self, descriptors: &mut [EntityBackupDescriptor]) { + for policy_override in &self.column_policy_overrides { + for descriptor in descriptors + .iter_mut() + .filter(|descriptor| descriptor.table_name == policy_override.table_name) + { + for column in descriptor + .columns + .iter_mut() + .filter(|column| column.column_name == policy_override.column_name) + { + column.backup_policy = policy_override.policy; + } + } + } } /// Deletes all rows from every backup-enabled table so an @@ -178,7 +230,9 @@ impl OrmBackupAdapter { } fn descriptors(&self) -> Vec { - self.database.list_backup_entities(&self.entities) + let mut descriptors = self.database.list_backup_entities(&self.entities); + self.apply_column_policy_overrides(&mut descriptors); + descriptors } async fn resolve_migration_version(&self) -> Result { From b52f0dcdba55e602e3fa8c14a012064781667b2f Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 13 Jul 2026 09:20:13 +1000 Subject: [PATCH 020/108] chore: pin backup crate dependencies Release graphql-orm-backup 0.3.1 with exact reviewed graphql-orm and graphql-orm-storage revisions. --- crates/graphql-orm-backup/CHANGELOG.md | 10 ++++++++++ crates/graphql-orm-backup/Cargo.lock | 10 +++++----- crates/graphql-orm-backup/Cargo.toml | 6 +++--- crates/graphql-orm-backup/README.md | 15 +++++++++++++-- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/graphql-orm-backup/CHANGELOG.md b/crates/graphql-orm-backup/CHANGELOG.md index c4595b4d..18e9c01d 100644 --- a/crates/graphql-orm-backup/CHANGELOG.md +++ b/crates/graphql-orm-backup/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.3.1 + +- Pinned `graphql-orm` 0.6.1 and `graphql-orm-storage` 0.4.0 to reviewed full + Git commit revisions so downstream builds do not advance when either + repository's default branch changes. +- Standardized dependency URLs on the canonical `.git` form so applications + can use the same source identities and avoid duplicate crate instances. +- This release changes no public Rust API, backup format, snapshot layout, or + restore behavior. + ## 0.3.0 - Added the optional `orm` feature with a generic [`OrmBackupAdapter`] that diff --git a/crates/graphql-orm-backup/Cargo.lock b/crates/graphql-orm-backup/Cargo.lock index 09ebfb17..dba4db36 100644 --- a/crates/graphql-orm-backup/Cargo.lock +++ b/crates/graphql-orm-backup/Cargo.lock @@ -709,8 +709,8 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.6.0" -source = "git+https://github.com/Dastari/graphql-orm#25e5aaf2f051e9c955d243e51a8e05a574e495a6" +version = "0.6.1" +source = "git+https://github.com/Dastari/graphql-orm.git?rev=510cd85d9fbc9ae60c7117a752370c590564949e#510cd85d9fbc9ae60c7117a752370c590564949e" dependencies = [ "async-graphql", "futures", @@ -725,7 +725,7 @@ dependencies = [ [[package]] name = "graphql-orm-backup" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "bytes", @@ -745,7 +745,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" version = "0.6.0" -source = "git+https://github.com/Dastari/graphql-orm#25e5aaf2f051e9c955d243e51a8e05a574e495a6" +source = "git+https://github.com/Dastari/graphql-orm.git?rev=510cd85d9fbc9ae60c7117a752370c590564949e#510cd85d9fbc9ae60c7117a752370c590564949e" dependencies = [ "convert_case", "proc-macro2", @@ -756,7 +756,7 @@ dependencies = [ [[package]] name = "graphql-orm-storage" version = "0.4.0" -source = "git+https://github.com/Dastari/graphql-orm-storage#3c92c96bdb9f4d7b48481f2a5038c7ba73dde81c" +source = "git+https://github.com/Dastari/graphql-orm-storage.git?rev=3c92c96bdb9f4d7b48481f2a5038c7ba73dde81c#3c92c96bdb9f4d7b48481f2a5038c7ba73dde81c" dependencies = [ "async-trait", "bytes", diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml index 87930d3f..52a57427 100644 --- a/crates/graphql-orm-backup/Cargo.toml +++ b/crates/graphql-orm-backup/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-backup" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm-backup" @@ -17,8 +17,8 @@ orm = ["dep:graphql-orm"] async-trait = "0.1" bytes = "1" futures = "0.3" -graphql-orm = { git = "https://github.com/Dastari/graphql-orm", optional = true, default-features = false } -graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm-storage", default-features = false } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "510cd85d9fbc9ae60c7117a752370c590564949e", version = "0.6.1", optional = true, default-features = false } +graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm-storage.git", rev = "3c92c96bdb9f4d7b48481f2a5038c7ba73dde81c", version = "0.4.0", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/crates/graphql-orm-backup/README.md b/crates/graphql-orm-backup/README.md index 76bdf6d1..5604eca6 100644 --- a/crates/graphql-orm-backup/README.md +++ b/crates/graphql-orm-backup/README.md @@ -33,15 +33,26 @@ backup layout, checksums, repository writes, restore ordering, and operational s ```toml [dependencies] -graphql-orm-backup = { git = "https://github.com/Dastari/graphql-orm-backup" } +graphql-orm-backup = { + git = "https://github.com/Dastari/graphql-orm-backup.git", + rev = "", + version = "0.3.1" +} ``` +GitHub with an exact reviewed revision is the supported distribution method. +Do not depend on a moving branch. Applications that also depend directly on +`graphql-orm` or `graphql-orm-storage` must use the same canonical Git URLs and +revisions as this crate so Cargo resolves one instance of each shared type. + The default `local` feature enables `LocalBackupRepository`. ```toml [dependencies] graphql-orm-backup = { - git = "https://github.com/Dastari/graphql-orm-backup", + git = "https://github.com/Dastari/graphql-orm-backup.git", + rev = "", + version = "0.3.1", default-features = false } ``` From c2a925548937d10e86766db6a7fd7e9993491a8d Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 13 Jul 2026 11:25:46 +1000 Subject: [PATCH 021/108] feat: add native SMB blob storage --- crates/graphql-orm-storage/CHANGELOG.md | 21 + crates/graphql-orm-storage/Cargo.lock | 1587 ++++++++++++++++- crates/graphql-orm-storage/Cargo.toml | 30 +- crates/graphql-orm-storage/MIGRATION.md | 44 + crates/graphql-orm-storage/README.md | 10 +- crates/graphql-orm-storage/docs/README.md | 3 + .../graphql-orm-storage/docs/agent-update.md | 9 +- .../docs/backup-integration.md | 5 +- crates/graphql-orm-storage/docs/blob-store.md | 4 +- crates/graphql-orm-storage/docs/native-smb.md | 103 ++ crates/graphql-orm-storage/docs/plan.md | 3 +- .../docs/provider-roadmap.md | 11 +- .../graphql-orm-storage/docs/release-notes.md | 12 + crates/graphql-orm-storage/src/backend.rs | 4 + crates/graphql-orm-storage/src/blob.rs | 1 - crates/graphql-orm-storage/src/error.rs | 44 + crates/graphql-orm-storage/src/lib.rs | 6 +- crates/graphql-orm-storage/src/smb.rs | 1221 +++++++++++++ crates/graphql-orm-storage/tests/samba/run.sh | 55 + .../tests/smb_integration.rs | 219 +++ 20 files changed, 3321 insertions(+), 71 deletions(-) create mode 100644 crates/graphql-orm-storage/CHANGELOG.md create mode 100644 crates/graphql-orm-storage/MIGRATION.md create mode 100644 crates/graphql-orm-storage/docs/native-smb.md create mode 100644 crates/graphql-orm-storage/src/smb.rs create mode 100755 crates/graphql-orm-storage/tests/samba/run.sh create mode 100644 crates/graphql-orm-storage/tests/smb_integration.rs diff --git a/crates/graphql-orm-storage/CHANGELOG.md b/crates/graphql-orm-storage/CHANGELOG.md new file mode 100644 index 00000000..fea2dfea --- /dev/null +++ b/crates/graphql-orm-storage/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## 0.5.0 + +- Added the feature-gated native `SmbStorageBackend` using a pure-Rust + SMB2/SMB3 client. The provider supports streamed reads and writes, safe + temporary-file publication, atomic conditional creation, signing and + encryption requirements, reconnect handling, and redaction-safe probing. +- Added `SmbStorageConfig`, `SmbDialect`, `SmbProbeOptions`, and + `SmbProbeResult` behind the `smb` feature. +- Added `StorageProviderErrorKind` and `StorageError::RemoteProvider` for + structured network-provider diagnostics and retry classification. +- Added unit coverage for SMB configuration, secret redaction, key mapping, + temporary-file filtering, and retry/error classification. +- Added opt-in Samba integration coverage for authentication, workgroup + credentials, signing, encryption, streamed round trips, interruption + cleanup, atomic conditional creation, and reconnect after server restart. +- `StorageBackend` and `StorageError` gain new variants. Exhaustive downstream + matches must add SMB and remote-provider cases; see [MIGRATION.md](MIGRATION.md). + +Earlier release details remain in [docs/release-notes.md](docs/release-notes.md). diff --git a/crates/graphql-orm-storage/Cargo.lock b/crates/graphql-orm-storage/Cargo.lock index 2927b08a..480fe01a 100644 --- a/crates/graphql-orm-storage/Cargo.lock +++ b/crates/graphql-orm-storage/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -23,6 +58,42 @@ dependencies = [ "rustversion", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "async-dnssd" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d49ffe175ab45bbfd74b548313d9d7cdfff27161a94b007b52eeeb5f9aaa15e" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-util", + "libc", + "log", + "pin-utils", + "pkg-config", + "tokio", + "winapi", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -31,7 +102,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -197,9 +268,9 @@ dependencies = [ "http 1.4.2", "http-body 1.0.1", "http-body-util", - "md-5", + "md-5 0.11.0", "pin-project-lite", - "sha1", + "sha1 0.11.0", "sha2 0.11.0", "tracing", ] @@ -339,7 +410,7 @@ checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -431,6 +502,36 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "binrw" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53195f985e88ab94d1cc87e80049dd2929fd39e4a772c5ae96a7e5c4aad3642" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5910da05ee556b789032c8ff5a61fb99239580aa3fd0bfaa8f4d094b2aee00ad" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.1" @@ -455,12 +556,33 @@ dependencies = [ "hybrid-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 = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[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" @@ -477,6 +599,15 @@ dependencies = [ "either", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.65" @@ -489,12 +620,62 @@ dependencies = [ "shlex", ] +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + [[package]] name = "cmake" version = "0.1.58" @@ -563,7 +744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ "digest 0.10.7", - "spin", + "spin 0.10.0", ] [[package]] @@ -575,6 +756,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf1e6e5492f8f0830c37f301f6349e0dac8b2466e4fe89eef90e9eef906cd046" +dependencies = [ + "crypto-common 0.1.7", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -582,7 +772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -594,6 +784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -606,6 +797,25 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "crypto-mac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -615,6 +825,42 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + [[package]] name = "der" version = "0.7.10" @@ -636,6 +882,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -668,7 +923,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -691,6 +946,31 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -709,9 +989,10 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -745,10 +1026,16 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -788,6 +1075,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[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" @@ -795,6 +1097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -803,6 +1106,23 @@ 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" @@ -811,7 +1131,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -832,9 +1152,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", ] @@ -857,8 +1181,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -880,15 +1206,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] name = "graphql-orm-storage" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-trait", "aws-credential-types", @@ -896,11 +1235,20 @@ dependencies = [ "bytes", "futures-core", "futures-util", + "picky", + "picky-krb", + "secrecy", "serde", "serde_json", "sha2 0.10.9", + "smb", + "smb-dtyp", + "smb-fscc", + "smb-msg", + "smb-rpc", + "smb-transport", "tempfile", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tokio-util", @@ -914,7 +1262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -994,6 +1342,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1308,6 +1665,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" @@ -1342,6 +1709,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1354,6 +1739,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1382,18 +1773,54 @@ dependencies = [ ] [[package]] -name = "md-5" -version = "0.11.0" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "memchr" -version = "2.8.0" +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "md4" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" @@ -1408,12 +1835,61 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "modular-bitfield" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a53d79ba8304ac1c4f9eb3b9d281f21f7be9d4626f72ce7df4ad8fbde4f38a74" +dependencies = [ + "modular-bitfield-impl", + "static_assertions", +] + +[[package]] +name = "modular-bitfield-impl" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a7d5f7076603ebc68de2dc6a650ec331a062a13abaa346975be747bbfa4b789" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "serde", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1423,6 +1899,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1430,6 +1916,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "oid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c19903c598813dba001b53beeae59bb77ad4892c5c1b9b3500ce4293a0d06c2" +dependencies = [ + "serde", ] [[package]] @@ -1438,6 +1934,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1450,6 +1952,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "p256" version = "0.13.2" @@ -1462,6 +1970,49 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", + "sha1 0.10.7", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1477,6 +2028,101 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "picky" +version = "7.0.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83be360ca0cc8659abfbda932098e606fe52fa129508b92f0ce2998c00679170" +dependencies = [ + "base64", + "digest 0.10.7", + "ed25519-dalek", + "hex", + "md-5 0.10.6", + "num-bigint-dig", + "p256", + "p384", + "p521", + "picky-asn1", + "picky-asn1-der", + "picky-asn1-x509", + "rand 0.8.7", + "rand_core 0.6.4", + "rsa", + "serde", + "sha1 0.10.7", + "sha2 0.10.9", + "sha3", + "thiserror 1.0.69", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "picky-asn1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff038f9360b934342fb3c0a1d6e82c438a2624b51c3c6e3e6d7cf252b6f3ee3" +dependencies = [ + "oid", + "serde", + "serde_bytes", + "time", + "zeroize", +] + +[[package]] +name = "picky-asn1-der" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d413165e4bf7f808b9a27cbaba657657a2921f0965db833f488c4d4be96dcd2e" +dependencies = [ + "picky-asn1", + "serde", + "serde_bytes", +] + +[[package]] +name = "picky-asn1-x509" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d493f73cf052073ca1fe38666f74c2396987aa6ea660e77dd624cc6c8f60389e" +dependencies = [ + "base64", + "num-bigint-dig", + "oid", + "picky-asn1", + "picky-asn1-der", + "serde", + "widestring", + "zeroize", +] + +[[package]] +name = "picky-krb" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e78a55491723b0a10bc2c02709a8d92d74ef674fe1b569cb4a08bac3d105487" +dependencies = [ + "aes", + "byteorder", + "cbc", + "crypto", + "des", + "hmac 0.12.1", + "num-bigint-dig", + "oid", + "pbkdf2", + "picky-asn1", + "picky-asn1-der", + "picky-asn1-x509", + "rand 0.8.7", + "serde", + "sha1 0.10.7", + "thiserror 1.0.69", + "uuid", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1489,6 +2135,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -1505,6 +2162,18 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1520,6 +2189,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1527,7 +2205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1548,6 +2226,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.41", + "socket2 0.6.4", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.41", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1569,6 +2303,38 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[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 0.6.4", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1578,12 +2344,67 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "regex-lite" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.41", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -1608,6 +2429,44 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha1 0.10.7", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-kbkdf" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb211667cbc3291d401a54f7499904001c2cfc3347844120443bffca6fa0590" +dependencies = [ + "generic-array", + "typenum", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1623,7 +2482,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1649,7 +2508,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.13", "subtle", @@ -1674,6 +2535,7 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ + "web-time", "zeroize", ] @@ -1744,13 +2606,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[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.1", "core-foundation", "core-foundation-sys", "libc", @@ -1783,6 +2654,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1800,7 +2681,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1816,6 +2697,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -1849,6 +2753,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1862,7 +2776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1877,6 +2791,121 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smb" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1315ec423faffdc9715f518c5cab5660b26484df42adaa87a3325f70c73417b" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "binrw", + "ccm", + "cmac", + "crypto-common 0.1.7", + "futures", + "futures-core", + "futures-util", + "hmac 0.12.1", + "log", + "maybe-async", + "modular-bitfield", + "pastey", + "rand 0.8.7", + "rust-kbkdf", + "sha2 0.10.9", + "smb-dtyp", + "smb-fscc", + "smb-msg", + "smb-rpc", + "smb-transport", + "sspi", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "smb-dtyp" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73e6cf408be4652ba7ef0e318c81842c96a6351cc20d7f13ef8ff6eb6b0371c" +dependencies = [ + "binrw", + "modular-bitfield", + "pastey", + "rand 0.8.7", + "time", +] + +[[package]] +name = "smb-fscc" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8c5b8c7e8ff63ad161f30528218a7c4751e43ee177f6743fa0019d20edbdc4" +dependencies = [ + "binrw", + "modular-bitfield", + "pastey", + "smb-dtyp", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "smb-msg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cbf5564fb068c7786d027c54aa07793476f643845d0a7c9e3071e0776eba0ad" +dependencies = [ + "binrw", + "modular-bitfield", + "pastey", + "smb-dtyp", + "smb-fscc", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "smb-rpc" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6b673767d02907f444d131f0c4d7b0fbbdc4199765054f650643bf551ceb89" +dependencies = [ + "binrw", + "maybe-async", + "modular-bitfield", + "pastey", + "smb-dtyp", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "smb-transport" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19b0ff9b464efc253fedf1d7205d1b936530fb44a3e57d9fbd5101dac58ff39" +dependencies = [ + "binrw", + "futures-core", + "futures-util", + "log", + "maybe-async", + "modular-bitfield", + "pastey", + "smb-dtyp", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-util", +] + [[package]] name = "socket2" version = "0.5.10" @@ -1897,6 +2926,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spin" version = "0.10.0" @@ -1913,17 +2948,79 @@ dependencies = [ "der", ] +[[package]] +name = "sspi" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523f6a99e26c1e6476a424d54bbda5354a01ee7f18b9d93dc48a8fd45ae8189b" +dependencies = [ + "async-dnssd", + "async-recursion", + "bitflags 2.11.1", + "byteorder", + "cfg-if", + "crypto-mac", + "futures", + "hmac 0.12.1", + "lazy_static", + "md-5 0.10.6", + "md4", + "num-bigint-dig", + "num-derive", + "num-traits", + "oid", + "picky", + "picky-asn1", + "picky-asn1-der", + "picky-asn1-x509", + "picky-krb", + "rand 0.8.7", + "reqwest", + "rsa", + "rustls 0.23.41", + "serde", + "serde_derive", + "sha1 0.10.7", + "sha2 0.10.9", + "time", + "tokio", + "tracing", + "url", + "uuid", + "windows", + "windows-registry", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] [[package]] name = "syn" @@ -1936,6 +3033,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1944,7 +3050,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1960,13 +3066,33 @@ dependencies = [ "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 2.0.117", ] [[package]] @@ -1977,7 +3103,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2020,6 +3146,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -2043,7 +3184,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2066,6 +3207,17 @@ dependencies = [ "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", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2085,8 +3237,31 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "pin-project-lite", + "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2120,7 +3295,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2156,6 +3331,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2250,6 +3435,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.121" @@ -2269,7 +3464,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2310,25 +3505,195 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -2337,7 +3702,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2346,14 +3711,40 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -2362,48 +3753,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2440,7 +3879,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2456,7 +3895,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2468,7 +3907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -2504,6 +3943,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xmlparser" version = "0.13.6" @@ -2529,10 +3980,30 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2550,7 +4021,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2559,6 +4030,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -2590,7 +4075,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/crates/graphql-orm-storage/Cargo.toml b/crates/graphql-orm-storage/Cargo.toml index 26adf114..c4cdac81 100644 --- a/crates/graphql-orm-storage/Cargo.toml +++ b/crates/graphql-orm-storage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-storage" -version = "0.4.0" +version = "0.5.0" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm-storage" @@ -10,6 +10,18 @@ description = "Provider-neutral object storage primitives for graphql-orm applic default = ["local"] local = ["dep:serde_json", "dep:tokio", "dep:tokio-util"] s3 = ["dep:aws-credential-types", "dep:aws-sdk-s3", "dep:tokio", "dep:tokio-util"] +smb = [ + "dep:picky", + "dep:picky-krb", + "dep:secrecy", + "dep:smb", + "dep:smb-dtyp", + "dep:smb-fscc", + "dep:smb-msg", + "dep:smb-rpc", + "dep:smb-transport", + "dep:tokio", +] azure = [] [dependencies] @@ -19,12 +31,21 @@ aws-sdk-s3 = { version = "1.137.0", features = ["behavior-version-latest"], opti bytes = "1" futures-core = "0.3" futures-util = "0.3" +picky = { version = "=7.0.0-rc.15", optional = true, default-features = false } +picky-krb = { version = "=0.11.1", optional = true } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", optional = true } +secrecy = { version = "0.10", optional = true } sha2 = "0.10" +smb = { version = "0.10.2", optional = true, default-features = false, features = ["async", "encrypt", "sign", "netbios-transport"] } +smb-dtyp = { version = "=0.10.2", optional = true } +smb-fscc = { version = "=0.10.2", optional = true } +smb-msg = { version = "=0.10.2", optional = true } +smb-rpc = { version = "=0.10.2", optional = true } +smb-transport = { version = "=0.10.2", optional = true } thiserror = "2" time = { version = "0.3", features = ["serde"] } -tokio = { version = "1", features = ["fs", "io-util"], optional = true } +tokio = { version = "1", features = ["fs", "io-util", "sync", "time"], optional = true } tokio-util = { version = "0.7", features = ["io"], optional = true } uuid = { version = "1", features = ["serde", "v4"] } @@ -46,3 +67,8 @@ required-features = ["local"] name = "local_streaming_object" path = "tests/local_streaming_object.rs" required-features = ["local"] + +[[test]] +name = "smb_integration" +path = "tests/smb_integration.rs" +required-features = ["smb"] diff --git a/crates/graphql-orm-storage/MIGRATION.md b/crates/graphql-orm-storage/MIGRATION.md new file mode 100644 index 00000000..099b9994 --- /dev/null +++ b/crates/graphql-orm-storage/MIGRATION.md @@ -0,0 +1,44 @@ +# Migration Guide + +## 0.4.x to 0.5.0 + +The default feature set and local filesystem behavior are unchanged. Native +SMB is opt-in: + +```toml +graphql-orm-storage = { + version = "0.5.0", + default-features = false, + features = ["smb"] +} +``` + +### Exhaustive enum matches + +`StorageBackend` adds `Smb`. It was already marked `#[non_exhaustive]`, so +external matches should retain a wildcard arm. + +`StorageError` adds `RemoteProvider`, carrying a redaction-safe +`StorageProviderErrorKind`, operation name, message, and retry flag. Code that +matches `StorageError` exhaustively inside this crate or through future enum +changes must handle this variant. Prefer `StorageError::is_retryable()` when +the decision does not require the detailed classification. + +### Native SMB configuration + +Native SMB configuration accepts a server name or IP address and share name; +it intentionally does not accept a UNC path, mapped drive, or local mount path. +Construct `SmbStorageConfig` with a runtime `secrecy::SecretString`. The crate +does not serialize or persist the password. + +Port 445, SMB 3.0 minimum, and required signing are secure defaults. Hosts that +previously used `LocalStorageBackend` over a mounted SMB directory can retain +that behavior as an explicitly named legacy provider or migrate to direct SMB +fields. Repository data and blob keys do not need conversion. + +### Backup consumers + +`graphql-orm-backup` 0.4.0 consumes this release through +`BlobStoreBackupRepository`. Publish or pin storage 0.5.0 before resolving the +backup crate so both the host and backup crate use one `graphql-orm-storage` +crate instance and therefore share identical trait types. diff --git a/crates/graphql-orm-storage/README.md b/crates/graphql-orm-storage/README.md index 2faed3fc..8b99a7d1 100644 --- a/crates/graphql-orm-storage/README.md +++ b/crates/graphql-orm-storage/README.md @@ -160,11 +160,13 @@ rows. - [Backup integration guidance](docs/backup-integration.md) - [Development and test commands](docs/development.md) - [Release notes](docs/release-notes.md) +- [Migration guide](MIGRATION.md) +- [Changelog](CHANGELOG.md) ## Status -Current crate version: `0.4.0`. +Current crate version: `0.5.0`. -Local filesystem and S3-compatible storage are implemented. Azure Blob remains -an explicit placeholder. Provider integration tests that require external -services are opt-in. +Local filesystem, S3-compatible storage, and feature-gated native SMB2/SMB3 +storage are implemented. Azure Blob remains an explicit placeholder. Provider +integration tests that require external services are opt-in. diff --git a/crates/graphql-orm-storage/docs/README.md b/crates/graphql-orm-storage/docs/README.md index 92f0929a..67a06a2f 100644 --- a/crates/graphql-orm-storage/docs/README.md +++ b/crates/graphql-orm-storage/docs/README.md @@ -5,6 +5,7 @@ repository front page. - [Usage guide](usage.md) - [BlobStore API](blob-store.md) +- [Native SMB2/SMB3](native-smb.md) - [Streaming APIs](streaming.md) - [Recording and large-object streams](recording-streams.md) - [Architecture and crate boundaries](architecture.md) @@ -13,6 +14,8 @@ repository front page. - [Agent update](agent-update.md) - [Implementation plan](plan.md) - [Release notes](release-notes.md) +- [Migration guide](../MIGRATION.md) +- [Changelog](../CHANGELOG.md) - [Development and tests](development.md) The root [README](../README.md) is the best starting point if you are new to diff --git a/crates/graphql-orm-storage/docs/agent-update.md b/crates/graphql-orm-storage/docs/agent-update.md index 9004ab44..f7c3ed08 100644 --- a/crates/graphql-orm-storage/docs/agent-update.md +++ b/crates/graphql-orm-storage/docs/agent-update.md @@ -1,6 +1,6 @@ # Agent Update -This update summarizes the `0.4.0` storage-provider boundary for agents working +This update summarizes the `0.5.0` storage-provider boundary for agents working on `graphql-orm-storage` or downstream crates. ## What Changed @@ -22,6 +22,7 @@ on `graphql-orm-storage` or downstream crates. - S3 now implements `BlobStore` and `ObjectStorage` behind the `s3` feature. - Azure Blob remains a placeholder that implements `BlobStore` and still returns `UnsupportedBackend`. +- Native SMB2/SMB3 implements `BlobStore` behind the `smb` feature. ## Provider Guidance @@ -40,7 +41,7 @@ metadata. object keys rather than primary object namespaces and generated `StoredObject` metadata. -Future adapter shape: +The downstream adapter is implemented as: ```rust pub struct BlobStoreBackupRepository { @@ -52,5 +53,5 @@ pub struct BlobStoreBackupRepository { ## Still Pending - Real Azure Blob `BlobStore` provider. -- Backup adapter implementation after downstream crate alignment. -- Provider integration tests that require external services beyond opt-in S3. +- Manual native SMB compatibility runs against Windows Server and common NAS + implementations; the automated Samba suite is opt-in. diff --git a/crates/graphql-orm-storage/docs/backup-integration.md b/crates/graphql-orm-storage/docs/backup-integration.md index 45325e45..294ab319 100644 --- a/crates/graphql-orm-storage/docs/backup-integration.md +++ b/crates/graphql-orm-storage/docs/backup-integration.md @@ -23,10 +23,9 @@ Backup repositories have different semantics: Those repository keys should not be forced through primary object metadata. -## Future Adapter Shape +## Adapter Shape -Once `graphql-orm-backup` depends on a version of this crate with `BlobStore`, it -can add an adapter like: +`graphql-orm-backup` 0.4.0 exposes this adapter: ```rust pub struct BlobStoreBackupRepository { diff --git a/crates/graphql-orm-storage/docs/blob-store.md b/crates/graphql-orm-storage/docs/blob-store.md index e60684df..6af690d8 100644 --- a/crates/graphql-orm-storage/docs/blob-store.md +++ b/crates/graphql-orm-storage/docs/blob-store.md @@ -110,7 +110,7 @@ generates object IDs, storage keys, sizes, hashes, and timestamps. ## Backup Integration -`BlobStore` is the intended future sharing point for `graphql-orm-backup`. -Backup repositories should adapt `BlobStore`; they should not use +`BlobStore` is the sharing point used by `graphql-orm-backup` through +`BlobStoreBackupRepository`. Backup repositories should adapt `BlobStore`; they should not use `StorageService` or `StoredObject`, because backup keys and primary object metadata have different semantics. diff --git a/crates/graphql-orm-storage/docs/native-smb.md b/crates/graphql-orm-storage/docs/native-smb.md new file mode 100644 index 00000000..306c5589 --- /dev/null +++ b/crates/graphql-orm-storage/docs/native-smb.md @@ -0,0 +1,103 @@ +# Native SMB2/SMB3 Blob Storage + +The `smb` feature provides `SmbStorageBackend`, a direct network `BlobStore`. +It does not use a mapped drive, OS mount, `mount.cifs`, Samba CLI, privileged +container, C library, or FFI. + +## Client selection + +The backend uses the MIT-licensed pure-Rust `smb` (smb-rs) client. Version +0.10.2 is pinned with compatible protocol/auth crates because 0.11's +release-candidate crypto graph conflicts with this crate's optional AWS SDK. +Re-evaluate the pin when those upstream dependency graphs converge. + +It supplies SMB 2.0.2 through 3.1.1 (never SMB1), pure-Rust NTLM credentials +with optional domain/workgroup, signing, SMB3 encryption, explicit +`FILE_CREATE`, flush/close, rename, recursive enumeration, delete, async +requests, and SMB credit concurrency. Kerberos, SMB1, guest/anonymous sessions, +QUIC, RDMA, and application-managed DFS namespaces are outside this backend's +supported contract. + +## Configuration + +```rust,no_run +use std::time::Duration; +use graphql_orm_storage::{SmbDialect, SmbStorageBackend, SmbStorageConfig}; +use secrecy::SecretString; + +# async fn example() -> Result<(), graphql_orm_storage::StorageError> { +let mut config = SmbStorageConfig::new( + "files.example.org", + "backups", + "backup-service", + SecretString::from("runtime secret"), +); +config.domain = Some("EXAMPLE".to_string()); +config.root_prefix = Some("repositories/production".to_string()); +config.min_dialect = SmbDialect::Smb3_0; +config.require_encryption = true; +config.connect_timeout = Duration::from_secs(10); +config.operation_timeout = Duration::from_secs(60); +let store = SmbStorageBackend::connect(config).await?; +# Ok(()) +# } +``` + +Port 445, SMB 3.0 minimum, and required signing are defaults. Encryption may be +required. Guest fallback is refused. Server/share fields are names, not local +paths, UNC paths, or SMB URLs. + +`root_prefix` and blob keys are `/`-separated relative keys. Empty segments, +`.`, `..`, absolute paths, backslashes, NUL, and platform prefixes are rejected. +Passwords use `SecretString`, are redacted from `Debug`, and never enter errors +or probe results. Hosts own encrypted persistence, rotation, authorization, and +audit; this crate does not serialize credentials. + +## Atomicity and streaming + +Normal writes create parents, stream to a UUID temporary file in the same +remote directory, flush, close, and rename over the final key. Failures attempt +to remove that unique temporary file, and listings filter its strict name form. + +Conditional writes issue SMB CREATE with `FILE_CREATE` directly; +`STATUS_OBJECT_NAME_COLLISION` becomes `Ok(None)`. There is no existence-check +race. This primitive backs repository locks and content-addressed deduplication. +Rename atomicity ultimately depends on the server filesystem, but the Samba +suite exercises same-directory overwrite rename and concurrent CREATE. + +Uploads consume `StorageByteStream` incrementally and hash while writing. +Downloads use fixed chunks and retain a bounded transfer permit. Connection and +operation deadlines are separate. Safe open/read recovery uses three bounded, +jittered reconnect attempts. Auth, permission, invalid configuration, and +collisions are not retried. Interrupted arbitrary upload streams are not +replayed; callers retry with a fresh stream. + +If a response is lost after conditional CREATE, the server may hold a partial +or complete target. Cleanup is best effort, so completed snapshots must still +be verified. Backup manifests are written last and do not advertise incomplete +payload sets. + +## Probe and troubleshooting + +`SmbStorageBackend::probe` connects, negotiates, authenticates, opens the share, +optionally creates the prefix, lists it, round-trips random bytes, compares, and +deletes the probe. Its result reports safe dialect/security/read/write facts. + +Use `StorageProviderErrorKind`, which distinguishes connectivity, +authentication, security negotiation, missing share, permission, missing path, +capacity, collision, timeout, connection loss, and protocol failures. Never put +runtime configuration or credentials in support bundles. + +## Manual compatibility plan + +For each target, create/verify/restore/delete/prune a full backup with a +multi-gigabyte object, interrupt one transfer, and race two lock acquisitions: + +- Windows Server 2022/2025 and a Windows 11-hosted share; +- current Samba with workgroup, signing, and encryption modes; +- a common NAS such as Synology DSM or QNAP QTS with SMB3 enabled; +- Linux, Windows, and macOS hosts; and +- x86-64 and ARM64 where deployed. + +Record dialect, security mode, filesystem, reconnect behavior, and flush/rename +limitations without recording usernames or secrets. diff --git a/crates/graphql-orm-storage/docs/plan.md b/crates/graphql-orm-storage/docs/plan.md index 87419bff..480f265b 100644 --- a/crates/graphql-orm-storage/docs/plan.md +++ b/crates/graphql-orm-storage/docs/plan.md @@ -37,7 +37,8 @@ This crate must not provide: - application-specific collection, record, media, tenant, or policy assumptions - database entities that force one application schema - file bytes stored in database rows -- backup repository providers such as Dropbox or SMB +- backup-specific providers such as Dropbox (native SMB is now a reusable + `BlobStore` provider) Applications should persist returned `StoredObject` metadata in their own `graphql-orm` entities. diff --git a/crates/graphql-orm-storage/docs/provider-roadmap.md b/crates/graphql-orm-storage/docs/provider-roadmap.md index 97cceb48..b169a694 100644 --- a/crates/graphql-orm-storage/docs/provider-roadmap.md +++ b/crates/graphql-orm-storage/docs/provider-roadmap.md @@ -18,7 +18,7 @@ Implemented behind the `s3` feature. S3 is implemented as a `BlobStore` first. The high-level `ObjectStorage` behavior delegates to the same S3 blob operations so backup integrations can -reuse the provider through a future adapter. +reuse the provider through `BlobStoreBackupRepository`. Expected configuration: @@ -52,6 +52,13 @@ Expected configuration: The implementation must use the same `storage_key` values as local storage. +## Phase 4: Native SMB2/SMB3 + +Implemented behind the `smb` feature through `BlobStore`. See +[Native SMB2/SMB3](native-smb.md). Mounted SMB remains a separate legacy model +using the local provider and OS-owned mount/reconnect behavior. + ## Out Of Scope -Dropbox and SMB are backup repository targets for `graphql-orm-backup`, not primary object storage backends for this crate. +Dropbox remains backup-specific. Native SMB is reusable for primary objects or +backup repositories through `BlobStore`. diff --git a/crates/graphql-orm-storage/docs/release-notes.md b/crates/graphql-orm-storage/docs/release-notes.md index 79ef9c06..150dbcbf 100644 --- a/crates/graphql-orm-storage/docs/release-notes.md +++ b/crates/graphql-orm-storage/docs/release-notes.md @@ -1,5 +1,17 @@ # Release Notes +## 0.5.0 + +- Added feature-gated, pure-Rust native SMB2/SMB3 `BlobStore` support with + redacted credentials, signing/encryption policy, atomic conditional CREATE, + streamed temp/flush/rename writes, paged listing, reconnect, and probe APIs. +- Added structured remote-provider error classifications. +- Added Samba tests covering auth, workgroup credentials, signing, encryption, + streaming round trips, interruption cleanup, atomic lock creation, and + reconnect after server restart. +- Added migration guidance for exhaustive enum matches, direct SMB + configuration, mounted-provider compatibility, and downstream release order. + This page records user-facing changes for recent `graphql-orm-storage` releases. diff --git a/crates/graphql-orm-storage/src/backend.rs b/crates/graphql-orm-storage/src/backend.rs index 5e32aa77..f2216d18 100644 --- a/crates/graphql-orm-storage/src/backend.rs +++ b/crates/graphql-orm-storage/src/backend.rs @@ -12,6 +12,8 @@ pub enum StorageBackend { S3, /// Azure Blob Storage. AzureBlob, + /// Native SMB2/SMB3 storage. + Smb, } impl StorageBackend { @@ -22,6 +24,7 @@ impl StorageBackend { Self::Local => "local", Self::S3 => "s3", Self::AzureBlob => "azure_blob", + Self::Smb => "smb", } } } @@ -34,6 +37,7 @@ impl FromStr for StorageBackend { "local" => Ok(Self::Local), "s3" | "s3-compatible" | "s3_compatible" => Ok(Self::S3), "azure_blob" | "azure-blob" | "azureblob" => Ok(Self::AzureBlob), + "smb" | "smb2" | "smb3" => Ok(Self::Smb), other => Err(StorageError::UnsupportedBackend { backend: other.to_string(), }), diff --git a/crates/graphql-orm-storage/src/blob.rs b/crates/graphql-orm-storage/src/blob.rs index 691e2fb3..b211a0de 100644 --- a/crates/graphql-orm-storage/src/blob.rs +++ b/crates/graphql-orm-storage/src/blob.rs @@ -369,7 +369,6 @@ pub fn validate_blob_key(key: &str) -> Result<(), StorageError> { Ok(()) } -#[cfg(feature = "local")] pub(crate) fn validate_blob_prefix(prefix: &str) -> Result<(), StorageError> { if prefix.is_empty() { Ok(()) diff --git a/crates/graphql-orm-storage/src/error.rs b/crates/graphql-orm-storage/src/error.rs index 4aff1dc2..8a6f5938 100644 --- a/crates/graphql-orm-storage/src/error.rs +++ b/crates/graphql-orm-storage/src/error.rs @@ -1,5 +1,33 @@ use std::path::PathBuf; +/// Provider-neutral classification for remote storage failures. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum StorageProviderErrorKind { + /// Name resolution or network connection failed. + Connectivity, + /// Supplied credentials were rejected. + Authentication, + /// Required signing, encryption, or protocol negotiation failed. + SecurityNegotiation, + /// The configured remote container or share does not exist. + ContainerNotFound, + /// The authenticated identity lacks permission. + PermissionDenied, + /// The requested remote key does not exist. + RemotePathNotFound, + /// The provider is out of quota or storage capacity. + Capacity, + /// A conditional create collided with an existing key. + ConditionalCreateConflict, + /// The operation exceeded its deadline. + Timeout, + /// An established remote connection was lost. + ConnectionLost, + /// The provider returned an invalid or unsupported protocol response. + Protocol, +} + /// Errors returned by storage services and provider backends. #[derive(Debug, thiserror::Error)] pub enum StorageError { @@ -21,6 +49,21 @@ pub enum StorageError { retryable: bool, }, + /// A remote provider operation failed with a structured classification. + #[error("remote storage provider error for {backend} during {operation}: {message}")] + RemoteProvider { + /// Storage backend that returned the error. + backend: String, + /// Provider-neutral failure category. + kind: StorageProviderErrorKind, + /// Stable operation name such as `connect`, `read`, or `rename`. + operation: String, + /// Redaction-safe diagnostic message. + message: String, + /// Whether retrying the operation may succeed. + retryable: bool, + }, + /// A storage key is empty, absolute, or contains unsafe path components. #[error("invalid storage key: {key}")] InvalidStorageKey { @@ -68,6 +111,7 @@ impl StorageError { pub const fn is_retryable(&self) -> bool { match self { Self::Provider { retryable, .. } => *retryable, + Self::RemoteProvider { retryable, .. } => *retryable, Self::Io { .. } => true, Self::UnsupportedBackend { .. } | Self::InvalidStorageKey { .. } diff --git a/crates/graphql-orm-storage/src/lib.rs b/crates/graphql-orm-storage/src/lib.rs index 0045f64a..3f931587 100644 --- a/crates/graphql-orm-storage/src/lib.rs +++ b/crates/graphql-orm-storage/src/lib.rs @@ -43,6 +43,8 @@ mod object; #[cfg(feature = "s3")] mod s3; mod service; +#[cfg(feature = "smb")] +mod smb; mod streaming_object; #[cfg(feature = "azure")] @@ -53,7 +55,7 @@ pub use blob::{ BoxedStorageStream, StorageByteStream, collect_storage_stream, validate_blob_key, }; pub use checksum::sha256_hex; -pub use error::StorageError; +pub use error::{StorageError, StorageProviderErrorKind}; pub use key::{build_storage_key, file_extension}; #[cfg(feature = "local")] pub use local::LocalStorageBackend; @@ -64,6 +66,8 @@ pub use object::{ #[cfg(feature = "s3")] pub use s3::{S3StorageBackend, S3StorageConfig}; pub use service::{ObjectStorage, StorageService}; +#[cfg(feature = "smb")] +pub use smb::{SmbDialect, SmbProbeOptions, SmbProbeResult, SmbStorageBackend, SmbStorageConfig}; pub use streaming_object::{ BoxedMultipartWriter, MultipartWriter, ObjectContentRange, ObjectInfo, ObjectMetadata, ObjectRangeBody, StreamingObjectStore, validate_object_bucket, diff --git a/crates/graphql-orm-storage/src/smb.rs b/crates/graphql-orm-storage/src/smb.rs new file mode 100644 index 00000000..475c35af --- /dev/null +++ b/crates/graphql-orm-storage/src/smb.rs @@ -0,0 +1,1221 @@ +//! Native SMB2/SMB3 blob storage. + +use std::{fmt, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use bytes::Bytes; +use futures_util::{StreamExt, stream}; +use secrecy::{ExposeSecret, SecretString}; +use sha2::{Digest, Sha256}; +use smb::{ + Client, ClientConfig, ConnectionConfig, CreateOptions, Dialect, DirAccessMask, Directory, File, + FileAccessMask, FileAttributes, FileCreateArgs, FileDispositionInformation, + FileFullDirectoryInformation, FileRenameInformation, FileStandardInformation, Resource, Status, + UncPath, connection::EncryptionMode, +}; +use smb_dtyp::binrw_util::sized_wide_string::SizedWideString; +use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock, Semaphore}; +use uuid::Uuid; + +use crate::{ + BlobBody, BlobListPage, BlobMetadata, BlobPutOptions, BlobStore, BlobWriteOutcome, + StorageBackend, StorageByteStream, StorageError, StorageProviderErrorKind, validate_blob_key, +}; + +const DEFAULT_PORT: u16 = 445; +const DEFAULT_TRANSFER_CONCURRENCY: usize = 8; +const READ_CHUNK_SIZE: usize = 256 * 1024; +const TEMP_SUFFIX: &str = ".uploading"; + +/// Lowest SMB2/SMB3 dialect accepted by a native SMB backend. +#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum SmbDialect { + /// SMB 2.0.2. + Smb2_0_2, + /// SMB 2.1. + Smb2_1, + /// SMB 3.0. + #[default] + Smb3_0, + /// SMB 3.0.2. + Smb3_0_2, + /// SMB 3.1.1. + Smb3_1_1, +} + +impl SmbDialect { + fn protocol(self) -> Dialect { + match self { + Self::Smb2_0_2 => Dialect::Smb0202, + Self::Smb2_1 => Dialect::Smb021, + Self::Smb3_0 => Dialect::Smb030, + Self::Smb3_0_2 => Dialect::Smb0302, + Self::Smb3_1_1 => Dialect::Smb0311, + } + } + + fn from_protocol(value: Dialect) -> Self { + match value { + Dialect::Smb0202 => Self::Smb2_0_2, + Dialect::Smb021 => Self::Smb2_1, + Dialect::Smb030 => Self::Smb3_0, + Dialect::Smb0302 => Self::Smb3_0_2, + Dialect::Smb0311 => Self::Smb3_1_1, + } + } +} + +/// Runtime configuration for a native SMB2/SMB3 storage connection. +#[derive(Clone)] +pub struct SmbStorageConfig { + /// DNS name or IP address. UNC URLs and local paths are rejected. + pub server: String, + /// TCP port, normally 445. + pub port: u16, + /// SMB share name without separators. + pub share: String, + /// Optional provider root expressed as a safe blob-key prefix. + pub root_prefix: Option, + /// Username used for NTLM authentication. + pub username: String, + /// Password used for NTLM authentication. Debug output is redacted. + pub password: SecretString, + /// Optional NTLM domain or workgroup. + pub domain: Option, + /// Lowest accepted dialect. SMB1 is never offered. + pub min_dialect: SmbDialect, + /// Require signed authenticated SMB messages. + pub require_signing: bool, + /// Require SMB3 encryption for the session. + pub require_encryption: bool, + /// DNS/TCP connection deadline. + pub connect_timeout: Duration, + /// Deadline for each storage operation. + pub operation_timeout: Duration, + /// Maximum number of simultaneous streaming transfers. + pub max_transfer_concurrency: usize, +} + +impl SmbStorageConfig { + /// Creates a configuration with secure defaults for port, dialect, and signing. + #[must_use] + pub fn new( + server: impl Into, + share: impl Into, + username: impl Into, + password: SecretString, + ) -> Self { + Self { + server: server.into(), + port: DEFAULT_PORT, + share: share.into(), + root_prefix: None, + username: username.into(), + password, + domain: None, + min_dialect: SmbDialect::default(), + require_signing: true, + require_encryption: false, + connect_timeout: Duration::from_secs(10), + operation_timeout: Duration::from_secs(60), + max_transfer_concurrency: DEFAULT_TRANSFER_CONCURRENCY, + } + } + + /// Validates all non-secret fields and ensures explicit credentials exist. + /// + /// # Errors + /// + /// Returns [`StorageError`] when a field is empty, path-like, unsafe, or + /// inconsistent with the requested security policy. + pub fn validate(&self) -> Result<(), StorageError> { + validate_server(&self.server)?; + validate_component("share", &self.share)?; + validate_component("username", &self.username)?; + if self.password.expose_secret().is_empty() { + return Err(invalid_config("password must not be empty")); + } + if let Some(domain) = &self.domain { + validate_component("domain", domain)?; + } + if let Some(prefix) = &self.root_prefix { + validate_blob_key(prefix)?; + } + if self.port == 0 { + return Err(invalid_config("port must be greater than zero")); + } + if self.connect_timeout.is_zero() || self.operation_timeout.is_zero() { + return Err(invalid_config("timeouts must be greater than zero")); + } + if self.max_transfer_concurrency == 0 { + return Err(invalid_config( + "max transfer concurrency must be greater than zero", + )); + } + if self.require_encryption && self.min_dialect < SmbDialect::Smb3_0 { + return Err(invalid_config( + "SMB3 encryption requires a minimum dialect of SMB 3.0", + )); + } + Ok(()) + } + + fn authenticated_username(&self) -> String { + match self.domain.as_deref() { + Some(domain) => format!(r"{domain}\{}", self.username), + None => self.username.clone(), + } + } +} + +impl fmt::Debug for SmbStorageConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SmbStorageConfig") + .field("server", &self.server) + .field("port", &self.port) + .field("share", &self.share) + .field("root_prefix", &self.root_prefix) + .field("username", &self.username) + .field("password", &"[REDACTED]") + .field("domain", &self.domain) + .field("min_dialect", &self.min_dialect) + .field("require_signing", &self.require_signing) + .field("require_encryption", &self.require_encryption) + .field("connect_timeout", &self.connect_timeout) + .field("operation_timeout", &self.operation_timeout) + .field("max_transfer_concurrency", &self.max_transfer_concurrency) + .finish() + } +} + +/// Options controlling whether a probe may create the configured root prefix. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SmbProbeOptions { + /// Create missing root-prefix directories before testing read/write access. + pub create_prefix: bool, +} + +/// Redaction-safe diagnostic result returned by [`SmbStorageBackend::probe`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SmbProbeResult { + /// Dialect selected by the server. + pub negotiated_dialect: SmbDialect, + /// Whether signing is active for the authenticated session. + pub signing_active: bool, + /// Whether encryption is required and active for this connection. + pub encryption_active: bool, + /// Whether server resolution and connection succeeded. + pub server_reachable: bool, + /// Whether authentication and tree connection succeeded. + pub share_reachable: bool, + /// Whether the repository prefix could be listed. + pub prefix_readable: bool, + /// Whether a random probe file round-tripped and was deleted. + pub prefix_writable: bool, +} + +/// Native SMB2/SMB3 [`BlobStore`] implementation. +pub struct SmbStorageBackend { + config: Arc, + client: RwLock>, + reconnect_lock: Mutex<()>, + transfer_limit: Arc, +} + +impl fmt::Debug for SmbStorageBackend { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SmbStorageBackend") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl SmbStorageBackend { + /// Validates the configuration, connects, authenticates, and opens the share. + /// + /// # Errors + /// + /// Returns a structured [`StorageError`] for validation, connectivity, + /// authentication, security negotiation, or share failures. + pub async fn connect(config: SmbStorageConfig) -> Result { + config.validate()?; + let transfer_concurrency = config.max_transfer_concurrency; + let config = Arc::new(config); + let client = connect_client(&config).await?; + Ok(Self { + config, + client: RwLock::new(Arc::new(client)), + reconnect_lock: Mutex::new(()), + transfer_limit: Arc::new(Semaphore::new(transfer_concurrency)), + }) + } + + /// Performs a destructive random-file round trip and returns safe diagnostics. + /// + /// # Errors + /// + /// Returns a structured, redaction-safe error if any probe stage fails. + pub async fn probe( + config: SmbStorageConfig, + options: SmbProbeOptions, + ) -> Result { + let backend = Self::connect(config).await?; + if options.create_prefix { + backend.ensure_root_prefix().await?; + } + let prefix_readable = backend.list_blobs("").await.is_ok(); + let key = format!(".graphql-orm-probe-{}", Uuid::new_v4()); + let expected = Bytes::copy_from_slice(Uuid::new_v4().as_bytes()); + backend + .put_blob( + &key, + StorageByteStream::from_bytes(expected.clone()), + BlobPutOptions::default(), + ) + .await?; + let loaded = backend.get_blob(&key).await?; + let actual = crate::collect_storage_stream(loaded.body).await?; + if actual != expected { + let _ = backend.delete_blob(&key).await; + return Err(remote_error( + StorageProviderErrorKind::Protocol, + "probe", + "probe file contents did not match", + false, + )); + } + backend.delete_blob(&key).await?; + + let client = backend.client().await; + let connection = client + .get_connection(&backend.config.server) + .await + .map_err(|error| map_smb_error("probe", error))?; + let info = connection.conn_info().ok_or_else(|| { + remote_error( + StorageProviderErrorKind::Protocol, + "probe", + "negotiated connection information is unavailable", + false, + ) + })?; + + Ok(SmbProbeResult { + negotiated_dialect: SmbDialect::from_protocol(info.negotiation.dialect_rev), + signing_active: true, + encryption_active: backend.config.require_encryption, + server_reachable: true, + share_reachable: true, + prefix_readable, + prefix_writable: true, + }) + } + + async fn client(&self) -> Arc { + Arc::clone(&*self.client.read().await) + } + + async fn reconnect_with_backoff(&self) -> Result<(), StorageError> { + let _guard = self.reconnect_lock.lock().await; + let delays = [100_u64, 250, 500]; + let mut last_error = None; + for delay in delays { + let jitter = u64::from(Uuid::new_v4().as_bytes()[0]) % 75; + tokio::time::sleep(Duration::from_millis(delay + jitter)).await; + match connect_client(&self.config).await { + Ok(client) => { + *self.client.write().await = Arc::new(client); + return Ok(()); + } + Err(error) => { + if !error.is_retryable() { + return Err(error); + } + last_error = Some(error); + } + } + } + Err(last_error.unwrap_or_else(|| { + remote_error( + StorageProviderErrorKind::ConnectionLost, + "reconnect", + "reconnect attempts were exhausted", + true, + ) + })) + } + + fn share_path(&self) -> Result { + UncPath::new(&self.config.server) + .and_then(|path| path.with_share(&self.config.share)) + .map_err(|error| map_smb_error("path", error)) + } + + fn mapped_key(&self, key: &str) -> Result { + validate_blob_key(key)?; + Ok(match &self.config.root_prefix { + Some(prefix) => format!("{prefix}/{key}"), + None => key.to_string(), + }) + } + + fn remote_path(&self, key: &str) -> Result { + Ok(self.share_path()?.with_path(&self.mapped_key(key)?)) + } + + async fn ensure_root_prefix(&self) -> Result<(), StorageError> { + if let Some(prefix) = &self.config.root_prefix { + self.ensure_directories(prefix).await?; + } + Ok(()) + } + + async fn ensure_parent_directories(&self, key: &str) -> Result<(), StorageError> { + let mapped = self.mapped_key(key)?; + let Some((parent, _)) = mapped.rsplit_once('/') else { + return Ok(()); + }; + self.ensure_directories(parent).await + } + + async fn ensure_directories(&self, path: &str) -> Result<(), StorageError> { + let client = self.client().await; + let base = self.share_path()?; + let mut current = String::new(); + for segment in path.split('/') { + if !current.is_empty() { + current.push('/'); + } + current.push_str(segment); + let target = base.clone().with_path(¤t); + let create = FileCreateArgs { + disposition: smb::CreateDisposition::OpenIf, + attributes: FileAttributes::new().with_directory(true), + options: CreateOptions::new().with_directory_file(true), + desired_access: DirAccessMask::new() + .with_list_directory(true) + .with_synchronize(true) + .into(), + }; + let resource = self + .with_timeout("create_directory", client.create_file(&target, &create)) + .await?; + close_resource(resource) + .await + .map_err(|error| map_smb_error("close", error))?; + } + Ok(()) + } + + async fn with_timeout( + &self, + operation: &'static str, + future: impl std::future::Future>, + ) -> Result { + tokio::time::timeout(self.config.operation_timeout, future) + .await + .map_err(|_| { + remote_error( + StorageProviderErrorKind::Timeout, + operation, + "operation timed out", + true, + ) + })? + .map_err(|error| map_smb_error(operation, error)) + } + + async fn open_file(&self, key: &str, access: FileAccessMask) -> Result { + let path = self.remote_path(key)?; + let client = self.client().await; + let first = self + .with_timeout( + "open", + client.create_file(&path, &FileCreateArgs::make_open_existing(access)), + ) + .await; + let resource = match first { + Ok(resource) => resource, + Err(error) if error.is_retryable() => { + self.reconnect_with_backoff().await?; + let client = self.client().await; + self.with_timeout( + "open", + client.create_file(&path, &FileCreateArgs::make_open_existing(access)), + ) + .await? + } + Err(error) => return Err(error), + }; + match resource { + Resource::File(file) => Ok(file), + other => { + let _ = close_resource(other).await; + Err(remote_error( + StorageProviderErrorKind::Protocol, + "open", + "remote key is not a file", + false, + )) + } + } + } + + async fn write_file( + &self, + key: &str, + body: StorageByteStream, + exclusive: bool, + ) -> Result { + let _permit = self.transfer_permit().await?; + self.ensure_parent_directories(key).await?; + let client = self.client().await; + let path = self.remote_path(key)?; + let args = if exclusive { + FileCreateArgs::make_create_new(FileAttributes::new(), CreateOptions::new()) + } else { + FileCreateArgs::make_overwrite(FileAttributes::new(), CreateOptions::new()) + }; + let resource = self + .with_timeout("create", client.create_file(&path, &args)) + .await?; + let file = match resource { + Resource::File(file) => file, + other => { + let _ = close_resource(other).await; + return Err(remote_error( + StorageProviderErrorKind::Protocol, + "create", + "remote key did not create a file", + false, + )); + } + }; + + let mut source = body.into_inner(); + let mut offset = 0_u64; + let mut hasher = Sha256::new(); + while let Some(chunk) = source.next().await { + let chunk = match chunk { + Ok(chunk) => chunk, + Err(error) => { + self.cleanup_failed_write(key, &file).await; + return Err(error); + } + }; + hasher.update(&chunk); + let mut written = 0; + while written < chunk.len() { + let result = tokio::time::timeout( + self.config.operation_timeout, + file.write_block(&chunk[written..], offset, None), + ) + .await; + let count = match result { + Ok(Ok(count)) => count, + Ok(Err(error)) => { + self.cleanup_failed_write(key, &file).await; + return Err(map_io_error("write", error)); + } + Err(_) => { + let error = remote_error( + StorageProviderErrorKind::Timeout, + "write", + "operation timed out", + true, + ); + self.cleanup_failed_write(key, &file).await; + return Err(error); + } + }; + if count == 0 { + let error = remote_error( + StorageProviderErrorKind::Protocol, + "write", + "server accepted a zero-length write", + false, + ); + self.cleanup_failed_write(key, &file).await; + return Err(error); + } + written += count; + offset = offset.saturating_add(u64::try_from(count).unwrap_or(u64::MAX)); + } + } + match tokio::time::timeout(self.config.operation_timeout, file.flush()).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + self.cleanup_failed_write(key, &file).await; + return Err(map_io_error("flush", error)); + } + Err(_) => { + let error = remote_error( + StorageProviderErrorKind::Timeout, + "flush", + "operation timed out", + true, + ); + self.cleanup_failed_write(key, &file).await; + return Err(error); + } + } + if let Err(error) = self.with_timeout("close", file.close()).await { + if error.is_retryable() { + let _ = self.reconnect_with_backoff().await; + } + let _ = self.remove_file(key).await; + return Err(error); + } + Ok(BlobWriteOutcome { + size_bytes: offset, + sha256_hex: format!("{:x}", hasher.finalize()), + }) + } + + async fn cleanup_failed_write(&self, key: &str, file: &File) { + let _ = self.with_timeout("close", file.close()).await; + let _ = self.remove_file(key).await; + } + + async fn rename(&self, from: &str, to: &str, replace: bool) -> Result<(), StorageError> { + self.ensure_parent_directories(to).await?; + let file = self + .open_file( + from, + FileAccessMask::new() + .with_generic_read(true) + .with_delete(true), + ) + .await?; + let destination = self.mapped_key(to)?.replace('/', "\\"); + let result = self + .with_timeout( + "rename", + file.set_info(FileRenameInformation { + replace_if_exists: replace.into(), + root_directory: 0, + file_name: SizedWideString::from(destination), + }), + ) + .await; + let close_result = self.with_timeout("close", file.close()).await; + result.and(close_result) + } + + async fn remove_file(&self, key: &str) -> Result<(), StorageError> { + let file = match self + .open_file(key, FileAccessMask::new().with_delete(true)) + .await + { + Ok(file) => file, + Err(error) if is_not_found(&error) => return Ok(()), + Err(error) => return Err(error), + }; + let result = self + .with_timeout( + "delete", + file.set_info(FileDispositionInformation::default()), + ) + .await; + let close_result = self.with_timeout("close", file.close()).await; + result.and(close_result) + } + + async fn transfer_permit(&self) -> Result { + Arc::clone(&self.transfer_limit) + .acquire_owned() + .await + .map_err(|_| { + remote_error( + StorageProviderErrorKind::Protocol, + "transfer", + "transfer limiter is closed", + false, + ) + }) + } + + async fn collect_keys(&self) -> Result, StorageError> { + let client = self.client().await; + let base = self.share_path()?; + let start = self.config.root_prefix.clone().unwrap_or_default(); + let mut stack = vec![start]; + let mut keys = Vec::new(); + + while let Some(directory_path) = stack.pop() { + let target = base.clone().with_path(&directory_path); + let resource = match self + .with_timeout( + "list", + client.create_file( + &target, + &FileCreateArgs::make_open_existing( + DirAccessMask::new() + .with_list_directory(true) + .with_synchronize(true) + .into(), + ), + ), + ) + .await + { + Ok(resource) => resource, + Err(error) if is_not_found(&error) => continue, + Err(error) => return Err(error), + }; + let directory = match resource { + Resource::Directory(directory) => Arc::new(directory), + other => { + let _ = close_resource(other).await; + continue; + } + }; + let mut entries = Directory::query::(&directory, "*") + .await + .map_err(|error| map_smb_error("list", error))?; + while let Some(entry) = entries.next().await { + let entry = entry.map_err(|error| map_smb_error("list", error))?; + let name = entry.file_name.to_string(); + if name == "." || name == ".." { + continue; + } + let child = if directory_path.is_empty() { + name + } else { + format!("{directory_path}/{name}") + }; + if entry.file_attributes.directory() { + stack.push(child); + } else if !is_temp_key(&child) { + let key = match &self.config.root_prefix { + Some(root) => child + .strip_prefix(root) + .and_then(|rest| rest.strip_prefix('/')) + .unwrap_or(&child) + .to_string(), + None => child, + }; + keys.push(key); + } + } + drop(entries); + self.with_timeout("close", directory.close()).await?; + } + keys.sort(); + Ok(keys) + } +} + +#[async_trait] +impl BlobStore for SmbStorageBackend { + fn backend(&self) -> StorageBackend { + StorageBackend::Smb + } + + async fn put_blob( + &self, + key: &str, + body: StorageByteStream, + _options: BlobPutOptions, + ) -> Result { + validate_blob_key(key)?; + let temp = temp_key(key)?; + let result = self.write_file(&temp, body, false).await; + let outcome = match result { + Ok(outcome) => outcome, + Err(error) => { + if error.is_retryable() { + let _ = self.reconnect_with_backoff().await; + } + let _ = self.remove_file(&temp).await; + return Err(error); + } + }; + if let Err(error) = self.rename(&temp, key, true).await { + if error.is_retryable() { + let _ = self.reconnect_with_backoff().await; + } + let _ = self.remove_file(&temp).await; + return Err(error); + } + Ok(outcome) + } + + async fn put_blob_if_not_exists( + &self, + key: &str, + body: StorageByteStream, + _options: BlobPutOptions, + ) -> Result, StorageError> { + validate_blob_key(key)?; + match self.write_file(key, body, true).await { + Ok(outcome) => Ok(Some(outcome)), + Err(error) if is_collision(&error) => Ok(None), + Err(error) => { + if error.is_retryable() { + let _ = self.reconnect_with_backoff().await; + } + Err(error) + } + } + } + + async fn get_blob(&self, key: &str) -> Result { + validate_blob_key(key)?; + let permit = self.transfer_permit().await?; + let file = self + .open_file(key, FileAccessMask::new().with_generic_read(true)) + .await?; + let standard: FileStandardInformation = + self.with_timeout("head", file.query_info()).await?; + let size = standard.end_of_file; + let operation_timeout = self.config.operation_timeout; + let stream_key = key.to_string(); + let body = stream::try_unfold( + (Some(file), 0_u64, size, permit, stream_key.clone()), + move |(file, offset, size, permit, key)| async move { + let Some(file) = file else { + return Ok(None); + }; + if offset >= size { + tokio::time::timeout(operation_timeout, file.close()) + .await + .map_err(|_| { + remote_error( + StorageProviderErrorKind::Timeout, + "close", + "operation timed out", + true, + ) + })? + .map_err(|error| map_smb_error("close", error))?; + return Ok(None); + } + let chunk_len = usize::try_from((size - offset).min(READ_CHUNK_SIZE as u64)) + .unwrap_or(READ_CHUNK_SIZE); + let mut buffer = vec![0_u8; chunk_len]; + let read = tokio::time::timeout( + operation_timeout, + file.read_block(&mut buffer, offset, None, false), + ) + .await + .map_err(|_| { + remote_error( + StorageProviderErrorKind::Timeout, + "read", + "operation timed out", + true, + ) + })? + .map_err(|error| map_io_error("read", error))?; + if read == 0 { + return Err(remote_error( + StorageProviderErrorKind::Protocol, + "read", + "unexpected end of remote file", + false, + )); + } + buffer.truncate(read); + let next = offset.saturating_add(u64::try_from(read).unwrap_or(u64::MAX)); + Ok(Some(( + Bytes::from(buffer), + (Some(file), next, size, permit, key), + ))) + }, + ); + Ok(BlobBody { + key: stream_key.clone(), + metadata: Some(BlobMetadata { + key: stream_key, + size_bytes: Some(size), + sha256_hex: None, + etag: None, + last_modified: None, + }), + body: StorageByteStream::with_size_hint(Box::pin(body), size), + }) + } + + async fn blob_exists(&self, key: &str) -> Result { + validate_blob_key(key)?; + match self + .open_file(key, FileAccessMask::new().with_generic_read(true)) + .await + { + Ok(file) => { + self.with_timeout("close", file.close()).await?; + Ok(true) + } + Err(error) if is_not_found(&error) => Ok(false), + Err(error) => Err(error), + } + } + + async fn head_blob(&self, key: &str) -> Result, StorageError> { + validate_blob_key(key)?; + let file = match self + .open_file(key, FileAccessMask::new().with_generic_read(true)) + .await + { + Ok(file) => file, + Err(error) if is_not_found(&error) => return Ok(None), + Err(error) => return Err(error), + }; + let standard: FileStandardInformation = + self.with_timeout("head", file.query_info()).await?; + self.with_timeout("close", file.close()).await?; + Ok(Some(BlobMetadata { + key: key.to_string(), + size_bytes: Some(standard.end_of_file), + sha256_hex: None, + etag: None, + last_modified: None, + })) + } + + async fn list_blobs_page( + &self, + prefix: &str, + continuation: Option, + limit: usize, + ) -> Result { + crate::blob::validate_blob_prefix(prefix)?; + if limit == 0 { + return Err(StorageError::PreconditionFailed { + key: prefix.to_string(), + condition: "list limit must be greater than zero".to_string(), + }); + } + let mut keys = self.collect_keys().await?; + keys.retain(|key| { + key.starts_with(prefix) + && continuation + .as_deref() + .is_none_or(|token| key.as_str() > token) + }); + let next_continuation = if keys.len() > limit { + keys.truncate(limit); + keys.last().cloned() + } else { + None + }; + Ok(BlobListPage { + keys, + next_continuation, + }) + } + + async fn delete_blob(&self, key: &str) -> Result<(), StorageError> { + validate_blob_key(key)?; + self.remove_file(key).await + } +} + +async fn connect_client(config: &SmbStorageConfig) -> Result { + let connection = ConnectionConfig { + port: Some(config.port), + timeout: Some(config.connect_timeout), + min_dialect: Some(config.min_dialect.protocol()), + encryption_mode: if config.require_encryption { + EncryptionMode::Required + } else { + EncryptionMode::Allowed + }, + allow_unsigned_guest_access: false, + smb2_only_negotiate: true, + ..ConnectionConfig::default() + }; + let client = Client::new(ClientConfig { + connection, + ..ClientConfig::default() + }); + let share = UncPath::new(&config.server) + .and_then(|path| path.with_share(&config.share)) + .map_err(|error| map_smb_error("connect", error))?; + let username = config.authenticated_username(); + tokio::time::timeout( + config.connect_timeout, + client.share_connect( + &share, + &username, + config.password.expose_secret().to_owned(), + ), + ) + .await + .map_err(|_| { + remote_error( + StorageProviderErrorKind::Timeout, + "connect", + "SMB negotiation, authentication, or share connection timed out", + true, + ) + })? + .map_err(|error| map_smb_error("connect", error))?; + Ok(client) +} + +async fn close_resource(resource: Resource) -> smb::Result<()> { + match resource { + Resource::File(file) => file.close().await, + Resource::Directory(directory) => directory.close().await, + Resource::Pipe(pipe) => pipe.close().await, + } +} + +fn validate_server(server: &str) -> Result<(), StorageError> { + if server.is_empty() + || server.trim() != server + || server.contains(['/', '\\', '\0']) + || server.contains("://") + || server == "." + || server == ".." + { + return Err(invalid_config( + "server must be a DNS name or IP address without a scheme or path", + )); + } + Ok(()) +} + +fn validate_component(name: &str, value: &str) -> Result<(), StorageError> { + if value.is_empty() + || value.trim() != value + || value.contains(['/', '\\', '\0']) + || value == "." + || value == ".." + { + return Err(invalid_config(&format!("{name} is invalid"))); + } + Ok(()) +} + +fn invalid_config(message: &str) -> StorageError { + remote_error( + StorageProviderErrorKind::Protocol, + "configuration", + message, + false, + ) +} + +fn temp_key(key: &str) -> Result { + let (parent, name) = key.rsplit_once('/').unwrap_or(("", key)); + let temp_name = format!(".{name}.{}{TEMP_SUFFIX}", Uuid::new_v4()); + let temp = if parent.is_empty() { + temp_name + } else { + format!("{parent}/{temp_name}") + }; + validate_blob_key(&temp)?; + Ok(temp) +} + +fn is_temp_key(key: &str) -> bool { + let Some(name) = key.rsplit('/').next() else { + return false; + }; + let Some(stem) = name.strip_suffix(TEMP_SUFFIX) else { + return false; + }; + stem.rsplit_once('.') + .is_some_and(|(_, uuid)| Uuid::parse_str(uuid).is_ok()) +} + +fn map_io_error(operation: &'static str, error: std::io::Error) -> StorageError { + let kind = match error.kind() { + std::io::ErrorKind::TimedOut => StorageProviderErrorKind::Timeout, + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::NotConnected + | std::io::ErrorKind::UnexpectedEof => StorageProviderErrorKind::ConnectionLost, + std::io::ErrorKind::PermissionDenied => StorageProviderErrorKind::PermissionDenied, + std::io::ErrorKind::NotFound => StorageProviderErrorKind::RemotePathNotFound, + std::io::ErrorKind::StorageFull | std::io::ErrorKind::QuotaExceeded => { + StorageProviderErrorKind::Capacity + } + _ => StorageProviderErrorKind::Protocol, + }; + let retryable = matches!( + kind, + StorageProviderErrorKind::Timeout + | StorageProviderErrorKind::ConnectionLost + | StorageProviderErrorKind::Connectivity + ); + remote_error(kind, operation, error.to_string(), retryable) +} + +fn map_smb_error(operation: &'static str, error: smb::Error) -> StorageError { + let status = match &error { + smb::Error::UnexpectedMessageStatus(status) + | smb::Error::ReceivedErrorMessage(status, _) => Some(*status), + _ => None, + }; + let kind = match status { + Some(value) if value == Status::LogonFailure as u32 => { + StorageProviderErrorKind::Authentication + } + Some(value) if value == Status::AccessDenied as u32 => { + StorageProviderErrorKind::PermissionDenied + } + Some(value) if value == Status::BadNetworkName as u32 => { + StorageProviderErrorKind::ContainerNotFound + } + Some(value) + if value == Status::ObjectNameNotFound as u32 + || value == Status::ObjectPathNotFound as u32 => + { + StorageProviderErrorKind::RemotePathNotFound + } + Some(value) if value == Status::ObjectNameCollision as u32 => { + StorageProviderErrorKind::ConditionalCreateConflict + } + Some(value) if value == Status::IoTimeout as u32 => StorageProviderErrorKind::Timeout, + Some(value) + if value == Status::NetworkNameDeleted as u32 + || value == Status::UserSessionDeleted as u32 + || value == Status::NetworkSessionExpired as u32 => + { + StorageProviderErrorKind::ConnectionLost + } + _ => match &error { + smb::Error::TransportError(smb::transport::TransportError::Timeout(_)) + | smb::Error::OperationTimeout(_, _) => StorageProviderErrorKind::Timeout, + smb::Error::TransportError(smb::transport::TransportError::NotConnected) + | smb::Error::ConnectionStopped => StorageProviderErrorKind::ConnectionLost, + smb::Error::MessageProcessingError(message) + if message.contains("Failed to send message to worker") => + { + StorageProviderErrorKind::ConnectionLost + } + smb::Error::TransportError(smb::transport::TransportError::IoError(_)) + | smb::Error::IoError(_) => StorageProviderErrorKind::Connectivity, + smb::Error::SspiError(_) => StorageProviderErrorKind::Authentication, + smb::Error::NegotiationError(_) + | smb::Error::SignatureVerificationFailed + | smb::Error::CryptoError(_) => StorageProviderErrorKind::SecurityNegotiation, + _ => StorageProviderErrorKind::Protocol, + }, + }; + let retryable = matches!( + kind, + StorageProviderErrorKind::Connectivity + | StorageProviderErrorKind::Timeout + | StorageProviderErrorKind::ConnectionLost + ); + remote_error(kind, operation, error.to_string(), retryable) +} + +fn remote_error( + kind: StorageProviderErrorKind, + operation: impl Into, + message: impl Into, + retryable: bool, +) -> StorageError { + StorageError::RemoteProvider { + backend: StorageBackend::Smb.as_str().to_string(), + kind, + operation: operation.into(), + message: message.into(), + retryable, + } +} + +fn is_not_found(error: &StorageError) -> bool { + matches!( + error, + StorageError::RemoteProvider { + kind: StorageProviderErrorKind::RemotePathNotFound, + .. + } + ) +} + +fn is_collision(error: &StorageError) -> bool { + matches!( + error, + StorageError::RemoteProvider { + kind: StorageProviderErrorKind::ConditionalCreateConflict, + .. + } + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> SmbStorageConfig { + SmbStorageConfig::new( + "files.example.test", + "backups", + "backup-user", + SecretString::from("correct horse battery staple"), + ) + } + + #[test] + fn configuration_defaults_are_secure() { + let config = config(); + assert_eq!(config.port, 445); + assert_eq!(config.min_dialect, SmbDialect::Smb3_0); + assert!(config.require_signing); + assert!(!config.require_encryption); + assert!(config.validate().is_ok()); + } + + #[test] + fn configuration_rejects_paths_and_unsafe_prefixes() { + for server in [r"\\server\share", "smb://server/share", "/mnt/share"] { + let mut config = config(); + config.server = server.to_string(); + assert!(config.validate().is_err(), "accepted {server}"); + } + let mut config = config(); + config.root_prefix = Some("backups/../escape".to_string()); + assert!(config.validate().is_err()); + } + + #[test] + fn debug_never_exposes_password() { + let rendered = format!("{:?}", config()); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains("correct horse battery staple")); + } + + #[test] + fn root_prefix_mapping_is_provider_relative() { + let mut config = config(); + config.root_prefix = Some("repositories/nightly".to_string()); + let mapped = match config.root_prefix.as_deref() { + Some(prefix) => format!("{prefix}/objects/data"), + None => "objects/data".to_string(), + }; + assert_eq!(mapped, "repositories/nightly/objects/data"); + } + + #[test] + fn temporary_names_are_filtered_strictly() { + let temp = temp_key("nested/blob.bin").expect("safe temp key"); + assert!(is_temp_key(&temp)); + assert!(!is_temp_key("nested/report.uploading")); + assert!(!is_temp_key("nested/blob.not-a-uuid.uploading")); + } + + #[test] + fn retry_classification_is_bounded_to_transient_errors() { + let timeout = remote_error(StorageProviderErrorKind::Timeout, "read", "timed out", true); + let denied = remote_error( + StorageProviderErrorKind::PermissionDenied, + "read", + "denied", + false, + ); + assert!(timeout.is_retryable()); + assert!(!denied.is_retryable()); + } +} diff --git a/crates/graphql-orm-storage/tests/samba/run.sh b/crates/graphql-orm-storage/tests/samba/run.sh new file mode 100755 index 00000000..b9c23eb4 --- /dev/null +++ b/crates/graphql-orm-storage/tests/samba/run.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +NORMAL=graphql-orm-smb-test +ENCRYPTED=graphql-orm-smb-encrypted +PASSWORD='BackupTest-42!' + +cleanup() { + docker rm -f "$NORMAL" "$ENCRYPTED" >/dev/null 2>&1 || true + docker volume rm graphql-orm-smb-test-data >/dev/null 2>&1 || true +} +trap cleanup EXIT +cleanup + +docker volume create graphql-orm-smb-test-data >/dev/null +docker run -d --rm --name "$NORMAL" -p 1445:445 \ + -v graphql-orm-smb-test-data:/share dperson/samba:latest \ + -p -w WORKGROUP -u "backup;$PASSWORD" \ + -s 'backups;/share;yes;no;no;backup' \ + -g 'server signing = mandatory' >/dev/null +docker run -d --rm --name "$ENCRYPTED" -p 1446:445 dperson/samba:latest \ + -p -w WORKGROUP -u "backup;$PASSWORD" \ + -s 'backups;/share;yes;no;no;backup' \ + -g 'server signing = mandatory' -g 'smb encrypt = required' >/dev/null + +sleep 2 + +cd "$ROOT" +SMB_TEST_SERVER=127.0.0.1 SMB_TEST_PORT=1445 SMB_TEST_SHARE=backups \ +SMB_TEST_USERNAME=backup SMB_TEST_PASSWORD="$PASSWORD" SMB_TEST_DOMAIN=WORKGROUP \ + cargo test --features smb --test smb_integration \ + samba_rejects_invalid_password -- --ignored --nocapture + +SMB_TEST_SERVER=127.0.0.1 SMB_TEST_PORT=1445 SMB_TEST_SHARE=backups \ +SMB_TEST_USERNAME=backup SMB_TEST_PASSWORD="$PASSWORD" SMB_TEST_DOMAIN=WORKGROUP \ + cargo test --features smb --test smb_integration \ + samba_round_trip_streaming_listing_and_atomic_create -- --ignored --nocapture + +SMB_TEST_SERVER=127.0.0.1 SMB_TEST_PORT=1446 SMB_TEST_SHARE=backups \ +SMB_TEST_USERNAME=backup SMB_TEST_PASSWORD="$PASSWORD" SMB_TEST_REQUIRE_ENCRYPTION=1 \ + cargo test --features smb --test smb_integration \ + samba_round_trip_streaming_listing_and_atomic_create -- --ignored --nocapture + +cd "$ROOT/../graphql-orm-backup" +SMB_TEST_SERVER=127.0.0.1 SMB_TEST_PORT=1445 SMB_TEST_SHARE=backups \ +SMB_TEST_USERNAME=backup SMB_TEST_PASSWORD="$PASSWORD" SMB_TEST_DOMAIN=WORKGROUP \ + cargo test --no-default-features --features smb --test smb_repository -- --ignored --nocapture + +cd "$ROOT" +SMB_TEST_SERVER=127.0.0.1 SMB_TEST_PORT=1445 SMB_TEST_SHARE=backups \ +SMB_TEST_USERNAME=backup SMB_TEST_PASSWORD="$PASSWORD" SMB_TEST_DOMAIN=WORKGROUP \ +SMB_TEST_CONTAINER_NAME="$NORMAL" \ + cargo test --features smb --test smb_integration \ + samba_reconnects_after_server_restart -- --ignored --nocapture diff --git a/crates/graphql-orm-storage/tests/smb_integration.rs b/crates/graphql-orm-storage/tests/smb_integration.rs new file mode 100644 index 00000000..7270a78e --- /dev/null +++ b/crates/graphql-orm-storage/tests/smb_integration.rs @@ -0,0 +1,219 @@ +use std::{env, sync::Arc, time::Duration}; + +use bytes::Bytes; +use futures_util::stream; +use graphql_orm_storage::{ + BlobPutOptions, BlobStore, SmbDialect, SmbProbeOptions, SmbStorageBackend, SmbStorageConfig, + StorageByteStream, collect_storage_stream, +}; +use secrecy::SecretString; +use uuid::Uuid; + +fn test_config(root_prefix: String) -> SmbStorageConfig { + let mut config = SmbStorageConfig::new( + env::var("SMB_TEST_SERVER").unwrap_or_else(|_| "127.0.0.1".to_string()), + env::var("SMB_TEST_SHARE").unwrap_or_else(|_| "backups".to_string()), + env::var("SMB_TEST_USERNAME").unwrap_or_else(|_| "backup".to_string()), + SecretString::from( + env::var("SMB_TEST_PASSWORD").unwrap_or_else(|_| "BackupTest-42!".to_string()), + ), + ); + config.port = env::var("SMB_TEST_PORT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(1445); + config.domain = env::var("SMB_TEST_DOMAIN").ok(); + config.require_encryption = env::var("SMB_TEST_REQUIRE_ENCRYPTION") + .ok() + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); + config.root_prefix = Some(root_prefix); + config.min_dialect = if config.require_encryption { + SmbDialect::Smb3_0 + } else { + SmbDialect::Smb2_1 + }; + config.connect_timeout = Duration::from_secs(5); + config.operation_timeout = Duration::from_secs(20); + config +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires SMB_TEST_* or the documented Samba container"] +async fn samba_round_trip_streaming_listing_and_atomic_create() { + let root = format!("graphql-orm-storage-tests/{}", Uuid::new_v4()); + let config = test_config(root.clone()); + let encryption_required = config.require_encryption; + let probe = SmbStorageBackend::probe( + config.clone(), + SmbProbeOptions { + create_prefix: true, + }, + ) + .await + .expect("probe"); + assert!(probe.server_reachable); + assert!(probe.share_reachable); + assert!(probe.signing_active); + assert_eq!(probe.encryption_active, encryption_required); + assert!(probe.prefix_readable); + assert!(probe.prefix_writable); + + let store = Arc::new(SmbStorageBackend::connect(config).await.expect("connect")); + store + .put_blob( + "nested/small.bin", + StorageByteStream::from_bytes(Bytes::from_static(b"small payload")), + BlobPutOptions::default(), + ) + .await + .expect("put small"); + let small = store.get_blob("nested/small.bin").await.expect("get small"); + assert_eq!( + collect_storage_stream(small.body) + .await + .expect("collect small"), + Bytes::from_static(b"small payload") + ); + + const CHUNK_SIZE: usize = 256 * 1024; + const CHUNK_COUNT: usize = 32; + let chunks = (0..CHUNK_COUNT).map(|index| { + let byte = u8::try_from(index % 251).expect("bounded byte"); + Ok(Bytes::from(vec![byte; CHUNK_SIZE])) + }); + let large = StorageByteStream::with_size_hint( + Box::pin(stream::iter(chunks)), + u64::try_from(CHUNK_SIZE * CHUNK_COUNT).expect("bounded size"), + ); + let outcome = store + .put_blob("large/stream.bin", large, BlobPutOptions::default()) + .await + .expect("put large stream"); + assert_eq!(outcome.size_bytes as usize, CHUNK_SIZE * CHUNK_COUNT); + let loaded = store + .get_blob("large/stream.bin") + .await + .expect("get large stream"); + let loaded = collect_storage_stream(loaded.body) + .await + .expect("collect large stream"); + assert_eq!(loaded.len(), CHUNK_SIZE * CHUNK_COUNT); + + let interrupted = StorageByteStream::new(Box::pin(stream::iter(vec![ + Ok(Bytes::from_static(b"partial")), + Err(graphql_orm_storage::StorageError::Provider { + backend: "test-source".to_string(), + message: "injected interruption".to_string(), + retryable: true, + }), + ]))); + store + .put_blob( + "interrupted/upload.bin", + interrupted, + BlobPutOptions::default(), + ) + .await + .expect_err("interrupted stream must fail"); + assert!( + !store + .blob_exists("interrupted/upload.bin") + .await + .expect("interrupted final exists") + ); + + let all = store.list_blobs("").await.expect("list all"); + assert_eq!( + all, + vec![ + "large/stream.bin".to_string(), + "nested/small.bin".to_string() + ] + ); + assert!(all.iter().all(|key| !key.ends_with(".uploading"))); + assert_eq!( + store.list_blobs("nested").await.expect("list prefix"), + vec!["nested/small.bin".to_string()] + ); + + let left = Arc::clone(&store); + let right = Arc::clone(&store); + let (left_result, right_result) = tokio::join!( + left.put_blob_if_not_exists( + "locks/repository.lock", + StorageByteStream::from_bytes(Bytes::from_static(b"left")), + BlobPutOptions::default(), + ), + right.put_blob_if_not_exists( + "locks/repository.lock", + StorageByteStream::from_bytes(Bytes::from_static(b"right")), + BlobPutOptions::default(), + ) + ); + let created = [ + left_result.expect("left create"), + right_result.expect("right create"), + ] + .into_iter() + .filter(Option::is_some) + .count(); + assert_eq!(created, 1, "exactly one FILE_CREATE must succeed"); + + for key in [ + "nested/small.bin", + "large/stream.bin", + "locks/repository.lock", + ] { + store.delete_blob(key).await.expect("delete"); + assert!(!store.blob_exists(key).await.expect("exists after delete")); + } + assert!(store.list_blobs("").await.expect("empty list").is_empty()); +} + +#[tokio::test] +#[ignore = "requires the documented Samba container"] +async fn samba_rejects_invalid_password() { + let mut config = test_config(format!("graphql-orm-storage-tests/{}", Uuid::new_v4())); + config.password = SecretString::from("definitely-wrong-password"); + let error = SmbStorageBackend::connect(config) + .await + .expect_err("authentication must fail"); + assert!(!error.is_retryable()); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires the managed Samba container harness"] +async fn samba_reconnects_after_server_restart() { + let Ok(container) = env::var("SMB_TEST_CONTAINER_NAME") else { + return; + }; + let root = format!("graphql-orm-storage-reconnect/{}", Uuid::new_v4()); + let store = SmbStorageBackend::connect(test_config(root)) + .await + .expect("connect before restart"); + store + .put_blob( + "reconnect/blob.bin", + StorageByteStream::from_bytes(Bytes::from_static(b"survives restart")), + BlobPutOptions::default(), + ) + .await + .expect("write before restart"); + + let status = std::process::Command::new("docker") + .args(["restart", &container]) + .status() + .expect("run docker restart"); + assert!(status.success(), "docker restart failed"); + + assert!( + store + .blob_exists("reconnect/blob.bin") + .await + .expect("reconnect and inspect remote state") + ); + store + .delete_blob("reconnect/blob.bin") + .await + .expect("delete after reconnect"); +} From dfc58a91217239322ac966c7086bb108bbd93c76 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 13 Jul 2026 11:28:22 +1000 Subject: [PATCH 022/108] docs: update SMB agent handoff --- crates/graphql-orm-storage/AGENTS.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/graphql-orm-storage/AGENTS.md b/crates/graphql-orm-storage/AGENTS.md index 348fbdba..4f304bed 100644 --- a/crates/graphql-orm-storage/AGENTS.md +++ b/crates/graphql-orm-storage/AGENTS.md @@ -20,7 +20,7 @@ This crate is a reusable storage companion for applications that use `graphql-or ## Current Agent Handoff -- Current crate version is `0.4.0`. +- Current crate version is `0.5.0`. - The storage provider boundary is now the streaming `BlobStore` trait. - `ObjectStorage` extends `BlobStore`; custom providers must implement `BlobStore` first. - `BlobStore` includes byte ranges, conditional writes, server-side copy, write options, and paged listing. @@ -28,5 +28,19 @@ This crate is a reusable storage companion for applications that use `graphql-or - `StreamingObjectStore` supports bucket/key large-object streaming, multipart writes, range reads, metadata, listing, and retention deletion. - `graphql-orm-backup` should adapt `BlobStore` directly for backup repository semantics; it should not use `StorageService`. - S3 is implemented behind the `s3` feature through the shared `BlobStore` provider layer. +- Native SMB2/SMB3 is implemented behind the `smb` feature as + `SmbStorageBackend`. Construct it from runtime `SmbStorageConfig` credentials; + never translate native SMB fields into a mount path or persist its password. +- Backup integrations should wrap `Arc` in + `graphql-orm-backup::BlobStoreBackupRepository`. Do not duplicate SMB + transport, manifest, retention, or locking code. +- `put_blob_if_not_exists` on SMB uses server-side `FILE_CREATE` and is the + required atomic primitive for repository locking. Do not replace it with an + exists-then-put sequence. +- Use `SmbStorageBackend::probe` for redaction-safe connection and read/write + validation. Use `tests/samba/run.sh` for the managed protocol and complete + backup lifecycle suite. - Azure Blob is still a feature-gated unsupported placeholder. Do not add real Azure SDK code without implementing the shared `BlobStore` provider layer first. -- See `docs/agent-update.md`, `docs/blob-store.md`, `docs/streaming.md`, and `docs/backup-integration.md` before making provider or backup-facing changes. +- See `docs/agent-update.md`, `docs/blob-store.md`, `docs/streaming.md`, + `docs/backup-integration.md`, `docs/native-smb.md`, and `MIGRATION.md` before + making provider or backup-facing changes. From 6225663718d141b656eddc8852b3beaf542351c7 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 13 Jul 2026 11:29:08 +1000 Subject: [PATCH 023/108] feat: add native SMB backup repositories --- crates/graphql-orm-backup/AGENTS.md | 22 + crates/graphql-orm-backup/CHANGELOG.md | 16 + crates/graphql-orm-backup/Cargo.lock | 1725 ++++++++++++++++- crates/graphql-orm-backup/Cargo.toml | 11 +- crates/graphql-orm-backup/MIGRATION.md | 58 + crates/graphql-orm-backup/README.md | 21 +- crates/graphql-orm-backup/docs/README.md | 5 +- .../docs/cloud-provider-direction.md | 15 +- .../docs/digitise-native-smb.md | 31 + crates/graphql-orm-backup/docs/plan.md | 8 +- .../docs/provider-roadmap.md | 9 +- crates/graphql-orm-backup/docs/smb.md | 91 +- crates/graphql-orm-backup/src/backup.rs | 43 +- crates/graphql-orm-backup/src/object_index.rs | 16 + crates/graphql-orm-backup/src/repository.rs | 53 + crates/graphql-orm-backup/src/restore.rs | 72 +- crates/graphql-orm-backup/src/verify.rs | 11 +- .../tests/full_backup_creation.rs | 122 +- .../tests/smb_repository.rs | 259 +++ 19 files changed, 2440 insertions(+), 148 deletions(-) create mode 100644 crates/graphql-orm-backup/MIGRATION.md create mode 100644 crates/graphql-orm-backup/docs/digitise-native-smb.md create mode 100644 crates/graphql-orm-backup/tests/smb_repository.rs diff --git a/crates/graphql-orm-backup/AGENTS.md b/crates/graphql-orm-backup/AGENTS.md index f3003b35..19d7563f 100644 --- a/crates/graphql-orm-backup/AGENTS.md +++ b/crates/graphql-orm-backup/AGENTS.md @@ -17,3 +17,25 @@ This crate is a reusable backup and restore companion for applications that use - Treat restore as a first-class feature. Every backup feature must have restore and verification tests. - Full backup and restore ship before incremental backup. - Incremental backup depends on a reliable graphql-orm change journal. + +## Current Agent Handoff + +- Current crate version is `0.4.0`. +- Native SMB repositories use + `graphql-orm-storage::SmbStorageBackend -> BlobStoreBackupRepository`; this + crate must not contain SMB transport code. +- Enable the `smb` feature and construct the backend with runtime credentials. + Reusable crates never persist those credentials. +- Full backup, referenced-object verification and restore use the streaming + methods on `BackupRepository`, `BackupObjectIndex`, and `RestoreObjectSink`. + Preserve their buffered defaults for source compatibility. +- Repository locking depends on atomic + `BlobStore::put_blob_if_not_exists`. Never implement locking with an + existence check followed by a write. +- Snapshot manifests and repository key layout are provider-independent and + unchanged in 0.4.0. +- Run the managed real-Samba suite with + `/home/toby/graphql-orm-storage/tests/samba/run.sh`; it includes this crate's + complete SMB snapshot lifecycle test. +- Read `docs/smb.md`, `docs/digitise-native-smb.md`, and `MIGRATION.md` before + changing provider integration or host guidance. diff --git a/crates/graphql-orm-backup/CHANGELOG.md b/crates/graphql-orm-backup/CHANGELOG.md index 18e9c01d..e5b1bf8c 100644 --- a/crates/graphql-orm-backup/CHANGELOG.md +++ b/crates/graphql-orm-backup/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.4.0 + +- Enabled native SMB repositories through the storage crate's `smb` feature + and `BlobStoreBackupRepository`; transport code remains in storage. +- Added backward-compatible streaming methods to `BackupRepository`, + `BackupObjectIndex`, and `RestoreObjectSink`. +- Full backup, verification, and object restore now stream referenced objects + and compute checksums incrementally. +- Added Samba lifecycle coverage for full create/load/list, verify, database + and object restore, prune, delete, and simultaneous repository locking. +- The snapshot format and repository key layout are unchanged. +- Added migration guidance for streaming trait defaults, native-versus-mounted + SMB configuration, dependency-source identity, release order, and host + authorization boundaries. +- Pinned `graphql-orm-storage` 0.5.0 to the reviewed native-SMB release commit. + ## 0.3.1 - Pinned `graphql-orm` 0.6.1 and `graphql-orm-storage` 0.4.0 to reviewed full diff --git a/crates/graphql-orm-backup/Cargo.lock b/crates/graphql-orm-backup/Cargo.lock index dba4db36..696de6a2 100644 --- a/crates/graphql-orm-backup/Cargo.lock +++ b/crates/graphql-orm-backup/Cargo.lock @@ -8,6 +8,41 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -23,12 +58,37 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + [[package]] name = "ascii_utils" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" +[[package]] +name = "async-dnssd" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d49ffe175ab45bbfd74b548313d9d7cdfff27161a94b007b52eeeb5f9aaa15e" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-util", + "libc", + "log", + "pin-utils", + "pkg-config", + "tokio", + "winapi", +] + [[package]] name = "async-graphql" version = "7.2.1" @@ -61,7 +121,7 @@ dependencies = [ "serde_urlencoded", "static_assertions_next", "tempfile", - "thiserror", + "thiserror 2.0.18", "uuid", ] @@ -78,8 +138,8 @@ dependencies = [ "proc-macro2", "quote", "strum", - "syn", - "thiserror", + "syn 2.0.118", + "thiserror 2.0.18", ] [[package]] @@ -124,6 +184,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -132,7 +203,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -154,12 +225,24 @@ dependencies = [ "num-traits", ] +[[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.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -172,6 +255,36 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "binrw" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53195f985e88ab94d1cc87e80049dd2929fd39e4a772c5ae96a7e5c4aad3642" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5910da05ee556b789032c8ff5a61fb99239580aa3fd0bfaa8f4d094b2aee00ad" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -190,12 +303,27 @@ 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.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + [[package]] name = "byteorder" version = "1.5.0" @@ -211,6 +339,15 @@ 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" version = "1.2.67" @@ -223,12 +360,62 @@ dependencies = [ "shlex", ] +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[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 = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -253,6 +440,22 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -262,6 +465,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -292,6 +504,27 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf1e6e5492f8f0830c37f301f6349e0dac8b2466e4fe89eef90e9eef906cd046" +dependencies = [ + "crypto-common", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -299,9 +532,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-mac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "darling" version = "0.20.11" @@ -333,7 +613,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -346,7 +626,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -357,7 +637,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -368,7 +648,16 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", ] [[package]] @@ -409,7 +698,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -419,7 +708,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", ] [[package]] @@ -442,7 +740,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -451,6 +749,45 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -460,6 +797,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -522,6 +880,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -643,7 +1017,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -683,6 +1057,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -692,8 +1067,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -703,8 +1080,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -725,18 +1115,19 @@ dependencies = [ [[package]] name = "graphql-orm-backup" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "bytes", "futures", "graphql-orm", "graphql-orm-storage", + "secrecy", "serde", "serde_json", "sha2", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "uuid", "zstd", @@ -750,28 +1141,48 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "graphql-orm-storage" -version = "0.4.0" -source = "git+https://github.com/Dastari/graphql-orm-storage.git?rev=3c92c96bdb9f4d7b48481f2a5038c7ba73dde81c#3c92c96bdb9f4d7b48481f2a5038c7ba73dde81c" +version = "0.5.0" +source = "git+https://github.com/Dastari/graphql-orm-storage.git?rev=f1a1f06483d5fd3a0b8fd17f013b3ad4dd9849c5#f1a1f06483d5fd3a0b8fd17f013b3ad4dd9849c5" dependencies = [ "async-trait", "bytes", "futures-core", "futures-util", + "picky", + "picky-krb", + "secrecy", "serde", "serde_json", "sha2", - "thiserror", + "smb", + "smb-dtyp", + "smb-fscc", + "smb-msg", + "smb-rpc", + "smb-transport", + "thiserror 2.0.18", "time", "tokio", "tokio-util", "uuid", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "handlebars" version = "6.4.2" @@ -785,7 +1196,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -881,13 +1292,95 @@ dependencies = [ ] [[package]] -name = "httparse" -version = "1.10.1" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "icu_collections" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" @@ -1007,6 +1500,22 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" @@ -1034,6 +1543,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1061,7 +1579,7 @@ version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags", + "bitflags 2.13.0", "libc", "plain", "redox_syscall 0.9.0", @@ -1114,6 +1632,23 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1124,6 +1659,15 @@ dependencies = [ "digest", ] +[[package]] +name = "md4" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda" +dependencies = [ + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1147,6 +1691,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "modular-bitfield" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a53d79ba8304ac1c4f9eb3b9d281f21f7be9d4626f72ce7df4ad8fbde4f38a74" +dependencies = [ + "modular-bitfield-impl", + "static_assertions", +] + +[[package]] +name = "modular-bitfield-impl" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a7d5f7076603ebc68de2dc6a650ec331a062a13abaa346975be747bbfa4b789" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "multer" version = "3.1.0" @@ -1175,7 +1740,8 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", + "serde", "smallvec", "zeroize", ] @@ -1186,6 +1752,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1230,12 +1807,77 @@ dependencies = [ "libm", ] +[[package]] +name = "oid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c19903c598813dba001b53beeae59bb77ad4892c5c1b9b3500ce4293a0d06c2" +dependencies = [ + "serde", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1262,7 +1904,24 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "sha1", ] [[package]] @@ -1310,7 +1969,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1322,12 +1981,113 @@ dependencies = [ "pest", ] +[[package]] +name = "picky" +version = "7.0.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83be360ca0cc8659abfbda932098e606fe52fa129508b92f0ce2998c00679170" +dependencies = [ + "base64", + "digest", + "ed25519-dalek", + "hex", + "md-5", + "num-bigint-dig", + "p256", + "p384", + "p521", + "picky-asn1", + "picky-asn1-der", + "picky-asn1-x509", + "rand 0.8.7", + "rand_core 0.6.4", + "rsa", + "serde", + "sha1", + "sha2", + "sha3", + "thiserror 1.0.69", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "picky-asn1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff038f9360b934342fb3c0a1d6e82c438a2624b51c3c6e3e6d7cf252b6f3ee3" +dependencies = [ + "oid", + "serde", + "serde_bytes", + "time", + "zeroize", +] + +[[package]] +name = "picky-asn1-der" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d413165e4bf7f808b9a27cbaba657657a2921f0965db833f488c4d4be96dcd2e" +dependencies = [ + "picky-asn1", + "serde", + "serde_bytes", +] + +[[package]] +name = "picky-asn1-x509" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d493f73cf052073ca1fe38666f74c2396987aa6ea660e77dd624cc6c8f60389e" +dependencies = [ + "base64", + "num-bigint-dig", + "oid", + "picky-asn1", + "picky-asn1-der", + "serde", + "widestring", + "zeroize", +] + +[[package]] +name = "picky-krb" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e78a55491723b0a10bc2c02709a8d92d74ef674fe1b569cb4a08bac3d105487" +dependencies = [ + "aes", + "byteorder", + "cbc", + "crypto", + "des", + "hmac", + "num-bigint-dig", + "oid", + "pbkdf2", + "picky-asn1", + "picky-asn1-der", + "picky-asn1-x509", + "rand 0.8.7", + "serde", + "sha1", + "thiserror 1.0.69", + "uuid", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkcs1" version = "0.7.5" @@ -1375,6 +2135,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1399,6 +2171,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1417,6 +2198,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -1440,7 +2277,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1450,7 +2298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1462,13 +2310,28 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -1477,7 +2340,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -1509,6 +2372,56 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1536,20 +2449,47 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", + "sha1", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rust-kbkdf" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb211667cbc3291d401a54f7499904001c2cfc3347844120443bffca6fa0590" +dependencies = [ + "generic-array", + "typenum", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -1562,6 +2502,7 @@ version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -1570,12 +2511,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ + "web-time", "zeroize", ] @@ -1602,12 +2556,73 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1618,6 +2633,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1635,7 +2660,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1671,7 +2696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1682,10 +2707,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1699,7 +2734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1717,6 +2752,121 @@ dependencies = [ "serde", ] +[[package]] +name = "smb" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1315ec423faffdc9715f518c5cab5660b26484df42adaa87a3325f70c73417b" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "binrw", + "ccm", + "cmac", + "crypto-common", + "futures", + "futures-core", + "futures-util", + "hmac", + "log", + "maybe-async", + "modular-bitfield", + "pastey", + "rand 0.8.7", + "rust-kbkdf", + "sha2", + "smb-dtyp", + "smb-fscc", + "smb-msg", + "smb-rpc", + "smb-transport", + "sspi", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "smb-dtyp" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73e6cf408be4652ba7ef0e318c81842c96a6351cc20d7f13ef8ff6eb6b0371c" +dependencies = [ + "binrw", + "modular-bitfield", + "pastey", + "rand 0.8.7", + "time", +] + +[[package]] +name = "smb-fscc" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8c5b8c7e8ff63ad161f30528218a7c4751e43ee177f6743fa0019d20edbdc4" +dependencies = [ + "binrw", + "modular-bitfield", + "pastey", + "smb-dtyp", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "smb-msg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cbf5564fb068c7786d027c54aa07793476f643845d0a7c9e3071e0776eba0ad" +dependencies = [ + "binrw", + "modular-bitfield", + "pastey", + "smb-dtyp", + "smb-fscc", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "smb-rpc" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6b673767d02907f444d131f0c4d7b0fbbdc4199765054f650643bf551ceb89" +dependencies = [ + "binrw", + "maybe-async", + "modular-bitfield", + "pastey", + "smb-dtyp", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "smb-transport" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19b0ff9b464efc253fedf1d7205d1b936530fb44a3e57d9fbd5101dac58ff39" +dependencies = [ + "binrw", + "futures-core", + "futures-util", + "log", + "maybe-async", + "modular-bitfield", + "pastey", + "smb-dtyp", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-util", +] + [[package]] name = "socket2" version = "0.6.4" @@ -1787,7 +2937,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -1806,7 +2956,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.118", ] [[package]] @@ -1829,7 +2979,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.118", "tokio", "url", ] @@ -1842,7 +2992,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.13.0", "byteorder", "bytes", "crc", @@ -1863,7 +3013,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.7", "rsa", "serde", "sha1", @@ -1871,7 +3021,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -1885,7 +3035,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.13.0", "byteorder", "crc", "dotenvy", @@ -1902,14 +3052,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.7", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -1934,10 +3084,55 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sspi" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523f6a99e26c1e6476a424d54bbda5354a01ee7f18b9d93dc48a8fd45ae8189b" +dependencies = [ + "async-dnssd", + "async-recursion", + "bitflags 2.13.0", + "byteorder", + "cfg-if", + "crypto-mac", + "futures", + "hmac", + "lazy_static", + "md-5", + "md4", + "num-bigint-dig", + "num-derive", + "num-traits", + "oid", + "picky", + "picky-asn1", + "picky-asn1-der", + "picky-asn1-x509", + "picky-krb", + "rand 0.8.7", + "reqwest", + "rsa", + "rustls", + "serde", + "serde_derive", + "sha1", + "sha2", + "time", + "tokio", "tracing", "url", "uuid", + "windows", + "windows-registry", + "windows-sys 0.60.2", + "zeroize", ] [[package]] @@ -1946,6 +3141,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "static_assertions_next" version = "1.1.2" @@ -1987,7 +3188,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1996,6 +3197,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.118" @@ -2007,6 +3219,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2015,7 +3236,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2031,13 +3252,33 @@ dependencies = [ "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 2.0.118", ] [[package]] @@ -2048,7 +3289,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2129,7 +3370,17 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", ] [[package]] @@ -2187,6 +3438,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -2207,7 +3503,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2219,6 +3515,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -2264,6 +3566,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2312,6 +3624,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2337,6 +3658,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -2356,7 +3687,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2369,6 +3700,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2397,12 +3748,153 @@ dependencies = [ "wasite", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2421,13 +3913,22 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2454,13 +3955,39 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -2473,6 +4000,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -2485,6 +4018,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -2497,12 +4036,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -2515,6 +4066,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -2527,6 +4084,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -2539,6 +4102,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -2551,6 +4120,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.3" @@ -2566,6 +4141,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "yoke" version = "0.8.3" @@ -2585,7 +4172,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2606,7 +4193,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2626,7 +4213,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2635,6 +4222,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zerotrie" @@ -2666,7 +4267,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml index 52a57427..463a60e9 100644 --- a/crates/graphql-orm-backup/Cargo.toml +++ b/crates/graphql-orm-backup/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-backup" -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm-backup" @@ -9,6 +9,7 @@ description = "Backup and restore orchestration primitives for graphql-orm appli [features] default = ["local"] local = ["graphql-orm-storage/local"] +smb = ["dep:secrecy", "graphql-orm-storage/smb"] # Requires the host application to enable exactly one graphql-orm backend # feature (sqlite or postgres). orm = ["dep:graphql-orm"] @@ -18,9 +19,10 @@ async-trait = "0.1" bytes = "1" futures = "0.3" graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "510cd85d9fbc9ae60c7117a752370c590564949e", version = "0.6.1", optional = true, default-features = false } -graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm-storage.git", rev = "3c92c96bdb9f4d7b48481f2a5038c7ba73dde81c", version = "0.4.0", default-features = false } +graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm-storage.git", rev = "f1a1f06483d5fd3a0b8fd17f013b3ad4dd9849c5", version = "0.5.0", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" +secrecy = { version = "0.10", optional = true } sha2 = "0.10" thiserror = "2" uuid = { version = "1", features = ["serde", "v4"] } @@ -49,3 +51,8 @@ required-features = ["local"] name = "restore_snapshot" path = "tests/restore_snapshot.rs" required-features = ["local"] + +[[test]] +name = "smb_repository" +path = "tests/smb_repository.rs" +required-features = ["smb"] diff --git a/crates/graphql-orm-backup/MIGRATION.md b/crates/graphql-orm-backup/MIGRATION.md new file mode 100644 index 00000000..93b4096a --- /dev/null +++ b/crates/graphql-orm-backup/MIGRATION.md @@ -0,0 +1,58 @@ +# Migration Guide + +## 0.3.x to 0.4.0 + +The snapshot format and repository key layout are unchanged. Existing local, +S3-backed, and custom repositories can read and write the same snapshots. + +### Streaming trait methods + +`BackupRepository`, `BackupObjectIndex`, and `RestoreObjectSink` add streaming +methods. They have buffered default implementations, so existing trait +implementations remain source-compatible and do not need immediate changes. + +Providers that handle large stored objects should override: + +- `BackupRepository::put_blob_stream` +- `BackupRepository::put_blob_stream_if_absent` +- `BackupRepository::get_blob_stream` +- `BackupObjectIndex::load_object_stream` +- `RestoreObjectSink::restore_object_stream` + +`BlobStoreBackupRepository` and `BlobStoreRestoreObjectSink` already provide +native streaming overrides. Small manifests and compressed database table +payloads retain their buffered convenience APIs. + +### Native SMB repositories + +Enable native SMB without the local provider: + +```toml +graphql-orm-backup = { + version = "0.4.0", + default-features = false, + features = ["smb"] +} +``` + +Construct `graphql-orm-storage::SmbStorageBackend`, erase it to +`Arc`, and pass it to `BlobStoreBackupRepository`. Do not pass a +mount path or UNC string as native SMB configuration. Existing mounted-share +deployments can continue through `LocalBackupRepository`, preferably under an +explicit legacy provider name. + +Credentials remain host-owned runtime inputs. No manifest or repository data +migration is required. + +### Dependency identity and release order + +Storage 0.5.0 must be released or pinned before backup 0.4.0. Applications that +also use `graphql-orm-storage` directly must resolve the same canonical source +and reviewed revision as this crate; otherwise Rust treats the duplicated +`BlobStore` traits as different types. + +### Host authorization + +No `agql-auth` migration is required. Hosts continue to authorize +configuration, validation, backup, restore, delete, and prune operations and to +provide an internal trusted path for scheduled backups. diff --git a/crates/graphql-orm-backup/README.md b/crates/graphql-orm-backup/README.md index 5604eca6..50ba9da0 100644 --- a/crates/graphql-orm-backup/README.md +++ b/crates/graphql-orm-backup/README.md @@ -21,8 +21,9 @@ backup layout, checksums, repository writes, restore ordering, and operational s - zstd-compressed JSON Lines table and change payloads - content-addressed object blobs keyed by SHA-256 - local filesystem repository with path traversal protection -- `graphql-orm-storage::BlobStore` repository adapter for shared local/S3 provider code -- mounted SMB support through local filesystem semantics and `LocalBackupRepository::open_existing` +- `graphql-orm-storage::BlobStore` repository adapter for shared local/S3/SMB provider code +- feature-gated native SMB2/SMB3 plus separately named mounted-SMB legacy support +- streaming backup, verification, and restore for large referenced objects - bounded concurrent object writes and checksum verification - advisory repository writer lock for backup, compaction, and pruning operations - synthetic-full compaction through `compact_chain` @@ -36,7 +37,7 @@ backup layout, checksums, repository writes, restore ordering, and operational s graphql-orm-backup = { git = "https://github.com/Dastari/graphql-orm-backup.git", rev = "", - version = "0.3.1" + version = "0.4.0" } ``` @@ -52,13 +53,17 @@ The default `local` feature enables `LocalBackupRepository`. graphql-orm-backup = { git = "https://github.com/Dastari/graphql-orm-backup.git", rev = "", - version = "0.3.1", + version = "0.4.0", default-features = false } ``` Use `default-features = false` when providing only custom repository implementations. +Enable `smb` for native SMB2/SMB3 through `SmbStorageBackend` and +`BlobStoreBackupRepository`. This release pins the reviewed +`graphql-orm-storage` 0.5.0 revision used by the backup crate. + Enable the `orm` feature for the ready-made `graphql-orm` runtime adapters. The host application must also enable exactly one `graphql-orm` backend feature (`sqlite` or `postgres`). @@ -159,7 +164,10 @@ events, object metadata persistence, or cloud credentials. - [Restore semantics](docs/restore-semantics.md) - [Provider roadmap](docs/provider-roadmap.md) - [Cloud provider direction](docs/cloud-provider-direction.md) -- [SMB mounted repository guidance](docs/smb.md) +- [Native and mounted SMB](docs/smb.md) +- [Digitise native SMB integration](docs/digitise-native-smb.md) +- [Migration guide](MIGRATION.md) +- [Changelog](CHANGELOG.md) - [graphql-orm integration brief](docs/graphql-orm-agent-brief.md) ## Status @@ -171,7 +179,8 @@ only supply entity metadata and object-table column names. Provider code is shared through `graphql-orm-storage::BlobStore`. `LocalBackupRepository` is a thin wrapper over the storage crate's local blob backend, and `BlobStoreBackupRepository` can adapt any -storage blob provider, including S3-compatible storage from `graphql-orm-storage`. +storage blob provider, including S3-compatible and native SMB storage from +`graphql-orm-storage`. Client-side encryption and content-defined chunking are intentionally out of scope for the current crate. diff --git a/crates/graphql-orm-backup/docs/README.md b/crates/graphql-orm-backup/docs/README.md index a1679ce8..81eccbbe 100644 --- a/crates/graphql-orm-backup/docs/README.md +++ b/crates/graphql-orm-backup/docs/README.md @@ -9,8 +9,11 @@ page. - [Restore semantics](restore-semantics.md) - [Provider roadmap](provider-roadmap.md) - [Cloud provider direction](cloud-provider-direction.md) -- [SMB mounted repository guidance](smb.md) +- [Native and mounted SMB](smb.md) +- [Digitise native SMB integration](digitise-native-smb.md) - [Implementation plan](plan.md) - [graphql-orm integration brief](graphql-orm-agent-brief.md) +- [Migration guide](../MIGRATION.md) +- [Changelog](../CHANGELOG.md) The root [README](../README.md) is the best starting point if you are new to the project. diff --git a/crates/graphql-orm-backup/docs/cloud-provider-direction.md b/crates/graphql-orm-backup/docs/cloud-provider-direction.md index 8b807344..2227fb6b 100644 --- a/crates/graphql-orm-backup/docs/cloud-provider-direction.md +++ b/crates/graphql-orm-backup/docs/cloud-provider-direction.md @@ -5,8 +5,8 @@ while `graphql-orm-storage` is expected to grow shared cloud blob support. ## Shared Layer -The intended shared point is a future lower-level `graphql-orm-storage::BlobStore` -abstraction, not the current high-level primary-object storage APIs. +The shared point is the lower-level `graphql-orm-storage::BlobStore` +abstraction, not the high-level primary-object storage APIs. Backup repositories and primary object storage have different semantics: @@ -30,7 +30,8 @@ Mapping: - `BackupRepository::put_blob` calls `BlobStore::put_blob` - `BackupRepository::put_blob_if_absent` calls `BlobStore::put_blob_if_not_exists` -- `BackupRepository::get_blob` collects the blob stream into `bytes::Bytes` +- `BackupRepository::get_blob_stream` preserves native streaming; the buffered + `get_blob` convenience method remains for small metadata - `BackupRepository::blob_exists` calls `BlobStore::blob_exists` - `BackupRepository::list_blobs` calls `BlobStore::list_blobs` - `BackupRepository::delete_blob` calls `BlobStore::delete_blob` @@ -39,10 +40,12 @@ The adapter must apply and strip its configured repository prefix consistently. ## Provider Ownership -- S3-compatible and Azure Blob provider SDK integration should live in - `graphql-orm-storage` once `BlobStore` exists. +- S3-compatible and Azure Blob provider SDK integration belongs in + `graphql-orm-storage`; S3 already implements `BlobStore`, while Azure remains + an explicit unsupported placeholder. - Dropbox is backup-specific and belongs in this crate. -- SMB starts as mounted filesystem support through `LocalBackupRepository`. +- Native SMB lives in `graphql-orm-storage`; mounted SMB remains an explicitly + named legacy use of `LocalBackupRepository`. ## Current Rule diff --git a/crates/graphql-orm-backup/docs/digitise-native-smb.md b/crates/graphql-orm-backup/docs/digitise-native-smb.md new file mode 100644 index 00000000..7e330374 --- /dev/null +++ b/crates/graphql-orm-backup/docs/digitise-native-smb.md @@ -0,0 +1,31 @@ +# Digitise Native SMB Integration Brief + +Keep Digitise settings and policy in the host; reusable crates expose storage +and backup primitives only. + +- Replace `backup.smb.mountPath` for the native provider with server, port, + share, optional root prefix, username, optional domain/workgroup, minimum + dialect, signing/encryption requirements, and timeout fields. +- Persist the password through Digitise's encrypted secret-settings service. + GraphQL and non-secret exports expose only `passwordConfigured`. +- Build `SmbStorageConfig` from resolved settings and return + `Arc` containing `BlobStoreBackupRepository` over an + `Arc` `SmbStorageBackend`. +- If mounted compatibility is needed, retain it under a distinct name such as + `mounted_smb_legacy`; never interpret a local path as native SMB config. +- Apply the existing platform-admin `agql-auth` guard to credential changes, + validation, backup, restore, delete, and prune. Restore also retains explicit + confirmation, maintenance mode, and operator policy. +- Audit configuration changes, probes, backup, restore, verify, delete, and + prune without authentication material. +- Scheduled backups use an internal trusted service path; do not fabricate a + GraphQL user or expose an unguarded resolver. + +No `agql-auth` change is required. SMB authentication proves an identity to a +remote storage server, separate from application-user authentication. The host +already expresses platform-admin authorization and trusted internal execution. + +Digitise currently builds its backup object index only from +`LocalStorageBackend`. Native SMB as a destination does not fix that independent +restriction. Build the index from the configured `Arc` so full +backups can read referenced objects from any supported primary provider. diff --git a/crates/graphql-orm-backup/docs/plan.md b/crates/graphql-orm-backup/docs/plan.md index 459fcf41..220e6724 100644 --- a/crates/graphql-orm-backup/docs/plan.md +++ b/crates/graphql-orm-backup/docs/plan.md @@ -53,9 +53,11 @@ Create a reusable backup and restore crate for applications using `graphql-orm`. - Stream database exports instead of holding rows in memory. - Add zstd compression for future change files. -- Add a `graphql-orm-storage::BlobStore` backup repository adapter after the shared storage crate exposes that lower-level abstraction. -- Add S3 backup repository through the shared `BlobStore` adapter path. +- Improve provider-native pagination for repositories with very large key sets. - Add Azure Blob backup repository through the shared `BlobStore` adapter path. - Add Dropbox backup repository. -- Implement full restore after graphql-orm import lands. - Implement incremental backup after graphql-orm change journal lands. + +Implemented since the initial plan: `BlobStoreBackupRepository`, S3 reuse, +native SMB reuse, full restore, referenced-object restore, deletion, pruning, +locking, and streamed referenced-object transfer. diff --git a/crates/graphql-orm-backup/docs/provider-roadmap.md b/crates/graphql-orm-backup/docs/provider-roadmap.md index 3c1bbe45..3b794960 100644 --- a/crates/graphql-orm-backup/docs/provider-roadmap.md +++ b/crates/graphql-orm-backup/docs/provider-roadmap.md @@ -43,12 +43,9 @@ Expected configuration: ## Phase 4: SMB -Initial SMB support should be mounted filesystem support using `LocalBackupRepository`. - -Native SMB protocol support is future work. Mounts, credentials, reconnect -behavior, and OS-level permissions are managed outside this crate. Use -`LocalBackupRepository::open_existing` to validate that the mounted path exists -and is a directory before using it as a repository root. +Native SMB is implemented by `graphql-orm-storage::SmbStorageBackend` and +adapted through `BlobStoreBackupRepository`. Mounted filesystem support remains +an explicitly named legacy deployment option. ## Phase 5: Dropbox diff --git a/crates/graphql-orm-backup/docs/smb.md b/crates/graphql-orm-backup/docs/smb.md index 5aa2fa9f..6d753d0e 100644 --- a/crates/graphql-orm-backup/docs/smb.md +++ b/crates/graphql-orm-backup/docs/smb.md @@ -1,54 +1,55 @@ -# SMB Mounted Repository Guidance - -Native SMB protocol support is out of scope for the current crate. Use an -operating-system mounted SMB share as a filesystem path and point -`LocalBackupRepository` at that mount. - -## Responsibilities Outside This Crate - -The host system or application deployment must manage: - -- SMB mount creation -- credentials -- reconnect behavior -- network availability -- filesystem permissions -- available space monitoring - -## Repository Root Validation - -Use `LocalBackupRepository::open_existing` when a repository root should already -exist: - -```rust -use graphql_orm_backup::LocalBackupRepository; - -# async fn example() -> Result<(), graphql_orm_backup::BackupError> { -let repository = LocalBackupRepository::open_existing("/mnt/backups").await?; -# Ok(()) +# Native and Mounted SMB Repositories + +Native SMB uses `graphql-orm-storage::SmbStorageBackend` through +`BlobStoreBackupRepository`. Backup orchestration contains no SMB transport +code, and the snapshot layout is unchanged. + +```rust,no_run +use std::sync::Arc; +use graphql_orm_backup::{BackupRepository, BlobStoreBackupRepository}; +use graphql_orm_storage::{BlobStore, SmbStorageBackend, SmbStorageConfig}; +use secrecy::SecretString; + +# async fn example() -> Result, Box> { +let config = SmbStorageConfig::new( + "files.example.org", + "backups", + "backup-service", + SecretString::from("runtime secret"), +); +let store: Arc = Arc::new(SmbStorageBackend::connect(config).await?); +Ok(Arc::new(BlobStoreBackupRepository::new(store))) # } ``` -`open_existing` validates that the path exists and is a directory. It does not -create the mount or change permissions. +Enable the `smb` feature. Backup 0.4.0 pins the reviewed storage 0.5.0 Git +revision so downstream builds use the implementation exercised by this +release. + +See the repository [migration guide](../MIGRATION.md) for streaming trait +compatibility, mounted-provider migration choices, and dependency-source +identity requirements. -## Write Semantics +Native SMB supports create, list, verify, restore, delete, prune, and locking +through provider-independent APIs. Normal writes use remote temporary files, +flush, close, and rename. Locks use atomic SMB `FILE_CREATE` through +`BlobStore::put_blob_if_not_exists`. -The local repository writes blobs by creating parent directories, writing a -temporary file, and renaming it into place. Mounted SMB deployments must support -that workflow reliably enough for the application’s backup requirements. +Large referenced objects use streaming extensions on `BackupRepository`, +`BackupObjectIndex`, and `RestoreObjectSink`. Existing implementations remain +source-compatible through buffered defaults. Small manifests and compressed +table payloads remain buffered convenience values. -## Key Safety +Credentials are runtime inputs. Neither reusable crate persists them or writes +them to manifests, locks, diagnostics, logs, or configuration exports. +Application authorization remains a host responsibility. -Repository keys are validated before joining them to the root path. Keys reject: +## Mounted SMB legacy mode -- empty keys -- absolute paths -- empty path segments -- `.` -- `..` -- backslashes -- NUL bytes -- platform prefix components +An OS-mounted share can remain a separately named legacy provider using +`LocalBackupRepository::open_existing`. The deployment then owns mount creation, +credentials, reconnect behavior, permissions, and capacity monitoring. -`list_blobs("")` is intentionally allowed and lists the whole repository. +Local repository key validation still rejects empty/absolute keys, empty +segments, `.`, `..`, backslashes, NUL, and platform prefixes. Mounted storage +must reliably support same-directory temporary write and rename semantics. diff --git a/crates/graphql-orm-backup/src/backup.rs b/crates/graphql-orm-backup/src/backup.rs index 2efe95b2..8224189d 100644 --- a/crates/graphql-orm-backup/src/backup.rs +++ b/crates/graphql-orm-backup/src/backup.rs @@ -6,8 +6,11 @@ use std::{ use bytes::Bytes; use futures::{StreamExt, TryStreamExt, stream}; use serde::Serialize; +use sha2::{Digest, Sha256}; use uuid::Uuid; +use graphql_orm_storage::StorageByteStream; + use crate::{ BACKUP_FORMAT_VERSION, BackupChangeAction, BackupChangeExport, BackupError, BackupKind, BackupObjectIndex, BackupRepository, BackupRow, BackupSnapshotManifest, BackupTableExport, @@ -532,10 +535,35 @@ async fn write_object_entries( let owned_refs = object_refs.to_vec(); let mut object_entries = stream::iter(owned_refs.into_iter().enumerate()) .map(|(index, object)| async move { - let bytes = objects.load_object(&object).await?; - let actual = sha256_hex(&bytes); let content_key = object_content_key(&object.sha256_hex); + let source = objects.load_object_stream(&object).await?; + let hash_state = std::sync::Arc::new(std::sync::Mutex::new(Sha256::new())); + let stream_state = std::sync::Arc::clone(&hash_state); + let stream = source.into_inner().map(move |chunk| { + let chunk = chunk?; + let mut hasher = match stream_state.lock() { + Ok(hasher) => hasher, + Err(poisoned) => poisoned.into_inner(), + }; + hasher.update(&chunk); + Ok(chunk) + }); + let written = repository + .put_blob_stream_if_absent(&content_key, StorageByteStream::new(Box::pin(stream))) + .await?; + let actual = if written { + let hasher = match hash_state.lock() { + Ok(hasher) => hasher.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + }; + format!("{:x}", hasher.finalize()) + } else { + hash_object_source(objects.load_object_stream(&object).await?).await? + }; if actual != object.sha256_hex { + if written { + repository.delete_blob(&content_key).await?; + } return Err(BackupError::ChecksumMismatch { key: content_key, expected: object.sha256_hex.clone(), @@ -543,8 +571,6 @@ async fn write_object_entries( }); } - repository.put_blob_if_absent(&content_key, bytes).await?; - Ok(( index, ObjectBackupEntry { @@ -565,6 +591,15 @@ async fn write_object_entries( Ok(object_entries) } +async fn hash_object_source(source: StorageByteStream) -> Result { + let mut hasher = Sha256::new(); + let mut stream = source.into_inner(); + while let Some(chunk) = stream.next().await { + hasher.update(&chunk?); + } + Ok(format!("{:x}", hasher.finalize())) +} + async fn release_lock( repository: &dyn BackupRepository, lock: RepositoryLock, diff --git a/crates/graphql-orm-backup/src/object_index.rs b/crates/graphql-orm-backup/src/object_index.rs index 1539589c..5484cdeb 100644 --- a/crates/graphql-orm-backup/src/object_index.rs +++ b/crates/graphql-orm-backup/src/object_index.rs @@ -2,6 +2,8 @@ use async_trait::async_trait; use bytes::Bytes; use uuid::Uuid; +use graphql_orm_storage::StorageByteStream; + use crate::BackupError; #[async_trait] @@ -31,6 +33,20 @@ pub trait BackupObjectIndex: Send + Sync { /// /// Returns [`BackupError`] if the object bytes cannot be loaded. async fn load_object(&self, object: &BackupObjectRef) -> Result; + + /// Loads an object's bytes as a stream. + /// + /// Existing indexes remain compatible through the buffered default. + /// Implementations backed by a streaming object store should override this + /// method so large objects remain bounded in memory. + async fn load_object_stream( + &self, + object: &BackupObjectRef, + ) -> Result { + Ok(StorageByteStream::from_bytes( + self.load_object(object).await?, + )) + } } /// Object metadata returned by an application object index. diff --git a/crates/graphql-orm-backup/src/repository.rs b/crates/graphql-orm-backup/src/repository.rs index 487eea5e..134a5580 100644 --- a/crates/graphql-orm-backup/src/repository.rs +++ b/crates/graphql-orm-backup/src/repository.rs @@ -19,6 +19,16 @@ pub trait BackupRepository: Send + Sync { /// persist the blob. async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError>; + /// Streams a blob to a repository key. + /// + /// The default implementation preserves compatibility by collecting the + /// stream and delegating to [`BackupRepository::put_blob`]. Repositories + /// with native streaming support should override this method. + async fn put_blob_stream(&self, key: &str, body: StorageByteStream) -> Result<(), BackupError> { + self.put_blob(key, collect_storage_stream(body).await?) + .await + } + /// Writes a blob only when no blob exists at the key. /// /// Returns `true` when the blob was written and `false` when the key @@ -36,6 +46,19 @@ pub trait BackupRepository: Send + Sync { Ok(true) } + /// Streams a blob only when no blob exists at the key. + /// + /// The default implementation collects for compatibility. Provider-backed + /// repositories should override it with an atomic conditional write. + async fn put_blob_stream_if_absent( + &self, + key: &str, + body: StorageByteStream, + ) -> Result { + self.put_blob_if_absent(key, collect_storage_stream(body).await?) + .await + } + /// Reads a blob from a repository key. /// /// # Errors @@ -44,6 +67,13 @@ pub trait BackupRepository: Send + Sync { /// the backend cannot read it. async fn get_blob(&self, key: &str) -> Result; + /// Loads a blob as a byte stream. + /// + /// The default implementation wraps the existing buffered API. + async fn get_blob_stream(&self, key: &str) -> Result { + Ok(StorageByteStream::from_bytes(self.get_blob(key).await?)) + } + /// Checks whether a blob exists. /// /// # Errors @@ -157,6 +187,13 @@ impl BackupRepository for BlobStoreBackupRepository { Ok(()) } + async fn put_blob_stream(&self, key: &str, body: StorageByteStream) -> Result<(), BackupError> { + self.store + .put_blob(&self.apply_prefix(key), body, BlobPutOptions::default()) + .await?; + Ok(()) + } + async fn put_blob_if_absent(&self, key: &str, body: Bytes) -> Result { let outcome = self .store @@ -169,11 +206,27 @@ impl BackupRepository for BlobStoreBackupRepository { Ok(outcome.is_some()) } + async fn put_blob_stream_if_absent( + &self, + key: &str, + body: StorageByteStream, + ) -> Result { + let outcome = self + .store + .put_blob_if_not_exists(&self.apply_prefix(key), body, BlobPutOptions::default()) + .await?; + Ok(outcome.is_some()) + } + async fn get_blob(&self, key: &str) -> Result { let body = self.store.get_blob(&self.apply_prefix(key)).await?; Ok(collect_storage_stream(body.body).await?) } + async fn get_blob_stream(&self, key: &str) -> Result { + Ok(self.store.get_blob(&self.apply_prefix(key)).await?.body) + } + async fn blob_exists(&self, key: &str) -> Result { Ok(self.store.blob_exists(&self.apply_prefix(key)).await?) } diff --git a/crates/graphql-orm-backup/src/restore.rs b/crates/graphql-orm-backup/src/restore.rs index c80be68f..fb5a31a1 100644 --- a/crates/graphql-orm-backup/src/restore.rs +++ b/crates/graphql-orm-backup/src/restore.rs @@ -2,8 +2,10 @@ use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; -use graphql_orm_storage::{BlobPutOptions, BlobStore, StorageByteStream}; +use futures::StreamExt; +use graphql_orm_storage::{BlobPutOptions, BlobStore, StorageByteStream, collect_storage_stream}; use serde::de::DeserializeOwned; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::{ @@ -59,6 +61,19 @@ pub trait RestoreObjectSink: Send + Sync { object: BackupObjectRef, bytes: Bytes, ) -> Result<(), BackupError>; + + /// Restores one object from a stream. + /// + /// Existing sinks remain compatible through the buffered default. Native + /// streaming sinks should override this method. + async fn restore_object_stream( + &self, + object: BackupObjectRef, + body: StorageByteStream, + ) -> Result<(), BackupError> { + self.restore_object(object, collect_storage_stream(body).await?) + .await + } } /// [`RestoreObjectSink`] that writes object bytes back to a @@ -94,6 +109,23 @@ impl RestoreObjectSink for BlobStoreRestoreObjectSink { .await?; Ok(()) } + + async fn restore_object_stream( + &self, + object: BackupObjectRef, + body: StorageByteStream, + ) -> Result<(), BackupError> { + self.store + .put_blob( + &object.storage_key, + body, + BlobPutOptions { + content_type: object.mime_type.clone(), + }, + ) + .await?; + Ok(()) + } } impl RestoreContext { @@ -209,18 +241,40 @@ pub async fn restore_objects( sink: &dyn RestoreObjectSink, ) -> Result<(), BackupError> { for object in &manifest.objects { - let bytes = repository.get_blob(&object.content_key).await?; - let actual = crate::bytes_sha256_hex(&bytes); - if actual != object.sha256_hex { + let source = repository.get_blob_stream(&object.content_key).await?; + let expected = object.sha256_hex.clone(); + let key = object.content_key.clone(); + let state = std::sync::Arc::new(std::sync::Mutex::new(Sha256::new())); + let hash_state = std::sync::Arc::clone(&state); + let stream = source.into_inner().map(move |chunk| { + let chunk = chunk?; + let mut hasher = match hash_state.lock() { + Ok(hasher) => hasher, + Err(poisoned) => poisoned.into_inner(), + }; + hasher.update(&chunk); + Ok::<_, graphql_orm_storage::StorageError>(chunk) + }); + sink.restore_object_stream( + object_ref_from_entry(object), + StorageByteStream::new(Box::pin(stream)), + ) + .await?; + let actual = format!( + "{:x}", + match state.lock() { + Ok(hasher) => hasher.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } + .finalize() + ); + if actual != expected { return Err(BackupError::ChecksumMismatch { - key: object.content_key.clone(), - expected: object.sha256_hex.clone(), + key, + expected, actual, }); } - - sink.restore_object(object_ref_from_entry(object), bytes) - .await?; } Ok(()) diff --git a/crates/graphql-orm-backup/src/verify.rs b/crates/graphql-orm-backup/src/verify.rs index 0e42f935..e7a4e3fb 100644 --- a/crates/graphql-orm-backup/src/verify.rs +++ b/crates/graphql-orm-backup/src/verify.rs @@ -1,8 +1,9 @@ use crate::{ BackupError, BackupRepository, BackupSnapshotManifest, DEFAULT_OBJECT_CONCURRENCY, - manifest::sha256_hex, verify_manifest_checksum, + verify_manifest_checksum, }; use futures::{StreamExt, TryStreamExt, stream}; +use sha2::{Digest, Sha256}; #[derive(Clone, Debug, Eq, PartialEq)] /// Verification concurrency settings. @@ -112,8 +113,12 @@ async fn verify_blob_checksum( content_key: String, expected_sha256_hex: String, ) -> Result<(), BackupError> { - let bytes = repository.get_blob(&content_key).await?; - let actual = sha256_hex(&bytes); + let mut body = repository.get_blob_stream(&content_key).await?.into_inner(); + let mut hasher = Sha256::new(); + while let Some(chunk) = body.next().await { + hasher.update(&chunk?); + } + let actual = format!("{:x}", hasher.finalize()); if actual != expected_sha256_hex { return Err(BackupError::ChecksumMismatch { key: content_key, diff --git a/crates/graphql-orm-backup/tests/full_backup_creation.rs b/crates/graphql-orm-backup/tests/full_backup_creation.rs index 5f6fd1b1..bf767cb4 100644 --- a/crates/graphql-orm-backup/tests/full_backup_creation.rs +++ b/crates/graphql-orm-backup/tests/full_backup_creation.rs @@ -1,10 +1,14 @@ use std::{ collections::HashMap, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, }; use async_trait::async_trait; use bytes::Bytes; +use futures::{StreamExt, stream}; use graphql_orm_backup::{ BackupChangeExport, BackupCompression, BackupError, BackupObjectIndex, BackupObjectRef, BackupRepository, BackupRow, BackupTableExport, DATABASE_EXPORT_FORMAT, FullBackupRequest, @@ -12,6 +16,7 @@ use graphql_orm_backup::{ create_full_backup, database_table_key, decompress_payload, object_content_key, snapshot_manifest_key, verify_manifest_and_objects, verify_manifest_checksum, }; +use graphql_orm_storage::StorageByteStream; use serde_json::{Map, Value}; use uuid::Uuid; @@ -68,6 +73,42 @@ async fn create_full_backup_writes_tables_objects_and_manifest_last() { ); } +#[tokio::test] +async fn create_full_backup_streams_large_objects_in_bounded_chunks() { + const CHUNK_SIZE: usize = 128 * 1024; + const CHUNK_COUNT: usize = 128; + let chunk = vec![0x5a; CHUNK_SIZE]; + let mut hasher = sha2::Sha256::new(); + use sha2::Digest; + for _ in 0..CHUNK_COUNT { + hasher.update(&chunk); + } + let hash = format!("{:x}", hasher.finalize()); + let repository = StreamingProbeRepository::default(); + let database = MockDatabase::new(vec![BackupTableExport { + table_name: "empty".to_string(), + rows: Vec::new(), + }]); + let objects = StreamingObjectIndex { + object: BackupObjectRef { + object_id: object_id(), + storage_key: "objects/large.bin".to_string(), + sha256_hex: hash, + size_bytes: u64::try_from(CHUNK_SIZE * CHUNK_COUNT).expect("bounded size"), + mime_type: Some("application/octet-stream".to_string()), + }, + chunk_size: CHUNK_SIZE, + chunk_count: CHUNK_COUNT, + }; + + create_full_backup(&repository, &database, &objects, backup_request()) + .await + .expect("streaming full backup"); + + assert_eq!(repository.max_chunk.load(Ordering::SeqCst), CHUNK_SIZE); + assert_eq!(repository.chunk_count.load(Ordering::SeqCst), CHUNK_COUNT); +} + #[tokio::test] async fn create_full_backup_deduplicates_existing_object_blob() { let repository = RecordingRepository::default(); @@ -304,6 +345,50 @@ impl BackupRepository for RecordingRepository { } } +#[derive(Default)] +struct StreamingProbeRepository { + inner: RecordingRepository, + max_chunk: AtomicUsize, + chunk_count: AtomicUsize, +} + +#[async_trait] +impl BackupRepository for StreamingProbeRepository { + async fn put_blob(&self, key: &str, body: Bytes) -> Result<(), BackupError> { + self.inner.put_blob(key, body).await + } + + async fn put_blob_stream_if_absent( + &self, + _key: &str, + body: StorageByteStream, + ) -> Result { + let mut stream = body.into_inner(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + self.max_chunk.fetch_max(chunk.len(), Ordering::SeqCst); + self.chunk_count.fetch_add(1, Ordering::SeqCst); + } + Ok(true) + } + + async fn get_blob(&self, key: &str) -> Result { + self.inner.get_blob(key).await + } + + async fn blob_exists(&self, key: &str) -> Result { + self.inner.blob_exists(key).await + } + + async fn list_blobs(&self, prefix: &str) -> Result, BackupError> { + self.inner.list_blobs(prefix).await + } + + async fn delete_blob(&self, key: &str) -> Result<(), BackupError> { + self.inner.delete_blob(key).await + } +} + struct MockDatabase { tables: Vec, } @@ -393,3 +478,38 @@ impl BackupObjectIndex for MockObjectIndex { Ok(self.bytes[index].clone()) } } + +struct StreamingObjectIndex { + object: BackupObjectRef, + chunk_size: usize, + chunk_count: usize, +} + +#[async_trait] +impl BackupObjectIndex for StreamingObjectIndex { + async fn list_objects_for_full_backup(&self) -> Result, BackupError> { + Ok(vec![self.object.clone()]) + } + + async fn list_objects_for_incremental_backup( + &self, + _since_snapshot_id: Uuid, + ) -> Result, BackupError> { + Ok(Vec::new()) + } + + async fn load_object(&self, _object: &BackupObjectRef) -> Result { + Err(BackupError::UnsupportedOperation { + operation: "buffered load must not be used".to_string(), + }) + } + + async fn load_object_stream( + &self, + _object: &BackupObjectRef, + ) -> Result { + let chunk_size = self.chunk_size; + let chunks = (0..self.chunk_count).map(move |_| Ok(Bytes::from(vec![0x5a; chunk_size]))); + Ok(StorageByteStream::new(Box::pin(stream::iter(chunks)))) + } +} diff --git a/crates/graphql-orm-backup/tests/smb_repository.rs b/crates/graphql-orm-backup/tests/smb_repository.rs new file mode 100644 index 00000000..945fb6a4 --- /dev/null +++ b/crates/graphql-orm-backup/tests/smb_repository.rs @@ -0,0 +1,259 @@ +use std::{ + env, + sync::{Arc, Mutex}, + time::Duration, +}; + +use async_trait::async_trait; +use bytes::Bytes; +use graphql_orm_backup::{ + BackupChangeExport, BackupError, BackupObjectIndex, BackupObjectRef, BackupRepository, + BackupRow, BackupTableExport, BlobStoreBackupRepository, BlobStoreRestoreObjectSink, + FullBackupRequest, GraphqlOrmBackupAdapter, GraphqlOrmBackupSchema, KeepPolicy, RepositoryLock, + RepositoryLockOptions, RestoreContext, bytes_sha256_hex, create_full_backup, delete_snapshot, + load_manifest, prune, restore_objects, restore_snapshot, verify_manifest_and_objects, +}; +use graphql_orm_storage::{ + BlobStore, SmbDialect, SmbStorageBackend, SmbStorageConfig, StorageByteStream, + collect_storage_stream, +}; +use secrecy::SecretString; +use serde_json::{Map, Value}; +use uuid::Uuid; + +fn config(prefix: String) -> SmbStorageConfig { + let mut config = SmbStorageConfig::new( + env::var("SMB_TEST_SERVER").unwrap_or_else(|_| "127.0.0.1".to_string()), + env::var("SMB_TEST_SHARE").unwrap_or_else(|_| "backups".to_string()), + env::var("SMB_TEST_USERNAME").unwrap_or_else(|_| "backup".to_string()), + SecretString::from( + env::var("SMB_TEST_PASSWORD").unwrap_or_else(|_| "BackupTest-42!".to_string()), + ), + ); + config.port = env::var("SMB_TEST_PORT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(1445); + config.domain = env::var("SMB_TEST_DOMAIN").ok(); + config.root_prefix = Some(prefix); + config.min_dialect = SmbDialect::Smb2_1; + config.connect_timeout = Duration::from_secs(5); + config.operation_timeout = Duration::from_secs(30); + config +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires SMB_TEST_* or the documented Samba container"] +async fn full_smb_snapshot_lifecycle_and_locking() { + let test_root = format!("graphql-orm-backup-tests/{}", Uuid::new_v4()); + let repository_store: Arc = Arc::new( + SmbStorageBackend::connect(config(format!("{test_root}/repository"))) + .await + .expect("repository SMB connect"), + ); + let repository = BlobStoreBackupRepository::new(repository_store); + let restore_store: Arc = Arc::new( + SmbStorageBackend::connect(config(format!("{test_root}/restored-objects"))) + .await + .expect("restore SMB connect"), + ); + + let object_bytes = Bytes::from(vec![0x6d; 2 * 1024 * 1024]); + let object_hash = bytes_sha256_hex(&object_bytes); + let object = BackupObjectRef { + object_id: Uuid::new_v4(), + storage_key: "originals/large-object.bin".to_string(), + sha256_hex: object_hash, + size_bytes: object_bytes.len() as u64, + mime_type: Some("application/octet-stream".to_string()), + }; + let objects = TestObjects { + object, + bytes: object_bytes, + }; + let database = TestDatabase::default(); + let first_id = Uuid::new_v4(); + let first = create_full_backup(&repository, &database, &objects, request(first_id, 1)) + .await + .expect("create first snapshot"); + let loaded = load_manifest(&repository, first_id) + .await + .expect("load first manifest"); + assert_eq!(loaded, first.manifest); + verify_manifest_and_objects(&repository, &loaded) + .await + .expect("verify first snapshot"); + + restore_snapshot( + &repository, + &database, + first_id, + RestoreContext::empty_database(), + ) + .await + .expect("restore database payload"); + assert_eq!(database.restored.lock().expect("restored lock").len(), 1); + restore_objects( + &repository, + &loaded, + &BlobStoreRestoreObjectSink::new(Arc::clone(&restore_store)), + ) + .await + .expect("restore stored object"); + let restored = restore_store + .get_blob("originals/large-object.bin") + .await + .expect("load restored object"); + assert_eq!( + collect_storage_stream(restored.body) + .await + .expect("collect restored object"), + objects.bytes + ); + + let second_id = Uuid::new_v4(); + create_full_backup(&repository, &database, &objects, request(second_id, 2)) + .await + .expect("create second snapshot"); + let pruned = prune( + &repository, + &KeepPolicy { + keep_last: 1, + lock: RepositoryLockOptions::default(), + }, + ) + .await + .expect("prune old snapshot"); + assert_eq!(pruned.deleted_snapshots, 1); + assert!( + !repository + .blob_exists(&format!("snapshots/{first_id}/manifest.json")) + .await + .expect("first manifest exists") + ); + + let left_options = RepositoryLockOptions::default(); + let right_options = RepositoryLockOptions::default(); + let (left, right) = tokio::join!( + RepositoryLock::acquire(&repository, &left_options), + RepositoryLock::acquire(&repository, &right_options) + ); + let acquired = match (left, right) { + (Ok(lock), Err(BackupError::RepositoryLocked { .. })) + | (Err(BackupError::RepositoryLocked { .. }), Ok(lock)) => lock, + other => panic!("exactly one repository lock must succeed: {other:?}"), + }; + acquired.release(&repository).await.expect("release lock"); + + let deleted = delete_snapshot(&repository, second_id, &RepositoryLockOptions::default()) + .await + .expect("delete second snapshot"); + assert_eq!(deleted.retained_snapshots, 0); +} + +fn request(snapshot_id: Uuid, created_at: i64) -> FullBackupRequest { + FullBackupRequest { + snapshot_id, + created_at, + app_id: "smb-integration-test".to_string(), + app_version: "1.0.0".to_string(), + } +} + +#[derive(Default)] +struct TestDatabase { + restored: Mutex>, +} + +#[async_trait] +impl GraphqlOrmBackupAdapter for TestDatabase { + async fn schema_snapshot(&self) -> Result { + Ok(GraphqlOrmBackupSchema { + backend: "sqlite".to_string(), + migration_version: "20260713000000".to_string(), + schema_hash: "smb-test-schema".to_string(), + }) + } + + async fn export_full(&self) -> Result, BackupError> { + let mut fields = Map::new(); + fields.insert("name".to_string(), Value::String("SMB test".to_string())); + Ok(vec![BackupTableExport { + table_name: "items".to_string(), + rows: vec![BackupRow { + table_name: "items".to_string(), + primary_key: "1".to_string(), + row_hash: bytes_sha256_hex(b"1"), + values: fields, + }], + }]) + } + + async fn export_incremental( + &self, + _parent_snapshot_id: Uuid, + ) -> Result, BackupError> { + Err(BackupError::UnsupportedOperation { + operation: "incremental test export".to_string(), + }) + } + + async fn restore_target_is_empty(&self) -> Result { + Ok(true) + } + + async fn restore_full( + &self, + export: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + self.restored.lock().expect("restored lock").extend(export); + Ok(()) + } + + async fn restore_incremental( + &self, + _changes: Vec, + _context: RestoreContext, + ) -> Result<(), BackupError> { + Ok(()) + } +} + +struct TestObjects { + object: BackupObjectRef, + bytes: Bytes, +} + +#[async_trait] +impl BackupObjectIndex for TestObjects { + async fn list_objects_for_full_backup(&self) -> Result, BackupError> { + Ok(vec![self.object.clone()]) + } + + async fn list_objects_for_incremental_backup( + &self, + _since_snapshot_id: Uuid, + ) -> Result, BackupError> { + Ok(Vec::new()) + } + + async fn load_object(&self, _object: &BackupObjectRef) -> Result { + Ok(self.bytes.clone()) + } + + async fn load_object_stream( + &self, + _object: &BackupObjectRef, + ) -> Result { + let chunks = self + .bytes + .chunks(128 * 1024) + .map(Bytes::copy_from_slice) + .map(Ok) + .collect::>(); + Ok(StorageByteStream::new(Box::pin(futures::stream::iter( + chunks, + )))) + } +} From aed51ea8736675142412ae315a92ddc20aab9f47 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 13 Jul 2026 16:40:57 +1000 Subject: [PATCH 024/108] Add auth, ORM, and Rust skill docs - Add `agql-auth` and `graphql-orm-macros` skill guidance - Add the `rust-skills` rule set and supporting docs --- .../.agents/skills/agql-auth/SKILL.md | 162 + .../skills/graphql-orm-macros/SKILL.md | 134 + .../.agents/skills/rust-skills/AGENTS.md | 335 ++ .../.agents/skills/rust-skills/CLAUDE.md | 335 ++ .../.agents/skills/rust-skills/LICENSE | 21 + .../.agents/skills/rust-skills/README.md | 196 + .../.agents/skills/rust-skills/SKILL.md | 335 ++ .../rust-skills/rules/anti-clone-excessive.md | 124 + .../rules/anti-collect-intermediate.md | 131 + .../rust-skills/rules/anti-empty-catch.md | 132 + .../rust-skills/rules/anti-expect-lazy.md | 95 + .../rust-skills/rules/anti-format-hot-path.md | 141 + .../rust-skills/rules/anti-index-over-iter.md | 125 + .../rules/anti-lock-across-await.md | 127 + .../rules/anti-over-abstraction.md | 120 + .../rust-skills/rules/anti-panic-expected.md | 131 + .../rules/anti-premature-optimize.md | 156 + .../rust-skills/rules/anti-string-for-str.md | 122 + .../rust-skills/rules/anti-stringly-typed.md | 167 + .../rust-skills/rules/anti-type-erasure.md | 134 + .../rust-skills/rules/anti-unwrap-abuse.md | 143 + .../rust-skills/rules/anti-vec-for-slice.md | 121 + .../rust-skills/rules/api-builder-must-use.md | 143 + .../rust-skills/rules/api-builder-pattern.md | 187 + .../rust-skills/rules/api-common-traits.md | 165 + .../rust-skills/rules/api-default-impl.md | 177 + .../rust-skills/rules/api-extension-trait.md | 163 + .../rust-skills/rules/api-from-not-into.md | 146 + .../rust-skills/rules/api-impl-asref.md | 142 + .../skills/rust-skills/rules/api-impl-into.md | 160 + .../skills/rust-skills/rules/api-must-use.md | 125 + .../rust-skills/rules/api-newtype-safety.md | 162 + .../rust-skills/rules/api-non-exhaustive.md | 177 + .../rules/api-parse-dont-validate.md | 184 + .../rust-skills/rules/api-sealed-trait.md | 168 + .../rust-skills/rules/api-serde-optional.md | 182 + .../skills/rust-skills/rules/api-typestate.md | 199 + .../rules/async-bounded-channel.md | 175 + .../rules/async-broadcast-pubsub.md | 185 + .../rules/async-cancellation-token.md | 203 + .../rules/async-clone-before-await.md | 171 + .../rust-skills/rules/async-join-parallel.md | 158 + .../rules/async-joinset-structured.md | 195 + .../rust-skills/rules/async-mpsc-queue.md | 171 + .../rust-skills/rules/async-no-lock-await.md | 156 + .../rules/async-oneshot-response.md | 191 + .../rust-skills/rules/async-select-racing.md | 198 + .../rust-skills/rules/async-spawn-blocking.md | 154 + .../rust-skills/rules/async-tokio-fs.md | 167 + .../rust-skills/rules/async-tokio-runtime.md | 169 + .../rust-skills/rules/async-try-join.md | 172 + .../rust-skills/rules/async-watch-latest.md | 189 + .../rust-skills/rules/doc-all-public.md | 113 + .../rust-skills/rules/doc-cargo-metadata.md | 147 + .../rust-skills/rules/doc-errors-section.md | 122 + .../rust-skills/rules/doc-examples-section.md | 161 + .../rust-skills/rules/doc-hidden-setup.md | 149 + .../rust-skills/rules/doc-intra-links.md | 138 + .../rust-skills/rules/doc-link-types.md | 169 + .../rust-skills/rules/doc-module-inner.md | 116 + .../rust-skills/rules/doc-panics-section.md | 128 + .../rust-skills/rules/doc-question-mark.md | 136 + .../rust-skills/rules/doc-safety-section.md | 131 + .../rust-skills/rules/err-anyhow-app.md | 179 + .../rust-skills/rules/err-context-chain.md | 144 + .../rust-skills/rules/err-custom-type.md | 152 + .../rust-skills/rules/err-doc-errors.md | 145 + .../rust-skills/rules/err-expect-bugs-only.md | 133 + .../skills/rust-skills/rules/err-from-impl.md | 152 + .../rust-skills/rules/err-lowercase-msg.md | 124 + .../rust-skills/rules/err-no-unwrap-prod.md | 115 + .../rust-skills/rules/err-question-mark.md | 151 + .../rules/err-result-over-panic.md | 130 + .../rust-skills/rules/err-source-chain.md | 155 + .../rust-skills/rules/err-thiserror-lib.md | 171 + .../rust-skills/rules/lint-cargo-metadata.md | 138 + .../rules/lint-deny-correctness.md | 107 + .../rust-skills/rules/lint-missing-docs.md | 154 + .../rules/lint-pedantic-selective.md | 118 + .../rust-skills/rules/lint-rustfmt-check.md | 157 + .../rust-skills/rules/lint-unsafe-doc.md | 133 + .../rust-skills/rules/lint-warn-complexity.md | 131 + .../rust-skills/rules/lint-warn-perf.md | 136 + .../rust-skills/rules/lint-warn-style.md | 135 + .../rust-skills/rules/lint-warn-suspicious.md | 122 + .../rust-skills/rules/lint-workspace-lints.md | 172 + .../rust-skills/rules/mem-arena-allocator.md | 168 + .../skills/rust-skills/rules/mem-arrayvec.md | 142 + .../rust-skills/rules/mem-assert-type-size.md | 168 + .../rust-skills/rules/mem-avoid-format.md | 147 + .../rules/mem-box-large-variant.md | 158 + .../rust-skills/rules/mem-boxed-slice.md | 139 + .../rust-skills/rules/mem-clone-from.md | 147 + .../rust-skills/rules/mem-compact-string.md | 149 + .../rules/mem-reuse-collections.md | 174 + .../rust-skills/rules/mem-smaller-integers.md | 159 + .../skills/rust-skills/rules/mem-smallvec.md | 138 + .../skills/rust-skills/rules/mem-thinvec.md | 142 + .../rust-skills/rules/mem-with-capacity.md | 156 + .../rules/mem-write-over-format.md | 172 + .../skills/rust-skills/rules/mem-zero-copy.md | 164 + .../rust-skills/rules/name-acronym-word.md | 99 + .../skills/rust-skills/rules/name-as-free.md | 104 + .../rules/name-consts-screaming.md | 94 + .../rust-skills/rules/name-crate-no-rs.md | 78 + .../rust-skills/rules/name-funcs-snake.md | 76 + .../rust-skills/rules/name-into-ownership.md | 123 + .../rust-skills/rules/name-is-has-bool.md | 127 + .../rust-skills/rules/name-iter-convention.md | 129 + .../rust-skills/rules/name-iter-method.md | 131 + .../rust-skills/rules/name-iter-type-match.md | 142 + .../rust-skills/rules/name-lifetime-short.md | 86 + .../rust-skills/rules/name-no-get-prefix.md | 154 + .../rust-skills/rules/name-to-expensive.md | 118 + .../rules/name-type-param-single.md | 92 + .../rust-skills/rules/name-types-camel.md | 65 + .../rust-skills/rules/name-variants-camel.md | 101 + .../rust-skills/rules/opt-bounds-check.md | 161 + .../rust-skills/rules/opt-cache-friendly.md | 187 + .../rust-skills/rules/opt-codegen-units.md | 142 + .../rust-skills/rules/opt-cold-unlikely.md | 152 + .../rules/opt-inline-always-rare.md | 141 + .../rules/opt-inline-never-cold.md | 181 + .../rust-skills/rules/opt-inline-small.md | 160 + .../rust-skills/rules/opt-likely-hint.md | 171 + .../rust-skills/rules/opt-lto-release.md | 130 + .../rust-skills/rules/opt-pgo-profile.md | 167 + .../rust-skills/rules/opt-simd-portable.md | 144 + .../rust-skills/rules/opt-target-cpu.md | 154 + .../rust-skills/rules/own-arc-shared.md | 141 + .../rules/own-borrow-over-clone.md | 95 + .../rust-skills/rules/own-clone-explicit.md | 135 + .../rust-skills/rules/own-copy-small.md | 124 + .../rust-skills/rules/own-cow-conditional.md | 135 + .../rust-skills/rules/own-lifetime-elision.md | 134 + .../rust-skills/rules/own-move-large.md | 134 + .../rust-skills/rules/own-mutex-interior.md | 105 + .../rust-skills/rules/own-rc-single-thread.md | 65 + .../rust-skills/rules/own-refcell-interior.md | 97 + .../rust-skills/rules/own-rwlock-readers.md | 122 + .../rust-skills/rules/own-slice-over-vec.md | 119 + .../rust-skills/rules/perf-black-box-bench.md | 153 + .../rust-skills/rules/perf-chain-avoid.md | 136 + .../rust-skills/rules/perf-collect-into.md | 133 + .../rust-skills/rules/perf-collect-once.md | 120 + .../rust-skills/rules/perf-drain-reuse.md | 137 + .../rust-skills/rules/perf-entry-api.md | 134 + .../rust-skills/rules/perf-extend-batch.md | 150 + .../rust-skills/rules/perf-iter-lazy.md | 123 + .../rust-skills/rules/perf-iter-over-index.md | 113 + .../rust-skills/rules/perf-profile-first.md | 175 + .../rust-skills/rules/perf-release-profile.md | 149 + .../skills/rust-skills/rules/proj-bin-dir.md | 142 + .../rust-skills/rules/proj-flat-small.md | 133 + .../rust-skills/rules/proj-lib-main-split.md | 148 + .../rust-skills/rules/proj-mod-by-feature.md | 130 + .../rust-skills/rules/proj-mod-rs-dir.md | 120 + .../rust-skills/rules/proj-prelude-module.md | 155 + .../rules/proj-pub-crate-internal.md | 139 + .../rules/proj-pub-super-parent.md | 135 + .../rules/proj-pub-use-reexport.md | 162 + .../rust-skills/rules/proj-workspace-deps.md | 186 + .../rust-skills/rules/proj-workspace-large.md | 162 + .../rules/test-arrange-act-assert.md | 160 + .../rust-skills/rules/test-cfg-test-module.md | 151 + .../rust-skills/rules/test-criterion-bench.md | 171 + .../rules/test-descriptive-names.md | 142 + .../rules/test-doctest-examples.md | 168 + .../rust-skills/rules/test-fixture-raii.md | 151 + .../rust-skills/rules/test-integration-dir.md | 144 + .../rust-skills/rules/test-mock-traits.md | 189 + .../rust-skills/rules/test-mockall-mocking.md | 226 + .../rules/test-proptest-properties.md | 161 + .../rust-skills/rules/test-should-panic.md | 130 + .../rust-skills/rules/test-tokio-async.md | 154 + .../rust-skills/rules/test-use-super.md | 127 + .../rust-skills/rules/type-enum-states.md | 154 + .../rust-skills/rules/type-generic-bounds.md | 142 + .../rust-skills/rules/type-never-diverge.md | 146 + .../rust-skills/rules/type-newtype-ids.md | 160 + .../rules/type-newtype-validated.md | 159 + .../rust-skills/rules/type-no-stringly.md | 144 + .../rust-skills/rules/type-option-nullable.md | 137 + .../rust-skills/rules/type-phantom-marker.md | 188 + .../rules/type-repr-transparent.md | 143 + .../rust-skills/rules/type-result-fallible.md | 131 + .../graphql-orm-ai/.github/workflows/ci.yml | 74 + crates/graphql-orm-ai/.gitignore | 8 + crates/graphql-orm-ai/AGENTS.md | 73 + crates/graphql-orm-ai/CHANGELOG.md | 70 + crates/graphql-orm-ai/Cargo.lock | 4459 +++++++++++++++++ crates/graphql-orm-ai/Cargo.toml | 53 + crates/graphql-orm-ai/MIGRATION.md | 84 + crates/graphql-orm-ai/README.md | 211 + crates/graphql-orm-ai/docs/README.md | 17 + crates/graphql-orm-ai/docs/architecture.md | 49 + crates/graphql-orm-ai/docs/development.md | 55 + crates/graphql-orm-ai/docs/getting-started.md | 40 + .../docs/implementation-status.md | 183 + crates/graphql-orm-ai/docs/plan.md | 2107 ++++++++ crates/graphql-orm-ai/docs/release-process.md | 44 + crates/graphql-orm-ai/docs/security.md | 49 + .../scripts/check-release-policy.sh | 71 + crates/graphql-orm-ai/src/access.rs | 119 + crates/graphql-orm-ai/src/approvals.rs | 216 + crates/graphql-orm-ai/src/budget.rs | 280 ++ crates/graphql-orm-ai/src/configuration.rs | 388 ++ .../graphql-orm-ai/src/content_protection.rs | 167 + crates/graphql-orm-ai/src/data.rs | 46 + crates/graphql-orm-ai/src/disclosure.rs | 321 ++ crates/graphql-orm-ai/src/domain.rs | 70 + crates/graphql-orm-ai/src/egress.rs | 316 ++ crates/graphql-orm-ai/src/error.rs | 78 + crates/graphql-orm-ai/src/execution.rs | 395 ++ crates/graphql-orm-ai/src/lib.rs | 91 + .../graphql-orm-ai/src/orm_configuration.rs | 869 ++++ crates/graphql-orm-ai/src/orm_sessions.rs | 1072 ++++ .../graphql-orm-ai/src/orm_subscriptions.rs | 198 + crates/graphql-orm-ai/src/persistence.rs | 1564 ++++++ crates/graphql-orm-ai/src/proposals.rs | 252 + crates/graphql-orm-ai/src/provider.rs | 547 ++ crates/graphql-orm-ai/src/providers.rs | 11 + crates/graphql-orm-ai/src/providers/mock.rs | 80 + crates/graphql-orm-ai/src/providers/openai.rs | 995 ++++ crates/graphql-orm-ai/src/restore.rs | 234 + crates/graphql-orm-ai/src/run_state.rs | 175 + crates/graphql-orm-ai/src/runtime.rs | 482 ++ crates/graphql-orm-ai/src/secrets.rs | 161 + crates/graphql-orm-ai/src/sessions.rs | 487 ++ crates/graphql-orm-ai/src/subscriptions.rs | 96 + crates/graphql-orm-ai/src/tools.rs | 632 +++ .../tests/configuration_graphql.rs | 156 + crates/graphql-orm-ai/tests/graphql_naming.rs | 27 + .../graphql-orm-ai/tests/orm_configuration.rs | 350 ++ crates/graphql-orm-ai/tests/orm_sessions.rs | 278 + .../graphql-orm-ai/tests/orm_subscriptions.rs | 231 + .../tests/project_boundaries.rs | 50 + .../tests/provider_and_content_security.rs | 232 + .../graphql-orm-ai/tests/run_and_restore.rs | 105 + .../graphql-orm-ai/tests/runtime_contracts.rs | 412 ++ crates/graphql-orm-ai/tests/schema_module.rs | 32 + .../tests/security_contracts.rs | 433 ++ .../graphql-orm-ai/tests/session_graphql.rs | 249 + 243 files changed, 47980 insertions(+) create mode 100644 crates/graphql-orm-ai/.agents/skills/agql-auth/SKILL.md create mode 100644 crates/graphql-orm-ai/.agents/skills/graphql-orm-macros/SKILL.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/AGENTS.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/CLAUDE.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/LICENSE create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/README.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/SKILL.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-clone-excessive.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-collect-intermediate.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-empty-catch.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-expect-lazy.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-format-hot-path.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-index-over-iter.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-lock-across-await.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-over-abstraction.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-panic-expected.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-premature-optimize.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-string-for-str.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-stringly-typed.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-type-erasure.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-vec-for-slice.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-must-use.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-pattern.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-common-traits.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-default-impl.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-extension-trait.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-from-not-into.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-asref.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-into.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-must-use.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-newtype-safety.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-non-exhaustive.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-parse-dont-validate.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-sealed-trait.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-serde-optional.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-typestate.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-bounded-channel.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-cancellation-token.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-clone-before-await.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-join-parallel.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-joinset-structured.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-mpsc-queue.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-no-lock-await.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-oneshot-response.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-select-racing.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-spawn-blocking.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-fs.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-runtime.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-try-join.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-watch-latest.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-all-public.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-cargo-metadata.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-errors-section.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-examples-section.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-hidden-setup.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-intra-links.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-link-types.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-module-inner.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-panics-section.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-question-mark.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-safety-section.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-anyhow-app.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-context-chain.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-custom-type.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-doc-errors.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-expect-bugs-only.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-from-impl.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-lowercase-msg.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-question-mark.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-result-over-panic.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-source-chain.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-thiserror-lib.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-cargo-metadata.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-deny-correctness.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-missing-docs.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-pedantic-selective.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-rustfmt-check.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-unsafe-doc.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-complexity.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-perf.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-style.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-suspicious.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-workspace-lints.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arena-allocator.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arrayvec.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-assert-type-size.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-avoid-format.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-box-large-variant.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-boxed-slice.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-clone-from.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-compact-string.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-reuse-collections.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smaller-integers.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smallvec.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-thinvec.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-with-capacity.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-write-over-format.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-zero-copy.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-acronym-word.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-as-free.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-consts-screaming.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-crate-no-rs.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-funcs-snake.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-into-ownership.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-is-has-bool.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-convention.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-method.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-type-match.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-lifetime-short.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-no-get-prefix.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-to-expensive.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-type-param-single.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-types-camel.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-variants-camel.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-bounds-check.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cache-friendly.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-codegen-units.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cold-unlikely.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-always-rare.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-never-cold.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-small.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-likely-hint.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-lto-release.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-pgo-profile.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-simd-portable.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-target-cpu.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-arc-shared.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-borrow-over-clone.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-clone-explicit.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-copy-small.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-cow-conditional.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-lifetime-elision.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-move-large.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-mutex-interior.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rc-single-thread.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-refcell-interior.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rwlock-readers.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-slice-over-vec.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-black-box-bench.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-chain-avoid.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-into.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-once.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-drain-reuse.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-entry-api.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-extend-batch.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-lazy.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-over-index.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-profile-first.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-release-profile.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-bin-dir.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-flat-small.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-lib-main-split.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-by-feature.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-rs-dir.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-prelude-module.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-crate-internal.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-super-parent.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-use-reexport.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-deps.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-large.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-arrange-act-assert.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-cfg-test-module.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-criterion-bench.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-descriptive-names.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-doctest-examples.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-fixture-raii.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-integration-dir.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mock-traits.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mockall-mocking.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-proptest-properties.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-should-panic.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-tokio-async.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-use-super.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-enum-states.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-generic-bounds.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-never-diverge.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-ids.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-validated.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-no-stringly.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-option-nullable.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-phantom-marker.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-repr-transparent.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-result-fallible.md create mode 100644 crates/graphql-orm-ai/.github/workflows/ci.yml create mode 100644 crates/graphql-orm-ai/.gitignore create mode 100644 crates/graphql-orm-ai/AGENTS.md create mode 100644 crates/graphql-orm-ai/CHANGELOG.md create mode 100644 crates/graphql-orm-ai/Cargo.lock create mode 100644 crates/graphql-orm-ai/Cargo.toml create mode 100644 crates/graphql-orm-ai/MIGRATION.md create mode 100644 crates/graphql-orm-ai/README.md create mode 100644 crates/graphql-orm-ai/docs/README.md create mode 100644 crates/graphql-orm-ai/docs/architecture.md create mode 100644 crates/graphql-orm-ai/docs/development.md create mode 100644 crates/graphql-orm-ai/docs/getting-started.md create mode 100644 crates/graphql-orm-ai/docs/implementation-status.md create mode 100644 crates/graphql-orm-ai/docs/plan.md create mode 100644 crates/graphql-orm-ai/docs/release-process.md create mode 100644 crates/graphql-orm-ai/docs/security.md create mode 100755 crates/graphql-orm-ai/scripts/check-release-policy.sh create mode 100644 crates/graphql-orm-ai/src/access.rs create mode 100644 crates/graphql-orm-ai/src/approvals.rs create mode 100644 crates/graphql-orm-ai/src/budget.rs create mode 100644 crates/graphql-orm-ai/src/configuration.rs create mode 100644 crates/graphql-orm-ai/src/content_protection.rs create mode 100644 crates/graphql-orm-ai/src/data.rs create mode 100644 crates/graphql-orm-ai/src/disclosure.rs create mode 100644 crates/graphql-orm-ai/src/domain.rs create mode 100644 crates/graphql-orm-ai/src/egress.rs create mode 100644 crates/graphql-orm-ai/src/error.rs create mode 100644 crates/graphql-orm-ai/src/execution.rs create mode 100644 crates/graphql-orm-ai/src/lib.rs create mode 100644 crates/graphql-orm-ai/src/orm_configuration.rs create mode 100644 crates/graphql-orm-ai/src/orm_sessions.rs create mode 100644 crates/graphql-orm-ai/src/orm_subscriptions.rs create mode 100644 crates/graphql-orm-ai/src/persistence.rs create mode 100644 crates/graphql-orm-ai/src/proposals.rs create mode 100644 crates/graphql-orm-ai/src/provider.rs create mode 100644 crates/graphql-orm-ai/src/providers.rs create mode 100644 crates/graphql-orm-ai/src/providers/mock.rs create mode 100644 crates/graphql-orm-ai/src/providers/openai.rs create mode 100644 crates/graphql-orm-ai/src/restore.rs create mode 100644 crates/graphql-orm-ai/src/run_state.rs create mode 100644 crates/graphql-orm-ai/src/runtime.rs create mode 100644 crates/graphql-orm-ai/src/secrets.rs create mode 100644 crates/graphql-orm-ai/src/sessions.rs create mode 100644 crates/graphql-orm-ai/src/subscriptions.rs create mode 100644 crates/graphql-orm-ai/src/tools.rs create mode 100644 crates/graphql-orm-ai/tests/configuration_graphql.rs create mode 100644 crates/graphql-orm-ai/tests/graphql_naming.rs create mode 100644 crates/graphql-orm-ai/tests/orm_configuration.rs create mode 100644 crates/graphql-orm-ai/tests/orm_sessions.rs create mode 100644 crates/graphql-orm-ai/tests/orm_subscriptions.rs create mode 100644 crates/graphql-orm-ai/tests/project_boundaries.rs create mode 100644 crates/graphql-orm-ai/tests/provider_and_content_security.rs create mode 100644 crates/graphql-orm-ai/tests/run_and_restore.rs create mode 100644 crates/graphql-orm-ai/tests/runtime_contracts.rs create mode 100644 crates/graphql-orm-ai/tests/schema_module.rs create mode 100644 crates/graphql-orm-ai/tests/security_contracts.rs create mode 100644 crates/graphql-orm-ai/tests/session_graphql.rs diff --git a/crates/graphql-orm-ai/.agents/skills/agql-auth/SKILL.md b/crates/graphql-orm-ai/.agents/skills/agql-auth/SKILL.md new file mode 100644 index 00000000..8a820ab0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/agql-auth/SKILL.md @@ -0,0 +1,162 @@ +--- +name: agql-auth +description: > + Use when working on authentication, authorization, principal references, + current-principal rehydration, delegation, recent-MFA, async-graphql context + wiring, or long-lived subscription authorization in graphql-orm-ai. +--- + +# agql-auth Skill + +## Use This Skill When + +- accepting `AuthPrincipal` from an authenticated GraphQL request +- persisting a non-secret principal reference for background AI work +- rehydrating current roles, scopes, tenant membership, and assurance +- checking token/session revocation during a run +- wiring authenticated websocket subscriptions +- enforcing recent MFA for high-impact approvals or secret configuration +- using audience/resource-bound API or service tokens +- designing bounded delegation for disconnected/background tasks +- deciding what auth behavior belongs in `agql-auth` versus `graphql-orm-ai` + +## Crate + +- Dependency: `agql-auth` +- Local repo: `../agql-auth` +- Upstream repo: `https://github.com/Dastari/agql-auth` +- The name refers to async-graphql integration, not to the ORM layer. + +## Preferred Usage + +Use `agql_auth::prelude::*` unless narrower imports make a public module clearer. + +Important existing types: + +- `AuthPrincipal` +- `AuthUser` +- `ApiTokenPrincipal` +- `AccessTokenValidator` +- `TokenStatusChecker` +- `TokenStatusRequest` +- `ReauthorizationPolicy` +- `SessionAssurance` +- `RecentMfaPolicy` +- `AuthorizationDecision` + +Planned reusable additions: + +- `PrincipalReference` +- `CurrentPrincipalResolver` +- bounded `DelegationGrant` +- reusable long-lived connection authorization state + +## Boundary + +`agql-auth` is the reusable authentication and principal-lifecycle runtime. + +It should own: + +- access/session/API-token validation +- revocation and expiry status contracts +- safe, serializable principal references +- current-principal rehydration contracts +- scope subset and audience/resource binding for delegations +- recent-MFA and assurance aging +- GraphQL request-context and websocket reauthorization helpers +- generic guards, status checks, and redacted authorization decisions + +`graphql-orm-ai` is the reusable agent runtime. + +It should own: + +- AI sessions, runs, messages, tools, approvals, and budgets +- tool-risk classification and argument-bound approval records +- AI-specific delegation constraints such as tool allowlists and cost ceilings +- provider egress and data-classification policy +- decisions to pause a run as `WAITING_REAUTH` + +Host applications should own: + +- concrete user/session/token persistence +- implementations that rehydrate current principals +- tenant/project membership and application resource policy +- HTTP/cookie/bearer extraction +- application GraphQL schema composition +- record- and field-level authorization + +Do not make `agql-auth` depend directly on `graphql-orm` unless there is a +deliberate shared-library design decision. Integrate through traits and safe +principal types. + +## Integration Rules + +1. Never persist bearer tokens. +Store only a safe `PrincipalReference` containing subject, session/token IDs, +tenant/resource binding, actor, correlation, and expiry metadata. + +2. Never trust stale role or scope snapshots. +Rehydrate the principal before provider egress, every application tool call, +after approval, and at long-run checkpoints. + +3. Reauthorize long-lived subscriptions. +Authenticate `connection_init`, schedule fail-closed status checks, age recent +MFA, and close or pause on revocation, expiry, or permission loss. + +4. Delegation cannot add authority. +Delegated scopes must be a subset of the current principal, have bounded +expiry, preserve actor/correlation identity, and remain revocable. + +5. Keep application authorization authoritative. +`agql-auth` provides authentication, coarse scopes, token lifecycle, and +assurance. The host's GraphQL resolver plus entity/row/field policies decide +whether a particular operation and record are allowed. + +6. Bind high-impact approvals to current assurance. +Publish, delete, permission, credential, and other sensitive operations should +require recent MFA when configured and must reauthorize after approval. + +7. Use resource-bound service principals for scheduled work. +Do not keep a user bearer token alive or silently convert user work into +unbounded system access. + +8. Keep audits redacted. +Record principal references, requirements, resource, result, reason code, and +correlation. Never include tokens, provider keys, prompts, or tool arguments in +auth audit structures. + +9. Keep MCP tokens audience-bound. +An optional MCP facade must authenticate its caller and must never pass an +application access token through to a downstream MCP server. + +10. Keep database tests isolated. +Any PostgreSQL or MSSQL auth integration test must use a disposable Docker +container and must never connect to a live local database. + +## When Not To Use + +- provider streaming or model event normalization +- ORM schema generation or database migration work +- tool discovery with no authentication impact +- frontend login UI with no backend contract change + +## Request Execution Pattern + +1. The transport authenticates a bearer token, cookie, or connection-init value. +2. It inserts `AuthPrincipal` and related safe auth context into the + async-graphql request. +3. `graphql-orm-ai` stores only `PrincipalReference` with durable work. +4. `CurrentPrincipalResolver` reconstructs current authority before execution. +5. The AI runtime evaluates its tool and approval policy. +6. The host executes the server-owned GraphQL document as that principal. +7. Normal resolver/entity/row/field policies make the final authorization + decision. + +## Project Guidance + +- expand `agql-auth` when the primitive is reusable across projects +- keep tool approval, model policy, and AI budgets in `graphql-orm-ai` +- keep application record policy in the host schema +- fail closed on status-check or rehydration errors +- preserve recent-MFA semantics across long-lived connections +- never trade a disconnected browser for broader background authority diff --git a/crates/graphql-orm-ai/.agents/skills/graphql-orm-macros/SKILL.md b/crates/graphql-orm-ai/.agents/skills/graphql-orm-macros/SKILL.md new file mode 100644 index 00000000..37b8e06e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/graphql-orm-macros/SKILL.md @@ -0,0 +1,134 @@ +--- +name: graphql-orm-macros +description: > + Use when working on the graphql-orm runtime plus graphql-orm-macros derive + layer for GraphQL entities, relations, CRUD operations, schema modules, + resolver metadata, migrations, pagination, durable streams, encryption, and + backend integration in graphql-orm-ai. +--- + +# graphql-orm Skill + +## Use This Skill When + +- deriving `GraphQLEntity`, `GraphQLRelations`, or `GraphQLOperations` +- composing schema roots with `schema_roots!` +- adding AI persistence entities without exposing unsafe generated CRUD +- changing resolver-operation metadata or schema-module integration +- implementing bidirectional keyset pagination or durable event streams +- reviewing relation loading or N+1 behavior +- changing runtime metadata, query rendering, schema diffing, migrations, or backup descriptors +- adding encrypted-field support +- implementing SQLite, PostgreSQL, or MSSQL backend behavior + +## Crates + +- Application-facing dependency: `graphql-orm` +- Runtime and macro repo: `../graphql-orm` +- Upstream runtime repo: `https://github.com/Dastari/graphql-orm` + +## Preferred Usage + +Import through the runtime crate: + +- `use graphql_orm::prelude::*;` +- `use graphql_orm::mutation_result;` +- use derive macros by name on structs + +`graphql-orm-ai` should normally depend only on `graphql-orm`. Do not add a +direct `graphql-orm-macros` dependency unless explicitly developing or +debugging the proc-macro crate. + +## Integration Rules + +1. Use the runtime-plus-macro split correctly. +Generated code comes from re-exported macros. Runtime behavior, metadata, query +rendering, relation loading, policy enforcement, migrations, and backend SQL +belong to `graphql-orm`. + +2. Keep all database syntax in `graphql-orm`. +`graphql-orm-ai` must use generated repository, transaction, migration, +pagination, stream, and backup APIs. Do not issue raw SQL or depend directly on +SQLx or Tiberius database execution APIs. + +3. Use macros for persistence boilerplate, not agent policy. +Model routing, tool policy, approvals, data classification, provider behavior, +and session orchestration belong in `graphql-orm-ai`. + +4. Keep generated types aligned with async-graphql. +Ensure generated output/input types remain compatible with async-graphql and +that sensitive/private fields are not accidentally exposed. + +5. Treat resolver metadata as discovery, not authorization. +Generated operation descriptors may describe every resolver, but AI tool +exposure remains default-deny and runtime resolver policies remain +authoritative. + +6. Keep subscriptions fail-closed. +Do not expose generated subscriptions as AI tools until row/field filtering, +durable replay, lag recovery, and long-lived reauthorization are implemented. + +7. Use stable keysets for large timelines. +Chat and event history must use bounded bidirectional keyset connections, never +unbounded lists or offset pagination for deep history. + +8. Keep persistence backend-agnostic at the AI layer. +Backend-specific SQL rendering, MSSQL write support, migration planning, vector +queries, and schema introspection belong in `graphql-orm`. + +9. Use schema modules for internal entities. +AI entities should contribute migration and backup metadata without forcing +ordinary generated CRUD fields into the host's public schema. + +10. Preserve ordinary authorization paths. +Application tools execute through the composed GraphQL schema with current auth +context. Do not replace this with trusted repository or system access. + +11. Keep database tests isolated. +SQLite may use temporary databases. PostgreSQL and MSSQL integration tests must +use disposable Docker containers and must never connect to live local +databases. + +## When Not To Use + +- provider HTTP/SSE protocol work with no ORM impact +- authentication, token lifecycle, or principal rehydration +- frontend-only GraphQL documents +- simple handwritten types where a derive would add unnecessary coupling + +## Common Pattern + +```rust +use graphql_orm::prelude::*; + +#[derive( + GraphQLEntity, + GraphQLOperations, + Clone, + Debug, + serde::Serialize, + serde::Deserialize, +)] +#[graphql_entity( + table = "ai_sessions", + plural = "AiSessions", + keyset = "updated_at desc, id desc" +)] +struct AiSession { + #[primary_key] + id: graphql_orm::uuid::Uuid, + + owner_subject: String, + updated_at: i64, +} +``` + +## Project Guidance + +- keep `graphql-orm-ai` project-agnostic +- use `graphql-orm` as the normal runtime and macro re-export surface +- contribute reusable persistence primitives back to `graphql-orm` +- do not work around missing ORM features with raw SQL in this crate +- preserve backward compatibility for existing generated resolver clients +- treat resolver metadata, encryption, streams, vector search, and MSSQL writes + as shared ORM concerns when they benefit multiple consumers diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/AGENTS.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/AGENTS.md new file mode 100644 index 00000000..c0f008d2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/AGENTS.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/CLAUDE.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/CLAUDE.md new file mode 100644 index 00000000..c0f008d2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/CLAUDE.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/LICENSE b/crates/graphql-orm-ai/.agents/skills/rust-skills/LICENSE new file mode 100644 index 00000000..3f270707 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Leonardo Maldonado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/README.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/README.md new file mode 100644 index 00000000..4fcace72 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/README.md @@ -0,0 +1,196 @@ +# Rust Skills + +179 Rust rules your AI coding agent can use to write better code. + +Works with Claude Code, Cursor, Windsurf, Copilot, Codex, Aider, Zed, Amp, Cline, and pretty much any other agent that supports skills. + +## Install + +```bash +npx add-skill leonardomso/rust-skills +``` + +That's it. The CLI figures out which agents you have and installs the skill to the right place. + +## How to use it + +After installing, just ask your agent: + +``` +/rust-skills review this function +``` + +``` +/rust-skills is my error handling idiomatic? +``` + +``` +/rust-skills check for memory issues +``` + +The agent loads the relevant rules and applies them to your code. + +## What's in here + +179 rules split into 14 categories: + +| Category | Rules | What it covers | +|----------|-------|----------------| +| **Ownership & Borrowing** | 12 | When to borrow vs clone, Arc/Rc, lifetimes | +| **Error Handling** | 12 | thiserror for libs, anyhow for apps, the `?` operator | +| **Memory** | 15 | SmallVec, arenas, avoiding allocations | +| **API Design** | 15 | Builder pattern, newtypes, sealed traits | +| **Async** | 15 | Tokio patterns, channels, spawn_blocking | +| **Optimization** | 12 | LTO, inlining, PGO, SIMD | +| **Naming** | 16 | Following Rust API Guidelines | +| **Type Safety** | 10 | Newtypes, parse don't validate | +| **Testing** | 13 | Proptest, mockall, criterion | +| **Docs** | 11 | Doc examples, intra-doc links | +| **Performance** | 11 | Iterators, entry API, collect patterns | +| **Project Structure** | 11 | Workspaces, module layout | +| **Linting** | 11 | Clippy config, CI setup | +| **Anti-patterns** | 15 | Common mistakes and how to fix them | + +Each rule has: +- Why it matters +- Bad code example +- Good code example +- Links to official docs when relevant + +## Manual install + +If `add-skill` doesn't work for your setup, here's how to install manually: + +
+Claude Code + +Global (applies to all projects): +```bash +git clone https://github.com/leonardomso/rust-skills.git ~/.claude/skills/rust-skills +``` + +Or just for one project: +```bash +git clone https://github.com/leonardomso/rust-skills.git .claude/skills/rust-skills +``` +
+ +
+OpenCode + +```bash +git clone https://github.com/leonardomso/rust-skills.git .opencode/skills/rust-skills +``` +
+ +
+Cursor + +```bash +git clone https://github.com/leonardomso/rust-skills.git .cursor/skills/rust-skills +``` + +Or just grab the skill file: +```bash +curl -o .cursorrules https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+Windsurf + +```bash +mkdir -p .windsurf/rules +curl -o .windsurf/rules/rust-skills.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+OpenAI Codex + +```bash +git clone https://github.com/leonardomso/rust-skills.git .codex/skills/rust-skills +``` + +Or use the AGENTS.md standard: +```bash +curl -o AGENTS.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+GitHub Copilot + +```bash +mkdir -p .github +curl -o .github/copilot-instructions.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+Aider + +Add to `.aider.conf.yml`: +```yaml +read: path/to/rust-skills/SKILL.md +``` + +Or pass it directly: +```bash +aider --read path/to/rust-skills/SKILL.md +``` +
+ +
+Zed + +```bash +curl -o AGENTS.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+Amp + +```bash +git clone https://github.com/leonardomso/rust-skills.git .agents/skills/rust-skills +``` +
+ +
+Cline / Roo Code + +```bash +mkdir -p .clinerules +curl -o .clinerules/rust-skills.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+Other agents (AGENTS.md) + +If your agent supports the [AGENTS.md](https://agents.md) standard: +```bash +curl -o AGENTS.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +## All rules + +See [SKILL.md](./SKILL.md) for the full list with links to each rule file. + +## Where these rules come from + +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Real code from ripgrep, tokio, serde, polars, axum +- Clippy docs + +## Contributing + +PRs welcome. Just follow the format of existing rules. + +## License + +MIT diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/SKILL.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/SKILL.md new file mode 100644 index 00000000..c0f008d2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/SKILL.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-clone-excessive.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-clone-excessive.md new file mode 100644 index 00000000..539dac2b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-clone-excessive.md @@ -0,0 +1,124 @@ +# anti-clone-excessive + +> Don't clone when borrowing works + +## Why It Matters + +`.clone()` allocates memory and copies data. When you only need to read data, borrowing (`&T`) is free. Excessive cloning wastes memory, CPU cycles, and often indicates misunderstanding of ownership. + +## Bad + +```rust +// Cloning to pass to a function that only reads +fn print_name(name: String) { // Takes ownership + println!("{}", name); +} +let name = "Alice".to_string(); +print_name(name.clone()); // Unnecessary clone +print_name(name); // Could have just done this + +// Cloning in a loop +for item in items.clone() { // Clones entire Vec + process(&item); +} + +// Cloning for comparison +if input.clone() == expected { // Pointless clone + // ... +} + +// Cloning struct fields +fn get_name(&self) -> String { + self.name.clone() // Caller might not need ownership +} +``` + +## Good + +```rust +// Accept reference if only reading +fn print_name(name: &str) { + println!("{}", name); +} +let name = "Alice".to_string(); +print_name(&name); // Borrow, no clone + +// Iterate by reference +for item in &items { + process(item); +} + +// Compare by reference +if input == expected { + // ... +} + +// Return reference when possible +fn get_name(&self) -> &str { + &self.name +} +``` + +## When to Clone + +```rust +// Need owned data for async move +let name = name.clone(); +tokio::spawn(async move { + process(name).await; +}); + +// Storing in a new struct +struct Cache { + data: String, +} +impl Cache { + fn store(&mut self, data: &str) { + self.data = data.to_string(); // Must own + } +} + +// Multiple owners (use Arc instead if frequent) +let shared = data.clone(); +thread::spawn(move || use_data(shared)); +``` + +## Alternatives to Clone + +| Instead of | Use | +|------------|-----| +| `s.clone()` for reading | `&s` | +| `vec.clone()` for iteration | `&vec` or `vec.iter()` | +| `Clone` for shared ownership | `Arc` | +| Clone in hot loop | Move outside loop | +| `s.to_string()` from `&str` | Accept `&str` if possible | + +## Pattern: Clone on Write + +```rust +use std::borrow::Cow; + +fn process(input: Cow) -> Cow { + if needs_modification(&input) { + Cow::Owned(modify(&input)) // Clone only if needed + } else { + input // No clone + } +} +``` + +## Detecting Excessive Clones + +```toml +# Cargo.toml +[lints.clippy] +clone_on_copy = "warn" +clone_on_ref_ptr = "warn" +redundant_clone = "warn" +``` + +## See Also + +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Borrowing patterns +- [own-cow-conditional](./own-cow-conditional.md) - Clone on write +- [own-arc-shared](./own-arc-shared.md) - Shared ownership diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-collect-intermediate.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-collect-intermediate.md new file mode 100644 index 00000000..b83a2fb2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-collect-intermediate.md @@ -0,0 +1,131 @@ +# anti-collect-intermediate + +> Don't collect intermediate iterators + +## Why It Matters + +Each `.collect()` allocates a new collection. Collecting intermediate results in a chain creates unnecessary allocations and prevents iterator fusion. Keep the chain lazy; collect only at the end. + +## Bad + +```rust +// Three allocations, three passes +fn process(data: Vec) -> Vec { + let step1: Vec<_> = data.into_iter() + .filter(|x| *x > 0) + .collect(); + + let step2: Vec<_> = step1.into_iter() + .map(|x| x * 2) + .collect(); + + step2.into_iter() + .filter(|x| *x < 100) + .collect() +} + +// Collecting just to check length +fn has_valid_items(items: &[Item]) -> bool { + let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + !valid.is_empty() +} + +// Collecting to iterate again +fn sum_valid(items: &[Item]) -> i64 { + let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + valid.iter().map(|i| i.value).sum() +} +``` + +## Good + +```rust +// Single allocation, single pass +fn process(data: Vec) -> Vec { + data.into_iter() + .filter(|x| *x > 0) + .map(|x| x * 2) + .filter(|x| *x < 100) + .collect() +} + +// No allocation - iterator short-circuits +fn has_valid_items(items: &[Item]) -> bool { + items.iter().any(|i| i.is_valid()) +} + +// No intermediate allocation +fn sum_valid(items: &[Item]) -> i64 { + items.iter() + .filter(|i| i.is_valid()) + .map(|i| i.value) + .sum() +} +``` + +## When Collection Is Needed + +```rust +// Need to iterate twice +let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); +let count = valid.len(); +for item in &valid { + process(item); +} + +// Need to sort (requires concrete collection) +let mut sorted: Vec<_> = items.iter() + .filter(|i| i.is_active()) + .collect(); +sorted.sort_by_key(|i| i.priority); + +// Need random access +let indexed: Vec<_> = items.iter().collect(); +let middle = indexed.get(indexed.len() / 2); +``` + +## Iterator Methods That Avoid Collection + +| Instead of Collecting to... | Use | +|-----------------------------|-----| +| Check if empty | `.any(|_| true)` or `.next().is_some()` | +| Check if any match | `.any(predicate)` | +| Check if all match | `.all(predicate)` | +| Count elements | `.count()` | +| Sum elements | `.sum()` | +| Find first | `.find(predicate)` | +| Get first | `.next()` | +| Get last | `.last()` | + +## Pattern: Deferred Collection + +```rust +// Return iterator, let caller collect if needed +fn valid_items(items: &[Item]) -> impl Iterator { + items.iter().filter(|i| i.is_valid()) +} + +// Caller decides +let count = valid_items(&items).count(); // No collection +let vec: Vec<_> = valid_items(&items).collect(); // Collection when needed +``` + +## Comparison + +| Pattern | Allocations | Passes | +|---------|-------------|--------| +| `.collect()` each step | N | N | +| Single chain, one `.collect()` | 1 | 1 | +| No collection (streaming) | 0 | 1 | + +## See Also + +- [perf-collect-once](./perf-collect-once.md) - Single collect +- [perf-iter-lazy](./perf-iter-lazy.md) - Lazy evaluation +- [perf-iter-over-index](./perf-iter-over-index.md) - Iterator patterns diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-empty-catch.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-empty-catch.md new file mode 100644 index 00000000..b2b63f21 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-empty-catch.md @@ -0,0 +1,132 @@ +# anti-empty-catch + +> Don't silently ignore errors + +## Why It Matters + +Empty error handling (`if let Err(_) = ...`, `let _ = result`, `.ok()`) silently discards errors. Failures go unnoticed, bugs hide, and debugging becomes impossible. Every error deserves acknowledgment—even if just logging. + +## Bad + +```rust +// Silently ignores errors +let _ = write_to_file(data); + +// Discards error completely +if let Err(_) = send_notification() { + // Nothing - error vanishes +} + +// Converts Result to Option, losing error info +let value = risky_operation().ok(); + +// Match with empty arm +match database.save(record) { + Ok(_) => println!("saved"), + Err(_) => {} // Silent failure +} + +// Ignored in loop +for item in items { + let _ = process(item); // Failures unnoticed +} +``` + +## Good + +```rust +// Log the error +if let Err(e) = write_to_file(data) { + error!("failed to write file: {}", e); +} + +// Propagate if possible +send_notification()?; + +// Or handle explicitly +match send_notification() { + Ok(_) => info!("notification sent"), + Err(e) => warn!("notification failed: {}", e), +} + +// Collect errors in batch operations +let (successes, failures): (Vec<_>, Vec<_>) = items + .into_iter() + .map(process) + .partition(Result::is_ok); + +if !failures.is_empty() { + warn!("{} items failed to process", failures.len()); +} + +// Explicit documentation when ignoring +// Intentionally ignored: cleanup failure is not critical +let _ = cleanup_temp_file(); // Add comment explaining why +``` + +## Acceptable Ignoring (Documented) + +```rust +// Close errors often ignored, but document it +// INTENTIONAL: TCP close errors are not actionable +let _ = stream.shutdown(Shutdown::Both); + +// Mutex poisoning recovery +// INTENTIONAL: We'll reset the state anyway +let guard = mutex.lock().unwrap_or_else(|e| e.into_inner()); +``` + +## Pattern: Collect and Report + +```rust +fn process_batch(items: Vec) -> BatchResult { + let mut errors = Vec::new(); + + for item in items { + if let Err(e) = process_item(&item) { + errors.push((item.id, e)); + } + } + + if errors.is_empty() { + BatchResult::AllSucceeded + } else { + BatchResult::PartialFailure(errors) + } +} +``` + +## Pattern: Best-Effort Operations + +```rust +// Metrics/telemetry can fail without affecting main flow +fn report_metric(name: &str, value: f64) { + if let Err(e) = metrics_client.record(name, value) { + // Log but don't propagate - metrics are not critical + debug!("failed to record metric {}: {}", name, e); + } +} +``` + +## Clippy Lint + +```toml +[lints.clippy] +let_underscore_drop = "warn" +ignored_unit_patterns = "warn" +``` + +## Decision Guide + +| Situation | Action | +|-----------|--------| +| Critical operation | `?` or handle explicitly | +| Non-critical, debugging needed | Log the error | +| Truly ignorable (rare) | `let _ =` with comment | +| Batch operation | Collect errors, report | + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Proper error handling +- [err-context-chain](./err-context-chain.md) - Adding context +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap issues diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-expect-lazy.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-expect-lazy.md new file mode 100644 index 00000000..24e2b3db --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-expect-lazy.md @@ -0,0 +1,95 @@ +# anti-expect-lazy + +> Don't use expect for recoverable errors + +## Why It Matters + +`.expect()` panics with a custom message, but it's still a panic. Using it for errors that could reasonably occur in production (network failures, file not found, invalid input) crashes the program instead of handling the error gracefully. + +Reserve `.expect()` for programming errors where panic is appropriate. + +## Bad + +```rust +// Network failures are expected - don't panic +let response = client.get(url).await.expect("failed to fetch"); + +// Files might not exist +let config = fs::read_to_string("config.toml").expect("config not found"); + +// User input can be invalid +let age: u32 = input.parse().expect("invalid age"); + +// Database queries can fail +let user = db.find_user(id).await.expect("user not found"); +``` + +## Good + +```rust +// Handle recoverable errors properly +let response = client.get(url).await + .context("failed to fetch URL")?; + +// Return error if file doesn't exist +let config = fs::read_to_string("config.toml") + .context("failed to read config file")?; + +// Validate and return error +let age: u32 = input.parse() + .map_err(|_| Error::InvalidInput("age must be a number"))?; + +// Handle missing data +let user = db.find_user(id).await? + .ok_or(Error::NotFound("user"))?; +``` + +## When expect() Is Appropriate + +Use `.expect()` for invariants that indicate bugs: + +```rust +// Mutex poisoning indicates a bug elsewhere +let guard = mutex.lock().expect("mutex poisoned"); + +// Regex is known valid at compile time +let re = Regex::new(r"^\d{4}$").expect("invalid regex"); + +// Thread spawn failure is unrecoverable +let handle = thread::spawn(|| work()).expect("failed to spawn thread"); + +// Static data that must be valid +let config: Config = toml::from_str(EMBEDDED_CONFIG) + .expect("embedded config is invalid"); +``` + +## Pattern: expect() vs unwrap() + +```rust +// unwrap: no context, hard to debug +let x = option.unwrap(); + +// expect: gives context, still panics +let x = option.expect("value should exist after validation"); + +// ?: proper error handling +let x = option.ok_or(Error::MissingValue)?; +``` + +## Decision Guide + +| Situation | Use | +|-----------|-----| +| User input | `?` with error | +| File/network I/O | `?` with error | +| Database operations | `?` with error | +| Parsed constants | `.expect()` | +| Thread/mutex operations | `.expect()` | +| After validation check | `.expect()` with explanation | +| Never expected to fail | `.expect()` documenting invariant | + +## See Also + +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to use expect +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoiding unwrap +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap anti-pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-format-hot-path.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-format-hot-path.md new file mode 100644 index 00000000..368627f6 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-format-hot-path.md @@ -0,0 +1,141 @@ +# anti-format-hot-path + +> Don't use format! in hot paths + +## Why It Matters + +`format!()` allocates a new `String` every call. In hot paths (loops, frequently called functions), this creates allocation churn that impacts performance. Pre-allocate, reuse buffers, or use `write!()` to an existing buffer. + +## Bad + +```rust +// format! in loop - allocates every iteration +fn log_events(events: &[Event]) { + for event in events { + let message = format!("[{}] {}: {}", event.level, event.source, event.message); + logger.log(&message); + } +} + +// format! for building parts +fn build_url(base: &str, path: &str, params: &[(&str, &str)]) -> String { + let mut url = format!("{}{}", base, path); + for (key, value) in params { + url = format!("{}{}={}&", url, key, value); // New allocation each time + } + url +} + +// format! for simple concatenation +fn greet(name: &str) -> String { + format!("Hello, {}!", name) // Fine for one-off, bad if called 1M times +} +``` + +## Good + +```rust +use std::fmt::Write; + +// Reuse buffer across iterations +fn log_events(events: &[Event]) { + let mut buffer = String::with_capacity(256); + for event in events { + buffer.clear(); + write!(buffer, "[{}] {}: {}", event.level, event.source, event.message).unwrap(); + logger.log(&buffer); + } +} + +// Build incrementally in single buffer +fn build_url(base: &str, path: &str, params: &[(&str, &str)]) -> String { + let mut url = String::with_capacity(base.len() + path.len() + params.len() * 20); + url.push_str(base); + url.push_str(path); + for (key, value) in params { + write!(url, "{}={}&", key, value).unwrap(); + } + url +} + +// For truly hot paths, avoid allocation entirely +fn greet_to_buf(name: &str, buffer: &mut String) { + buffer.clear(); + buffer.push_str("Hello, "); + buffer.push_str(name); + buffer.push('!'); +} +``` + +## Comparison + +| Approach | Allocations | Performance | +|----------|-------------|-------------| +| `format!()` in loop | N | Slow | +| `write!()` to reused buffer | 1 | Fast | +| `push_str()` + `push()` | 1 | Fastest | +| Pre-sized `String::with_capacity()` | 1 (no realloc) | Fast | + +## When format! Is Fine + +```rust +// One-time initialization +let config_path = format!("{}/config.toml", home_dir); + +// Error messages (not hot path) +return Err(format!("invalid input: {}", input)); + +// Debug output +println!("Debug: {:?}", value); +``` + +## Pattern: Formatter Buffer Pool + +```rust +use std::cell::RefCell; + +thread_local! { + static BUFFER: RefCell = RefCell::new(String::with_capacity(256)); +} + +fn format_event(event: &Event) -> String { + BUFFER.with(|buf| { + let mut buf = buf.borrow_mut(); + buf.clear(); + write!(buf, "[{}] {}", event.level, event.message).unwrap(); + buf.clone() // Still one allocation per call, but no parsing + }) +} +``` + +## Pattern: Display Implementation + +```rust +struct Event { + level: Level, + message: String, +} + +impl std::fmt::Display for Event { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.level, self.message) + } +} + +// Caller controls allocation +let mut buf = String::new(); +write!(buf, "{}", event)?; +``` + +## Clippy Lint + +```toml +[lints.clippy] +format_in_format_args = "warn" +``` + +## See Also + +- [mem-avoid-format](./mem-avoid-format.md) - Avoiding format +- [mem-write-over-format](./mem-write-over-format.md) - Using write! +- [mem-reuse-collections](./mem-reuse-collections.md) - Buffer reuse diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-index-over-iter.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-index-over-iter.md new file mode 100644 index 00000000..c22d85f0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-index-over-iter.md @@ -0,0 +1,125 @@ +# anti-index-over-iter + +> Don't use indexing when iterators work + +## Why It Matters + +Manual indexing (`for i in 0..len`) requires bounds checks on every access, prevents SIMD optimization, and introduces off-by-one error risks. Iterators eliminate these issues and are more idiomatic Rust. + +## Bad + +```rust +// Manual indexing - bounds checked every access +fn sum_squares(data: &[i32]) -> i64 { + let mut result = 0i64; + for i in 0..data.len() { + result += (data[i] as i64) * (data[i] as i64); + } + result +} + +// Index-based with multiple arrays +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + let mut sum = 0.0; + for i in 0..a.len().min(b.len()) { + sum += a[i] * b[i]; + } + sum +} + +// Mutation with indices +fn normalize(data: &mut [f64]) { + let max = data.iter().cloned().fold(0.0, f64::max); + for i in 0..data.len() { + data[i] /= max; + } +} +``` + +## Good + +```rust +// Iterator - no bounds checks, SIMD-friendly +fn sum_squares(data: &[i32]) -> i64 { + data.iter() + .map(|&x| (x as i64) * (x as i64)) + .sum() +} + +// Zip - handles length mismatch automatically +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| x * y) + .sum() +} + +// Mutable iteration +fn normalize(data: &mut [f64]) { + let max = data.iter().cloned().fold(0.0, f64::max); + for x in data.iter_mut() { + *x /= max; + } +} +``` + +## When Indices Are Needed + +Sometimes you genuinely need indices: + +```rust +// Need index in output +for (i, item) in items.iter().enumerate() { + println!("{}: {}", i, item); +} + +// Non-sequential access +for i in (0..len).step_by(2) { + swap(&mut data[i], &mut data[i + 1]); +} + +// Multi-dimensional iteration +for i in 0..rows { + for j in 0..cols { + matrix[i][j] = i * cols + j; + } +} +``` + +## Comparison + +| Pattern | Bounds Checks | SIMD | Safety | +|---------|---------------|------|--------| +| `for i in 0..len { data[i] }` | Every access | Limited | Off-by-one risk | +| `for x in &data` | None | Good | Safe | +| `for x in data.iter()` | None | Good | Safe | +| `data.iter().enumerate()` | None | Good | Safe | + +## Common Conversions + +| Index Pattern | Iterator Pattern | +|---------------|------------------| +| `for i in 0..v.len()` | `for x in &v` | +| `v[0]` | `v.first()` | +| `v[v.len()-1]` | `v.last()` | +| `for i in 0..a.len() { a[i] + b[i] }` | `a.iter().zip(&b)` | +| `for i in 0..v.len() { v[i] *= 2 }` | `for x in &mut v { *x *= 2 }` | + +## Performance Note + +```rust +// Iterator version can auto-vectorize +let sum: i32 = data.iter().sum(); + +// Manual indexing prevents vectorization +let mut sum = 0; +for i in 0..data.len() { + sum += data[i]; +} +``` + +## See Also + +- [perf-iter-over-index](./perf-iter-over-index.md) - Performance details +- [opt-bounds-check](./opt-bounds-check.md) - Bounds check elimination +- [perf-iter-lazy](./perf-iter-lazy.md) - Lazy iterators diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-lock-across-await.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-lock-across-await.md new file mode 100644 index 00000000..20742d82 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-lock-across-await.md @@ -0,0 +1,127 @@ +# anti-lock-across-await + +> Don't hold locks across await points + +## Why It Matters + +Holding a `Mutex` or `RwLock` guard across an `.await` causes the lock to be held while the task is suspended. Other tasks waiting for the lock block indefinitely. With `std::sync::Mutex`, this is even worse—it can deadlock the entire runtime. + +## Bad + +```rust +use std::sync::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +// DEADLOCK RISK: std::sync::Mutex held across await +async fn bad_std_mutex(data: &Mutex>) { + let mut guard = data.lock().unwrap(); + do_async_work().await; // Lock held during await! + guard.push(42); +} + +// BLOCKS OTHER TASKS: tokio Mutex held across await +async fn bad_async_mutex(data: &AsyncMutex>) { + let mut guard = data.lock().await; + slow_network_call().await; // Lock held for entire call! + guard.push(42); +} +``` + +## Good + +```rust +use std::sync::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +// Release lock before await +async fn good_approach(data: &Mutex>) { + let value = { + let guard = data.lock().unwrap(); + guard.last().copied() // Extract what you need + }; // Lock released here + + let result = do_async_work(value).await; + + { + let mut guard = data.lock().unwrap(); + guard.push(result); + } +} + +// Minimize lock scope with async mutex +async fn good_async_mutex(data: &AsyncMutex>, item: i32) { + // Quick lock, quick release + data.lock().await.push(item); + + // Async work without lock + let result = slow_network_call().await; + + // Quick lock again + data.lock().await.push(result); +} +``` + +## Pattern: Clone Before Await + +```rust +async fn process(data: &AsyncMutex) -> Result<()> { + // Clone inside lock scope + let config = data.lock().await.clone(); + + // Now use config freely across awaits + let result = fetch_data(&config.url).await?; + process_result(&config, result).await?; + + Ok(()) +} +``` + +## Pattern: Restructure to Avoid Lock + +```rust +// Instead of locking a shared map +struct Service { + data: AsyncMutex>, +} + +// Use channels or owned data +struct BetterService { + // Each task owns its data via channels + sender: mpsc::Sender, +} + +impl BetterService { + async fn request(&self, key: String) -> Data { + let (tx, rx) = oneshot::channel(); + self.sender.send(Request { key, respond: tx }).await?; + rx.await? + } +} +``` + +## What Can Cross Await + +| Type | Safe Across Await? | +|------|--------------------| +| `std::sync::Mutex` guard | **NO** - can deadlock | +| `std::sync::RwLock` guard | **NO** - can deadlock | +| `tokio::sync::Mutex` guard | Allowed but blocks tasks | +| `tokio::sync::RwLock` guard | Allowed but blocks tasks | +| Owned values | Yes | +| `Arc` | Yes | +| References | Depends on lifetime | + +## Detection + +```toml +# Cargo.toml +[lints.clippy] +await_holding_lock = "deny" +await_holding_refcell_ref = "deny" +``` + +## See Also + +- [async-no-lock-await](./async-no-lock-await.md) - Async lock patterns +- [async-clone-before-await](./async-clone-before-await.md) - Clone pattern +- [own-mutex-interior](./own-mutex-interior.md) - Mutex usage diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-over-abstraction.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-over-abstraction.md new file mode 100644 index 00000000..44a12f02 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-over-abstraction.md @@ -0,0 +1,120 @@ +# anti-over-abstraction + +> Don't over-abstract with excessive generics + +## Why It Matters + +Generics and traits are powerful but come at a cost: compile times, binary size, and cognitive load. Over-abstraction—making everything generic "for flexibility"—often adds complexity without benefit. Start concrete; generalize when you have real use cases. + +## Bad + +```rust +// Overly generic for a simple function +fn add(a: T, b: U) -> R +where + T: Into, + U: Into, + R: std::ops::Add, +{ + a.into() + b.into() +} + +// Just call add(1, 2) - why make it this complex? + +// Trait explosion +trait Readable {} +trait Writable {} +trait ReadWritable: Readable + Writable {} +trait AsyncReadable {} +trait AsyncWritable {} +trait AsyncReadWritable: AsyncReadable + AsyncWritable {} + +// Abstract factory pattern (Java flashback) +trait Factory { + fn create(&self) -> T; +} +trait FactoryFactory, T> { + fn create_factory(&self) -> F; +} +``` + +## Good + +```rust +// Concrete implementation - clear and simple +fn add_i32(a: i32, b: i32) -> i32 { + a + b +} + +// Generic when actually needed (e.g., library code) +fn add>(a: T, b: T) -> T { + a + b +} + +// Simple traits for actual polymorphism needs +trait Storage { + fn save(&self, key: &str, value: &[u8]) -> Result<(), Error>; + fn load(&self, key: &str) -> Result, Error>; +} + +// Concrete types first +struct FileStorage { path: PathBuf } +struct MemoryStorage { data: HashMap> } +``` + +## Signs of Over-Abstraction + +| Sign | Symptom | +|------|---------| +| Single implementation | Generic trait with only one impl | +| Type parameter soup | `T, U, V, W` everywhere | +| Marker traits | Traits with no methods | +| Deep trait bounds | `where T: A + B + C + D + E` | +| Phantom generics | Type parameters not used meaningfully | + +## When to Generalize + +Generalize when: +- You have 2+ concrete types that share behavior +- You're writing library code for public consumption +- Performance requires static dispatch +- The abstraction simplifies the API + +Don't generalize when: +- You "might need it later" (YAGNI) +- Only one type will ever implement it +- It makes code harder to understand + +## Rule of Three + +Wait until you have three similar concrete implementations before abstracting: + +```rust +// Version 1: Just FileStorage +struct FileStorage { /* ... */ } + +// Version 2: Added MemoryStorage, similar interface +struct MemoryStorage { /* ... */ } + +// Version 3: Now Redis too - time to abstract +trait Storage { + fn save(&self, key: &str, value: &[u8]) -> Result<()>; + fn load(&self, key: &str) -> Result>; +} +``` + +## Prefer Concrete Types in Private Code + +```rust +// Internal function - concrete type is fine +fn process_orders(db: &PostgresDb, orders: Vec) { } + +// Public API - might benefit from abstraction +pub fn process_orders(storage: &S, orders: Vec) { } +``` + +## See Also + +- [type-generic-bounds](./type-generic-bounds.md) - Minimal bounds +- [api-sealed-trait](./api-sealed-trait.md) - Controlled extension +- [anti-type-erasure](./anti-type-erasure.md) - When Box is wrong diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-panic-expected.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-panic-expected.md new file mode 100644 index 00000000..cecb34cb --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-panic-expected.md @@ -0,0 +1,131 @@ +# anti-panic-expected + +> Don't panic on expected or recoverable errors + +## Why It Matters + +Panics crash the program. They're for unrecoverable situations—bugs, corrupted state, invariant violations. Using panic for expected conditions (network failures, file not found, invalid input) makes programs fragile and forces callers to catch panics or die. + +Use `Result` for recoverable errors. + +## Bad + +```rust +// Network failures are expected +fn fetch_data(url: &str) -> Data { + let response = reqwest::blocking::get(url) + .expect("network error"); // Crashes on timeout + response.json().expect("invalid json") // Crashes on bad response +} + +// User input is often invalid +fn parse_config(input: &str) -> Config { + toml::from_str(input).expect("invalid config") // Crashes on typo +} + +// Files may not exist +fn load_settings() -> Settings { + let content = fs::read_to_string("settings.json") + .expect("settings not found"); // Crashes if missing + serde_json::from_str(&content).expect("invalid settings") +} + +// Custom panic for validation +fn process_age(age: i32) { + if age < 0 { + panic!("age cannot be negative"); // Should return error + } +} +``` + +## Good + +```rust +// Return errors for expected failures +fn fetch_data(url: &str) -> Result { + let response = reqwest::blocking::get(url) + .context("failed to connect")?; + let data = response.json() + .context("failed to parse response")?; + Ok(data) +} + +// Validate and return Result +fn parse_config(input: &str) -> Result { + toml::from_str(input).map_err(ConfigError::Parse) +} + +// Handle missing files gracefully +fn load_settings() -> Result { + let content = fs::read_to_string("settings.json")?; + let settings = serde_json::from_str(&content)?; + Ok(settings) +} + +// Return error for validation failure +fn process_age(age: i32) -> Result<(), ValidationError> { + if age < 0 { + return Err(ValidationError::NegativeAge); + } + Ok(()) +} +``` + +## When to Panic + +Panic IS appropriate for: + +```rust +// Bug detection - invariant violated +fn get_unchecked(&self, index: usize) -> &T { + assert!(index < self.len(), "index out of bounds - this is a bug"); + unsafe { self.data.get_unchecked(index) } +} + +// Unrecoverable state +fn init() { + if !CAN_PROCEED { + panic!("system requirements not met"); + } +} + +// Tests +#[test] +fn test_fails() { + panic!("expected panic in test"); +} +``` + +## Decision Guide + +| Condition | Action | +|-----------|--------| +| Invalid user input | Return `Err` | +| Network failure | Return `Err` | +| File not found | Return `Err` | +| Malformed data | Return `Err` | +| Bug/impossible state | `panic!` or `unreachable!` | +| Failed assertion in test | `panic!` | +| Unrecoverable init failure | `panic!` | + +## Anti-pattern: panic! for Control Flow + +```rust +// BAD: Using panic for control flow +fn find_or_die(items: &[Item], id: u64) -> &Item { + items.iter() + .find(|i| i.id == id) + .unwrap_or_else(|| panic!("item {} not found", id)) +} + +// GOOD: Return Option or Result +fn find(items: &[Item], id: u64) -> Option<&Item> { + items.iter().find(|i| i.id == id) +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Use Result +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap anti-pattern +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to expect diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-premature-optimize.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-premature-optimize.md new file mode 100644 index 00000000..646007ec --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-premature-optimize.md @@ -0,0 +1,156 @@ +# anti-premature-optimize + +> Don't optimize before profiling + +## Why It Matters + +Premature optimization wastes time, complicates code, and often targets the wrong bottlenecks. Most code isn't performance-critical; the hot 10% matters. Profile first, then optimize the actual bottlenecks with data-driven decisions. + +## Bad + +```rust +// "Optimizing" without measurement +fn sum(data: &[i32]) -> i32 { + // Using unsafe "for performance" without profiling + unsafe { + let mut sum = 0; + for i in 0..data.len() { + sum += *data.get_unchecked(i); + } + sum + } +} + +// Complex caching with no evidence it's needed +lazy_static! { + static ref CACHE: RwLock>> = + RwLock::new(HashMap::new()); +} + +// Hand-rolled data structures "for speed" +struct MyVec { + ptr: *mut T, + len: usize, + cap: usize, +} +``` + +## Good + +```rust +// Simple, idiomatic - let compiler optimize +fn sum(data: &[i32]) -> i32 { + data.iter().sum() +} + +// Profile, then optimize if needed +fn sum_optimized(data: &[i32]) -> i32 { + // After profiling showed this is a bottleneck, + // we measured that manual SIMD gives 3x speedup + #[cfg(target_arch = "x86_64")] + { + // SIMD implementation with benchmark data + } + #[cfg(not(target_arch = "x86_64"))] + { + data.iter().sum() + } +} + +// Use standard library - it's well-optimized +let cache: HashMap = HashMap::new(); +``` + +## Profiling Workflow + +```bash +# 1. Write correct code first +cargo build --release + +# 2. Profile with real workloads +cargo flamegraph --bin my_app -- --real-args +# or +cargo bench + +# 3. Identify hotspots (top 10% of time) + +# 4. Measure before optimizing +# 5. Optimize ONE thing +# 6. Measure after - verify improvement +# 7. Repeat if still slow +``` + +## Optimization Principles + +| Do | Don't | +|----|-------| +| Profile first | Guess at bottlenecks | +| Optimize hotspots | Optimize everything | +| Measure improvement | Assume it's faster | +| Keep it simple | Add complexity speculatively | +| Trust the compiler | Outsmart the compiler | + +## When to Optimize + +```rust +// AFTER profiling shows this is 40% of runtime +#[inline] +fn hot_function(data: &[u8]) -> u64 { + // Optimized implementation justified by benchmarks +} + +// Clear, measurable benefit documented +/// Pre-allocated buffer for repeated formatting. +/// Benchmarks show 3x speedup for >1000 calls/sec workloads. +struct FormatterPool { + buffers: Vec, +} +``` + +## Common Premature Optimizations + +| Premature | Reality | +|-----------|---------| +| `#[inline(always)]` everywhere | Compiler usually knows better | +| `unsafe` for bounds check removal | Iterator does this safely | +| Custom allocator | Default is usually fine | +| Object pooling | Allocator is fast enough | +| Manual SIMD | Auto-vectorization works | + +## Profile Tools + +```bash +# Sampling profiler +perf record ./target/release/app && perf report + +# Flamegraph +cargo install flamegraph +cargo flamegraph + +# Criterion benchmarks +cargo bench + +# Memory profiling +valgrind --tool=massif ./target/release/app +``` + +## Document Optimizations + +```rust +/// Lookup table for fast character classification. +/// +/// # Performance +/// +/// Benchmarked with criterion (benchmarks/char_class.rs): +/// - Table lookup: 2.3ns/op +/// - Match statement: 8.7ns/op +/// +/// Justified for hot path in parser (called 10M+ times). +static CHAR_CLASS: [CharClass; 256] = [/* ... */]; +``` + +## See Also + +- [perf-profile-first](./perf-profile-first.md) - Profile before optimize +- [test-criterion-bench](./test-criterion-bench.md) - Benchmarking +- [opt-inline-small](./opt-inline-small.md) - Inline guidelines diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-string-for-str.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-string-for-str.md new file mode 100644 index 00000000..16de1414 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-string-for-str.md @@ -0,0 +1,122 @@ +# anti-string-for-str + +> Don't accept &String when &str works + +## Why It Matters + +`&String` is strictly less flexible than `&str`. A `&str` can be created from `String`, `&str`, literals, and slices. A `&String` requires exactly a `String`. This forces callers to allocate when they might not need to. + +## Bad + +```rust +// Forces callers to have a String +fn greet(name: &String) { + println!("Hello, {}", name); +} + +// Caller must allocate +greet(&"Alice".to_string()); // Unnecessary allocation +greet(&name); // Only works if name is String + +// In struct +struct Config { + name: String, +} + +impl Config { + fn set_name(&mut self, name: &String) { // Too restrictive + self.name = name.clone(); + } +} +``` + +## Good + +```rust +// Accept &str - works with String, &str, literals +fn greet(name: &str) { + println!("Hello, {}", name); +} + +// All these work +greet("Alice"); // String literal +greet(&name); // &String coerces to &str +greet(name.as_str()); // Explicit &str + +// In struct +impl Config { + fn set_name(&mut self, name: &str) { + self.name = name.to_string(); + } + + // Or accept owned String if caller usually has one + fn set_name_owned(&mut self, name: String) { + self.name = name; + } + + // Or be generic + fn set_name_into(&mut self, name: impl Into) { + self.name = name.into(); + } +} +``` + +## Deref Coercion + +`String` implements `Deref`, so `&String` automatically coerces to `&str`: + +```rust +fn takes_str(s: &str) { } + +let owned = String::from("hello"); +takes_str(&owned); // &String -> &str via Deref +``` + +## When to Accept &String + +Rarely. Maybe if you need `String`-specific methods: + +```rust +fn needs_capacity(s: &String) -> usize { + s.capacity() // Only String has capacity() +} +``` + +But usually you'd take `&str` and let the caller manage the `String`. + +## Pattern: Flexible APIs + +```rust +// Most flexible: accept anything that can become &str +fn process(input: impl AsRef) { + let s: &str = input.as_ref(); + // ... +} + +process("literal"); +process(String::from("owned")); +process(&some_string); +``` + +## Similar Anti-patterns + +| Anti-pattern | Better | +|--------------|--------| +| `&String` | `&str` | +| `&Vec` | `&[T]` | +| `&Box` | `&T` | +| `&PathBuf` | `&Path` | +| `&OsString` | `&OsStr` | + +## Clippy Detection + +```toml +[lints.clippy] +ptr_arg = "warn" # Catches &String, &Vec, &PathBuf +``` + +## See Also + +- [anti-vec-for-slice](./anti-vec-for-slice.md) - Similar pattern for Vec +- [own-slice-over-vec](./own-slice-over-vec.md) - Slice patterns +- [api-impl-asref](./api-impl-asref.md) - AsRef pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-stringly-typed.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-stringly-typed.md new file mode 100644 index 00000000..fac95796 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-stringly-typed.md @@ -0,0 +1,167 @@ +# anti-stringly-typed + +> Don't use strings where enums or newtypes would provide type safety + +## Why It Matters + +Strings are the most primitive way to represent data—they accept any value, provide no validation, and offer no IDE support. When you have a fixed set of valid values or a semantic type, use enums or newtypes. The compiler catches mistakes at compile time instead of runtime. + +## Bad + +```rust +fn process_order(status: &str, priority: &str) { + // What are valid statuses? "pending"? "Pending"? "PENDING"? + // What are valid priorities? "high"? "1"? "urgent"? + match status { + "pending" => { ... } + "completed" => { ... } + _ => panic!("unknown status"), // Runtime error + } +} + +struct User { + email: String, // Any string, even "not an email" + phone: String, // Any string, even "hello" + user_id: String, // Could be confused with other string IDs +} + +// Easy to make mistakes +process_order("complete", "high"); // Typo: "complete" vs "completed" +process_order("high", "pending"); // Swapped arguments - compiles! +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OrderStatus { + Pending, + Processing, + Completed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Priority { + Low, + Medium, + High, + Critical, +} + +fn process_order(status: OrderStatus, priority: Priority) { + match status { + OrderStatus::Pending => { ... } + OrderStatus::Processing => { ... } + OrderStatus::Completed => { ... } + OrderStatus::Cancelled => { ... } + } // Exhaustive - compiler checks all cases +} + +// Validated newtypes +struct Email(String); +struct PhoneNumber(String); +struct UserId(u64); + +impl Email { + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(ValidationError::InvalidEmail) + } + } +} + +struct User { + email: Email, // Must be valid email + phone: PhoneNumber, // Must be valid phone + user_id: UserId, // Can't confuse with other IDs +} + +// Compile errors catch mistakes +process_order(OrderStatus::Completed, Priority::High); // Clear and correct +process_order(Priority::High, OrderStatus::Pending); // Compile error! +``` + +## Parsing Strings to Types + +```rust +use std::str::FromStr; + +#[derive(Debug, Clone, Copy)] +enum OrderStatus { + Pending, + Processing, + Completed, + Cancelled, +} + +impl FromStr for OrderStatus { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "pending" => Ok(OrderStatus::Pending), + "processing" => Ok(OrderStatus::Processing), + "completed" => Ok(OrderStatus::Completed), + "cancelled" | "canceled" => Ok(OrderStatus::Cancelled), + _ => Err(ParseError::UnknownStatus(s.to_string())), + } + } +} + +// Parse at boundary, use types internally +fn handle_request(status_str: &str) -> Result<(), Error> { + let status: OrderStatus = status_str.parse()?; // Validate once + process_order(status); // Type-safe from here + Ok(()) +} +``` + +## With Serde + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Status { + Pending, + InProgress, + Completed, +} + +// JSON: {"status": "in_progress"} +// Deserialization validates automatically +``` + +## Error Messages + +```rust +#[derive(Debug, Clone, Copy)] +enum Color { + Red, + Green, + Blue, +} + +impl std::fmt::Display for Color { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Color::Red => write!(f, "red"), + Color::Green => write!(f, "green"), + Color::Blue => write!(f, "blue"), + } + } +} + +// Type-safe and displayable +println!("Selected color: {}", Color::Red); +``` + +## See Also + +- [api-newtype-safety](./api-newtype-safety.md) - Newtype pattern +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Parse at boundaries +- [type-newtype-ids](./type-newtype-ids.md) - Type-safe IDs diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-type-erasure.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-type-erasure.md new file mode 100644 index 00000000..b1d1535b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-type-erasure.md @@ -0,0 +1,134 @@ +# anti-type-erasure + +> Don't use Box when impl Trait works + +## Why It Matters + +`Box` (type erasure) introduces heap allocation and dynamic dispatch overhead. When you have a single concrete type or can use generics, `impl Trait` provides the same flexibility with zero overhead through monomorphization. + +## Bad + +```rust +// Unnecessary type erasure +fn get_iterator() -> Box> { + Box::new((0..10).map(|x| x * 2)) +} + +// Boxing for no reason +fn make_handler() -> Box i32> { + Box::new(|x| x + 1) +} + +// Vec of boxed trait objects when one type would do +fn get_validators() -> Vec> { + vec![ + Box::new(LengthValidator), + Box::new(RegexValidator), + ] +} +``` + +## Good + +```rust +// impl Trait - zero overhead, inlined +fn get_iterator() -> impl Iterator { + (0..10).map(|x| x * 2) +} + +// impl Fn - no boxing +fn make_handler() -> impl Fn(i32) -> i32 { + |x| x + 1 +} + +// When mixed types are genuinely needed, Box is OK +fn get_validators() -> Vec> { + // Actually different types at runtime - Box is appropriate + config.validators.iter() + .map(|v| v.create_validator()) + .collect() +} +``` + +## When to Use Box + +Type erasure IS appropriate when: + +```rust +// Heterogeneous collection of different types +let handlers: Vec> = vec![ + Box::new(LogHandler), + Box::new(MetricsHandler), + Box::new(AuthHandler), +]; + +// Type cannot be known at compile time +fn create_from_config(config: &Config) -> Box { + match config.db_type { + DbType::Postgres => Box::new(PostgresDb::new()), + DbType::Sqlite => Box::new(SqliteDb::new()), + } +} + +// Recursive types +struct Node { + value: i32, + children: Vec>, +} + +// Breaking cycles in complex ownership +struct EventLoop { + handlers: Vec>, +} +``` + +## Comparison + +| Approach | Allocation | Dispatch | Binary Size | +|----------|------------|----------|-------------| +| `impl Trait` | Stack/inline | Static | Larger (monomorphization) | +| `Box` | Heap | Dynamic | Smaller | +| Generics `` | Stack/inline | Static | Larger | + +## impl Trait Positions + +```rust +// Return position - caller doesn't need to know concrete type +fn process() -> impl Future { } + +// Argument position - like generics but simpler +fn handle(handler: impl Handler) { } + +// Can't use in trait definitions (use associated types instead) +trait Processor { + type Output: Display; // Not impl Display + fn process(&self) -> Self::Output; +} +``` + +## Pattern: Enum Instead of dyn + +```rust +// Instead of Box +enum Shape { + Circle { radius: f64 }, + Rectangle { width: f64, height: f64 }, + Triangle { base: f64, height: f64 }, +} + +impl Shape { + fn area(&self) -> f64 { + match self { + Shape::Circle { radius } => PI * radius * radius, + Shape::Rectangle { width, height } => width * height, + Shape::Triangle { base, height } => 0.5 * base * height, + } + } +} +``` + +## See Also + +- [anti-over-abstraction](./anti-over-abstraction.md) - Excessive generics +- [type-generic-bounds](./type-generic-bounds.md) - Generic constraints +- [mem-box-large-variant](./mem-box-large-variant.md) - Boxing enum variants diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md new file mode 100644 index 00000000..ca332612 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md @@ -0,0 +1,143 @@ +# anti-unwrap-abuse + +> Don't use `.unwrap()` in production code + +## Why It Matters + +`.unwrap()` panics on `None` or `Err`, crashing your program. In production, this means lost data, failed requests, and unhappy users. It also makes debugging harder since panic messages often lack context. + +## Bad + +```rust +// Crashes if file doesn't exist +let content = std::fs::read_to_string("config.toml").unwrap(); + +// Crashes on invalid input +let num: i32 = user_input.parse().unwrap(); + +// Crashes if key missing +let value = map.get("key").unwrap(); + +// Crashes if channel closed +let msg = receiver.recv().unwrap(); +``` + +## Good + +```rust +// Propagate with ? +fn load_config() -> Result { + let content = std::fs::read_to_string("config.toml")?; + Ok(toml::from_str(&content)?) +} + +// Provide default +let num: i32 = user_input.parse().unwrap_or(0); + +// Handle missing key +let value = map.get("key").ok_or(Error::MissingKey)?; + +// Or use if-let +if let Some(value) = map.get("key") { + process(value); +} + +// Channel with proper handling +match receiver.recv() { + Ok(msg) => handle(msg), + Err(_) => break, // Channel closed +} +``` + +## When unwrap() Is Acceptable + +```rust +// 1. Tests - panics are expected failures +#[test] +fn test_parse() { + let result = parse("valid").unwrap(); // OK in tests + assert_eq!(result, expected); +} + +// 2. Const/static initialization (compile-time guaranteed) +static REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^\d+$").unwrap() // Known-valid pattern +}); + +// 3. After a check that guarantees success +if map.contains_key("key") { + let value = map.get("key").unwrap(); // Just checked +} +// Better: use if-let or entry API instead + +// 4. Truly impossible cases with proof comment +let last = vec.pop().unwrap(); +// OK only if you just checked !vec.is_empty() +// Better: use last() or pattern match +``` + +## Alternatives to unwrap() + +```rust +// unwrap_or - provide default +let x = opt.unwrap_or(default); + +// unwrap_or_default - use Default trait +let x = opt.unwrap_or_default(); + +// unwrap_or_else - compute default lazily +let x = opt.unwrap_or_else(|| expensive_default()); + +// ? operator - propagate errors +let x = opt.ok_or(Error::Missing)?; + +// if let - handle Some/Ok case +if let Some(x) = opt { + use_x(x); +} + +// match - handle all cases +match opt { + Some(x) => use_x(x), + None => handle_none(), +} + +// map - transform if present +let y = opt.map(|x| x + 1); + +// and_then - chain fallible operations +let z = opt.and_then(|x| x.checked_add(1)); +``` + +## expect() Is Slightly Better + +```rust +// unwrap() - no context +let file = File::open(path).unwrap(); +// Panics with: "called `Result::unwrap()` on an `Err` value: Os { code: 2, ... }" + +// expect() - adds context +let file = File::open(path) + .expect("config file should exist at startup"); +// Panics with: "config file should exist at startup: Os { code: 2, ... }" + +// But still use only for invariants, not error handling +``` + +## Clippy Lint + +```rust +// Enable these lints to catch unwrap usage: +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] // Stricter + +// Or per-function: +#[allow(clippy::unwrap_used)] +fn tests_only() { } +``` + +## See Also + +- [err-question-mark](err-question-mark.md) - Use ? for propagation +- [err-result-over-panic](err-result-over-panic.md) - Return Result instead of panicking +- [anti-expect-lazy](anti-expect-lazy.md) - Don't use expect for recoverable errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-vec-for-slice.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-vec-for-slice.md new file mode 100644 index 00000000..ed50a038 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-vec-for-slice.md @@ -0,0 +1,121 @@ +# anti-vec-for-slice + +> Don't accept &Vec when &[T] works + +## Why It Matters + +`&Vec` is strictly less flexible than `&[T]`. A slice can be created from `Vec`, arrays, and other slice-like types. Accepting `&Vec` forces callers to have exactly a `Vec`, preventing them from using arrays, slices, or other collections. + +## Bad + +```rust +// Forces callers to have a Vec +fn sum(numbers: &Vec) -> i32 { + numbers.iter().sum() +} + +// Caller must allocate +let arr = [1, 2, 3, 4, 5]; +sum(&arr.to_vec()); // Unnecessary allocation + +// Slice won't work +let slice: &[i32] = &[1, 2, 3]; +// sum(slice); // Error: expected &Vec +``` + +## Good + +```rust +// Accept slice - works with Vec, arrays, slices +fn sum(numbers: &[i32]) -> i32 { + numbers.iter().sum() +} + +// All these work +sum(&[1, 2, 3, 4, 5]); // Array +sum(&vec![1, 2, 3]); // Vec +sum(&numbers[1..3]); // Slice of slice +sum(numbers.as_slice()); // Explicit slice +``` + +## Deref Coercion + +`Vec` implements `Deref`, so `&Vec` automatically coerces to `&[T]`: + +```rust +fn takes_slice(s: &[i32]) { } + +let vec = vec![1, 2, 3]; +takes_slice(&vec); // &Vec -> &[i32] via Deref +``` + +## Mutable Slices + +Same applies to `&mut`: + +```rust +// Bad +fn double(numbers: &mut Vec) { + for n in numbers.iter_mut() { + *n *= 2; + } +} + +// Good +fn double(numbers: &mut [i32]) { + for n in numbers.iter_mut() { + *n *= 2; + } +} +``` + +## When to Accept &Vec + +Rarely. Only when you need Vec-specific operations: + +```rust +fn needs_capacity(v: &Vec) -> usize { + v.capacity() // Only Vec has capacity +} + +fn might_grow(v: &mut Vec) { + v.push(42); // Slice can't push +} +``` + +## Pattern: Accepting Multiple Types + +```rust +// Accept anything that can be viewed as a slice +fn process>(data: T) { + let bytes: &[u8] = data.as_ref(); + // ... +} + +process(&[1u8, 2, 3]); // Array +process(vec![1u8, 2, 3]); // Vec +process(&some_vec); // &Vec +process(b"bytes"); // Byte string +``` + +## Similar Anti-patterns + +| Anti-pattern | Better | +|--------------|--------| +| `&Vec` | `&[T]` | +| `&String` | `&str` | +| `&PathBuf` | `&Path` | +| `&Box` | `&T` | + +## Clippy Detection + +```toml +[lints.clippy] +ptr_arg = "warn" # Catches &Vec, &String, &PathBuf +``` + +## See Also + +- [anti-string-for-str](./anti-string-for-str.md) - Similar for String +- [own-slice-over-vec](./own-slice-over-vec.md) - Slice patterns +- [api-impl-asref](./api-impl-asref.md) - AsRef pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-must-use.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-must-use.md new file mode 100644 index 00000000..988849dc --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-must-use.md @@ -0,0 +1,143 @@ +# api-builder-must-use + +> Mark builder methods with `#[must_use]` to prevent silent drops + +## Why It Matters + +Builder pattern methods return a modified builder. Without `#[must_use]`, calling a builder method and ignoring the return value silently does nothing—the builder is dropped, and the configuration is lost. This creates confusing bugs where code appears correct but has no effect. + +## Bad + +```rust +struct RequestBuilder { + url: String, + timeout: Option, + headers: Vec<(String, String)>, +} + +impl RequestBuilder { + fn timeout(mut self, duration: Duration) -> Self { + self.timeout = Some(duration); + self + } + + fn header(mut self, key: &str, value: &str) -> Self { + self.headers.push((key.to_string(), value.to_string())); + self + } +} + +// Bug: builder methods are ignored - no warning! +let request = RequestBuilder::new("https://api.example.com"); +request.timeout(Duration::from_secs(30)); // Dropped silently! +request.header("Authorization", "Bearer token"); // Dropped silently! +let response = request.send(); // Sends with no timeout or headers +``` + +## Good + +```rust +struct RequestBuilder { + url: String, + timeout: Option, + headers: Vec<(String, String)>, +} + +impl RequestBuilder { + #[must_use = "builder methods return modified builder - chain or assign"] + fn timeout(mut self, duration: Duration) -> Self { + self.timeout = Some(duration); + self + } + + #[must_use = "builder methods return modified builder - chain or assign"] + fn header(mut self, key: &str, value: &str) -> Self { + self.headers.push((key.to_string(), value.to_string())); + self + } +} + +// Now warns: unused return value that must be used +let request = RequestBuilder::new("https://api.example.com"); +request.timeout(Duration::from_secs(30)); // Warning! + +// Correct: chain methods +let response = RequestBuilder::new("https://api.example.com") + .timeout(Duration::from_secs(30)) + .header("Authorization", "Bearer token") + .send(); +``` + +## Apply to Entire Type + +```rust +#[must_use = "builders do nothing unless consumed"] +struct ConfigBuilder { + log_level: Level, + max_connections: usize, +} + +// Now all methods returning Self warn if ignored +impl ConfigBuilder { + fn log_level(mut self, level: Level) -> Self { + self.log_level = level; + self + } + + fn max_connections(mut self, n: usize) -> Self { + self.max_connections = n; + self + } + + fn build(self) -> Config { + Config { + log_level: self.log_level, + max_connections: self.max_connections, + } + } +} +``` + +## Message Guidelines + +```rust +// Descriptive message helps users understand +#[must_use = "builder methods return modified builder"] +fn with_foo(self, foo: Foo) -> Self { ... } + +#[must_use = "this creates a new String and does not modify the original"] +fn to_uppercase(&self) -> String { ... } + +#[must_use = "iterator adaptors are lazy - use .collect() to consume"] +fn map(self, f: F) -> Map { ... } +``` + +## Clippy Lint + +```toml +[lints.clippy] +must_use_candidate = "warn" # Suggests where #[must_use] would help +return_self_not_must_use = "warn" # Specifically for -> Self methods +``` + +## Standard Library Examples + +```rust +// std::Option - must_use on map, and, or +let x: Option = Some(5); +x.map(|v| v * 2); // Warning: unused return value + +// std::Result - must_use on the type itself +#[must_use = "this `Result` may be an `Err` variant, which should be handled"] +pub enum Result { ... } + +// Iterator adaptors +let v = vec![1, 2, 3]; +v.iter().map(|x| x * 2); // Warning: iterators are lazy +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Builder pattern best practices +- [api-must-use](./api-must-use.md) - General must_use guidelines +- [err-result-over-panic](./err-result-over-panic.md) - Result types are must_use diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-pattern.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-pattern.md new file mode 100644 index 00000000..f825d1dd --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-pattern.md @@ -0,0 +1,187 @@ +# api-builder-pattern + +> Use Builder pattern for complex construction + +## Why It Matters + +When a type has many optional parameters or complex initialization, the Builder pattern provides a clear, flexible API. It avoids constructors with many parameters (which are error-prone) and makes the code self-documenting. + +## Bad + +```rust +// Constructor with many parameters - hard to read, easy to get wrong +let client = Client::new( + "https://api.example.com", // Which is which? + 30, // Timeout? Retries? + true, // What does this mean? + None, + Some("auth_token"), + false, +); + +// Or many Option fields +struct Client { + url: String, + timeout: Option, + retries: Option, + // ... 10 more optional fields +} +``` + +## Good + +```rust +#[derive(Default)] +#[must_use = "builders do nothing unless you call build()"] +pub struct ClientBuilder { + base_url: Option, + timeout: Option, + max_retries: u32, + auth_token: Option, +} + +impl ClientBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Sets the base URL for all requests. + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = Some(url.into()); + self + } + + /// Sets the request timeout. Default is 30 seconds. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Sets the maximum number of retries. Default is 3. + pub fn max_retries(mut self, n: u32) -> Self { + self.max_retries = n; + self + } + + /// Sets the authentication token. + pub fn auth_token(mut self, token: impl Into) -> Self { + self.auth_token = Some(token.into()); + self + } + + /// Builds the client with the configured options. + pub fn build(self) -> Result { + let base_url = self.base_url + .ok_or(BuilderError::MissingBaseUrl)?; + + Ok(Client { + base_url, + timeout: self.timeout.unwrap_or(Duration::from_secs(30)), + max_retries: self.max_retries, + auth_token: self.auth_token, + }) + } +} + +// Usage - clear and self-documenting +let client = ClientBuilder::new() + .base_url("https://api.example.com") + .timeout(Duration::from_secs(10)) + .max_retries(5) + .auth_token("secret") + .build()?; +``` + +## Builder Variations + +```rust +// 1. Infallible builder (build() returns T, not Result) +impl WidgetBuilder { + pub fn build(self) -> Widget { + Widget { + color: self.color.unwrap_or(Color::Black), + size: self.size.unwrap_or(Size::Medium), + } + } +} + +// 2. Typestate builder (compile-time required field checking) +pub struct ClientBuilder { + url: Url, + timeout: Option, +} + +pub struct NoUrl; +pub struct HasUrl(String); + +impl ClientBuilder { + pub fn new() -> Self { + Self { url: NoUrl, timeout: None } + } + + pub fn url(self, url: String) -> ClientBuilder { + ClientBuilder { url: HasUrl(url), timeout: self.timeout } + } +} + +impl ClientBuilder { + pub fn build(self) -> Client { + // url is guaranteed to be set + Client { url: self.url.0, timeout: self.timeout } + } +} + +// 3. Consuming vs borrowing (consuming is more common) +// Consuming (takes self) +pub fn timeout(mut self, t: Duration) -> Self { ... } + +// Borrowing (takes &mut self, allows reuse) +pub fn timeout(&mut self, t: Duration) -> &mut Self { ... } +``` + +## Evidence from reqwest + +```rust +// https://github.com/seanmonstar/reqwest/blob/master/src/async_impl/client.rs + +#[must_use] +pub struct ClientBuilder { + config: Config, +} + +impl ClientBuilder { + pub fn new() -> ClientBuilder { + ClientBuilder { + config: Config::default(), + } + } + + pub fn timeout(mut self, timeout: Duration) -> ClientBuilder { + self.config.timeout = Some(timeout); + self + } + + pub fn build(self) -> Result { + // Validation and construction + } +} +``` + +## Key Attributes + +```rust +#[derive(Default)] // Enables MyBuilder::default() +#[must_use = "builders do nothing unless you call build()"] +pub struct MyBuilder { ... } + +impl MyBuilder { + #[must_use] // Each method should have this + pub fn option(mut self, value: T) -> Self { ... } +} +``` + +## See Also + +- [api-builder-must-use](api-builder-must-use.md) - Add #[must_use] to builders +- [api-typestate](api-typestate.md) - Compile-time state machines +- [api-impl-into](api-impl-into.md) - Accept impl Into for flexibility diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-common-traits.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-common-traits.md new file mode 100644 index 00000000..64a61ae0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-common-traits.md @@ -0,0 +1,165 @@ +# api-common-traits + +> Implement standard traits (Debug, Clone, PartialEq, etc.) for public types + +## Why It Matters + +Standard traits make your types interoperable with the Rust ecosystem. `Debug` enables `println!("{:?}")` and error messages. `Clone` allows explicit duplication. `PartialEq` enables `==`. Without these, users can't use your types in common patterns like testing, collections, or debugging. + +## Bad + +```rust +// Bare struct - severely limited usability +pub struct Point { + pub x: f64, + pub y: f64, +} + +// Can't debug +println!("{:?}", point); // Error: Debug not implemented + +// Can't compare +if point1 == point2 { } // Error: PartialEq not implemented + +// Can't use in HashMap +let mut map: HashMap = HashMap::new(); // Error: Hash not implemented + +// Can't clone +let copy = point.clone(); // Error: Clone not implemented +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +// Now everything works +println!("{:?}", point); +assert_eq!(point1, point2); +let copy = point; // Copy, not just Clone + +// For hashable types +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct UserId(u64); + +let mut map: HashMap = HashMap::new(); +``` + +## Trait Derivation Guide + +| Trait | Derive When | Requirements | +|-------|-------------|--------------| +| `Debug` | Always for public types | All fields implement Debug | +| `Clone` | Type can be duplicated | All fields implement Clone | +| `Copy` | Small, simple types | All fields implement Copy, no Drop | +| `PartialEq` | Comparison makes sense | All fields implement PartialEq | +| `Eq` | Total equality | PartialEq, no floating-point fields | +| `Hash` | Used as HashMap/HashSet key | Eq, consistent with PartialEq | +| `Default` | Sensible default exists | All fields implement Default | +| `PartialOrd` | Ordering makes sense | PartialEq, all fields implement PartialOrd | +| `Ord` | Total ordering | Eq + PartialOrd, no floating-point | + +## Common Trait Bundles + +```rust +// ID types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EntityId(u64); + +// Value types +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Vector2 { x: f32, y: f32 } + +// Configuration +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Config { + name: String, + options: HashMap, +} + +// Error types +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + InvalidSyntax(String), + UnexpectedToken(Token), +} +``` + +## Manual Implementations + +```rust +// When derive doesn't do what you want +struct CaseInsensitiveString(String); + +impl PartialEq for CaseInsensitiveString { + fn eq(&self, other: &Self) -> bool { + self.0.to_lowercase() == other.0.to_lowercase() + } +} + +impl Eq for CaseInsensitiveString {} + +impl Hash for CaseInsensitiveString { + fn hash(&self, state: &mut H) { + // Must be consistent with PartialEq + self.0.to_lowercase().hash(state); + } +} + +// Custom Debug for sensitive data +struct Password(String); + +impl Debug for Password { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Password([REDACTED])") + } +} +``` + +## Serde Traits + +```rust +use serde::{Serialize, Deserialize}; + +// For serializable types +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiResponse { + pub status: String, + pub data: Vec, +} + +// With custom serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub verbose: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} +``` + +## Minimum Recommended + +```rust +// At minimum, public types should have: +#[derive(Debug, Clone, PartialEq)] +pub struct MyType { ... } + +// Add based on use case: +// + Eq, Hash → for HashMap keys +// + Ord, PartialOrd → for BTreeMap, sorting +// + Default → for Option::unwrap_or_default() +// + Copy → for small value types +// + Serialize → for serialization +``` + +## See Also + +- [own-copy-small](./own-copy-small.md) - When to implement Copy +- [api-default-impl](./api-default-impl.md) - Implementing Default +- [doc-examples-section](./doc-examples-section.md) - Documenting trait implementations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-default-impl.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-default-impl.md new file mode 100644 index 00000000..be82a02a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-default-impl.md @@ -0,0 +1,177 @@ +# api-default-impl + +> Implement `Default` for types with sensible default values + +## Why It Matters + +`Default` is a standard trait that provides a canonical way to create a default instance. It integrates with many ecosystem patterns: `Option::unwrap_or_default()`, `#[derive(Default)]`, struct update syntax `..Default::default()`, and generic code that requires `T: Default`. Implementing it makes your types more ergonomic. + +## Bad + +```rust +struct Config { + timeout: Duration, + retries: u32, + verbose: bool, +} + +impl Config { + // Custom constructor - works but non-standard + fn new() -> Self { + Config { + timeout: Duration::from_secs(30), + retries: 3, + verbose: false, + } + } +} + +// Can't use with standard patterns +let config: Config = Default::default(); // Error: Default not implemented +let timeout = settings.get("timeout").unwrap_or_default(); // Won't work +``` + +## Good + +```rust +#[derive(Default)] +struct Config { + #[default = Duration::from_secs(30)] // Nightly, or implement manually + timeout: Duration, + retries: u32, // Defaults to 0 with derive + verbose: bool, // Defaults to false with derive +} + +// Or implement manually for custom defaults +impl Default for Config { + fn default() -> Self { + Config { + timeout: Duration::from_secs(30), + retries: 3, + verbose: false, + } + } +} + +// Now works with all standard patterns +let config = Config::default(); +let config = Config { retries: 5, ..Default::default() }; +let value = map.get("key").cloned().unwrap_or_default(); +``` + +## Derive vs Manual + +```rust +// Derive: all fields use their own Default +#[derive(Default)] +struct Simple { + count: u32, // 0 + name: String, // "" + items: Vec, // [] +} + +// Manual: when you need custom defaults +struct Connection { + host: String, + port: u16, + timeout: Duration, +} + +impl Default for Connection { + fn default() -> Self { + Connection { + host: "localhost".to_string(), + port: 8080, + timeout: Duration::from_secs(30), + } + } +} +``` + +## Builder with Default + +```rust +#[derive(Default)] +struct ServerBuilder { + host: String, + port: u16, + workers: usize, +} + +impl ServerBuilder { + fn host(mut self, host: impl Into) -> Self { + self.host = host.into(); + self + } + + fn port(mut self, port: u16) -> Self { + self.port = port; + self + } +} + +// Clean initialization +let server = ServerBuilder::default() + .host("0.0.0.0") + .port(3000) + .build(); +``` + +## Default with Required Fields + +```rust +// When some fields have no sensible default, don't implement Default +struct User { + id: UserId, // No sensible default + name: String, // Could default to "" +} + +// Instead, provide a constructor +impl User { + fn new(id: UserId, name: impl Into) -> Self { + User { id, name: name.into() } + } +} + +// Or use builder with required fields +struct UserBuilder { + id: Option, + name: String, +} + +impl Default for UserBuilder { + fn default() -> Self { + UserBuilder { + id: None, + name: String::new(), + } + } +} +``` + +## Generic Default + +```rust +// Require Default in generic bounds when needed +fn create_or_default(opt: Option) -> T { + opt.unwrap_or_default() +} + +// PhantomData is Default regardless of T +use std::marker::PhantomData; +struct Wrapper { + _marker: PhantomData, +} + +impl Default for Wrapper { + fn default() -> Self { + Wrapper { _marker: PhantomData } + } +} +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Building complex types +- [api-common-traits](./api-common-traits.md) - Other common traits to implement +- [api-from-not-into](./api-from-not-into.md) - Conversion traits diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-extension-trait.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-extension-trait.md new file mode 100644 index 00000000..92e8dd90 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-extension-trait.md @@ -0,0 +1,163 @@ +# api-extension-trait + +> Use extension traits to add methods to external types + +## Why It Matters + +Rust's orphan rules prevent implementing external traits on external types. Extension traits provide a workaround: define a new trait with your methods, then implement it for the external type. This pattern is used extensively in the ecosystem (e.g., `itertools::Itertools`, `tokio::AsyncReadExt`). + +## Bad + +```rust +// Can't add methods directly to external types +impl Vec { + fn as_hex(&self) -> String { + // Error: cannot define inherent impl for a type outside this crate + } +} + +// Can't implement external trait for external type +impl SomeExternalTrait for Vec { + // Error: orphan rules violation +} +``` + +## Good + +```rust +// Define an extension trait +pub trait ByteSliceExt { + fn as_hex(&self) -> String; + fn is_ascii_printable(&self) -> bool; +} + +// Implement for the external type +impl ByteSliceExt for [u8] { + fn as_hex(&self) -> String { + self.iter() + .map(|b| format!("{:02x}", b)) + .collect() + } + + fn is_ascii_printable(&self) -> bool { + self.iter().all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace()) + } +} + +// Usage: import the trait to use the methods +use my_crate::ByteSliceExt; + +let data: &[u8] = b"hello"; +println!("{}", data.as_hex()); // "68656c6c6f" +``` + +## Convention: Ext Suffix + +```rust +// Standard naming: TypeExt for extending Type +pub trait OptionExt { + fn unwrap_or_log(self, msg: &str) -> Option; +} + +impl OptionExt for Option { + fn unwrap_or_log(self, msg: &str) -> Option { + if self.is_none() { + log::warn!("{}", msg); + } + self + } +} + +// For generic extensions +pub trait ResultExt { + fn log_err(self) -> Self; +} + +impl ResultExt for Result { + fn log_err(self) -> Self { + if let Err(ref e) = self { + log::error!("{}", e); + } + self + } +} +``` + +## Ecosystem Examples + +```rust +// itertools::Itertools +use itertools::Itertools; +let groups = vec![1, 1, 2, 2, 3].into_iter().group_by(|x| *x); + +// futures::StreamExt +use futures::StreamExt; +let next = stream.next().await; + +// tokio::io::AsyncReadExt +use tokio::io::AsyncReadExt; +let mut buf = [0u8; 1024]; +reader.read(&mut buf).await?; + +// anyhow::Context +use anyhow::Context; +let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path))?; +``` + +## Scoped Extensions + +```rust +// Extension only visible where imported +mod string_utils { + pub trait StringExt { + fn truncate_ellipsis(&self, max_len: usize) -> String; + } + + impl StringExt for str { + fn truncate_ellipsis(&self, max_len: usize) -> String { + if self.len() <= max_len { + self.to_string() + } else { + format!("{}...", &self[..max_len.saturating_sub(3)]) + } + } + } +} + +// Only available when explicitly imported +use string_utils::StringExt; +let short = "very long string".truncate_ellipsis(10); +``` + +## Generic Extensions with Bounds + +```rust +pub trait VecExt { + fn push_if_unique(&mut self, item: T) + where + T: PartialEq; +} + +impl VecExt for Vec { + fn push_if_unique(&mut self, item: T) + where + T: PartialEq, + { + if !self.contains(&item) { + self.push(item); + } + } +} + +// Works with any T: PartialEq +let mut v = vec![1, 2, 3]; +v.push_if_unique(2); // No-op +v.push_if_unique(4); // Adds 4 +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Controlling trait implementations +- [api-impl-into](./api-impl-into.md) - Using standard conversion traits +- [name-as-free](./name-as-free.md) - Naming conventions for conversions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-from-not-into.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-from-not-into.md new file mode 100644 index 00000000..8500a805 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-from-not-into.md @@ -0,0 +1,146 @@ +# api-from-not-into + +> Implement `From`, not `Into` - From gives you Into for free + +## Why It Matters + +The standard library has a blanket implementation: `impl Into for T where U: From`. This means implementing `From for U` automatically gives you `Into for T`. Implementing `Into` directly bypasses this and is considered non-idiomatic. Always implement `From`. + +## Bad + +```rust +struct UserId(u64); + +// Non-idiomatic: implementing Into directly +impl Into for u64 { + fn into(self) -> UserId { + UserId(self) + } +} + +// Works, but now you can't use From syntax +let id = UserId::from(42); // Error: From not implemented +let id: UserId = 42.into(); // Works, but limited +``` + +## Good + +```rust +struct UserId(u64); + +// Idiomatic: implement From +impl From for UserId { + fn from(id: u64) -> Self { + UserId(id) + } +} + +// Now both work automatically +let id = UserId::from(42); // From syntax +let id: UserId = 42.into(); // Into syntax (via blanket impl) + +// And Into bound works in generics +fn process(id: impl Into) { + let id: UserId = id.into(); +} +process(42u64); // Works! +``` + +## Blanket Implementation + +```rust +// This is in std, you don't write it +impl Into for T +where + U: From, +{ + fn into(self) -> U { + U::from(self) + } +} + +// So when you implement From: +impl From for MyType { ... } + +// You automatically get: +// impl Into for String { ... } +``` + +## Multiple From Implementations + +```rust +struct Email(String); + +impl From for Email { + fn from(s: String) -> Self { + Email(s) + } +} + +impl From<&str> for Email { + fn from(s: &str) -> Self { + Email(s.to_string()) + } +} + +// All of these work +let e1 = Email::from("test@example.com"); +let e2 = Email::from(String::from("test@example.com")); +let e3: Email = "test@example.com".into(); +let e4: Email = String::from("test@example.com").into(); +``` + +## TryFrom for Fallible Conversions + +```rust +use std::convert::TryFrom; + +struct PositiveInt(u32); + +// Fallible conversion +impl TryFrom for PositiveInt { + type Error = &'static str; + + fn try_from(value: i32) -> Result { + if value > 0 { + Ok(PositiveInt(value as u32)) + } else { + Err("value must be positive") + } + } +} + +// Usage +let pos = PositiveInt::try_from(42)?; // From-style +let pos: PositiveInt = 42.try_into()?; // Into-style (via blanket) +``` + +## Clippy Lint + +```toml +[lints.clippy] +from_over_into = "warn" # Warns when implementing Into instead of From +``` + +```rust +// Clippy will warn: +impl Into for Foo { // Warning: prefer From + fn into(self) -> Bar { ... } +} +``` + +## When Into IS Needed (Rare) + +```rust +// Only when implementing for external types in specific trait bounds +// This is very rare and usually indicates a design issue + +// Example: you can't implement From for ExternalB +// because of orphan rules. But you usually shouldn't need to. +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - Using Into in function parameters +- [err-from-impl](./err-from-impl.md) - From for error types +- [api-newtype-safety](./api-newtype-safety.md) - Newtype conversions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-asref.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-asref.md new file mode 100644 index 00000000..688f8cc0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-asref.md @@ -0,0 +1,142 @@ +# api-impl-asref + +> Use `AsRef` when you only need to borrow the inner data + +## Why It Matters + +`AsRef` provides a cheap borrowed view of data without taking ownership or copying. Functions accepting `impl AsRef` can work with multiple types that contain or represent `T`, making APIs flexible while avoiding unnecessary allocations. Use `AsRef` when you only need to read, `Into` when you need to own. + +## Bad + +```rust +// Forces callers to provide exact types +fn process_text(text: &str) { ... } +fn read_file(path: &Path) { ... } + +// Can't call directly with owned types +let s = String::from("hello"); +process_text(&s); // Works but verbose + +let p = PathBuf::from("/file"); +read_file(&p); // Works but verbose +read_file("/file"); // Error! &str != &Path +``` + +## Good + +```rust +// Accept anything that can be viewed as the target type +fn process_text(text: impl AsRef) { + let s: &str = text.as_ref(); + println!("{}", s); +} + +fn read_file(path: impl AsRef) -> io::Result> { + std::fs::read(path.as_ref()) +} + +// All of these work: +process_text("literal"); // &str +process_text(String::from("owned")); // String +process_text(Cow::from("cow")); // Cow + +read_file("/path/to/file"); // &str +read_file(Path::new("/path")); // &Path +read_file(PathBuf::from("/path")); // PathBuf +read_file(OsStr::new("/path")); // &OsStr +``` + +## AsRef vs Into vs Borrow + +```rust +// AsRef: cheap borrow, no ownership transfer +fn read(p: impl AsRef) { + let path: &Path = p.as_ref(); +} + +// Into: ownership transfer, may allocate +fn store(p: impl Into) { + let owned: PathBuf = p.into(); +} + +// Borrow: like AsRef but with Eq/Hash consistency guarantee +use std::borrow::Borrow; +fn lookup(map: &HashMap, key: &Q) -> Option<&V> +where + String: Borrow, + Q: Hash + Eq, +{ + map.get(key) +} +``` + +## Implement AsRef for Custom Types + +```rust +struct Name(String); + +impl AsRef for Name { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef<[u8]> for Name { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } +} + +// Now Name works with functions expecting AsRef +fn greet(name: impl AsRef) { + println!("Hello, {}!", name.as_ref()); +} + +greet(Name("Alice".into())); +``` + +## Common AsRef Implementations + +```rust +// Standard library provides many +impl AsRef for String { ... } +impl AsRef for str { ... } +impl AsRef<[u8]> for str { ... } +impl AsRef<[u8]> for String { ... } +impl AsRef<[u8]> for Vec { ... } +impl AsRef for str { ... } +impl AsRef for String { ... } +impl AsRef for PathBuf { ... } +impl AsRef for OsStr { ... } +impl AsRef for str { ... } +``` + +## When to Use Which + +| Trait | Use When | +|-------|----------| +| `&T` | Single type, simple API | +| `AsRef` | Read-only access, multiple input types | +| `Into` | Need to store/own the value | +| `Borrow` | HashMap/HashSet keys, Eq/Hash needed | +| `Deref` | Smart pointer semantics | + +## Pattern: Optional AsRef Bound + +```rust +// When T itself might be passed +fn process, U>(value: T) { + let inner: &U = value.as_ref(); +} + +// More flexible: accept T or &T +fn process + ?Sized, U: ?Sized>(value: &T) { + let inner: &U = value.as_ref(); +} +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - When to use Into instead +- [own-slice-over-vec](./own-slice-over-vec.md) - Using slices for flexibility +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Preferring borrows diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-into.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-into.md new file mode 100644 index 00000000..9d670752 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-into.md @@ -0,0 +1,160 @@ +# api-impl-into + +> Accept `impl Into` for flexible APIs, implement `From` for conversions + +## Why It Matters + +APIs that accept `impl Into` are ergonomic—callers can pass the target type directly or any type that converts to it. This reduces boilerplate `.into()` calls at call sites. Implement `From` rather than `Into` because `From` implies `Into` through a blanket implementation. + +## Bad + +```rust +// Requires exact type - forces callers to convert +fn process_path(path: PathBuf) { ... } +fn set_name(name: String) { ... } + +// Caller must convert explicitly +process_path(PathBuf::from("/path/to/file")); +process_path("/path/to/file".to_path_buf()); // Verbose +process_path("/path/to/file".into()); // Explicit + +set_name(String::from("Alice")); +set_name("Alice".to_string()); // Verbose +``` + +## Good + +```rust +// Accept anything that converts to the target type +fn process_path(path: impl Into) { + let path = path.into(); // Convert once inside + // ... +} + +fn set_name(name: impl Into) { + let name = name.into(); + // ... +} + +// Callers are ergonomic +process_path("/path/to/file"); // &str converts automatically +process_path(PathBuf::from(".")); // PathBuf works too + +set_name("Alice"); // &str +set_name(String::from("Alice")); // String +set_name(format!("User-{}", id)); // String from format! +``` + +## Implement From, Not Into + +```rust +struct UserId(u64); + +// ✅ Implement From +impl From for UserId { + fn from(id: u64) -> Self { + UserId(id) + } +} + +// Into is automatically provided by blanket impl +let id: UserId = 42u64.into(); // Works! + +// ❌ Don't implement Into directly +impl Into for u64 { + fn into(self) -> UserId { + UserId(self) // This works but is non-idiomatic + } +} +``` + +## Common Conversions + +```rust +// String-like types +fn log_message(msg: impl Into) { ... } +log_message("literal"); // &str +log_message(String::from("own")); // String +log_message(Cow::from("cow")); // Cow + +// Path-like types +fn read_file(path: impl AsRef) { ... } // AsRef for borrowed access +fn write_file(path: impl Into) { ... } // Into when storing + +// Duration +fn set_timeout(duration: impl Into) { ... } +set_timeout(Duration::from_secs(5)); +// Note: no blanket impl for integers, would need custom wrapper +``` + +## AsRef vs Into + +```rust +// AsRef: borrow as &T, no conversion cost +fn count_bytes(data: impl AsRef<[u8]>) -> usize { + data.as_ref().len() // Just borrows, no allocation +} +count_bytes("hello"); // &str -> &[u8] +count_bytes(b"hello"); // &[u8] -> &[u8] +count_bytes(vec![1, 2, 3]); // &Vec -> &[u8] + +// Into: convert to owned T, may allocate +fn store_data(data: impl Into>) { + let owned: Vec = data.into(); // Takes ownership + // ... +} +``` + +## When NOT to Use impl Into + +```rust +// ❌ Trait objects need Sized +fn process(handler: impl Into>) { } +// Better: just take Box directly + +// ❌ Recursive types +struct Node { + children: Vec>, // Error: impl Trait not allowed here +} + +// ❌ Performance-critical hot paths (minor overhead of trait dispatch) +fn hot_path(value: impl Into) { + // Consider taking u64 directly if called billions of times +} + +// ❌ When you need to name the type +fn returns_impl() -> impl Into { } // Opaque, hard to use +``` + +## Builder Pattern with Into + +```rust +struct Config { + name: String, + path: PathBuf, +} + +impl Config { + fn new(name: impl Into) -> Self { + Config { + name: name.into(), + path: PathBuf::new(), + } + } + + fn path(mut self, path: impl Into) -> Self { + self.path = path.into(); + self + } +} + +// Clean builder calls +let config = Config::new("myapp") + .path("/etc/myapp"); +``` + +## See Also + +- [api-impl-asref](./api-impl-asref.md) - When to use AsRef instead +- [api-from-not-into](./api-from-not-into.md) - Why From is preferred +- [err-from-impl](./err-from-impl.md) - From for error conversion diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-must-use.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-must-use.md new file mode 100644 index 00000000..1e94a217 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-must-use.md @@ -0,0 +1,125 @@ +# api-must-use + +> Mark types and functions with `#[must_use]` when ignoring results is likely a bug + +## Why It Matters + +Some return values should never be ignored—`Result`, locks, RAII guards, computed values that have no side effects. Without `#[must_use]`, silently discarding these values can introduce subtle bugs that are hard to detect. The attribute generates compiler warnings when the value is unused. + +## Bad + +```rust +// Result ignored - error silently dropped +fn send_email(to: &str, body: &str) -> Result<(), EmailError> { ... } + +send_email("user@example.com", "Hello!"); // No warning if Result ignored! +// Email may have failed, but we don't know + +// Computed value ignored - likely a bug +fn compute_checksum(data: &[u8]) -> u32 { ... } + +let data = vec![1, 2, 3, 4]; +compute_checksum(&data); // Result discarded - pointless call +``` + +## Good + +```rust +#[must_use = "this `Result` may be an `Err` that should be handled"] +fn send_email(to: &str, body: &str) -> Result<(), EmailError> { ... } + +send_email("user@example.com", "Hello!"); +// Warning: unused `Result` that must be used + +// Mark pure functions +#[must_use = "this returns a new value and does not modify the input"] +fn compute_checksum(data: &[u8]) -> u32 { ... } + +compute_checksum(&data); +// Warning: unused return value of `compute_checksum` that must be used +``` + +## Apply to Types + +```rust +// Mark the type itself when it should always be used +#[must_use = "futures do nothing unless polled"] +struct MyFuture { ... } + +// Mark RAII guards +#[must_use = "if unused, the lock will be immediately released"] +struct MutexGuard<'a, T> { ... } + +// Mark results/errors +#[must_use = "errors should be handled"] +enum AppError { ... } +``` + +## Standard Library Examples + +```rust +// Result and Option are #[must_use] +let v: Vec = vec![1, 2, 3]; +v.first(); // Warning: unused Option + +// Iterator adapters are #[must_use] +v.iter().map(|x| x * 2); // Warning: iterators are lazy + +// String methods that return new values +let s = "hello"; +s.to_uppercase(); // Warning: unused String +``` + +## When to Apply + +```rust +// ✅ Pure functions (no side effects) +#[must_use] +fn add(a: i32, b: i32) -> i32 { a + b } + +// ✅ Builder methods returning Self +#[must_use = "builder methods return a new builder"] +fn with_timeout(self, t: Duration) -> Self { ... } + +// ✅ Fallible operations +#[must_use] +fn try_parse(s: &str) -> Result { ... } + +// ✅ Iterators and futures (lazy) +#[must_use = "iterators are lazy and do nothing unless consumed"] +struct Map { ... } + +// ❌ Side-effecting functions where result is optional +fn log(msg: &str) -> Result<(), io::Error> { ... } // Might be ok to ignore + +// ❌ Methods with useful side effects +fn vec.push(item); // Mutates vec, no return to use +``` + +## Custom Messages + +```rust +#[must_use = "creating a guard does nothing without assignment"] +struct ScopeGuard { ... } + +#[must_use = "this returns the old value"] +fn replace(&mut self, new: T) -> T { ... } + +#[must_use = "use `.await` to execute the future"] +async fn fetch() -> Data { ... } +``` + +## Clippy Lints + +```toml +[lints.clippy] +must_use_candidate = "warn" # Suggests where to add #[must_use] +unused_must_use = "deny" # Built-in, treat warnings as errors +double_must_use = "warn" # Redundant #[must_use] +``` + +## See Also + +- [api-builder-must-use](./api-builder-must-use.md) - Builder pattern must_use +- [err-result-over-panic](./err-result-over-panic.md) - Result types require handling +- [lint-deny-correctness](./lint-deny-correctness.md) - Enabling useful lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-newtype-safety.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-newtype-safety.md new file mode 100644 index 00000000..9afbb09f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-newtype-safety.md @@ -0,0 +1,162 @@ +# api-newtype-safety + +> Use newtypes to prevent mixing semantically different values + +## Why It Matters + +Raw primitives like `u64` or `String` carry no semantic meaning. A function taking `(u64, u64)` can easily be called with arguments swapped. Newtypes wrap primitives in distinct types, making the compiler catch mistakes at compile time rather than runtime. + +## Bad + +```rust +struct User { + id: u64, + group_id: u64, + created_at: u64, // Unix timestamp +} + +fn add_user_to_group(user_id: u64, group_id: u64) { ... } + +// Bug: arguments swapped - compiles fine, fails at runtime +let user = User { id: 100, group_id: 5, created_at: 1234567890 }; +add_user_to_group(user.group_id, user.id); // Silent bug! + +// Bug: wrong field used - timestamp passed as ID +add_user_to_group(user.created_at, user.group_id); // Compiles fine! +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct UserId(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct GroupId(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct Timestamp(u64); + +struct User { + id: UserId, + group_id: GroupId, + created_at: Timestamp, +} + +fn add_user_to_group(user_id: UserId, group_id: GroupId) { ... } + +// Compile error: expected UserId, found GroupId +let user = User { ... }; +add_user_to_group(user.group_id, user.id); // Error! + +// Compile error: expected UserId, found Timestamp +add_user_to_group(user.created_at, user.group_id); // Error! +``` + +## Derive Common Traits + +```rust +// Minimal: just enough for your use case +#[derive(Debug, Clone, Copy)] +struct MeterId(u32); + +// Full ID type: hashable, comparable, displayable +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct OrderId(u64); + +impl std::fmt::Display for OrderId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ORD-{:08}", self.0) + } +} + +// With serde for serialization +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] // Serializes as raw u64 +struct ProductId(u64); +``` + +## Constructor Patterns + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct Email(String); + +impl Email { + /// Creates a new Email, validating the format. + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(EmailError::InvalidFormat) + } + } + + /// Returns the email as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// Usage enforces validation +let email = Email::new("user@example.com")?; // Must go through validation +``` + +## Zero-Cost Abstraction + +```rust +use std::mem::size_of; + +#[derive(Clone, Copy)] +struct Miles(f64); + +#[derive(Clone, Copy)] +struct Kilometers(f64); + +// Same size as raw f64 +assert_eq!(size_of::(), size_of::()); +assert_eq!(size_of::(), size_of::()); + +// But can't accidentally mix them +fn drive(distance: Miles) { ... } + +let km = Kilometers(100.0); +drive(km); // Error: expected Miles, found Kilometers + +// Explicit conversion +impl From for Miles { + fn from(km: Kilometers) -> Self { + Miles(km.0 * 0.621371) + } +} + +drive(km.into()); // Explicit, visible conversion +``` + +## When Newtypes Help Most + +```rust +// ✅ IDs that could be confused +fn transfer(from: AccountId, to: AccountId, amount: Money) { ... } + +// ✅ Units that shouldn't mix +struct Celsius(f64); +struct Fahrenheit(f64); + +// ✅ Validated strings +struct Username(String); // Validated alphanumeric +struct Password(String); // Never logged + +// ✅ Different meanings of same type +struct Milliseconds(u64); +struct Seconds(u64); + +// ❌ Overkill: single use, no confusion possible +struct X(i32); // Just use i32 +``` + +## See Also + +- [type-newtype-ids](./type-newtype-ids.md) - Newtype pattern for IDs +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven validation +- [own-copy-small](./own-copy-small.md) - Making newtypes Copy diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-non-exhaustive.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-non-exhaustive.md new file mode 100644 index 00000000..621e4bb5 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-non-exhaustive.md @@ -0,0 +1,177 @@ +# api-non-exhaustive + +> Use `#[non_exhaustive]` on public enums and structs for forward compatibility + +## Why It Matters + +Adding a variant to a public enum or a field to a public struct is normally a breaking change—downstream code may match exhaustively or use struct literal syntax. `#[non_exhaustive]` forces external code to use wildcards in matches and constructors, allowing you to add variants/fields in minor versions without breaking callers. + +## Bad + +```rust +// Public enum - adding variant breaks downstream matches +pub enum ErrorKind { + NotFound, + PermissionDenied, + TimedOut, +} + +// Downstream code +match error.kind() { + ErrorKind::NotFound => ..., + ErrorKind::PermissionDenied => ..., + ErrorKind::TimedOut => ..., + // No wildcard - will break when you add ErrorKind::Interrupted +} + +// Public struct - adding field breaks downstream construction +pub struct Config { + pub name: String, + pub value: i32, +} + +// Downstream code +let config = Config { name: "test".into(), value: 42 }; +// Will break when you add `pub enabled: bool` +``` + +## Good + +```rust +// Can add variants in minor versions +#[non_exhaustive] +pub enum ErrorKind { + NotFound, + PermissionDenied, + TimedOut, + // Future: can add Interrupted here without breaking changes +} + +// Downstream code MUST have wildcard +match error.kind() { + ErrorKind::NotFound => ..., + ErrorKind::PermissionDenied => ..., + ErrorKind::TimedOut => ..., + _ => ..., // Required by non_exhaustive +} + +// Can add fields in minor versions +#[non_exhaustive] +pub struct Config { + pub name: String, + pub value: i32, +} + +// Downstream CANNOT use struct literal syntax +// let config = Config { name: "test".into(), value: 42 }; // Error! + +// Must use constructor +impl Config { + pub fn new(name: impl Into, value: i32) -> Self { + Config { name: name.into(), value } + } +} +``` + +## How It Works + +```rust +#[non_exhaustive] +pub enum Status { + Active, + Inactive, +} + +// Inside your crate: exhaustive match is allowed +fn internal(s: Status) { + match s { + Status::Active => {}, + Status::Inactive => {}, + // No wildcard needed inside defining crate + } +} + +// Outside your crate: wildcard required +fn external(s: my_crate::Status) { + match s { + my_crate::Status::Active => {}, + my_crate::Status::Inactive => {}, + _ => {}, // REQUIRED + } +} +``` + +## Struct Usage + +```rust +#[non_exhaustive] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + // Provide constructor + pub fn new(x: f64, y: f64) -> Self { + Point { x, y } + } +} + +// External code can read fields but not construct with literals +fn external(p: Point) { + println!("x: {}, y: {}", p.x, p.y); // Reading is fine + + // let p2 = Point { x: 1.0, y: 2.0 }; // Error! + let p2 = Point::new(1.0, 2.0); // Must use constructor +} +``` + +## Non-Exhaustive Variants + +```rust +pub enum Message { + // Specific variant is non-exhaustive + #[non_exhaustive] + Error { code: u32, message: String }, + + Ok(Data), +} + +// Can destructure Ok normally +// But Error requires `..` to handle future fields +match msg { + Message::Ok(data) => {}, + Message::Error { code, message, .. } => {}, // `..` required +} +``` + +## When to Use + +```rust +// ✅ Use for public API types that may evolve +#[non_exhaustive] +pub enum ApiError { ... } + +#[non_exhaustive] +pub struct Options { ... } + +// ✅ Use for error types +#[non_exhaustive] +pub enum MyError { ... } + +// ❌ Don't use for internal types +enum InternalState { ... } // Not public, no concern + +// ❌ Don't use for stable, complete types +pub enum Ordering { // Less, Equal, Greater is complete + Less, + Equal, + Greater, +} +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Controlling trait implementations +- [err-custom-type](./err-custom-type.md) - Error type design +- [api-builder-pattern](./api-builder-pattern.md) - Alternative to struct literals diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-parse-dont-validate.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-parse-dont-validate.md new file mode 100644 index 00000000..8328cfc7 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-parse-dont-validate.md @@ -0,0 +1,184 @@ +# api-parse-dont-validate + +> Parse into validated types at boundaries + +## Why It Matters + +Instead of validating data and hoping you remember to check everywhere, parse it into a type that can only be constructed from valid data. The type system then guarantees validity - you can't forget to validate because invalid states are unrepresentable. + +## Bad + +```rust +// Validation scattered throughout codebase +fn send_email(email: &str) -> Result<(), Error> { + // Did someone validate this already? Who knows! + if !is_valid_email(email) { + return Err(Error::InvalidEmail); + } + // Send email... +} + +fn add_to_mailing_list(email: &str) -> Result<(), Error> { + // Duplicate validation, or did we forget? + if !is_valid_email(email) { + return Err(Error::InvalidEmail); + } + // Add to list... +} + +// Easy to forget validation +fn process_user_email(email: &str) { + // Oops, no validation! + database.store_email(email); +} +``` + +## Good + +```rust +/// A validated email address. +/// Can only be constructed via `Email::parse()`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Email(String); + +impl Email { + /// Parses and validates an email address. + pub fn parse(s: impl Into) -> Result { + let s = s.into(); + if Self::is_valid(&s) { + Ok(Email(s)) + } else { + Err(EmailError::Invalid) + } + } + + fn is_valid(s: &str) -> bool { + s.contains('@') && s.len() > 3 // Simplified + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// Now functions can accept Email - guaranteed valid! +fn send_email(email: &Email) -> Result<(), Error> { + // No validation needed - Email is always valid + smtp_send(email.as_str()) +} + +fn add_to_mailing_list(email: Email) { + // No validation needed + list.push(email); +} +``` + +## More Examples + +```rust +// Port number (1-65535) +pub struct Port(u16); + +impl Port { + pub fn new(n: u16) -> Option { + if n > 0 { Some(Port(n)) } else { None } + } + + pub fn get(&self) -> u16 { + self.0 + } +} + +// Non-empty string +pub struct NonEmptyString(String); + +impl NonEmptyString { + pub fn new(s: impl Into) -> Option { + let s = s.into(); + if s.is_empty() { None } else { Some(Self(s)) } + } +} + +// Positive integer +pub struct PositiveI32(i32); + +impl PositiveI32 { + pub fn new(n: i32) -> Option { + if n > 0 { Some(Self(n)) } else { None } + } +} + +// Bounded value +pub struct Percentage(u8); + +impl Percentage { + pub fn new(n: u8) -> Option { + if n <= 100 { Some(Self(n)) } else { None } + } +} +``` + +## Parsing at Boundaries + +```rust +// Parse at the system boundary (API, CLI, config file) +fn handle_request(raw: RawRequest) -> Result { + // Parse ALL inputs upfront + let email = Email::parse(&raw.email)?; + let age = Age::parse(raw.age)?; + let username = Username::parse(&raw.username)?; + + // Now work with validated types + process_user(email, age, username) +} + +fn process_user(email: Email, age: Age, username: Username) { + // All inputs guaranteed valid - no checks needed +} +``` + +## Evidence from sqlx + +```rust +// sqlx parses SQL at compile time, ensuring query validity +// https://github.com/launchbadge/sqlx/blob/master/src/macros/mod.rs + +// The query! macro parses and validates SQL +let user = sqlx::query!("SELECT * FROM users WHERE id = ?", id) + .fetch_one(&pool) + .await?; + +// If SQL is invalid, compilation fails - invalid state unrepresentable +``` + +## Combining with Display + +```rust +use std::fmt; + +pub struct Email(String); + +impl Email { + pub fn parse(s: &str) -> Result { ... } +} + +// Implement Display for easy printing +impl fmt::Display for Email { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// Implement AsRef for easy borrowing +impl AsRef for Email { + fn as_ref(&self) -> &str { + &self.0 + } +} +``` + +## See Also + +- [api-newtype-safety](api-newtype-safety.md) - Use newtypes for type safety +- [type-newtype-validated](type-newtype-validated.md) - Newtypes for validated data +- [api-typestate](api-typestate.md) - Compile-time state machines diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-sealed-trait.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-sealed-trait.md new file mode 100644 index 00000000..0d2df3a8 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-sealed-trait.md @@ -0,0 +1,168 @@ +# api-sealed-trait + +> Use sealed traits to prevent external implementations while allowing use + +## Why It Matters + +Public traits can be implemented by anyone, which may be undesirable when you need to guarantee behavior or add methods in future versions. A sealed trait can be used by external code but not implemented by it, giving you control over implementations while maintaining a usable API. + +## Bad + +```rust +// Anyone can implement this trait +pub trait DatabaseDriver { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; +} + +// External crate implements it incorrectly +impl DatabaseDriver for MyBadDriver { + fn connect(&self, url: &str) -> Connection { + // Buggy implementation that doesn't handle errors + unsafe { force_connect(url) } + } +} + +// Later, you want to add a required method - BREAKING CHANGE +pub trait DatabaseDriver { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; + fn transaction(&self) -> Transaction; // External impls now broken! +} +``` + +## Good + +```rust +// Create a private module with a private trait +mod private { + pub trait Sealed {} +} + +// Public trait requires the private trait +pub trait DatabaseDriver: private::Sealed { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; +} + +// Only your crate can implement Sealed, thus DatabaseDriver +pub struct PostgresDriver; +impl private::Sealed for PostgresDriver {} +impl DatabaseDriver for PostgresDriver { + fn connect(&self, url: &str) -> Connection { ... } + fn execute(&self, query: &str) -> Result { ... } +} + +pub struct MySqlDriver; +impl private::Sealed for MySqlDriver {} +impl DatabaseDriver for MySqlDriver { + fn connect(&self, url: &str) -> Connection { ... } + fn execute(&self, query: &str) -> Result { ... } +} + +// External crate cannot implement - private::Sealed is not accessible +// impl DatabaseDriver for ExternalDriver { } // Error! + +// But external code CAN use the trait +fn use_driver(driver: &impl DatabaseDriver) { + let conn = driver.connect("postgres://localhost"); +} +``` + +## Full Pattern + +```rust +pub mod db { + mod private { + pub trait Sealed {} + } + + /// Database driver trait. + /// + /// This trait is sealed and cannot be implemented outside this crate. + pub trait Driver: private::Sealed { + /// Connects to the database. + fn connect(&self, url: &str) -> Result; + + /// Executes a query. + fn execute(&self, sql: &str) -> Result; + } + + pub struct Postgres; + impl private::Sealed for Postgres {} + impl Driver for Postgres { ... } + + pub struct Sqlite; + impl private::Sealed for Sqlite {} + impl Driver for Sqlite { ... } +} + +// Usage works fine +use db::{Driver, Postgres}; + +fn query(driver: &impl Driver) { + driver.execute("SELECT 1")?; +} + +query(&Postgres); +``` + +## Benefits of Sealing + +```rust +// 1. Add methods without breaking changes +pub trait Format: private::Sealed { + fn format(&self) -> String; + + // Added later - not breaking because no external impls exist + fn format_pretty(&self) -> String { + self.format() // Default implementation + } +} + +// 2. Guarantee invariants +pub trait SafeBuffer: private::Sealed { + // You control all implementations, so you know they're all correct + fn get(&self, index: usize) -> Option<&u8>; +} + +// 3. Use as marker traits +pub trait ValidConfig: private::Sealed {} +// Only validated configs implement this +``` + +## Partially Sealed + +```rust +// Allow implementing some methods but not all +mod private { + pub trait SealedCore {} +} + +pub trait Plugin: private::SealedCore { + // Sealed - only we implement + fn initialize(&self); + fn shutdown(&self); + + // Open - users can override + fn name(&self) -> &str { "unnamed" } +} + +// Only we can add new required sealed methods +// Users can customize open methods +``` + +## When to Seal + +| Seal When | Don't Seal When | +|-----------|-----------------| +| API stability is critical | You want extension points | +| Implementation correctness is hard | Users need custom implementations | +| You'll add methods later | Trait is simple and stable | +| Safety invariants required | Standard patterns (Iterator, etc.) | + +## See Also + +- [api-non-exhaustive](./api-non-exhaustive.md) - Related pattern for enums/structs +- [api-extension-trait](./api-extension-trait.md) - Adding methods to external types +- [api-typestate](./api-typestate.md) - Compile-time state guarantees diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-serde-optional.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-serde-optional.md new file mode 100644 index 00000000..022ffbd2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-serde-optional.md @@ -0,0 +1,182 @@ +# api-serde-optional + +> Make serde a feature flag, not a hard dependency for library crates + +## Why It Matters + +Not all users of your library need serialization. Making serde a required dependency adds compile time and binary size for everyone. Feature flags let users opt-in to serde support only when needed, following Rust's philosophy of zero-cost abstractions and minimal dependencies. + +## Bad + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"] } + +// lib.rs +use serde::{Serialize, Deserialize}; + +// Every user pays for serde, even if they don't need it +#[derive(Serialize, Deserialize)] +pub struct Config { + pub name: String, + pub value: i32, +} +``` + +## Good + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"], optional = true } + +[features] +default = [] +serde = ["dep:serde"] + +// lib.rs +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Config { + pub name: String, + pub value: i32, +} + +// Users opt-in: +// my_crate = { version = "1.0", features = ["serde"] } +``` + +## Macro Pattern + +```rust +// Reusable macro for serde derives +#[cfg(feature = "serde")] +macro_rules! impl_serde { + ($($t:ty),*) => { + $( + impl serde::Serialize for $t { + // ... + } + impl<'de> serde::Deserialize<'de> for $t { + // ... + } + )* + }; +} + +#[cfg(not(feature = "serde"))] +macro_rules! impl_serde { + ($($t:ty),*) => {}; +} + +// Or use cfg_attr for derived impls +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Point { + pub x: f64, + pub y: f64, +} +``` + +## Feature Documentation + +```rust +// lib.rs + +//! # Features +//! +//! - `serde`: Enables `Serialize` and `Deserialize` implementations for all types. +//! +//! # Example with serde +//! +//! ```toml +//! [dependencies] +//! my_crate = { version = "1.0", features = ["serde"] } +//! ``` + +#![cfg_attr(docsrs, feature(doc_cfg))] + +/// A configuration type. +/// +/// When the `serde` feature is enabled, this type implements +/// `Serialize` and `Deserialize`. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(docsrs, doc(cfg(feature = "serde")))] +pub struct Config { + pub name: String, +} +``` + +## Multiple Optional Dependencies + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"], optional = true } +rkyv = { version = "0.7", optional = true } +borsh = { version = "0.10", optional = true } + +[features] +default = [] +serde = ["dep:serde"] +rkyv = ["dep:rkyv"] +borsh = ["dep:borsh"] + +// lib.rs +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "rkyv", derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize))] +#[cfg_attr(feature = "borsh", derive(borsh::BorshSerialize, borsh::BorshDeserialize))] +pub struct Message { + pub id: u64, + pub content: String, +} +``` + +## Testing with Features + +```bash +# Test without serde +cargo test + +# Test with serde +cargo test --features serde + +# Test all feature combinations +cargo test --all-features +``` + +```rust +// Test serde round-trip when feature enabled +#[cfg(feature = "serde")] +#[test] +fn test_serde_roundtrip() { + let config = Config { name: "test".into() }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(config, parsed); +} +``` + +## When to Make Serde Required + +```rust +// ✅ Required: Library is about serialization +// (e.g., json-schema, config-file parser) +[dependencies] +serde = "1.0" + +// ✅ Required: Domain heavily uses serde +// (e.g., API client, data format library) + +// ❌ Optional: General-purpose utility library +// ❌ Optional: Math/algorithm library +// ❌ Optional: Most libraries! +``` + +## See Also + +- [proj-lib-main-split](./proj-lib-main-split.md) - Library structure +- [api-common-traits](./api-common-traits.md) - Core trait implementations +- [lint-deny-correctness](./lint-deny-correctness.md) - Feature testing diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-typestate.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-typestate.md new file mode 100644 index 00000000..94b970e4 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-typestate.md @@ -0,0 +1,199 @@ +# api-typestate + +> Use typestate pattern to encode state machine invariants in the type system + +## Why It Matters + +State machines with runtime state checks ("are we connected?", "is the transaction started?") can have invalid transitions. The typestate pattern uses different types for each state, making invalid state transitions compile errors. The compiler enforces your state machine. + +## Bad + +```rust +struct Connection { + state: ConnectionState, + socket: Option, +} + +enum ConnectionState { + Disconnected, + Connected, + Authenticated, +} + +impl Connection { + fn send(&mut self, data: &[u8]) -> Result<(), Error> { + // Runtime check - can fail if called in wrong state + if self.state != ConnectionState::Authenticated { + return Err(Error::NotAuthenticated); + } + self.socket.as_mut().unwrap().write_all(data)?; + Ok(()) + } + + fn authenticate(&mut self, password: &str) -> Result<(), Error> { + // Runtime check - can fail + if self.state != ConnectionState::Connected { + return Err(Error::NotConnected); + } + // ... + } +} + +// Bug: forgot to authenticate +let mut conn = Connection::new(); +conn.connect()?; +conn.send(b"data")?; // Runtime error: NotAuthenticated +``` + +## Good + +```rust +// Different types for each state +struct Disconnected; +struct Connected { socket: TcpStream } +struct Authenticated { socket: TcpStream, session: Session } + +struct Connection { + state: State, +} + +impl Connection { + fn new() -> Self { + Connection { state: Disconnected } + } + + fn connect(self, addr: &str) -> Result, Error> { + let socket = TcpStream::connect(addr)?; + Ok(Connection { state: Connected { socket } }) + } +} + +impl Connection { + fn authenticate(self, password: &str) -> Result, Error> { + let session = do_auth(&self.state.socket, password)?; + Ok(Connection { + state: Authenticated { socket: self.state.socket, session } + }) + } +} + +impl Connection { + fn send(&mut self, data: &[u8]) -> Result<(), Error> { + // No runtime check needed - type guarantees we're authenticated + self.state.socket.write_all(data)?; + Ok(()) + } +} + +// Bug: forgot to authenticate +let conn = Connection::new(); +let conn = conn.connect("server:8080")?; +conn.send(b"data"); // Compile error! send() not available on Connection + +// Correct usage +let conn = Connection::new(); +let conn = conn.connect("server:8080")?; +let mut conn = conn.authenticate("secret")?; +conn.send(b"data")?; // Works - type is Connection +``` + +## Builder Typestate + +```rust +// Enforce required fields via typestate +struct BuilderNoUrl; +struct BuilderWithUrl { url: String } + +struct RequestBuilder { + state: State, + timeout: Option, +} + +impl RequestBuilder { + fn new() -> Self { + RequestBuilder { + state: BuilderNoUrl, + timeout: None, + } + } + + fn url(self, url: &str) -> RequestBuilder { + RequestBuilder { + state: BuilderWithUrl { url: url.to_string() }, + timeout: self.timeout, + } + } +} + +impl RequestBuilder { + fn timeout(mut self, t: Duration) -> Self { + self.timeout = Some(t); + self + } + + // Only available once URL is set + fn build(self) -> Request { + Request { + url: self.state.url, + timeout: self.timeout, + } + } +} + +// Compile error: build() not available +let bad = RequestBuilder::new().build(); + +// Correct: must set URL first +let good = RequestBuilder::new() + .url("https://example.com") + .timeout(Duration::from_secs(30)) + .build(); +``` + +## Transaction Example + +```rust +struct NotStarted; +struct InProgress { tx_id: u64 } +struct Committed; + +struct Transaction { + conn: Connection, + state: State, +} + +impl Transaction { + fn begin(conn: Connection) -> Result, Error> { + let tx_id = conn.execute("BEGIN")?; + Ok(Transaction { + conn, + state: InProgress { tx_id }, + }) + } +} + +impl Transaction { + fn execute(&mut self, sql: &str) -> Result<(), Error> { + self.conn.execute(sql) + } + + fn commit(self) -> Result, Error> { + self.conn.execute("COMMIT")?; + Ok(Transaction { + conn: self.conn, + state: Committed, + }) + } + + fn rollback(self) -> Connection { + let _ = self.conn.execute("ROLLBACK"); + self.conn + } +} +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Basic builder pattern +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven invariants +- [api-sealed-trait](./api-sealed-trait.md) - Restricting trait implementations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-bounded-channel.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-bounded-channel.md new file mode 100644 index 00000000..0c31e4ce --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-bounded-channel.md @@ -0,0 +1,175 @@ +# async-bounded-channel + +> Use bounded channels to apply backpressure and prevent unbounded memory growth + +## Why It Matters + +Unbounded channels grow without limit when producers outpace consumers. In production, this leads to memory exhaustion. Bounded channels apply backpressure—producers wait when the channel is full, naturally throttling the system. This prevents OOM and makes resource usage predictable. + +## Bad + +```rust +use tokio::sync::mpsc; + +// Unbounded channel - can grow forever +let (tx, mut rx) = mpsc::unbounded_channel::(); + +// Fast producer, slow consumer = unbounded memory growth +tokio::spawn(async move { + loop { + let msg = generate_message(); + tx.send(msg).unwrap(); // Never blocks, never fails (until OOM) + } +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + slow_process(msg).await; // Can't keep up + } +}); +// Memory grows unboundedly until crash +``` + +## Good + +```rust +use tokio::sync::mpsc; + +// Bounded channel - backpressure when full +let (tx, mut rx) = mpsc::channel::(100); // Max 100 items + +// Producer waits when channel full +tokio::spawn(async move { + loop { + let msg = generate_message(); + // Blocks if channel is full - natural backpressure + tx.send(msg).await.unwrap(); + } +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + slow_process(msg).await; + } +}); +// Memory usage capped at ~100 messages +``` + +## Choosing Buffer Size + +```rust +// Too small: frequent blocking, reduced throughput +let (tx, rx) = mpsc::channel::(1); + +// Too large: delayed backpressure, memory waste +let (tx, rx) = mpsc::channel::(1_000_000); + +// Guidelines: +// - Start with expected burst size +// - Measure actual usage in production +// - Err on the smaller side initially + +// Small items, high throughput +let (tx, rx) = mpsc::channel::(1000); + +// Large items, moderate throughput +let (tx, rx) = mpsc::channel::(100); + +// Low latency requirement +let (tx, rx) = mpsc::channel::(10); +``` + +## Handling Full Channel + +```rust +use tokio::sync::mpsc; +use tokio::time::{timeout, Duration}; + +let (tx, mut rx) = mpsc::channel::(100); + +// Option 1: Wait indefinitely (default) +tx.send(msg).await?; + +// Option 2: Try send, fail if full +match tx.try_send(msg) { + Ok(()) => println!("Sent"), + Err(TrySendError::Full(msg)) => { + println!("Channel full, dropping message"); + } + Err(TrySendError::Closed(msg)) => { + println!("Receiver dropped"); + } +} + +// Option 3: Timeout +match timeout(Duration::from_secs(1), tx.send(msg)).await { + Ok(Ok(())) => println!("Sent"), + Ok(Err(_)) => println!("Channel closed"), + Err(_) => println!("Timeout - channel full for too long"), +} + +// Option 4: send with permit reservation +let permit = tx.reserve().await?; +permit.send(msg); // Guaranteed to succeed +``` + +## Channel Types + +```rust +// mpsc: many producers, single consumer +let (tx, rx) = mpsc::channel::(100); +let tx2 = tx.clone(); // Can clone sender + +// oneshot: single value, one producer, one consumer +let (tx, rx) = oneshot::channel::(); +tx.send(response); // Can only send once + +// broadcast: multiple consumers, each gets all messages +let (tx, _) = broadcast::channel::(100); +let mut rx1 = tx.subscribe(); +let mut rx2 = tx.subscribe(); + +// watch: single latest value, multiple consumers +let (tx, rx) = watch::channel::(initial); +// Receivers see latest value, not all values +``` + +## Worker Pool Pattern + +```rust +async fn process_with_workers(items: Vec) -> Vec { + let (tx, rx) = mpsc::channel(100); + let rx = Arc::new(Mutex::new(rx)); + + // Spawn worker pool + let workers: Vec<_> = (0..4).map(|_| { + let rx = rx.clone(); + tokio::spawn(async move { + loop { + let item = { + let mut rx = rx.lock().await; + rx.recv().await + }; + match item { + Some(item) => process(item).await, + None => break, + } + } + }) + }).collect(); + + // Send items + for item in items { + tx.send(item).await.unwrap(); + } + drop(tx); // Signal workers to stop + + futures::future::join_all(workers).await; +} +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Multi-producer patterns +- [async-oneshot-response](./async-oneshot-response.md) - Request-response pattern +- [async-watch-latest](./async-watch-latest.md) - Latest-value broadcasting diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md new file mode 100644 index 00000000..86ea938b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md @@ -0,0 +1,185 @@ +# async-broadcast-pubsub + +> Use `broadcast` channel for pub/sub where all subscribers receive all messages + +## Why It Matters + +Unlike `mpsc` where one consumer receives each message, `broadcast` delivers each message to all subscribers. This is ideal for event broadcasting, real-time notifications, or when multiple components need to react to the same events independently. + +## Bad + +```rust +use tokio::sync::mpsc; + +// mpsc only delivers to ONE consumer +let (tx, mut rx) = mpsc::channel::(100); + +// Only one of these receives each message! +let mut rx2 = ???; // Can't clone receiver +``` + +## Good + +```rust +use tokio::sync::broadcast; + +// broadcast delivers to ALL subscribers +let (tx, _) = broadcast::channel::(100); + +// Each subscriber gets ALL messages +let mut rx1 = tx.subscribe(); +let mut rx2 = tx.subscribe(); + +tokio::spawn(async move { + while let Ok(event) = rx1.recv().await { + handle_in_logger(event); + } +}); + +tokio::spawn(async move { + while let Ok(event) = rx2.recv().await { + handle_in_metrics(event); + } +}); + +// Both subscribers receive this +tx.send(Event::UserLogin { user_id: 42 })?; +``` + +## Broadcast Semantics + +```rust +use tokio::sync::broadcast; + +let (tx, mut rx1) = broadcast::channel::(16); +let mut rx2 = tx.subscribe(); + +tx.send(1)?; +tx.send(2)?; + +// Both receive all messages +assert_eq!(rx1.recv().await?, 1); +assert_eq!(rx1.recv().await?, 2); +assert_eq!(rx2.recv().await?, 1); +assert_eq!(rx2.recv().await?, 2); +``` + +## Handling Lagging Receivers + +```rust +use tokio::sync::broadcast::{self, error::RecvError}; + +let (tx, mut rx) = broadcast::channel::(16); + +loop { + match rx.recv().await { + Ok(event) => { + process(event); + } + Err(RecvError::Lagged(count)) => { + // Receiver couldn't keep up, missed `count` messages + log::warn!("Missed {} events", count); + // Continue receiving new messages + } + Err(RecvError::Closed) => { + break; // All senders dropped + } + } +} +``` + +## Event Bus Pattern + +```rust +use tokio::sync::broadcast; + +#[derive(Clone, Debug)] +enum AppEvent { + UserLoggedIn { user_id: u64 }, + OrderCreated { order_id: u64 }, + SystemShutdown, +} + +struct EventBus { + tx: broadcast::Sender, +} + +impl EventBus { + fn new() -> Self { + let (tx, _) = broadcast::channel(1000); + EventBus { tx } + } + + fn publish(&self, event: AppEvent) { + // Ignore error if no subscribers + let _ = self.tx.send(event); + } + + fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +// Usage +let bus = EventBus::new(); + +// Logger subscribes +let mut log_rx = bus.subscribe(); +tokio::spawn(async move { + while let Ok(event) = log_rx.recv().await { + log::info!("Event: {:?}", event); + } +}); + +// Metrics subscribes +let mut metrics_rx = bus.subscribe(); +tokio::spawn(async move { + while let Ok(event) = metrics_rx.recv().await { + record_metric(&event); + } +}); + +// Publish events +bus.publish(AppEvent::UserLoggedIn { user_id: 42 }); +``` + +## Broadcast vs Watch + +```rust +// broadcast: subscribers get ALL messages +// Good for: events, logs, notifications +let (tx, _) = broadcast::channel::(100); + +// watch: subscribers get LATEST value only +// Good for: config changes, state updates +let (tx, _) = watch::channel(initial_state); + +// If subscriber is slow: +// - broadcast: they receive old messages (or lag) +// - watch: they skip to latest (no history) +``` + +## Clone Requirement + +```rust +// broadcast requires Clone because message is cloned to each receiver +use tokio::sync::broadcast; + +#[derive(Clone)] // Required for broadcast +struct Event { + data: String, +} + +let (tx, _) = broadcast::channel::(100); + +// For non-Clone types, wrap in Arc +use std::sync::Arc; + +let (tx, _) = broadcast::channel::>(100); +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Single-consumer channels +- [async-watch-latest](./async-watch-latest.md) - Latest-value only +- [async-bounded-channel](./async-bounded-channel.md) - Buffer sizing diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-cancellation-token.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-cancellation-token.md new file mode 100644 index 00000000..549f3055 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-cancellation-token.md @@ -0,0 +1,203 @@ +# async-cancellation-token + +> Use `CancellationToken` for graceful shutdown and task cancellation + +## Why It Matters + +Dropping a `JoinHandle` doesn't cancel the task—it just detaches it. For graceful shutdown, you need explicit cancellation. `tokio_util::sync::CancellationToken` provides a cooperative cancellation mechanism that tasks can check and respond to, enabling clean resource cleanup. + +## Bad + +```rust +// Dropping handle doesn't stop the task +let handle = tokio::spawn(async { + loop { + do_work().await; + } +}); + +drop(handle); // Task continues running in background! + +// Using bool flag - not async-aware +let running = Arc::new(AtomicBool::new(true)); + +tokio::spawn({ + let running = running.clone(); + async move { + while running.load(Ordering::Relaxed) { + do_work().await; // Can't wake up if blocked here + } + } +}); + +running.store(false, Ordering::Relaxed); +// Task won't stop until current do_work() completes +``` + +## Good + +```rust +use tokio_util::sync::CancellationToken; + +let token = CancellationToken::new(); + +let handle = tokio::spawn({ + let token = token.clone(); + async move { + loop { + tokio::select! { + _ = token.cancelled() => { + println!("Shutting down gracefully"); + cleanup().await; + break; + } + _ = do_work() => { + // Work completed + } + } + } + } +}); + +// Later: trigger cancellation +token.cancel(); +handle.await?; // Task completes cleanly +``` + +## CancellationToken API + +```rust +use tokio_util::sync::CancellationToken; + +// Create token +let token = CancellationToken::new(); + +// Clone for sharing (cheap Arc-based clone) +let token2 = token.clone(); + +// Check if cancelled (non-blocking) +if token.is_cancelled() { + return; +} + +// Wait for cancellation (async) +token.cancelled().await; + +// Trigger cancellation +token.cancel(); + +// Child tokens - cancelled when parent is cancelled +let child = token.child_token(); +``` + +## Hierarchical Cancellation + +```rust +async fn run_server(shutdown: CancellationToken) { + let listener = TcpListener::bind("0.0.0.0:8080").await?; + + loop { + tokio::select! { + _ = shutdown.cancelled() => { + println!("Server shutting down"); + break; + } + result = listener.accept() => { + let (socket, _) = result?; + // Each connection gets child token + let conn_token = shutdown.child_token(); + tokio::spawn(handle_connection(socket, conn_token)); + } + } + } + + // Child tokens auto-cancelled when we exit +} + +async fn handle_connection(socket: TcpStream, token: CancellationToken) { + loop { + tokio::select! { + _ = token.cancelled() => { + // Connection cleanup + break; + } + data = socket.read() => { + // Handle data + } + } + } +} +``` + +## Graceful Shutdown Pattern + +```rust +use tokio::signal; + +async fn main() -> Result<()> { + let shutdown = CancellationToken::new(); + + // Spawn signal handler + let shutdown_trigger = shutdown.clone(); + tokio::spawn(async move { + signal::ctrl_c().await.expect("failed to listen for Ctrl+C"); + println!("Received Ctrl+C, initiating shutdown..."); + shutdown_trigger.cancel(); + }); + + // Run application with shutdown token + run_app(shutdown).await +} + +async fn run_app(shutdown: CancellationToken) -> Result<()> { + let mut tasks = JoinSet::new(); + + tasks.spawn(worker_task(shutdown.child_token())); + tasks.spawn(server_task(shutdown.child_token())); + + // Wait for shutdown or task completion + tokio::select! { + _ = shutdown.cancelled() => { + println!("Shutdown requested, waiting for tasks..."); + } + Some(result) = tasks.join_next() => { + // A task completed/failed + result??; + } + } + + // Wait for remaining tasks with timeout + tokio::time::timeout( + Duration::from_secs(30), + async { while tasks.join_next().await.is_some() {} } + ).await.ok(); + + Ok(()) +} +``` + +## DropGuard Pattern + +```rust +use tokio_util::sync::CancellationToken; + +// Auto-cancel on drop +let token = CancellationToken::new(); +let guard = token.clone().drop_guard(); + +tokio::spawn({ + let token = token.clone(); + async move { + token.cancelled().await; + println!("Cancelled!"); + } +}); + +drop(guard); // Automatically calls token.cancel() +``` + +## See Also + +- [async-joinset-structured](./async-joinset-structured.md) - Managing multiple tasks +- [async-select-racing](./async-select-racing.md) - select! for cancellation +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime shutdown diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-clone-before-await.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-clone-before-await.md new file mode 100644 index 00000000..2a019995 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-clone-before-await.md @@ -0,0 +1,171 @@ +# async-clone-before-await + +> Clone Arc/Rc data before await points to avoid holding references across suspension + +## Why It Matters + +References held across `.await` points extend the future's lifetime and can cause borrow checker issues or prevent `Send` bounds. Cloning `Arc`/`Rc` before the await ensures the future only holds owned data, making it `Send` and avoiding lifetime complications. + +## Bad + +```rust +use std::sync::Arc; + +async fn process(data: Arc) { + // Borrow extends across await - future is not Send + let slice = &data.items[..]; // Borrow of Arc contents + + expensive_async_operation().await; // Await with active borrow + + use_slice(slice); // Still using the borrow +} + +// Error: future cannot be sent between threads safely +// because `&[Item]` cannot be sent between threads safely +tokio::spawn(process(data)); +``` + +## Good + +```rust +use std::sync::Arc; + +async fn process(data: Arc) { + // Clone what you need before await + let items = data.items.clone(); // Owned Vec + + expensive_async_operation().await; + + use_items(&items); // Using owned data +} + +// Or clone the Arc itself +async fn share_data(data: Arc) { + let data = data.clone(); // Another Arc handle + + some_async_work().await; + + process(&data); // Safe - we own the Arc +} +``` + +## The Send Problem + +```rust +// Futures must be Send to spawn on multi-threaded runtime +async fn not_send() { + let rc = Rc::new(42); // Rc is !Send + + tokio::time::sleep(Duration::from_secs(1)).await; + + println!("{}", rc); // rc held across await +} + +tokio::spawn(not_send()); // ERROR: future is not Send + +// Fix: use Arc or don't hold across await +async fn is_send() { + let arc = Arc::new(42); // Arc is Send + + tokio::time::sleep(Duration::from_secs(1)).await; + + println!("{}", arc); +} + +tokio::spawn(is_send()); // OK +``` + +## Minimizing Clones + +```rust +// Bad: clone everything eagerly +async fn wasteful(data: Arc) { + let data = (*data).clone(); // Clones entire LargeData + async_work().await; + use_one_field(&data.small_field); +} + +// Good: clone only what you need +async fn efficient(data: Arc) { + let small = data.small_field.clone(); // Clone only needed field + async_work().await; + use_one_field(&small); +} + +// Good: if you need the whole thing, keep the Arc +async fn arc_efficient(data: Arc) { + let data = data.clone(); // Cheap Arc clone + async_work().await; + use_data(&data); // Access through Arc +} +``` + +## Spawn Pattern + +```rust +// Common pattern: clone for spawned task +let shared = Arc::new(SharedState::new()); + +for i in 0..10 { + let shared = shared.clone(); // Clone before moving into spawn + tokio::spawn(async move { + // Task owns its Arc clone + shared.do_something(i).await; + }); +} +``` + +## Scope-Based Approach + +```rust +// Limit borrow scope to before await +async fn scoped(data: Arc) { + // Scope 1: borrow, compute, drop borrow + let computed = { + let slice = &data.items[..]; // Borrow + compute_something(slice) // Use + }; // Borrow ends here + + // Now safe to await + expensive_async_operation().await; + + use_computed(computed); +} +``` + +## MutexGuard Across Await + +```rust +use tokio::sync::Mutex; + +// BAD: holding guard across await +async fn bad(mutex: Arc>) { + let mut guard = mutex.lock().await; + guard.value += 1; + + slow_operation().await; // Guard held during await! + + guard.value += 1; +} + +// GOOD: release before await +async fn good(mutex: Arc>) { + { + let mut guard = mutex.lock().await; + guard.value += 1; + } // Guard released + + slow_operation().await; + + { + let mut guard = mutex.lock().await; + guard.value += 1; + } +} +``` + +## See Also + +- [async-no-lock-await](./async-no-lock-await.md) - Lock guards across await +- [own-arc-shared](./own-arc-shared.md) - Arc usage patterns +- [async-spawn-blocking](./async-spawn-blocking.md) - Blocking in async diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-join-parallel.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-join-parallel.md new file mode 100644 index 00000000..bab3c682 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-join-parallel.md @@ -0,0 +1,158 @@ +# async-join-parallel + +> Use `join!` or `try_join!` for concurrent independent futures + +## Why It Matters + +Awaiting futures sequentially takes the sum of their durations. `join!` runs futures concurrently, taking only as long as the slowest one. For independent operations like multiple API calls or parallel file reads, this can dramatically reduce latency. + +## Bad + +```rust +async fn fetch_data() -> (User, Posts, Comments) { + // Sequential: 300ms total (100 + 100 + 100) + let user = fetch_user().await; // 100ms + let posts = fetch_posts().await; // 100ms + let comments = fetch_comments().await; // 100ms + + (user, posts, comments) +} + +async fn read_configs() -> Result<(Config, Settings)> { + // Sequential: 20ms + 20ms = 40ms + let config = fs::read_to_string("config.toml").await?; + let settings = fs::read_to_string("settings.json").await?; + + Ok((parse_config(&config)?, parse_settings(&settings)?)) +} +``` + +## Good + +```rust +use tokio::join; + +async fn fetch_data() -> (User, Posts, Comments) { + // Concurrent: ~100ms total (max of all three) + let (user, posts, comments) = join!( + fetch_user(), + fetch_posts(), + fetch_comments(), + ); + + (user, posts, comments) +} + +use tokio::try_join; + +async fn read_configs() -> Result<(Config, Settings)> { + // Concurrent: ~20ms total + let (config_str, settings_str) = try_join!( + fs::read_to_string("config.toml"), + fs::read_to_string("settings.json"), + )?; + + Ok((parse_config(&config_str)?, parse_settings(&settings_str)?)) +} +``` + +## join! vs try_join! + +```rust +// join! - all futures run to completion, returns tuple +let (a, b, c) = join!(future_a, future_b, future_c); + +// try_join! - short-circuits on first error +let (a, b, c) = try_join!(fallible_a, fallible_b, fallible_c)?; +// If fallible_b fails, returns Err immediately +// Other futures may still be running (cancellation is async) +``` + +## futures::join_all for Dynamic Collections + +```rust +use futures::future::join_all; + +async fn fetch_all_users(ids: &[u64]) -> Vec { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + join_all(futures).await +} + +// With fallible futures +use futures::future::try_join_all; + +async fn fetch_all_users(ids: &[u64]) -> Result> { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + try_join_all(futures).await +} +``` + +## Limiting Concurrency + +```rust +use futures::stream::{self, StreamExt}; + +async fn fetch_with_limit(ids: &[u64]) -> Vec> { + stream::iter(ids) + .map(|id| fetch_user(*id)) + .buffer_unordered(10) // Max 10 concurrent requests + .collect() + .await +} + +// Or with tokio::sync::Semaphore +use tokio::sync::Semaphore; + +async fn fetch_with_semaphore(ids: &[u64]) -> Vec { + let semaphore = Arc::new(Semaphore::new(10)); + + let futures: Vec<_> = ids.iter().map(|id| { + let semaphore = semaphore.clone(); + async move { + let _permit = semaphore.acquire().await.unwrap(); + fetch_user(*id).await + } + }).collect(); + + join_all(futures).await +} +``` + +## When NOT to Use join! + +```rust +// ❌ Dependent futures - must be sequential +async fn create_and_populate() -> Result<()> { + let db = create_database().await?; // Must complete first + populate_tables(&db).await?; // Depends on db + Ok(()) +} + +// ❌ Short-circuiting logic +async fn find_first() -> Option { + // Want to stop when one succeeds + // Use select! instead +} + +// ❌ Shared mutable state +async fn bad_shared_state() { + let counter = Arc::new(Mutex::new(0)); + // This might work but can cause contention + join!( + increment(counter.clone()), + increment(counter.clone()), + ); +} +``` + +## See Also + +- [async-try-join](./async-try-join.md) - Error handling in concurrent futures +- [async-select-racing](./async-select-racing.md) - Racing futures +- [async-joinset-structured](./async-joinset-structured.md) - Dynamic task sets diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-joinset-structured.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-joinset-structured.md new file mode 100644 index 00000000..a98c522d --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-joinset-structured.md @@ -0,0 +1,195 @@ +# async-joinset-structured + +> Use `JoinSet` for managing dynamic collections of spawned tasks + +## Why It Matters + +When spawning a variable number of tasks, collecting `JoinHandle`s in a `Vec` and using `join_all` works but lacks flexibility. `JoinSet` provides a better abstraction: add/remove tasks dynamically, get results as they complete, and abort all on drop. It's the idiomatic way to manage task collections. + +## Bad + +```rust +// Manual handle management +let mut handles: Vec>> = Vec::new(); + +for url in urls { + handles.push(tokio::spawn(fetch(url))); +} + +// Wait for all, in order (not as they complete) +let results = futures::future::join_all(handles).await; + +// No easy way to cancel all, handle errors progressively, or add more tasks +``` + +## Good + +```rust +use tokio::task::JoinSet; + +let mut set = JoinSet::new(); + +for url in urls { + set.spawn(fetch(url.clone())); +} + +// Process results as they complete +while let Some(result) = set.join_next().await { + match result { + Ok(Ok(data)) => process(data), + Ok(Err(e)) => log::error!("Task failed: {}", e), + Err(e) => log::error!("Task panicked: {}", e), + } +} + +// All tasks done, set is empty +``` + +## Dynamic Task Addition + +```rust +use tokio::task::JoinSet; + +async fn worker_pool(mut rx: mpsc::Receiver) { + let mut set = JoinSet::new(); + let max_concurrent = 10; + + loop { + tokio::select! { + // Accept new tasks if under limit + Some(task) = rx.recv(), if set.len() < max_concurrent => { + set.spawn(process_task(task)); + } + + // Process completed tasks + Some(result) = set.join_next() => { + handle_result(result); + } + + // Exit when no tasks and channel closed + else => break, + } + } +} +``` + +## Abort on Drop + +```rust +use tokio::task::JoinSet; + +{ + let mut set = JoinSet::new(); + set.spawn(long_running_task()); + set.spawn(another_task()); + + // Early exit + return; +} // JoinSet dropped here - all tasks are aborted! + +// Explicit abort +let mut set = JoinSet::new(); +set.spawn(task()); +set.abort_all(); // Cancel all tasks +``` + +## Error Handling Pattern + +```rust +use tokio::task::JoinSet; + +async fn fetch_all(urls: &[String]) -> Vec> { + let mut set = JoinSet::new(); + let mut results = Vec::new(); + + for url in urls { + set.spawn(fetch(url.clone())); + } + + while let Some(join_result) = set.join_next().await { + let result = match join_result { + Ok(task_result) => task_result, + Err(join_error) => { + if join_error.is_panic() { + Err(Error::TaskPanicked) + } else { + Err(Error::TaskCancelled) + } + } + }; + results.push(result); + } + + results +} +``` + +## With Cancellation + +```rust +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +async fn run_workers(shutdown: CancellationToken) { + let mut set = JoinSet::new(); + + for i in 0..4 { + let token = shutdown.child_token(); + set.spawn(async move { + loop { + tokio::select! { + _ = token.cancelled() => break, + _ = do_work(i) => {} + } + } + }); + } + + // Wait for shutdown + shutdown.cancelled().await; + + // Abort remaining tasks + set.abort_all(); + + // Wait for all to finish (drain aborted tasks) + while set.join_next().await.is_some() {} +} +``` + +## Spawning with Context + +```rust +use tokio::task::JoinSet; + +let mut set: JoinSet<(usize, Result)> = JoinSet::new(); + +for (index, url) in urls.iter().enumerate() { + let url = url.clone(); + set.spawn(async move { + (index, fetch(&url).await) + }); +} + +// Results include their index +while let Some(result) = set.join_next().await { + if let Ok((index, data)) = result { + results[index] = Some(data); + } +} +``` + +## JoinSet vs join_all + +| Feature | JoinSet | join_all | +|---------|---------|----------| +| Add tasks dynamically | Yes | No | +| Results as-completed | Yes | No (all at once) | +| Abort all on drop | Yes | No | +| Cancel individual | Yes | No | +| Memory efficient | Yes | Pre-allocates | + +## See Also + +- [async-join-parallel](./async-join-parallel.md) - Static concurrent futures +- [async-cancellation-token](./async-cancellation-token.md) - Cancellation patterns +- [async-try-join](./async-try-join.md) - Error handling in joins diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-mpsc-queue.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-mpsc-queue.md new file mode 100644 index 00000000..765b02fc --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-mpsc-queue.md @@ -0,0 +1,171 @@ +# async-mpsc-queue + +> Use `mpsc` channels for async message queues between tasks + +## Why It Matters + +`tokio::sync::mpsc` (multi-producer, single-consumer) is the workhorse channel for async Rust. It provides async send/receive, backpressure via bounded capacity, and efficient cloning of senders. It's the default choice for task-to-task communication. + +## Bad + +```rust +use std::sync::mpsc; // Wrong! Blocks the async runtime + +let (tx, rx) = std::sync::mpsc::channel(); + +tokio::spawn(async move { + tx.send("hello").unwrap(); // Might block +}); + +tokio::spawn(async move { + let msg = rx.recv().unwrap(); // BLOCKS the executor thread! +}); +``` + +## Good + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); + +tokio::spawn(async move { + tx.send("hello".to_string()).await.unwrap(); +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + println!("Received: {}", msg); + } +}); +``` + +## Sender Cloning + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); + +// Multiple producers +for i in 0..10 { + let tx = tx.clone(); // Cheap clone + tokio::spawn(async move { + tx.send(Event { source: i }).await.unwrap(); + }); +} + +// Drop original sender so channel closes when all clones dropped +drop(tx); + +// Consumer +while let Some(event) = rx.recv().await { + process(event); +} +// Loop exits when all senders dropped +``` + +## Message Handler Pattern + +```rust +use tokio::sync::mpsc; + +enum Command { + Get { key: String, reply: oneshot::Sender> }, + Set { key: String, value: Value }, + Delete { key: String }, +} + +async fn run_store(mut commands: mpsc::Receiver) { + let mut store = HashMap::new(); + + while let Some(cmd) = commands.recv().await { + match cmd { + Command::Get { key, reply } => { + let _ = reply.send(store.get(&key).cloned()); + } + Command::Set { key, value } => { + store.insert(key, value); + } + Command::Delete { key } => { + store.remove(&key); + } + } + } +} + +// Usage +async fn client(tx: mpsc::Sender) -> Option { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Command::Get { + key: "foo".to_string(), + reply: reply_tx + }).await.unwrap(); + + reply_rx.await.unwrap() +} +``` + +## Graceful Shutdown + +```rust +async fn worker(mut rx: mpsc::Receiver, shutdown: CancellationToken) { + loop { + tokio::select! { + _ = shutdown.cancelled() => { + // Drain remaining messages + while let Ok(task) = rx.try_recv() { + process(task).await; + } + break; + } + Some(task) = rx.recv() => { + process(task).await; + } + else => break, // Channel closed + } + } +} +``` + +## WeakSender for Optional Producers + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); +let weak = tx.downgrade(); // Doesn't keep channel alive + +tokio::spawn(async move { + // Strong sender - keeps channel alive + tx.send("from strong".into()).await.unwrap(); +}); + +tokio::spawn(async move { + // Weak sender - may fail if strong senders dropped + if let Some(tx) = weak.upgrade() { + tx.send("from weak".into()).await.unwrap(); + } +}); +``` + +## Permit Pattern + +```rust +// Reserve slot before preparing message +let permit = tx.reserve().await?; + +// Now we have guaranteed capacity +let message = expensive_to_create_message(); +permit.send(message); // Never fails + +// Useful when message creation is expensive +// and you don't want to create it if channel is full +``` + +## See Also + +- [async-bounded-channel](./async-bounded-channel.md) - Why bounded channels +- [async-oneshot-response](./async-oneshot-response.md) - Request-response with oneshot +- [async-broadcast-pubsub](./async-broadcast-pubsub.md) - Multiple consumers diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-no-lock-await.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-no-lock-await.md new file mode 100644 index 00000000..b1c4715c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-no-lock-await.md @@ -0,0 +1,156 @@ +# async-no-lock-await + +> Never hold `Mutex`/`RwLock` across `.await` + +## Why It Matters + +Holding a lock across an `.await` point can cause deadlocks and severely hurt performance. The task may be suspended while holding the lock, blocking all other tasks waiting for it - potentially indefinitely. + +## Bad + +```rust +use tokio::sync::Mutex; + +async fn bad_update(state: &Mutex) { + let mut guard = state.lock().await; + + // BAD: Lock held across await! + let data = fetch_from_network().await; + + guard.value = data; +} // Lock finally released + +// This can deadlock or starve other tasks +``` + +## Good + +```rust +use tokio::sync::Mutex; + +async fn good_update(state: &Mutex) { + // Fetch data BEFORE taking the lock + let data = fetch_from_network().await; + + // Lock only for the quick update + let mut guard = state.lock().await; + guard.value = data; +} // Lock released immediately + +// Alternative: Clone data out, process, then update +async fn good_update_v2(state: &Mutex) { + // Extract what we need + let id = { + let guard = state.lock().await; + guard.id.clone() + }; // Lock released! + + // Do async work without lock + let data = fetch_by_id(id).await; + + // Quick update + state.lock().await.value = data; +} +``` + +## The Problem Visualized + +```rust +// Task A: +let guard = mutex.lock().await; // Acquires lock +expensive_io().await; // Suspended, still holding lock! +// ... many milliseconds pass ... +drop(guard); // Finally releases + +// Task B, C, D: +let guard = mutex.lock().await; // All blocked waiting for A! +``` + +## Patterns for Extraction + +```rust +use tokio::sync::Mutex; + +// Pattern 1: Clone out, process, update +async fn pattern_clone(state: &Mutex) { + let config = state.lock().await.config.clone(); + let result = process_with_io(&config).await; + state.lock().await.result = result; +} + +// Pattern 2: Compute closure, apply +async fn pattern_closure(state: &Mutex) { + let update = compute_update().await; + + state.lock().await.apply(update); +} + +// Pattern 3: Message passing +async fn pattern_message( + state: &Mutex, + tx: mpsc::Sender, +) { + let update = compute_update().await; + tx.send(update).await.unwrap(); +} + +// Separate task handles updates +async fn state_manager( + state: Arc>, + mut rx: mpsc::Receiver, +) { + while let Some(update) = rx.recv().await { + state.lock().await.apply(update); + } +} +``` + +## Using RwLock + +```rust +use tokio::sync::RwLock; + +async fn read_heavy(state: &RwLock) { + // Multiple readers OK, but still don't hold across await + let value = { + let guard = state.read().await; + guard.value.clone() + }; + + // Process without lock + let result = process(value).await; + + // Write lock for update + state.write().await.result = result; +} +``` + +## std::sync::Mutex vs tokio::sync::Mutex + +```rust +// std::sync::Mutex: Blocks the entire thread +// - Use for quick, CPU-only operations +// - NEVER use in async code with await inside + +// tokio::sync::Mutex: Async-aware, yields to runtime +// - Use in async code +// - Still don't hold across await points! + +// std::sync::Mutex in async (quick operation, OK): +async fn quick_update(state: &std::sync::Mutex) { + state.lock().unwrap().counter += 1; // No await, OK +} + +// tokio::sync::Mutex (must use if lock scope has await): +async fn must_await_inside(state: &tokio::sync::Mutex) { + let mut guard = state.lock().await; + // Only if you REALLY need the lock during async op + // (usually you don't - redesign instead) +} +``` + +## See Also + +- [async-spawn-blocking](async-spawn-blocking.md) - Use spawn_blocking for CPU work +- [async-clone-before-await](async-clone-before-await.md) - Clone data before await +- [anti-lock-across-await](anti-lock-across-await.md) - Anti-pattern reference diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-oneshot-response.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-oneshot-response.md new file mode 100644 index 00000000..c7fc8218 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-oneshot-response.md @@ -0,0 +1,191 @@ +# async-oneshot-response + +> Use `oneshot` channel for request-response patterns + +## Why It Matters + +When one task needs to send a request and wait for exactly one response, `oneshot` is the perfect fit. It's a single-use channel optimized for this pattern—no buffering, no clone overhead. Combined with `mpsc`, it enables clean actor-style message passing. + +## Bad + +```rust +// Using mpsc for single response - wasteful +let (tx, mut rx) = mpsc::channel::(1); +send_request().await; +let response = rx.recv().await.unwrap(); +// Channel persists, could accidentally receive more + +// Using shared state - complex +let result = Arc::new(Mutex::new(None)); +send_request(result.clone()).await; +while result.lock().await.is_none() { + tokio::time::sleep(Duration::from_millis(10)).await; // Polling! +} +``` + +## Good + +```rust +use tokio::sync::oneshot; + +let (tx, rx) = oneshot::channel::(); + +// Send request with reply channel +send_request(Request { data, reply: tx }).await; + +// Wait for response +let response = rx.await?; + +// Channel is consumed - can't accidentally reuse +``` + +## Request-Response Pattern + +```rust +use tokio::sync::{mpsc, oneshot}; + +enum Request { + Get { + key: String, + reply: oneshot::Sender>, + }, + Set { + key: String, + value: Value, + reply: oneshot::Sender, + }, +} + +// Service handler +async fn service(mut rx: mpsc::Receiver) { + let mut store = HashMap::new(); + + while let Some(req) = rx.recv().await { + match req { + Request::Get { key, reply } => { + let value = store.get(&key).cloned(); + let _ = reply.send(value); // Ignore if receiver dropped + } + Request::Set { key, value, reply } => { + store.insert(key, value); + let _ = reply.send(true); + } + } + } +} + +// Client +async fn get_value(tx: &mpsc::Sender, key: &str) -> Option { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Request::Get { + key: key.to_string(), + reply: reply_tx, + }).await.ok()?; + + reply_rx.await.ok()? +} +``` + +## With Timeout + +```rust +use tokio::time::{timeout, Duration}; + +async fn request_with_timeout( + tx: &mpsc::Sender, + key: &str, +) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Request::Get { + key: key.to_string(), + reply: reply_tx, + }).await.map_err(|_| Error::ServiceDown)?; + + timeout(Duration::from_secs(5), reply_rx) + .await + .map_err(|_| Error::Timeout)? + .map_err(|_| Error::ServiceDown)? + .ok_or(Error::NotFound) +} +``` + +## Error Handling + +```rust +use tokio::sync::oneshot; + +let (tx, rx) = oneshot::channel::(); + +// Sender dropped without sending +drop(tx); +match rx.await { + Ok(value) => println!("Got: {}", value), + Err(oneshot::error::RecvError { .. }) => { + println!("Sender dropped"); + } +} + +// Receiver dropped before send +let (tx, rx) = oneshot::channel::(); +drop(rx); +match tx.send("hello".to_string()) { + Ok(()) => println!("Sent"), + Err(value) => println!("Receiver dropped, value: {}", value), +} +``` + +## Closed Detection + +```rust +// Check if receiver is still waiting +let (tx, rx) = oneshot::channel::(); + +// In producer +if tx.is_closed() { + println!("Receiver already gone, skip expensive computation"); +} else { + let result = expensive_computation(); + tx.send(result).ok(); +} + +// Async wait for close +let tx_clone = tx.clone(); // Note: can't actually clone, just showing concept +tokio::select! { + _ = tx.closed() => println!("Receiver dropped"), + result = compute() => { tx.send(result).ok(); } +} +``` + +## Response Type Wrapper + +```rust +// Standardize request-response pattern +struct RpcRequest { + request: Req, + reply: oneshot::Sender, +} + +impl RpcRequest { + fn new(request: Req) -> (Self, oneshot::Receiver) { + let (tx, rx) = oneshot::channel(); + (RpcRequest { request, reply: tx }, rx) + } + + fn respond(self, response: Res) { + let _ = self.reply.send(response); + } +} + +// Usage +let (req, rx) = RpcRequest::new(GetUser { id: 42 }); +tx.send(req).await?; +let user = rx.await?; +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Pair with oneshot for request-response +- [async-bounded-channel](./async-bounded-channel.md) - Channel sizing +- [async-select-racing](./async-select-racing.md) - Timeout patterns diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-select-racing.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-select-racing.md new file mode 100644 index 00000000..ba71893b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-select-racing.md @@ -0,0 +1,198 @@ +# async-select-racing + +> Use `select!` to race futures and handle the first to complete + +## Why It Matters + +Sometimes you need the first result from multiple futures—timeout vs operation, cancellation vs work, or competing alternatives. `tokio::select!` lets you race futures and handle whichever completes first, while properly cancelling the others. + +## Bad + +```rust +// Can't express "whichever finishes first" +async fn fetch_with_fallback() -> Data { + match fetch_primary().await { + Ok(data) => data, + Err(_) => fetch_fallback().await.unwrap(), // Sequential, not racing + } +} + +// Manual timeout is error-prone +async fn fetch_with_timeout() -> Option { + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(5) { + return None; + } + // How do we check timeout while awaiting? + } +} +``` + +## Good + +```rust +use tokio::select; + +async fn fetch_with_timeout() -> Result { + select! { + result = fetch_data() => result, + _ = tokio::time::sleep(Duration::from_secs(5)) => { + Err(Error::Timeout) + } + } +} + +async fn fetch_with_fallback() -> Data { + select! { + result = fetch_primary() => { + match result { + Ok(data) => data, + Err(_) => fetch_fallback().await.unwrap() + } + } + _ = tokio::time::sleep(Duration::from_secs(1)) => { + // Primary too slow, use fallback + fetch_fallback().await.unwrap() + } + } +} +``` + +## select! Syntax + +```rust +select! { + // Pattern = future => handler + result = async_operation() => { + // Handle result + println!("Got: {:?}", result); + } + + // Can bind with pattern matching + Ok(data) = fallible_operation() => { + process(data); + } + + // Conditional branches with if guards + msg = channel.recv(), if should_receive => { + handle_message(msg); + } + + // else branch for when all futures are disabled + else => { + println!("All branches disabled"); + } +} +``` + +## Cancellation Behavior + +```rust +async fn select_example() { + select! { + _ = operation_a() => { + println!("A completed first"); + // operation_b() is dropped/cancelled + } + _ = operation_b() => { + println!("B completed first"); + // operation_a() is dropped/cancelled + } + } +} + +// Futures are cancelled at their next .await point +// For immediate cancellation, futures must be cancel-safe +``` + +## Biased Selection + +```rust +// By default, select! randomly picks when multiple are ready +// Use biased mode for deterministic priority +select! { + biased; // Check branches in order + + msg = high_priority.recv() => handle_high(msg), + msg = low_priority.recv() => handle_low(msg), +} + +// Without biased, both channels have equal chance +// when both have messages ready +``` + +## Loop with select! + +```rust +async fn event_loop( + mut commands: mpsc::Receiver, + shutdown: CancellationToken, +) { + loop { + select! { + _ = shutdown.cancelled() => { + println!("Shutting down"); + break; + } + Some(cmd) = commands.recv() => { + process_command(cmd).await; + } + else => { + // commands channel closed + break; + } + } + } +} +``` + +## Racing Multiple of Same Type + +```rust +// Race multiple servers for fastest response +async fn fastest_response(servers: &[String]) -> Result { + let futures = servers.iter() + .map(|s| fetch_from(s)) + .collect::>(); + + // select! requires static branches, use select_all for dynamic + let (result, _index, _remaining) = + futures::future::select_all(futures).await; + + result +} +``` + +## Common Patterns + +```rust +// Timeout +select! { + result = operation() => result, + _ = sleep(Duration::from_secs(5)) => Err(Timeout), +} + +// Cancellation +select! { + result = operation() => result, + _ = cancel_token.cancelled() => Err(Cancelled), +} + +// Interval with cancellation +let mut interval = tokio::time::interval(Duration::from_secs(1)); +loop { + select! { + _ = shutdown.cancelled() => break, + _ = interval.tick() => { + do_periodic_work().await; + } + } +} +``` + +## See Also + +- [async-cancellation-token](./async-cancellation-token.md) - Cancellation patterns +- [async-join-parallel](./async-join-parallel.md) - All futures, not racing +- [async-bounded-channel](./async-bounded-channel.md) - Channel operations in select diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-spawn-blocking.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-spawn-blocking.md new file mode 100644 index 00000000..8312d339 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-spawn-blocking.md @@ -0,0 +1,154 @@ +# async-spawn-blocking + +> Use `spawn_blocking` for CPU-intensive work + +## Why It Matters + +Async runtimes like Tokio use a small number of threads to handle many tasks. CPU-intensive or blocking operations on these threads starve other tasks. `spawn_blocking` moves such work to a dedicated thread pool. + +## Bad + +```rust +// BAD: Blocks the async runtime thread +async fn process_image(data: &[u8]) -> ProcessedImage { + // CPU-intensive work on async thread! + let resized = resize_image(data); // Blocks! + let compressed = compress(resized); // Blocks! + compressed +} + +// BAD: Synchronous file I/O in async context +async fn read_large_file(path: &Path) -> Vec { + std::fs::read(path).unwrap() // Blocks the runtime! +} +``` + +## Good + +```rust +use tokio::task; + +// GOOD: Offload CPU work to blocking pool +async fn process_image(data: Vec) -> ProcessedImage { + task::spawn_blocking(move || { + let resized = resize_image(&data); + compress(resized) + }) + .await + .expect("spawn_blocking failed") +} + +// GOOD: Use async file I/O +async fn read_large_file(path: &Path) -> tokio::io::Result> { + tokio::fs::read(path).await +} + +// GOOD: Or spawn_blocking for unavoidable sync I/O +async fn read_with_sync_lib(path: PathBuf) -> Vec { + task::spawn_blocking(move || { + sync_library::read_file(&path) + }) + .await + .unwrap() +} +``` + +## What Counts as Blocking + +```rust +// CPU-intensive operations +- Cryptographic operations (hashing, encryption) +- Image/video processing +- Compression/decompression +- Complex parsing +- Mathematical computations + +// Blocking I/O +- std::fs operations +- Synchronous database drivers +- Synchronous HTTP clients +- Thread::sleep + +// Example thresholds (rough guidelines): +// < 10µs: OK on async thread +// 10µs - 1ms: Consider spawn_blocking +// > 1ms: Definitely spawn_blocking +``` + +## Practical Examples + +```rust +// Password hashing (CPU-intensive) +async fn hash_password(password: String) -> String { + task::spawn_blocking(move || { + bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap() +} + +// JSON parsing of large documents +async fn parse_large_json(data: String) -> serde_json::Value { + task::spawn_blocking(move || { + serde_json::from_str(&data).unwrap() + }) + .await + .unwrap() +} + +// Compression +async fn compress_data(data: Vec) -> Vec { + task::spawn_blocking(move || { + let mut encoder = flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + ); + encoder.write_all(&data).unwrap(); + encoder.finish().unwrap() + }) + .await + .unwrap() +} +``` + +## spawn_blocking vs spawn + +```rust +// spawn: Runs async code on runtime threads +tokio::spawn(async { + // Async code here + some_async_operation().await; +}); + +// spawn_blocking: Runs sync code on blocking thread pool +tokio::task::spawn_blocking(|| { + // Synchronous, possibly CPU-intensive code + heavy_computation(); +}); + +// spawn_blocking returns JoinHandle that can be awaited +let result = tokio::task::spawn_blocking(|| { + expensive_sync_operation() +}).await?; +``` + +## Rayon for Parallel CPU Work + +```rust +// For parallel CPU work, consider Rayon inside spawn_blocking +async fn parallel_process(items: Vec) -> Vec { + task::spawn_blocking(move || { + use rayon::prelude::*; + items.par_iter() + .map(|item| cpu_intensive_transform(item)) + .collect() + }) + .await + .unwrap() +} +``` + +## See Also + +- [async-tokio-fs](async-tokio-fs.md) - Use tokio::fs for async file I/O +- [async-no-lock-await](async-no-lock-await.md) - Don't hold locks across await diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-fs.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-fs.md new file mode 100644 index 00000000..afe599c6 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-fs.md @@ -0,0 +1,167 @@ +# async-tokio-fs + +> Use `tokio::fs` instead of `std::fs` in async code + +## Why It Matters + +`std::fs` operations are blocking—they stop the current thread until the syscall completes. In async code, this blocks the executor thread, preventing it from running other tasks. `tokio::fs` wraps filesystem operations in `spawn_blocking`, keeping the executor responsive. + +## Bad + +```rust +async fn process_files(paths: &[PathBuf]) -> Result> { + let mut contents = Vec::new(); + + for path in paths { + // BLOCKS the entire executor thread! + let data = std::fs::read_to_string(path)?; + contents.push(data); + } + + Ok(contents) +} + +// While reading a file, NO other tasks can run on this thread +``` + +## Good + +```rust +use tokio::fs; + +async fn process_files(paths: &[PathBuf]) -> Result> { + let mut contents = Vec::new(); + + for path in paths { + // Non-blocking: allows other tasks to run + let data = fs::read_to_string(path).await?; + contents.push(data); + } + + Ok(contents) +} + +// Even better: concurrent reads +async fn process_files_concurrent(paths: &[PathBuf]) -> Result> { + let futures: Vec<_> = paths.iter() + .map(|path| fs::read_to_string(path)) + .collect(); + + futures::future::try_join_all(futures).await +} +``` + +## tokio::fs API + +```rust +use tokio::fs; + +// Reading +let contents = fs::read_to_string("file.txt").await?; +let bytes = fs::read("file.bin").await?; + +// Writing +fs::write("output.txt", "contents").await?; + +// File operations +let file = fs::File::open("file.txt").await?; +let file = fs::File::create("new.txt").await?; + +// Directory operations +fs::create_dir("new_dir").await?; +fs::create_dir_all("nested/dir/path").await?; +fs::remove_dir("empty_dir").await?; +fs::remove_dir_all("dir_with_contents").await?; + +// Metadata +let metadata = fs::metadata("file.txt").await?; +let canonical = fs::canonicalize("./relative").await?; + +// Rename/remove +fs::rename("old.txt", "new.txt").await?; +fs::remove_file("file.txt").await?; + +// Read directory +let mut entries = fs::read_dir("some_dir").await?; +while let Some(entry) = entries.next_entry().await? { + println!("{}", entry.path().display()); +} +``` + +## Async File I/O + +```rust +use tokio::fs::File; +use tokio::io::{AsyncReadExt, AsyncWriteExt, AsyncBufReadExt, BufReader}; + +// Read with buffer +let mut file = File::open("large.bin").await?; +let mut buffer = vec![0u8; 4096]; +let bytes_read = file.read(&mut buffer).await?; + +// Read all +let mut contents = Vec::new(); +file.read_to_end(&mut contents).await?; + +// Write +let mut file = File::create("output.bin").await?; +file.write_all(b"data").await?; +file.flush().await?; + +// Buffered line reading +let file = File::open("lines.txt").await?; +let reader = BufReader::new(file); +let mut lines = reader.lines(); + +while let Some(line) = lines.next_line().await? { + println!("{}", line); +} +``` + +## When std::fs is Acceptable + +```rust +// Startup/initialization (before async runtime) +fn main() { + let config = std::fs::read_to_string("config.toml") + .expect("config file required"); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(run_with_config(config)); +} + +// Single-threaded current_thread runtime (less impact) +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Still prefer tokio::fs, but impact is lower +} + +// When file operations are rare and quick +// (e.g., reading small config once per hour) +``` + +## Performance Considerations + +```rust +// tokio::fs uses spawn_blocking internally +// For many small files, the overhead adds up + +// Batch operations when possible +let paths: Vec<_> = entries.iter() + .map(|e| e.path()) + .collect(); + +let contents = futures::future::try_join_all( + paths.iter().map(|p| fs::read_to_string(p)) +).await?; + +// For heavy I/O, consider memory-mapped files +// (requires unsafe or mmap crate) +``` + +## See Also + +- [async-spawn-blocking](./async-spawn-blocking.md) - How tokio::fs works internally +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime configuration +- [err-context-chain](./err-context-chain.md) - Adding path context to IO errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-runtime.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-runtime.md new file mode 100644 index 00000000..1bc7554a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-runtime.md @@ -0,0 +1,169 @@ +# async-tokio-runtime + +> Configure Tokio runtime appropriately for your workload + +## Why It Matters + +Tokio's default multi-threaded runtime isn't always optimal. CPU-bound work needs different configuration than IO-bound work. Incorrect configuration leads to poor performance, blocked workers, or resource exhaustion. Understanding runtime options lets you tune for your specific use case. + +## Bad + +```rust +// Default runtime for everything - not optimal +#[tokio::main] +async fn main() { + // CPU-heavy work on async executor starves IO tasks + for data in datasets { + let result = heavy_computation(data).await; + } +} + +// Single-threaded when multi-threaded is needed +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Can't utilize multiple cores for concurrent tasks + for _ in 0..1000 { + tokio::spawn(async { /* IO work */ }); + } +} +``` + +## Good + +```rust +// Multi-threaded for concurrent IO (default) +#[tokio::main] +async fn main() { + // Good for many concurrent network connections + let handles: Vec<_> = urls.iter() + .map(|url| tokio::spawn(fetch(url.clone()))) + .collect(); + + futures::future::join_all(handles).await; +} + +// Current-thread for single-threaded scenarios +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Good for single-connection clients, simpler debugging + let client = Client::new(); + client.run().await; +} + +// Custom configuration +#[tokio::main(worker_threads = 4)] +async fn main() { + // Limit to 4 worker threads +} + +// Or manual setup for more control +fn main() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .thread_name("my-worker") + .build() + .unwrap(); + + runtime.block_on(async_main()); +} +``` + +## Runtime Types + +| Runtime | Use Case | Configuration | +|---------|----------|---------------| +| Multi-thread | IO-bound, many connections | `#[tokio::main]` (default) | +| Current-thread | CLI tools, tests, single connection | `flavor = "current_thread"` | +| Custom | Fine-tuned performance | `Builder::new_*()` | + +## Worker Thread Tuning + +```rust +use tokio::runtime::Builder; + +// IO-bound: more threads than cores can help +let io_runtime = Builder::new_multi_thread() + .worker_threads(num_cpus::get() * 2) // IO can benefit from oversubscription + .max_blocking_threads(32) // For spawn_blocking calls + .enable_io() + .enable_time() + .build()?; + +// CPU-bound: match core count +let cpu_runtime = Builder::new_multi_thread() + .worker_threads(num_cpus::get()) // No benefit from more than cores + .build()?; +``` + +## Multiple Runtimes + +```rust +// Separate runtimes for different workloads +struct App { + io_runtime: Runtime, + cpu_runtime: Runtime, +} + +impl App { + fn new() -> Self { + Self { + io_runtime: Builder::new_multi_thread() + .worker_threads(8) + .thread_name("io-worker") + .build() + .unwrap(), + cpu_runtime: Builder::new_multi_thread() + .worker_threads(4) + .thread_name("cpu-worker") + .build() + .unwrap(), + } + } + + fn spawn_io(&self, future: F) + where F: Future + Send + 'static, F::Output: Send + 'static + { + self.io_runtime.spawn(future); + } + + fn spawn_cpu(&self, task: F) + where F: FnOnce() + Send + 'static + { + self.cpu_runtime.spawn_blocking(task); + } +} +``` + +## Runtime in Tests + +```rust +// Single test runtime +#[tokio::test] +async fn test_single() { + assert!(true); +} + +// Multi-threaded test +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_concurrent() { + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { tx.send(42).unwrap() }); + assert_eq!(rx.await.unwrap(), 42); +} + +// Custom runtime in test +#[test] +fn test_with_custom_runtime() { + let rt = Builder::new_current_thread().build().unwrap(); + rt.block_on(async { + // test code + }); +} +``` + +## See Also + +- [async-spawn-blocking](./async-spawn-blocking.md) - Handling blocking code +- [async-no-lock-await](./async-no-lock-await.md) - Avoiding lock issues +- [async-joinset-structured](./async-joinset-structured.md) - Managing spawned tasks diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-try-join.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-try-join.md new file mode 100644 index 00000000..9170a0e7 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-try-join.md @@ -0,0 +1,172 @@ +# async-try-join + +> Use `try_join!` for concurrent fallible operations with early return on error + +## Why It Matters + +When running multiple fallible operations concurrently, `try_join!` returns `Err` as soon as any future fails, without waiting for the others. This provides fail-fast behavior while still running operations in parallel. For many operations, use `futures::future::try_join_all`. + +## Bad + +```rust +// Sequential - slow and no early return benefit +async fn fetch_all() -> Result<(A, B, C)> { + let a = fetch_a().await?; // If this fails, we wait for nothing + let b = fetch_b().await?; // But if this fails, we waited for A + let c = fetch_c().await?; + Ok((a, b, c)) +} + +// join! ignores errors +async fn fetch_all() -> (Result, Result, Result) { + let (a, b, c) = join!(fetch_a(), fetch_b(), fetch_c()); + // All complete even if first one failed + (a, b, c) // Now we have to handle three Results +} +``` + +## Good + +```rust +use tokio::try_join; + +async fn fetch_all() -> Result<(A, B, C)> { + // Concurrent AND fail-fast + let (a, b, c) = try_join!( + fetch_a(), + fetch_b(), + fetch_c(), + )?; + + Ok((a, b, c)) +} + +// For dynamic collections +use futures::future::try_join_all; + +async fn fetch_users(ids: &[u64]) -> Result> { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + try_join_all(futures).await +} +``` + +## Error Handling Patterns + +```rust +// Different error types - need common error type +async fn mixed_operations() -> Result<(A, B), Error> { + let (a, b) = try_join!( + fetch_a().map_err(Error::from), // Convert errors + fetch_b().map_err(Error::from), + )?; + Ok((a, b)) +} + +// Collect all results, then handle errors +async fn all_or_nothing(ids: &[u64]) -> Result> { + try_join_all(ids.iter().map(|id| fetch_user(*id))).await +} + +// Collect successes, log failures +async fn best_effort(ids: &[u64]) -> Vec { + let results = futures::future::join_all( + ids.iter().map(|id| fetch_user(*id)) + ).await; + + results.into_iter() + .filter_map(|r| match r { + Ok(user) => Some(user), + Err(e) => { + log::warn!("Failed to fetch user: {}", e); + None + } + }) + .collect() +} +``` + +## Cancellation Behavior + +```rust +// try_join! cancels remaining futures on error +async fn with_cancellation() -> Result<()> { + // If fetch_a() fails, fetch_b() and fetch_c() are dropped + // But "dropped" != "immediately stopped" + // They stop at their next .await point + + try_join!( + async { + fetch_a().await?; + cleanup_a().await; // May not run if other future fails + Ok::<_, Error>(()) + }, + async { + fetch_b().await?; + cleanup_b().await; // May not run if other future fails + Ok::<_, Error>(()) + }, + )?; + + Ok(()) +} + +// For guaranteed cleanup, use Drop guards or explicit handling +``` + +## With Timeout + +```rust +use tokio::time::{timeout, Duration}; + +async fn fetch_with_timeout() -> Result<(A, B)> { + timeout( + Duration::from_secs(10), + try_join!(fetch_a(), fetch_b()) + ) + .await + .map_err(|_| Error::Timeout)? +} + +// Per-operation timeout +async fn individual_timeouts() -> Result<(A, B)> { + try_join!( + timeout(Duration::from_secs(5), fetch_a()) + .map_err(|_| Error::Timeout) + .and_then(|r| async { r }), + timeout(Duration::from_secs(5), fetch_b()) + .map_err(|_| Error::Timeout) + .and_then(|r| async { r }), + ) +} +``` + +## try_join! vs FuturesUnordered + +```rust +use futures::stream::{FuturesUnordered, StreamExt}; + +// try_join!: wait for all, fail fast +let (a, b, c) = try_join!(fa, fb, fc)?; + +// FuturesUnordered: process as they complete +let mut futures = FuturesUnordered::new(); +futures.push(fetch_a()); +futures.push(fetch_b()); +futures.push(fetch_c()); + +while let Some(result) = futures.next().await { + match result { + Ok(data) => process(data), + Err(e) => return Err(e), // Can fail fast manually + } +} +``` + +## See Also + +- [async-join-parallel](./async-join-parallel.md) - Non-fallible concurrent futures +- [async-select-racing](./async-select-racing.md) - First-to-complete semantics +- [err-question-mark](./err-question-mark.md) - Error propagation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-watch-latest.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-watch-latest.md new file mode 100644 index 00000000..5ed4baf2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-watch-latest.md @@ -0,0 +1,189 @@ +# async-watch-latest + +> Use `watch` channel for sharing the latest value with multiple observers + +## Why It Matters + +`watch` is optimized for scenarios where receivers only care about the most recent value, not the history of changes. Unlike `broadcast`, slow receivers don't lag—they simply skip intermediate values. This is perfect for configuration, state, or status that should always reflect the current situation. + +## Bad + +```rust +// Using broadcast when only latest value matters +let (tx, _) = broadcast::channel::(100); + +// Receivers might process stale configs if they're slow +// And they waste time processing intermediate values + +// Using mpsc with buffered stale values +let (tx, mut rx) = mpsc::channel::(100); +// Receiver might process outdated statuses +``` + +## Good + +```rust +use tokio::sync::watch; + +let (tx, rx) = watch::channel(Config::default()); + +// Multiple observers +let rx1 = rx.clone(); +let rx2 = rx.clone(); + +// Observer 1: waits for changes +tokio::spawn(async move { + let mut rx = rx1; + while rx.changed().await.is_ok() { + let config = rx.borrow(); + apply_config(&*config); + } +}); + +// Observer 2: also sees all changes +tokio::spawn(async move { + let mut rx = rx2; + while rx.changed().await.is_ok() { + let config = rx.borrow(); + log_config_change(&*config); + } +}); + +// Update the value +tx.send(Config::new())?; +``` + +## watch Semantics + +```rust +use tokio::sync::watch; + +let (tx, mut rx) = watch::channel("initial"); + +// Immediate read - no waiting +assert_eq!(*rx.borrow(), "initial"); + +// Wait for change +tx.send("updated")?; +rx.changed().await?; +assert_eq!(*rx.borrow(), "updated"); + +// Multiple rapid updates - receiver sees latest +tx.send("v1")?; +tx.send("v2")?; +tx.send("v3")?; +rx.changed().await?; +assert_eq!(*rx.borrow(), "v3"); // Skipped v1, v2 +``` + +## Configuration Reload Pattern + +```rust +use tokio::sync::watch; +use std::sync::Arc; + +struct AppConfig { + log_level: Level, + max_connections: usize, +} + +async fn config_watcher(tx: watch::Sender>) { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + + if let Ok(new_config) = reload_config_from_disk() { + // Only notifies if value actually changed + tx.send_if_modified(|current| { + if *current != new_config { + *current = Arc::new(new_config); + true + } else { + false + } + }); + } + } +} + +async fn worker(mut config_rx: watch::Receiver>) { + loop { + tokio::select! { + _ = config_rx.changed() => { + let config = config_rx.borrow().clone(); + reconfigure(&config); + } + _ = do_work() => {} + } + } +} +``` + +## State Machine Updates + +```rust +#[derive(Clone, PartialEq)] +enum ConnectionState { + Disconnected, + Connecting, + Connected, + Error(String), +} + +struct Connection { + state_tx: watch::Sender, + state_rx: watch::Receiver, +} + +impl Connection { + async fn wait_connected(&mut self) -> Result<(), Error> { + loop { + let state = self.state_rx.borrow().clone(); + match state { + ConnectionState::Connected => return Ok(()), + ConnectionState::Error(e) => return Err(Error::Connection(e)), + _ => { + self.state_rx.changed().await?; + } + } + } + } +} +``` + +## Borrow vs Clone + +```rust +use tokio::sync::watch; + +let (tx, rx) = watch::channel(vec![1, 2, 3]); + +// borrow() returns Ref - must not hold across await +{ + let data = rx.borrow(); + println!("{:?}", *data); +} // Ref dropped here + +// For use across await, clone the data +let data = rx.borrow().clone(); +some_async_operation().await; +use_data(&data); // Safe + +// Or use borrow_and_update() to mark as seen +let data = rx.borrow_and_update().clone(); +``` + +## watch vs broadcast vs mpsc + +| Feature | watch | broadcast | mpsc | +|---------|-------|-----------|------| +| Receivers | Multiple | Multiple | Single | +| Message delivery | Latest only | All messages | All messages | +| Slow receiver | Skips to latest | Lags/misses | Backpressure | +| Clone required | No | Yes | No | +| Best for | Config, status | Events | Work queues | + +## See Also + +- [async-broadcast-pubsub](./async-broadcast-pubsub.md) - When history matters +- [async-mpsc-queue](./async-mpsc-queue.md) - Work queue patterns +- [async-cancellation-token](./async-cancellation-token.md) - Related pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-all-public.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-all-public.md new file mode 100644 index 00000000..00f3df0b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-all-public.md @@ -0,0 +1,113 @@ +# doc-all-public + +> Document all public items with `///` doc comments + +## Why It Matters + +Public items define your crate's API contract. Without documentation, users must read source code to understand how to use your library. Well-documented APIs reduce support burden, improve adoption, and serve as the primary reference for users. + +Rust's `cargo doc` generates beautiful HTML documentation from doc comments, but only if you write them. + +## Bad + +```rust +pub struct Config { + pub timeout: Duration, + pub retries: u32, + pub base_url: String, +} + +pub fn connect(config: Config) -> Result { + // ... +} + +pub enum Status { + Pending, + Active, + Failed, +} +``` + +## Good + +```rust +/// Configuration for establishing a connection to the service. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Config; +/// use std::time::Duration; +/// +/// let config = Config { +/// timeout: Duration::from_secs(30), +/// retries: 3, +/// base_url: "https://api.example.com".to_string(), +/// }; +/// ``` +pub struct Config { + /// Maximum time to wait for a response before timing out. + pub timeout: Duration, + + /// Number of retry attempts for failed requests. + pub retries: u32, + + /// Base URL for all API requests. + pub base_url: String, +} + +/// Establishes a connection using the provided configuration. +/// +/// # Errors +/// +/// Returns an error if the connection cannot be established +/// or if the configuration is invalid. +pub fn connect(config: Config) -> Result { + // ... +} + +/// Represents the current status of a job. +pub enum Status { + /// Job is waiting to be processed. + Pending, + /// Job is currently being processed. + Active, + /// Job has failed and will not be retried. + Failed, +} +``` + +## What to Document + +| Item Type | Required Content | +|-----------|------------------| +| Structs | Purpose, usage example | +| Struct fields | What the field represents | +| Enums | When to use each variant | +| Enum variants | What state it represents | +| Functions | What it does, parameters, return value | +| Traits | Contract and expected behavior | +| Trait methods | Default implementation behavior | +| Type aliases | Why the alias exists | +| Constants | What the value represents | + +## Enforcement + +Enable the `missing_docs` lint to catch undocumented public items: + +```rust +#![warn(missing_docs)] +``` + +Or in `Cargo.toml` for workspace-wide enforcement: + +```toml +[workspace.lints.rust] +missing_docs = "warn" +``` + +## See Also + +- [doc-module-inner](./doc-module-inner.md) - Module-level documentation +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [lint-missing-docs](./lint-missing-docs.md) - Enforcing documentation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-cargo-metadata.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-cargo-metadata.md new file mode 100644 index 00000000..482a8d97 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-cargo-metadata.md @@ -0,0 +1,147 @@ +# doc-cargo-metadata + +> Fill `Cargo.toml` metadata for published crates + +## Why It Matters + +Cargo.toml metadata appears on crates.io, in search results, and helps users evaluate your crate. Missing metadata makes your crate look unprofessional, harder to find, and harder to trust. Complete metadata improves discoverability and adoption. + +## Bad + +```toml +[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" + +[dependencies] +# ... +``` + +## Good + +```toml +[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +# Required for crates.io +description = "A fast, ergonomic HTTP client for Rust" +license = "MIT OR Apache-2.0" +repository = "https://github.com/username/my-awesome-crate" + +# Highly recommended +documentation = "https://docs.rs/my-awesome-crate" +readme = "README.md" +keywords = ["http", "client", "async", "networking"] +categories = ["network-programming", "web-programming::http-client"] +authors = ["Your Name "] +homepage = "https://my-awesome-crate.dev" + +# Optional but helpful +include = ["src/**/*", "Cargo.toml", "LICENSE*", "README.md"] +exclude = ["tests/fixtures/*", ".github/*"] + +[badges] +maintenance = { status = "actively-developed" } + +[dependencies] +# ... +``` + +## Required Fields for Publishing + +| Field | Purpose | +|-------|---------| +| `name` | Crate name on crates.io | +| `version` | Semver version | +| `license` or `license-file` | SPDX license identifier | +| `description` | One-line summary (≤256 chars) | + +## Recommended Fields + +| Field | Purpose | Example | +|-------|---------|---------| +| `repository` | Link to source code | `https://github.com/user/repo` | +| `documentation` | Link to docs | `https://docs.rs/crate` | +| `readme` | Path to README | `README.md` | +| `keywords` | Search terms (max 5) | `["http", "async"]` | +| `categories` | crates.io categories | `["network-programming"]` | +| `rust-version` | MSRV | `"1.70"` | + +## Keywords Best Practices + +```toml +# Good: specific, searchable terms +keywords = ["json", "serialization", "serde", "parsing"] + +# Bad: too generic or redundant +keywords = ["rust", "library", "awesome", "fast", "best"] +``` + +## Categories + +Choose from [crates.io categories](https://crates.io/category_slugs): + +```toml +categories = [ + "network-programming", + "web-programming::http-client", + "asynchronous", +] +``` + +## License Patterns + +```toml +# Single license +license = "MIT" + +# Dual license (common in Rust ecosystem) +license = "MIT OR Apache-2.0" + +# Custom license file +license-file = "LICENSE" +``` + +## Include/Exclude + +Control what gets published: + +```toml +# Explicit include (whitelist) +include = [ + "src/**/*", + "Cargo.toml", + "LICENSE*", + "README.md", + "CHANGELOG.md", +] + +# Or exclude (blacklist) +exclude = [ + "tests/fixtures/large-file.bin", + ".github/*", + "benches/*", +] +``` + +## Verification + +Check your package before publishing: + +```bash +# See what will be included +cargo package --list + +# Check metadata +cargo publish --dry-run +``` + +## See Also + +- [doc-module-inner](./doc-module-inner.md) - Crate-level documentation +- [lint-cargo-metadata](./lint-cargo-metadata.md) - Linting Cargo.toml +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace management diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-errors-section.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-errors-section.md new file mode 100644 index 00000000..2c861ec9 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-errors-section.md @@ -0,0 +1,122 @@ +# doc-errors-section + +> Include `# Errors` section for fallible functions + +## Why It Matters + +Functions returning `Result` can fail in specific, documented ways. The `# Errors` section tells users exactly when and why a function might return an error, enabling them to handle failures appropriately without reading source code. + +This is especially critical for library code where users cannot easily inspect implementation details. + +## Bad + +```rust +/// Opens a file and reads its contents. +pub fn read_file(path: &Path) -> Result { + // Users have no idea what errors to expect +} + +/// Connects to the database. +pub async fn connect(url: &str) -> Result { + // Multiple failure modes, none documented +} +``` + +## Good + +```rust +/// Opens a file and reads its contents as a UTF-8 string. +/// +/// # Errors +/// +/// Returns an error if: +/// - The file does not exist ([`Error::NotFound`]) +/// - The process lacks permission to read the file ([`Error::PermissionDenied`]) +/// - The file contains invalid UTF-8 ([`Error::InvalidUtf8`]) +pub fn read_file(path: &Path) -> Result { + // ... +} + +/// Establishes a connection to the database. +/// +/// # Errors +/// +/// This function will return an error if: +/// - The URL is malformed ([`DbError::InvalidUrl`]) +/// - The database server is unreachable ([`DbError::ConnectionFailed`]) +/// - Authentication fails ([`DbError::AuthenticationFailed`]) +/// - The connection pool is exhausted ([`DbError::PoolExhausted`]) +pub async fn connect(url: &str) -> Result { + // ... +} +``` + +## Error Documentation Patterns + +### Simple Single Error + +```rust +/// Parses a string as an integer. +/// +/// # Errors +/// +/// Returns [`ParseIntError`] if the string is not a valid integer. +pub fn parse_int(s: &str) -> Result { + s.parse() +} +``` + +### Multiple Error Variants + +```rust +/// Sends an HTTP request and returns the response. +/// +/// # Errors +/// +/// | Error | Condition | +/// |-------|-----------| +/// | [`HttpError::Timeout`] | Request exceeded timeout duration | +/// | [`HttpError::InvalidUrl`] | URL could not be parsed | +/// | [`HttpError::ConnectionRefused`] | Server refused connection | +/// | [`HttpError::TlsError`] | TLS handshake failed | +pub fn send(request: Request) -> Result { + // ... +} +``` + +### Propagated Errors + +```rust +/// Loads configuration from a file. +/// +/// # Errors +/// +/// Returns an error if: +/// - The configuration file cannot be read (IO error) +/// - The file contains invalid TOML syntax +/// - Required fields are missing from the configuration +/// +/// The underlying error is wrapped with context about which +/// configuration file failed to load. +pub fn load_config(path: &Path) -> Result { + // ... +} +``` + +## Linking to Error Types + +Use intra-doc links to connect error variants to their definitions: + +```rust +/// # Errors +/// +/// Returns [`ValidationError::TooShort`] if the input is less than +/// the minimum length, or [`ValidationError::InvalidChars`] if it +/// contains forbidden characters. +``` + +## See Also + +- [doc-panics-section](./doc-panics-section.md) - Documenting panics +- [err-doc-errors](./err-doc-errors.md) - Error documentation patterns +- [doc-intra-links](./doc-intra-links.md) - Linking to types diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-examples-section.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-examples-section.md new file mode 100644 index 00000000..c8036ec3 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-examples-section.md @@ -0,0 +1,161 @@ +# doc-examples-section + +> Include `# Examples` with runnable code + +## Why It Matters + +Examples are the most valuable part of documentation. They show users exactly how to use your API. Rust's doc tests ensure examples stay correct as code evolves. + +## Bad + +```rust +/// Parses a string into a Foo. +pub fn parse(s: &str) -> Result { + // No examples - users have to guess usage +} + +/// A widget for doing things. +/// +/// This widget is very useful. +pub struct Widget { + // Still no examples +} +``` + +## Good + +```rust +/// Parses a string into a Foo. +/// +/// # Examples +/// +/// ``` +/// use my_crate::parse; +/// +/// let foo = parse("hello").unwrap(); +/// assert_eq!(foo.name(), "hello"); +/// ``` +/// +/// Handles empty strings: +/// +/// ``` +/// use my_crate::parse; +/// +/// let foo = parse("").unwrap(); +/// assert!(foo.is_empty()); +/// ``` +pub fn parse(s: &str) -> Result { + // ... +} +``` + +## Use ? Not unwrap() + +```rust +/// Loads configuration from a file. +/// +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// use my_crate::Config; +/// +/// let config = Config::load("config.toml")?; +/// println!("Port: {}", config.port); +/// # Ok(()) +/// # } +/// ``` +pub fn load(path: &str) -> Result { + // ... +} +``` + +## Hide Setup Code + +```rust +/// Processes items from a database. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Database, Item}; +/// # fn get_db() -> Database { Database::mock() } +/// let db = get_db(); +/// let items = db.process_items()?; +/// assert!(!items.is_empty()); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +pub fn process_items(&self) -> Result, Error> { + // ... +} +``` + +## Multiple Examples + +```rust +/// Creates a new buffer with the specified capacity. +/// +/// # Examples +/// +/// Basic usage: +/// +/// ``` +/// use my_crate::Buffer; +/// +/// let buf = Buffer::with_capacity(1024); +/// assert_eq!(buf.capacity(), 1024); +/// ``` +/// +/// Zero capacity creates an empty buffer: +/// +/// ``` +/// use my_crate::Buffer; +/// +/// let buf = Buffer::with_capacity(0); +/// assert!(buf.is_empty()); +/// ``` +pub fn with_capacity(cap: usize) -> Self { + // ... +} +``` + +## Show Error Cases + +```rust +/// Divides two numbers. +/// +/// # Examples +/// +/// ``` +/// use my_crate::divide; +/// +/// assert_eq!(divide(10, 2), Ok(5)); +/// ``` +/// +/// Division by zero returns an error: +/// +/// ``` +/// use my_crate::{divide, MathError}; +/// +/// assert_eq!(divide(10, 0), Err(MathError::DivisionByZero)); +/// ``` +pub fn divide(a: i32, b: i32) -> Result { + // ... +} +``` + +## Running Doc Tests + +```bash +# Run all doc tests +cargo test --doc + +# Run doc tests for specific item +cargo test --doc my_function +``` + +## See Also + +- [doc-question-mark](doc-question-mark.md) - Use ? in examples +- [doc-hidden-setup](doc-hidden-setup.md) - Hide setup code with # +- [doc-errors-section](doc-errors-section.md) - Document error conditions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-hidden-setup.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-hidden-setup.md new file mode 100644 index 00000000..5ef3b30f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-hidden-setup.md @@ -0,0 +1,149 @@ +# doc-hidden-setup + +> Use `# ` prefix to hide example setup code + +## Why It Matters + +Doc examples often require setup code (imports, struct initialization, mock data) that distracts from the main point. The `# ` prefix hides lines from rendered documentation while keeping them in the compiled test, showing users only the relevant code. + +This keeps examples focused and readable while ensuring they still compile and run. + +## Bad + +```rust +/// Processes a batch of items. +/// +/// # Examples +/// +/// ``` +/// use my_crate::{Processor, Config, Item}; +/// use std::sync::Arc; +/// +/// let config = Config { +/// batch_size: 100, +/// timeout_ms: 5000, +/// retry_count: 3, +/// }; +/// let processor = Processor::new(Arc::new(config)); +/// let items = vec![ +/// Item::new("a"), +/// Item::new("b"), +/// Item::new("c"), +/// ]; +/// +/// // This is the actual example - buried after 15 lines of setup +/// let results = processor.process_batch(&items)?; +/// assert!(results.all_succeeded()); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +pub fn process_batch(&self, items: &[Item]) -> Result { + // ... +} +``` + +## Good + +```rust +/// Processes a batch of items. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Processor, Config, Item, Error}; +/// # use std::sync::Arc; +/// # let config = Config { batch_size: 100, timeout_ms: 5000, retry_count: 3 }; +/// # let processor = Processor::new(Arc::new(config)); +/// # let items = vec![Item::new("a"), Item::new("b"), Item::new("c")]; +/// let results = processor.process_batch(&items)?; +/// assert!(results.all_succeeded()); +/// # Ok::<(), Error>(()) +/// ``` +pub fn process_batch(&self, items: &[Item]) -> Result { + // ... +} +``` + +Users see only: + +```rust +let results = processor.process_batch(&items)?; +assert!(results.all_succeeded()); +``` + +## What to Hide + +| Hide | Show | +|------|------| +| `use` statements | Core API usage | +| Type definitions | Method calls | +| Mock/test data setup | Key parameters | +| Error handling boilerplate | Return value handling | +| `Ok(())` return | Assertions (sometimes) | + +## Pattern: Hiding Multi-Line Setup + +```rust +/// # Examples +/// +/// ``` +/// # use my_crate::{Client, Request}; +/// # fn main() -> Result<(), Box> { +/// # let client = Client::builder() +/// # .timeout(30) +/// # .retry(3) +/// # .build()?; +/// let response = client.send(Request::get("/users"))?; +/// println!("Status: {}", response.status()); +/// # Ok(()) +/// # } +/// ``` +``` + +## Pattern: Showing Setup When Relevant + +Sometimes setup IS the point—don't hide it: + +```rust +/// Creates a new client with custom configuration. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Client; +/// +/// // Configuration IS the example - show it +/// let client = Client::builder() +/// .base_url("https://api.example.com") +/// .timeout_secs(30) +/// .max_retries(3) +/// .build()?; +/// # Ok::<(), my_crate::Error>(()) +/// ``` +``` + +## Pattern: `ignore` and `no_run` + +For examples that shouldn't run in tests: + +```rust +/// # Examples +/// +/// ```no_run +/// # use my_crate::Server; +/// // This would actually start a server - don't run in tests +/// let server = Server::bind("0.0.0.0:8080").await?; +/// server.run().await?; +/// # Ok::<(), my_crate::Error>(()) +/// ``` + +/// ```ignore +/// // Pseudocode or incomplete example +/// let magic = do_something_undefined(); +/// ``` +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Writing examples +- [doc-question-mark](./doc-question-mark.md) - Using `?` in examples +- [test-doctest-examples](./test-doctest-examples.md) - Doctests as tests diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-intra-links.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-intra-links.md new file mode 100644 index 00000000..47c693f9 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-intra-links.md @@ -0,0 +1,138 @@ +# doc-intra-links + +> Use intra-doc links to reference types and items + +## Why It Matters + +Intra-doc links (`[TypeName]`, `[method](Self::method)`) create clickable references in generated documentation. They're verified at doc-build time, catching broken links early. Unlike URL links, they automatically update when items are renamed or moved. + +## Bad + +```rust +/// Returns the length of the buffer. +/// +/// See also `capacity()` for the allocated size, and the +/// `Buffer` struct for more details. +pub fn len(&self) -> usize { + self.data.len() +} + +/// Parses the input using std::str::FromStr trait. +/// Check the Error enum for possible failures. +pub fn parse(input: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Returns the length of the buffer. +/// +/// See also [`capacity()`](Self::capacity) for the allocated size, and +/// [`Buffer`] for more details. +pub fn len(&self) -> usize { + self.data.len() +} + +/// Parses the input using [`FromStr`] trait. +/// Check [`Error`] for possible failures. +/// +/// [`FromStr`]: std::str::FromStr +pub fn parse(input: &str) -> Result { + // ... +} +``` + +## Link Syntax + +| Syntax | Links To | Example | +|--------|----------|---------| +| `[Name]` | Item in scope | `[Vec]`, `[Option]` | +| `[path::Name]` | Fully qualified item | `[std::vec::Vec]` | +| `[Self::method]` | Method on current type | `[Self::new]` | +| `[Type::method]` | Method on other type | `[String::new]` | +| `[Type::CONST]` | Associated constant | `[usize::MAX]` | +| `[text](path)` | Custom text | `[see here](Self::len)` | + +## Common Patterns + +### Linking to Self Members + +```rust +impl Buffer { + /// Creates an empty buffer. + /// + /// Use [`with_capacity`](Self::with_capacity) if you know the size. + pub fn new() -> Self { /* ... */ } + + /// Creates a buffer with pre-allocated capacity. + /// + /// See [`new`](Self::new) for the default constructor. + pub fn with_capacity(cap: usize) -> Self { /* ... */ } +} +``` + +### Linking to Trait Methods + +```rust +/// Converts to a string representation. +/// +/// This is the implementation of [`Display::fmt`](std::fmt::Display::fmt). +impl Display for MyType { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + // ... + } +} +``` + +### Disambiguation + +When names conflict, use suffixes: + +```rust +/// See [`foo()`](fn@foo) for the function and [`foo`](mod@foo) for the module. + +/// Works with [`Error`](struct@Error) struct or [`Error`](trait@Error) trait. +``` + +| Suffix | Item Type | +|--------|-----------| +| `fn@` | Function | +| `mod@` | Module | +| `struct@` | Struct | +| `enum@` | Enum | +| `trait@` | Trait | +| `type@` | Type alias | +| `const@` | Constant | +| `macro@` | Macro | + +### Reference-Style Links + +For repeated links or long paths: + +```rust +/// Parses using [`serde`] with [`Deserialize`] trait. +/// Returns a [`Result`] that may contain [`Error`]. +/// +/// [`serde`]: https://serde.rs +/// [`Deserialize`]: serde::Deserialize +/// [`Result`]: std::result::Result +/// [`Error`]: crate::Error +``` + +## Verification + +Enable link checking in CI: + +```bash +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +``` + +This fails if any intra-doc links are broken. + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documenting public items +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [doc-errors-section](./doc-errors-section.md) - Documenting errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-link-types.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-link-types.md new file mode 100644 index 00000000..f2b5b0f0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-link-types.md @@ -0,0 +1,169 @@ +# doc-link-types + +> Use intra-doc links to connect related types and functions + +## Why It Matters + +Intra-doc links (`[`TypeName`]`) create clickable references in generated documentation. They enable navigation between related items, verify that referenced items exist at compile time, and update automatically when items are renamed. Plain text references become stale and unclickable. + +## Bad + +```rust +/// Parses input and returns a ParseResult. +/// +/// See also: ParseError for error types. +/// Uses the Tokenizer internally. +pub fn parse(input: &str) -> ParseResult { + // "ParseResult", "ParseError", "Tokenizer" are not clickable + // No verification they exist +} +``` + +## Good + +```rust +/// Parses input and returns a [`ParseResult`]. +/// +/// # Errors +/// +/// Returns [`ParseError::InvalidSyntax`] if the input contains invalid tokens. +/// Returns [`ParseError::UnexpectedEof`] if the input ends prematurely. +/// +/// # Related +/// +/// - [`Tokenizer`] - The underlying tokenizer used by this parser +/// - [`parse_file`] - Convenience function for parsing files +/// - [`ParseOptions`] - Configuration options for parsing +pub fn parse(input: &str) -> ParseResult { + // All links are clickable and verified +} +``` + +## Link Syntax + +```rust +/// Basic link to type in same module +/// See [`MyType`] for details. + +/// Link to method +/// Use [`MyType::new`] to create instances. + +/// Link to associated type +/// Returns [`Iterator::Item`]. + +/// Link to module +/// See the [`parser`] module. + +/// Link to external crate type +/// Works with [`std::collections::HashMap`]. + +/// Link with custom text +/// See [the parser][`parse`] for details. + +/// Link to module item +/// See [`crate::utils::helper`]. + +/// Link to parent module item +/// See [`super::Parent`]. +``` + +## Common Patterns + +```rust +/// A configuration builder. +/// +/// # Example +/// +/// ``` +/// use my_crate::Config; +/// +/// let config = Config::builder() +/// .timeout(30) +/// .build()?; +/// ``` +/// +/// # Methods +/// +/// - [`Config::builder`] - Create a new builder +/// - [`Config::default`] - Create with defaults +/// +/// # Related Types +/// +/// - [`ConfigBuilder`] - The builder returned by [`Config::builder`] +/// - [`ConfigError`] - Errors that can occur when building +pub struct Config { ... } + +impl Config { + /// Creates a new [`ConfigBuilder`]. + /// + /// This is equivalent to [`ConfigBuilder::new`]. + pub fn builder() -> ConfigBuilder { ... } +} +``` + +## Linking to Trait Items + +```rust +/// Implements [`Iterator`] for lazy evaluation. +/// +/// The [`Iterator::next`] method advances the cursor. +/// +/// For parallel iteration, see [`rayon::ParallelIterator`]. +pub struct MyIterator { ... } + +impl Iterator for MyIterator { + /// Advances and returns the next value. + /// + /// See also [`Iterator::nth`] for skipping elements. + fn next(&mut self) -> Option { ... } +} +``` + +## Broken Link Detection + +```bash +# Catch broken intra-doc links +RUSTDOCFLAGS="-D warnings" cargo doc + +# Or in CI +cargo doc --no-deps 2>&1 | grep "warning: unresolved link" +``` + +```toml +# Cargo.toml - deny broken links +[lints.rustdoc] +broken_intra_doc_links = "deny" +``` + +## Module-Level Documentation + +```rust +//! # Parser Module +//! +//! This module provides parsing utilities. +//! +//! ## Main Types +//! +//! - [`Parser`] - The main parser struct +//! - [`Token`] - Tokens produced by tokenization +//! - [`Ast`] - The abstract syntax tree +//! +//! ## Functions +//! +//! - [`parse`] - Parse a string +//! - [`parse_file`] - Parse a file +//! +//! ## Errors +//! +//! All functions return [`ParseError`] on failure. + +pub struct Parser { ... } +pub enum Token { ... } +pub struct Ast { ... } +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Code examples in docs +- [err-doc-errors](./err-doc-errors.md) - Documenting errors +- [lint-deny-correctness](./lint-deny-correctness.md) - Lint settings diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-module-inner.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-module-inner.md new file mode 100644 index 00000000..9a4e028c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-module-inner.md @@ -0,0 +1,116 @@ +# doc-module-inner + +> Use `//!` for module-level documentation + +## Why It Matters + +Inner doc comments (`//!`) document the module itself, not the next item. They appear at the top of module files and describe the module's purpose, contents, and usage patterns. This helps users understand what a module provides before diving into individual items. + +Module docs are the first thing users see in `cargo doc` when navigating to a module. + +## Bad + +```rust +// This module handles authentication +// It provides JWT and session-based auth + +mod auth; + +pub use auth::*; +``` + +```rust +// auth.rs +/// Authentication utilities // Wrong: this documents nothing useful +use std::collections::HashMap; + +pub struct Session { /* ... */ } +``` + +## Good + +```rust +//! Authentication and authorization utilities. +//! +//! This module provides multiple authentication strategies: +//! +//! - [`JwtAuth`] - JSON Web Token based authentication +//! - [`SessionAuth`] - Cookie-based session authentication +//! - [`ApiKeyAuth`] - API key authentication for services +//! +//! # Examples +//! +//! ``` +//! use my_crate::auth::{JwtAuth, Authenticator}; +//! +//! let auth = JwtAuth::new("secret-key"); +//! let token = auth.generate_token(&user)?; +//! ``` +//! +//! # Feature Flags +//! +//! - `jwt` - Enables JWT authentication (enabled by default) +//! - `sessions` - Enables session-based authentication + +use std::collections::HashMap; + +pub struct Session { /* ... */ } +``` + +## Where to Use Inner Docs + +| Location | Purpose | +|----------|---------| +| `lib.rs` | Crate-level documentation (appears on crate root) | +| `mod.rs` | Module documentation for directory modules | +| `module.rs` | Module documentation for single-file modules | + +## Crate Root Example + +```rust +//! # My Awesome Crate +//! +//! `my_crate` provides utilities for handling complex workflows. +//! +//! ## Quick Start +//! +//! ```rust +//! use my_crate::prelude::*; +//! +//! let workflow = Workflow::builder() +//! .add_step(Step::new("fetch")) +//! .add_step(Step::new("process")) +//! .build(); +//! ``` +//! +//! ## Modules +//! +//! - [`workflow`] - Core workflow engine +//! - [`steps`] - Built-in workflow steps +//! - [`prelude`] - Common imports +//! +//! ## Feature Flags +//! +//! | Feature | Description | +//! |---------|-------------| +//! | `async` | Async workflow execution | +//! | `serde` | Serialization support | + +pub mod workflow; +pub mod steps; +pub mod prelude; +``` + +## Key Sections for Module Docs + +1. **Brief description** - One-line summary +2. **Overview** - What the module provides +3. **Examples** - How to use it +4. **Feature flags** - Optional functionality +5. **See Also** - Related modules + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documenting public items +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Crate metadata diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-panics-section.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-panics-section.md new file mode 100644 index 00000000..30a8b889 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-panics-section.md @@ -0,0 +1,128 @@ +# doc-panics-section + +> Include `# Panics` section for functions that can panic + +## Why It Matters + +Panics are exceptional conditions that crash the program (or unwind the stack). Users need to know when a function might panic so they can ensure preconditions are met or avoid the function in contexts where panics are unacceptable (e.g., `no_std`, embedded, FFI). + +If a function can panic, document exactly when. + +## Bad + +```rust +/// Returns the element at the given index. +pub fn get(index: usize) -> &T { + &self.data[index] // Panics if out of bounds - not documented! +} + +/// Divides two numbers. +pub fn divide(a: i32, b: i32) -> i32 { + a / b // Panics on division by zero - not documented! +} +``` + +## Good + +```rust +/// Returns the element at the given index. +/// +/// # Panics +/// +/// Panics if `index` is out of bounds (i.e., `index >= self.len()`). +/// +/// # Examples +/// +/// ``` +/// let v = vec![1, 2, 3]; +/// assert_eq!(v.get(1), &2); +/// ``` +pub fn get(&self, index: usize) -> &T { + &self.data[index] +} + +/// Divides two numbers. +/// +/// # Panics +/// +/// Panics if `divisor` is zero. +/// +/// For a non-panicking version, use [`checked_divide`]. +pub fn divide(dividend: i32, divisor: i32) -> i32 { + dividend / divisor +} + +/// Divides two numbers, returning `None` if the divisor is zero. +pub fn checked_divide(dividend: i32, divisor: i32) -> Option { + if divisor == 0 { + None + } else { + Some(dividend / divisor) + } +} +``` + +## Common Panic Conditions + +| Operation | Panic Condition | +|-----------|-----------------| +| Index access `[i]` | Index out of bounds | +| Division `/`, `%` | Division by zero | +| `.unwrap()` | `None` or `Err` value | +| `.expect()` | `None` or `Err` value | +| `slice::split_at(mid)` | `mid > len` | +| `Vec::remove(i)` | `i >= len` | +| Overflow (debug) | Integer overflow | + +## Pattern: Panic vs Return Error + +Document why you chose to panic vs return `Result`: + +```rust +/// Creates a new buffer with the given capacity. +/// +/// # Panics +/// +/// Panics if `capacity` is zero. A buffer must have at least +/// one byte of capacity. +/// +/// This panics rather than returning an error because a zero-capacity +/// buffer represents a programming error, not a runtime condition. +pub fn new(capacity: usize) -> Self { + assert!(capacity > 0, "capacity must be non-zero"); + // ... +} +``` + +## Pattern: Debug-Only Panics + +```rust +/// Adds an item to the collection. +/// +/// # Panics +/// +/// In debug builds, panics if the collection is at capacity. +/// In release builds, this is a no-op when at capacity. +pub fn push(&mut self, item: T) { + debug_assert!(self.len < self.capacity, "collection at capacity"); + // ... +} +``` + +## Provide Non-Panicking Alternatives + +When documenting a panicking function, point to safe alternatives: + +```rust +/// # Panics +/// +/// Panics if the index is out of bounds. +/// +/// For a non-panicking version, use [`get`] which returns `Option<&T>`. +``` + +## See Also + +- [doc-errors-section](./doc-errors-section.md) - Documenting errors +- [doc-safety-section](./doc-safety-section.md) - Documenting unsafe +- [err-result-over-panic](./err-result-over-panic.md) - Preferring Result diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-question-mark.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-question-mark.md new file mode 100644 index 00000000..6c85233f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-question-mark.md @@ -0,0 +1,136 @@ +# doc-question-mark + +> Use `?` in examples, not `.unwrap()` + +## Why It Matters + +Doc examples should model best practices. Using `.unwrap()` teaches users to ignore errors, while `?` demonstrates proper error propagation. Examples with `?` also fail the doctest if an error occurs, catching bugs in documentation. + +Rust doctests wrap examples in a function that returns `Result<(), E>` by default when you use `?`, making this pattern easy to adopt. + +## Bad + +```rust +/// Reads a configuration file. +/// +/// # Examples +/// +/// ``` +/// let config = Config::from_file("config.toml").unwrap(); +/// println!("{:?}", config.database_url); +/// ``` +pub fn from_file(path: &str) -> Result { + // ... +} + +/// Fetches data from the API. +/// +/// # Examples +/// +/// ``` +/// let client = Client::new(); +/// let response = client.get("https://api.example.com").unwrap(); +/// let data: Data = response.json().unwrap(); +/// ``` +pub async fn get(&self, url: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Reads a configuration file. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Config, Error}; +/// # fn main() -> Result<(), Error> { +/// let config = Config::from_file("config.toml")?; +/// println!("{:?}", config.database_url); +/// # Ok(()) +/// # } +/// ``` +pub fn from_file(path: &str) -> Result { + // ... +} + +/// Fetches data from the API. +/// +/// # Examples +/// +/// ```no_run +/// # use my_crate::{Client, Data, Error}; +/// # async fn example() -> Result<(), Error> { +/// let client = Client::new(); +/// let response = client.get("https://api.example.com").await?; +/// let data: Data = response.json().await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn get(&self, url: &str) -> Result { + // ... +} +``` + +## Doctest Wrapper Pattern + +Rust wraps doc examples in a function. You can make this explicit: + +```rust +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// let value = parse_config("key=value")?; +/// assert_eq!(value.key, "value"); +/// # Ok(()) +/// # } +/// ``` +``` + +Or use the implicit wrapper (Rust 2021+): + +```rust +/// # Examples +/// +/// ``` +/// # use my_crate::parse_config; +/// let value = parse_config("key=value")?; +/// assert_eq!(value.key, "value"); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +``` + +## When to Use `.unwrap()` + +There are specific cases where `.unwrap()` is acceptable in examples: + +```rust +/// # Examples +/// +/// ``` +/// // Static regex that is known at compile time to be valid +/// let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); +/// +/// // Parsing a literal that cannot fail +/// let n: i32 = "42".parse().unwrap(); +/// ``` +``` + +But still prefer `?` when demonstrating error handling patterns. + +## Comparison + +| Pattern | Behavior on Error | Teaches | +|---------|-------------------|---------| +| `.unwrap()` | Panics with generic message | Bad habits | +| `.expect()` | Panics with custom message | Slightly better | +| `?` | Propagates error, test fails | Best practices | + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Writing examples +- [doc-hidden-setup](./doc-hidden-setup.md) - Hiding setup code +- [err-question-mark](./err-question-mark.md) - Error propagation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-safety-section.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-safety-section.md new file mode 100644 index 00000000..52206f10 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-safety-section.md @@ -0,0 +1,131 @@ +# doc-safety-section + +> Include `# Safety` section for unsafe functions + +## Why It Matters + +Unsafe functions require callers to uphold invariants that the compiler cannot verify. The `# Safety` section documents exactly what the caller must guarantee for the function to be sound. Without this, users cannot safely call the function. + +This is not optional—it's a requirement for sound unsafe code. + +## Bad + +```rust +/// Reads a value from a raw pointer. +pub unsafe fn read_ptr(ptr: *const T) -> T { + // What guarantees must the caller provide? Unknown! + ptr.read() +} + +/// Creates a string from raw parts. +pub unsafe fn string_from_raw(ptr: *mut u8, len: usize, cap: usize) -> String { + String::from_raw_parts(ptr, len, cap) +} +``` + +## Good + +```rust +/// Reads a value from a raw pointer. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `ptr` is valid for reads of `size_of::()` bytes +/// - `ptr` is properly aligned for type `T` +/// - `ptr` points to a properly initialized value of type `T` +/// - The memory referenced by `ptr` is not mutated during this call +pub unsafe fn read_ptr(ptr: *const T) -> T { + ptr.read() +} + +/// Creates a `String` from raw parts. +/// +/// # Safety +/// +/// The caller must guarantee that: +/// - `ptr` was allocated by the same allocator that `String` uses +/// - `len` is less than or equal to `cap` +/// - The first `len` bytes at `ptr` are valid UTF-8 +/// - `cap` is the capacity that `ptr` was allocated with +/// - No other code will use `ptr` after this call (ownership is transferred) +/// +/// Violating these requirements leads to undefined behavior including +/// memory corruption, use-after-free, or invalid UTF-8 in strings. +pub unsafe fn string_from_raw(ptr: *mut u8, len: usize, cap: usize) -> String { + String::from_raw_parts(ptr, len, cap) +} +``` + +## Key Elements of Safety Documentation + +| Element | Description | +|---------|-------------| +| **Preconditions** | What must be true before calling | +| **Pointer validity** | Alignment, null-ness, lifetime | +| **Memory ownership** | Who owns what, transfer semantics | +| **Invariants** | Type invariants that must hold | +| **Consequences** | What happens if violated | + +## Pattern: Unsafe Trait Implementations + +```rust +/// A type that can be safely zeroed. +/// +/// # Safety +/// +/// Implementing this trait guarantees that: +/// - All bit patterns of zeros represent a valid value of this type +/// - The type has no padding bytes that could leak data +/// - The type contains no references or pointers +pub unsafe trait Zeroable { + fn zeroed() -> Self; +} + +// SAFETY: u32 is a primitive integer type where all zero bits +// represent a valid value (0). +unsafe impl Zeroable for u32 { + fn zeroed() -> Self { + 0 + } +} +``` + +## Pattern: Unsafe Blocks in Safe Functions + +When a safe function contains unsafe blocks, document the invariants: + +```rust +/// Returns a reference to the element at the given index. +/// +/// Returns `None` if the index is out of bounds. +pub fn get(&self, index: usize) -> Option<&T> { + if index < self.len { + // SAFETY: We just verified that index < len, so this + // access is within bounds. + Some(unsafe { self.data.get_unchecked(index) }) + } else { + None + } +} +``` + +## Common Safety Requirements + +```rust +/// # Safety +/// +/// - Pointer must be non-null +/// - Pointer must be aligned to `align_of::()` +/// - Pointer must be valid for reads/writes of `size_of::()` bytes +/// - Pointer must point to an initialized value of `T` +/// - The referenced memory must not be accessed through any other pointer +/// for the duration of the returned reference +/// - The total size must not exceed `isize::MAX` +``` + +## See Also + +- [doc-panics-section](./doc-panics-section.md) - Documenting panics +- [lint-unsafe-doc](./lint-unsafe-doc.md) - Enforcing unsafe documentation +- [doc-errors-section](./doc-errors-section.md) - Documenting errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-anyhow-app.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-anyhow-app.md new file mode 100644 index 00000000..b9eca49b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-anyhow-app.md @@ -0,0 +1,179 @@ +# err-anyhow-app + +> Use `anyhow` for application error handling + +## Why It Matters + +Applications often don't need typed errors - they just need to report what went wrong with good context. `anyhow` provides easy error handling with context chaining, backtraces, and conversion from any error type. + +## Bad + +```rust +// Tedious type management +fn load_config() -> Result> { + let path = find_config()?; // Returns FindError + let content = std::fs::read_to_string(&path)?; // Returns io::Error + let config: Config = toml::from_str(&content)?; // Returns toml::Error + validate(&config)?; // Returns ValidationError + Ok(config) +} + +// No context - hard to debug +fn process() -> Result<(), Box> { + let data = fetch()?; // Which fetch failed? + transform(data)?; // What was being transformed? + save()?; // Where was it saving to? + Ok(()) +} +``` + +## Good + +```rust +use anyhow::{Context, Result}; + +fn load_config() -> Result { + let path = find_config() + .context("failed to locate config file")?; + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read config from {}", path.display()))?; + + let config: Config = toml::from_str(&content) + .context("failed to parse config as TOML")?; + + validate(&config) + .context("config validation failed")?; + + Ok(config) +} + +// Error message: "config validation failed: field 'port' must be > 0" +// Full chain preserved for debugging +``` + +## Key Features + +```rust +use anyhow::{anyhow, bail, ensure, Context, Result}; + +fn example() -> Result<()> { + // Create ad-hoc errors + let err = anyhow!("something went wrong"); + + // Early return with error + bail!("aborting due to {}", reason); + + // Assert with error + ensure!(condition, "condition was false"); + + // Add context to any error + risky_operation() + .context("risky operation failed")?; + + // Dynamic context + fetch(url) + .with_context(|| format!("failed to fetch {}", url))?; + + Ok(()) +} +``` + +## Main Function Pattern + +```rust +use anyhow::Result; + +fn main() -> Result<()> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} + +// Or with custom exit handling +fn main() { + if let Err(e) = run() { + eprintln!("Error: {:#}", e); // Pretty-print with causes + std::process::exit(1); + } +} + +fn run() -> Result<()> { + // Application logic + Ok(()) +} +``` + +## Error Display Formats + +```rust +use anyhow::Result; + +fn show_error(err: anyhow::Error) { + // Just the top-level message + println!("{}", err); + // "config validation failed" + + // With cause chain (# alternate format) + println!("{:#}", err); + // "config validation failed: field 'port' must be > 0" + + // Debug format with backtrace + println!("{:?}", err); + // Full backtrace if RUST_BACKTRACE=1 + + // Iterate through cause chain + for cause in err.chain() { + println!("Caused by: {}", cause); + } +} +``` + +## Combining with thiserror + +```rust +// In your library crate - typed errors +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ApiError { + #[error("rate limited")] + RateLimited, + #[error("not found: {0}")] + NotFound(String), +} + +// In your application - anyhow for handling +use anyhow::{Context, Result}; + +fn fetch_user(id: u64) -> Result { + api::get_user(id) + .with_context(|| format!("failed to fetch user {}", id)) +} + +// Can still downcast if needed +fn handle_error(err: anyhow::Error) { + if let Some(api_err) = err.downcast_ref::() { + match api_err { + ApiError::RateLimited => wait_and_retry(), + ApiError::NotFound(id) => log_missing(id), + } + } +} +``` + +## When to Use Which + +| Situation | Use | +|-----------|-----| +| Library public API | `thiserror` | +| Application code | `anyhow` | +| CLI tools | `anyhow` | +| Internal library code | Either | +| Need to match error variants | `thiserror` | +| Just need to report errors | `anyhow` | + +## See Also + +- [err-thiserror-lib](err-thiserror-lib.md) - Use thiserror for libraries +- [err-context-chain](err-context-chain.md) - Add context to errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-context-chain.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-context-chain.md new file mode 100644 index 00000000..7ab41cce --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-context-chain.md @@ -0,0 +1,144 @@ +# err-context-chain + +> Add context with `.context()` or `.with_context()` + +## Why It Matters + +Raw errors often lack information about what operation failed. Adding context creates an error chain that tells the full story: what you were trying to do, and why it failed. + +## Bad + +```rust +// Raw error - no context +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + let content = std::fs::read_to_string(&path)?; + Ok(serde_json::from_str(&content)?) +} + +// Error message: "No such file or directory (os error 2)" +// Which file? What were we doing? +``` + +## Good + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read user file: {}", path))?; + + let user: User = serde_json::from_str(&content) + .with_context(|| format!("failed to parse user {} JSON", id))?; + + Ok(user) +} + +// Error: "failed to parse user 42 JSON" +// Caused by: "expected ':' at line 5 column 12" +``` + +## context() vs with_context() + +```rust +// context() - static string (slight allocation) +fs::read_to_string(path) + .context("failed to read config")?; + +// with_context() - lazy evaluation (only allocates on error) +fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path))?; + +// Use with_context() when: +// - Message includes runtime data (format!) +// - Computing the message is expensive +// - Error path is cold (most of the time) +``` + +## Building Context Chains + +```rust +fn process_order(order_id: u64) -> Result<()> { + let order = fetch_order(order_id) + .with_context(|| format!("failed to fetch order {}", order_id))?; + + let user = load_user(order.user_id) + .with_context(|| format!("failed to load user for order {}", order_id))?; + + let payment = process_payment(&order, &user) + .context("payment processing failed")?; + + ship_order(&order, &payment) + .context("shipping failed")?; + + Ok(()) +} + +// Full error chain: +// "shipping failed" +// Caused by: "carrier API returned 503" +// Caused by: "connection refused" +``` + +## Displaying Error Chains + +```rust +fn main() { + if let Err(e) = run() { + // Just top-level message + eprintln!("Error: {}", e); + + // Full chain with alternate format + eprintln!("Error: {:#}", e); + + // Debug format (includes backtrace if enabled) + eprintln!("Error: {:?}", e); + + // Iterate through chain + for (i, cause) in e.chain().enumerate() { + eprintln!(" {}: {}", i, cause); + } + } +} +``` + +## With thiserror + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("failed to load config from {path}")] + ConfigLoad { + path: String, + #[source] + cause: std::io::Error, + }, + + #[error("failed to connect to database")] + Database { + #[source] + cause: sqlx::Error, + }, +} + +// Usage +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| AppError::ConfigLoad { + path: path.to_string(), + cause: e, + })?; + // ... +} +``` + +## See Also + +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications +- [err-source-chain](err-source-chain.md) - Use #[source] to chain errors +- [err-question-mark](err-question-mark.md) - Use ? for propagation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-custom-type.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-custom-type.md new file mode 100644 index 00000000..6dbaf849 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-custom-type.md @@ -0,0 +1,152 @@ +# err-custom-type + +> Define custom error types for domain-specific failures + +## Why It Matters + +Generic errors like `String`, `Box`, or catch-all enums obscure what can actually go wrong. Custom error types document failure modes in the type system, enable pattern matching for specific handling, and provide clear API contracts. They make your code self-documenting and help callers handle errors appropriately. + +## Bad + +```rust +// Generic string errors - no structure +fn validate_user(user: &User) -> Result<(), String> { + if user.name.is_empty() { + return Err("Name is empty".to_string()); + } + if user.age > 150 { + return Err("Age is invalid".to_string()); + } + Ok(()) +} + +// Caller can't match on specific errors +match validate_user(&user) { + Ok(()) => save(user), + Err(msg) => { + // Can only do string comparison - fragile! + if msg.contains("Name") { + prompt_for_name() + } + } +} +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ValidationError { + #[error("name cannot be empty")] + EmptyName, + + #[error("name exceeds maximum length of {max} characters")] + NameTooLong { max: usize, actual: usize }, + + #[error("invalid age {0}: must be between 0 and 150")] + InvalidAge(u8), + + #[error("email format is invalid: {0}")] + InvalidEmail(String), +} + +fn validate_user(user: &User) -> Result<(), ValidationError> { + if user.name.is_empty() { + return Err(ValidationError::EmptyName); + } + if user.name.len() > 100 { + return Err(ValidationError::NameTooLong { + max: 100, + actual: user.name.len() + }); + } + if user.age > 150 { + return Err(ValidationError::InvalidAge(user.age)); + } + Ok(()) +} + +// Caller can match specifically +match validate_user(&user) { + Ok(()) => save(user), + Err(ValidationError::EmptyName) => prompt_for_name(), + Err(ValidationError::InvalidAge(age)) => { + show_error(&format!("Please enter a valid age (you entered {})", age)) + } + Err(e) => show_error(&e.to_string()), +} +``` + +## Error Type Design Guidelines + +```rust +// 1. Group related errors in domain-specific enums +#[derive(Error, Debug)] +pub enum AuthError { + #[error("invalid credentials")] + InvalidCredentials, + #[error("account locked after {attempts} failed attempts")] + AccountLocked { attempts: u32 }, + #[error("token expired")] + TokenExpired, +} + +#[derive(Error, Debug)] +pub enum PaymentError { + #[error("insufficient funds: need {required}, have {available}")] + InsufficientFunds { required: Decimal, available: Decimal }, + #[error("card declined: {reason}")] + CardDeclined { reason: String }, +} + +// 2. Include relevant data for error handling/display +#[derive(Error, Debug)] +pub enum FileError { + #[error("file not found: {path}")] + NotFound { path: PathBuf }, + #[error("permission denied for {path}")] + PermissionDenied { path: PathBuf }, +} + +// 3. Consider #[non_exhaustive] for public APIs +#[derive(Error, Debug)] +#[non_exhaustive] // Allows adding variants without breaking changes +pub enum ApiError { + #[error("rate limited")] + RateLimited, + #[error("not found")] + NotFound, +} +``` + +## When to Use What + +| Error Pattern | Use Case | +|---------------|----------| +| Custom enum | Library with specific failure modes | +| `thiserror` | Libraries needing `std::error::Error` | +| `anyhow::Error` | Applications, prototypes | +| Struct with source | Single error type with wrapped cause | + +## Struct-Based Errors + +For single error types with rich context: + +```rust +#[derive(Error, Debug)] +#[error("query failed for table '{table}' with filter '{filter}'")] +pub struct QueryError { + pub table: String, + pub filter: String, + #[source] + pub source: DatabaseError, +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - thiserror for error definitions +- [err-anyhow-app](./err-anyhow-app.md) - When to use anyhow instead +- [api-non-exhaustive](./api-non-exhaustive.md) - Forward-compatible enums diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-doc-errors.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-doc-errors.md new file mode 100644 index 00000000..b1262a33 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-doc-errors.md @@ -0,0 +1,145 @@ +# err-doc-errors + +> Document error conditions with `# Errors` section in doc comments + +## Why It Matters + +Users of your API need to know what can go wrong and why. The `# Errors` documentation section is the standard Rust convention for describing when a function returns `Err`. Good error documentation helps callers handle errors appropriately and understand the contract of your API. + +## Bad + +```rust +/// Loads a configuration from the specified path. +pub fn load_config(path: &Path) -> Result { + // No documentation of error conditions + // Caller must read source code to understand what can fail +} + +/// Parses and validates the input string. +/// +/// Returns the parsed value. // What about errors? +pub fn parse_input(input: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Loads a configuration from the specified path. +/// +/// # Errors +/// +/// Returns an error if: +/// - The file at `path` does not exist or cannot be read +/// - The file contents are not valid TOML +/// - Required configuration keys are missing +/// - Configuration values are out of valid ranges +/// +/// # Examples +/// +/// ``` +/// # use mylib::{load_config, ConfigError}; +/// # fn main() -> Result<(), ConfigError> { +/// let config = load_config("app.toml")?; +/// # Ok(()) +/// # } +/// ``` +pub fn load_config(path: &Path) -> Result { + // ... +} + +/// Parses and validates the input string as a positive integer. +/// +/// # Errors +/// +/// Returns [`ParseError::Empty`] if the input is empty. +/// Returns [`ParseError::InvalidFormat`] if the input contains non-digit characters. +/// Returns [`ParseError::Overflow`] if the value exceeds `i64::MAX`. +/// Returns [`ParseError::NotPositive`] if the value is zero or negative. +pub fn parse_positive_int(input: &str) -> Result { + // ... +} +``` + +## Linking to Error Variants + +```rust +/// Attempts to connect to the database. +/// +/// # Errors +/// +/// This function will return an error if: +/// +/// - [`DbError::ConnectionFailed`] - The database server is unreachable +/// - [`DbError::AuthenticationFailed`] - Invalid credentials +/// - [`DbError::Timeout`] - Connection attempt exceeded timeout +/// - [`DbError::TlsError`] - TLS handshake failed +/// +/// See [`DbError`] for more details on each variant. +pub fn connect(config: &DbConfig) -> Result { + // ... +} +``` + +## Panic vs Error Documentation + +```rust +/// Divides two numbers. +/// +/// # Errors +/// +/// Returns [`MathError::DivisionByZero`] if `divisor` is zero. +/// +/// # Panics +/// +/// Panics if called from a non-main thread (debug builds only). +pub fn divide(dividend: i64, divisor: i64) -> Result { + // ... +} +``` + +## Error Section Format Options + +```rust +// Style 1: Bullet list (good for multiple conditions) +/// # Errors +/// +/// Returns an error if: +/// - The file does not exist +/// - The file cannot be read +/// - The content is invalid UTF-8 + +// Style 2: Returns statements (good for mapping to variants) +/// # Errors +/// +/// Returns [`Error::NotFound`] if the item doesn't exist. +/// Returns [`Error::PermissionDenied`] if access is forbidden. + +// Style 3: Prose (good for complex conditions) +/// # Errors +/// +/// This function returns an error when the input fails validation. +/// Validation includes checking that all required fields are present, +/// that numeric fields are within allowed ranges, and that string +/// fields match their expected formats. +``` + +## Clippy Lint + +```toml +# Cargo.toml - require error documentation +[lints.clippy] +missing_errors_doc = "warn" +``` + +```rust +// This will warn without # Errors section +pub fn might_fail() -> Result<(), Error> { Ok(()) } +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Examples in documentation +- [err-thiserror-lib](./err-thiserror-lib.md) - Defining error types +- [api-must-use](./api-must-use.md) - Marking Results as must_use diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-expect-bugs-only.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-expect-bugs-only.md new file mode 100644 index 00000000..d4370422 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-expect-bugs-only.md @@ -0,0 +1,133 @@ +# err-expect-bugs-only + +> Use `expect()` only for invariants that indicate bugs, not user errors + +## Why It Matters + +`expect()` is better than `unwrap()` because it provides context, but it still panics. Reserve it for situations where failure indicates a bug in your code—a violated invariant, not a user error or external failure. The message should explain why the invariant should hold, helping future developers understand and fix the bug. + +## Bad + +```rust +// User input can legitimately fail - don't expect +fn parse_user_input(input: &str) -> Config { + serde_json::from_str(input) + .expect("Invalid JSON") // User error, not a bug! +} + +// Network can fail - don't expect +fn fetch_data(url: &str) -> Data { + reqwest::get(url) + .expect("Network request failed") // External failure! + .json() + .expect("Invalid response") +} + +// File might not exist - don't expect +fn load_config() -> Config { + let content = fs::read_to_string("config.json") + .expect("Config file missing"); // Environment issue! +} +``` + +## Good + +```rust +// Invariant: after insert, key exists +fn cache_and_get(&mut self, key: String, value: Value) -> &Value { + self.cache.insert(key.clone(), value); + self.cache.get(&key) + .expect("BUG: key must exist immediately after insert") +} + +// Invariant: regex is compile-time constant +fn create_parser() -> Regex { + Regex::new(r"^\d{4}-\d{2}-\d{2}$") + .expect("BUG: date regex is invalid - this is a compile-time constant") +} + +// Invariant: already validated +fn process_validated(data: ValidatedData) -> Result { + let value = data.required_field + .expect("BUG: ValidatedData guarantees required_field is Some"); + // ... +} + +// Invariant: type system guarantees +fn get_first(vec: Vec) -> T +where + Vec: NonEmpty, // Hypothetical trait +{ + vec.into_iter().next() + .expect("BUG: NonEmpty Vec cannot be empty") +} +``` + +## expect() Message Guidelines + +Messages should: +1. Start with "BUG:" or similar to indicate it's an invariant +2. Explain WHY the invariant should hold +3. Help developers fix the issue + +```rust +// ❌ Bad messages +.expect("failed") // No context +.expect("should not be None") // Doesn't explain why +.expect("Invalid state") // Vague + +// ✅ Good messages +.expect("BUG: HashMap entry exists after insert") +.expect("BUG: validated input must parse - validation is broken") +.expect("BUG: static regex compilation failed - regex syntax error in source") +``` + +## Pattern: Validate Once, expect() After + +```rust +struct ValidatedEmail(String); + +impl ValidatedEmail { + pub fn new(email: &str) -> Result { + // Validation happens here, returns Result + if !is_valid_email(email) { + return Err(EmailError::Invalid); + } + Ok(ValidatedEmail(email.to_string())) + } + + pub fn domain(&self) -> &str { + // After validation, expect() is fine + self.0.split('@').nth(1) + .expect("BUG: ValidatedEmail must contain @") + } +} +``` + +## Alternatives When expect() Is Wrong + +```rust +// Don't: expect on user data +let port: u16 = input.parse().expect("Invalid port"); + +// Do: Return Result +let port: u16 = input.parse().map_err(|_| ConfigError::InvalidPort)?; + +// Do: Provide default +let port: u16 = input.parse().unwrap_or(8080); + +// Do: Handle explicitly +let port: u16 = match input.parse() { + Ok(p) => p, + Err(_) => { + log::warn!("Invalid port '{}', using default", input); + 8080 + } +}; +``` + +## See Also + +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoiding unwrap in production +- [err-result-over-panic](./err-result-over-panic.md) - When to return Result +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven validation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-from-impl.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-from-impl.md new file mode 100644 index 00000000..79613dde --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-from-impl.md @@ -0,0 +1,152 @@ +# err-from-impl + +> Implement `From` for error conversions to enable `?` operator + +## Why It Matters + +The `?` operator automatically converts errors using `From` trait. By implementing `From for YourError`, you enable seamless error propagation without explicit `.map_err()` calls. This makes error handling code cleaner and ensures consistent error wrapping throughout your codebase. + +## Bad + +```rust +#[derive(Debug)] +enum AppError { + Io(std::io::Error), + Parse(serde_json::Error), + Database(diesel::result::Error), +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| AppError::Io(e))?; // Manual conversion everywhere + + let config: Config = serde_json::from_str(&content) + .map_err(|e| AppError::Parse(e))?; // Repeated boilerplate + + save_to_db(&config) + .map_err(|e| AppError::Database(e))?; // Gets tedious + + Ok(config) +} +``` + +## Good + +```rust +#[derive(Debug)] +enum AppError { + Io(std::io::Error), + Parse(serde_json::Error), + Database(diesel::result::Error), +} + +// Implement From for each source error type +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::Io(err) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> Self { + AppError::Parse(err) + } +} + +impl From for AppError { + fn from(err: diesel::result::Error) -> Self { + AppError::Database(err) + } +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // Auto-converts + let config: Config = serde_json::from_str(&content)?; // Clean! + save_to_db(&config)?; + Ok(config) +} +``` + +## Use thiserror for Automatic From + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum AppError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), // Auto-generates From impl + + #[error("Parse error: {0}")] + Parse(#[from] serde_json::Error), // #[from] does the work + + #[error("Database error: {0}")] + Database(#[from] diesel::result::Error), +} + +// Now ? just works +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + let config: Config = serde_json::from_str(&content)?; + save_to_db(&config)?; + Ok(config) +} +``` + +## From with Context + +Sometimes you need to add context during conversion: + +```rust +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config from '{path}': {source}")] + ReadFailed { + path: String, + #[source] + source: std::io::Error, + }, +} + +// Can't use #[from] when you need extra context +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|source| ConfigError::ReadFailed { + path: path.to_string(), + source, + })?; + // ... +} + +// Or use anyhow for ad-hoc context +use anyhow::{Context, Result}; + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config from '{}'", path))?; + // ... +} +``` + +## Blanket From Implementations + +Be careful with blanket implementations: + +```rust +// ❌ Too broad - conflicts with other From impls +impl From for AppError { + fn from(err: E) -> Self { + AppError::Other(err.to_string()) + } +} + +// ✅ Specific implementations +impl From for AppError { ... } +impl From for AppError { ... } +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Using thiserror for libraries +- [err-source-chain](./err-source-chain.md) - Preserving error chains +- [err-question-mark](./err-question-mark.md) - The ? operator diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-lowercase-msg.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-lowercase-msg.md new file mode 100644 index 00000000..0ffd3259 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-lowercase-msg.md @@ -0,0 +1,124 @@ +# err-lowercase-msg + +> Start error messages lowercase, no trailing punctuation + +## Why It Matters + +Error messages are often chained, logged, or displayed with additional context. Consistent formatting—lowercase start, no trailing period—allows clean composition: "failed to load config: invalid JSON: unexpected token". Mixed case and punctuation create awkward output: "Failed to load config.: Invalid JSON.: Unexpected token.". + +## Bad + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file.")] // Capital F, trailing period + ReadFailed(#[from] std::io::Error), + + #[error("Invalid JSON format!")] // Capital I, exclamation + ParseFailed(#[from] serde_json::Error), + + #[error("The requested key was not found")] // Reads like a sentence + KeyNotFound(String), +} + +// Chained output: "Config load error: Failed to read config file.: No such file" +// Awkward capitalization and punctuation +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("failed to read config file")] // lowercase, no period + ReadFailed(#[from] std::io::Error), + + #[error("invalid JSON format")] // lowercase, no period + ParseFailed(#[from] serde_json::Error), + + #[error("key not found: {0}")] // lowercase, data at end + KeyNotFound(String), +} + +// Chained output: "config load error: failed to read config file: no such file" +// Clean, consistent +``` + +## Rust Standard Library Convention + +The standard library follows this convention: + +```rust +// std::io::Error messages +"entity not found" +"permission denied" +"connection refused" + +// std::num::ParseIntError +"invalid digit found in string" + +// std::str::Utf8Error +"invalid utf-8 sequence" +``` + +## Formatting Guidelines + +| Do | Don't | +|----|-------| +| `"failed to parse config"` | `"Failed to parse config."` | +| `"invalid input: expected number"` | `"Invalid input - expected a number!"` | +| `"connection timed out after {0}s"` | `"Connection Timed Out After {0} seconds."` | +| `"key '{0}' not found"` | `"Key Not Found: {0}"` | + +## Context Addition Pattern + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let data = fetch(id) + .with_context(|| format!("failed to fetch user {}", id))?; + + parse_user(data) + .with_context(|| "failed to parse user data")? +} + +// Output: "failed to fetch user 42: connection refused" +// All lowercase, clean chain +``` + +## Display vs Debug + +```rust +#[derive(Error, Debug)] +#[error("invalid configuration")] // Display: for users/logs +pub struct ConfigError { + path: PathBuf, + source: io::Error, +} + +// Debug output (for developers) can have more detail +// Display output (for users) should be clean +``` + +## When to Use Capitals + +```rust +// Proper nouns / acronyms keep their case +#[error("invalid JSON syntax")] // JSON is an acronym +#[error("OAuth token expired")] // OAuth is a proper noun +#[error("HTTP request failed")] // HTTP is an acronym + +// Error codes can be uppercase +#[error("error code E0001: invalid input")] +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Error definition with thiserror +- [err-context-chain](./err-context-chain.md) - Adding context to errors +- [doc-examples-section](./doc-examples-section.md) - Documentation conventions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md new file mode 100644 index 00000000..87c81560 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md @@ -0,0 +1,115 @@ +# err-no-unwrap-prod + +> Avoid `unwrap()` in production code; use `?`, `expect()`, or handle errors + +## Why It Matters + +`unwrap()` panics on `None` or `Err` without any context about what went wrong. In production, this creates cryptic crash messages that are hard to debug. Either propagate errors with `?`, use `expect()` with a message explaining the invariant, or handle the error explicitly. + +## Bad + +```rust +fn process_request(req: Request) -> Response { + let user_id = req.headers.get("X-User-Id").unwrap(); // Why did it fail? + let user = database.find_user(user_id).unwrap(); // Which operation? + let data = user.preferences.get("theme").unwrap(); // No context + + Response::new(data) +} + +// Crash message: "called `Option::unwrap()` on a `None` value" +// Where? Why? No idea. +``` + +## Good + +```rust +// Option 1: Propagate with ? +fn process_request(req: Request) -> Result { + let user_id = req.headers + .get("X-User-Id") + .ok_or(AppError::MissingHeader("X-User-Id"))?; + + let user = database.find_user(user_id)?; + + let data = user.preferences + .get("theme") + .ok_or(AppError::MissingPreference("theme"))?; + + Ok(Response::new(data)) +} + +// Option 2: expect() for invariants (not user input) +fn get_config_value(&self, key: &str) -> &str { + self.config + .get(key) + .expect("BUG: required config key missing after validation") +} + +// Option 3: Provide defaults +fn get_theme(user: &User) -> &str { + user.preferences + .get("theme") + .unwrap_or(&"default") +} + +// Option 4: Match for complex handling +fn process_optional(value: Option) -> ProcessedData { + match value { + Some(data) => process(data), + None => { + log::warn!("No data provided, using fallback"); + ProcessedData::default() + } + } +} +``` + +## `expect()` vs `unwrap()` + +```rust +// Bad: no context +let port = config.get("port").unwrap(); + +// Better: explains the invariant +let port = config.get("port") + .expect("config must contain 'port' after validation"); + +// Best: propagate if it's not truly an invariant +let port = config.get("port") + .ok_or_else(|| ConfigError::MissingKey("port"))?; +``` + +## Alternatives to unwrap() + +| Situation | Use Instead | +|-----------|-------------| +| Can propagate error | `?` operator | +| Has sensible default | `unwrap_or()`, `unwrap_or_default()` | +| Default requires computation | `unwrap_or_else(\|\| ...)` | +| Internal invariant | `expect("explanation")` | +| Need to handle both cases | `match` or `if let` | + +## Clippy Lints + +```toml +# Cargo.toml +[lints.clippy] +unwrap_used = "warn" # Warn on unwrap() +expect_used = "warn" # Also warn on expect() (stricter) +``` + +```rust +// Allow in specific places where it's justified +#[allow(clippy::unwrap_used)] +fn definitely_safe() { + // Unwrap is safe here because... + let x = Some(5).unwrap(); +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Return Result instead of panicking +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When expect() is appropriate +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Patterns for avoiding unwrap diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-question-mark.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-question-mark.md new file mode 100644 index 00000000..2d5ec42b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-question-mark.md @@ -0,0 +1,151 @@ +# err-question-mark + +> Use `?` operator for clean propagation + +## Why It Matters + +The `?` operator is Rust's idiomatic way to propagate errors. It's concise, readable, and automatically converts between compatible error types using `From`. It replaces verbose `match` or `unwrap()` calls. + +## Bad + +```rust +// Verbose match-based error handling +fn load_config() -> Result { + let content = match std::fs::read_to_string("config.toml") { + Ok(c) => c, + Err(e) => return Err(Error::Io(e)), + }; + + let config = match toml::from_str(&content) { + Ok(c) => c, + Err(e) => return Err(Error::Parse(e)), + }; + + Ok(config) +} + +// Or worse - using unwrap +fn load_config_bad() -> Config { + let content = std::fs::read_to_string("config.toml").unwrap(); + toml::from_str(&content).unwrap() +} +``` + +## Good + +```rust +fn load_config() -> Result { + let content = std::fs::read_to_string("config.toml")?; + let config = toml::from_str(&content)?; + Ok(config) +} + +// Even more concise +fn load_config() -> Result { + Ok(toml::from_str(&std::fs::read_to_string("config.toml")?)?) +} +``` + +## How ? Works + +```rust +// This: +let x = expr?; + +// Expands roughly to: +let x = match expr { + Ok(val) => val, + Err(err) => return Err(From::from(err)), +}; +``` + +## Combining with Context + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read user file: {}", path))?; + + let user: User = serde_json::from_str(&content) + .context("failed to parse user JSON")?; + + Ok(user) +} +``` + +## ? with Option + +```rust +fn get_first_word(text: &str) -> Option<&str> { + let first_line = text.lines().next()?; + let first_word = first_line.split_whitespace().next()?; + Some(first_word) +} + +// Convert Option to Result +fn get_required_config(key: &str) -> Result { + config.get(key) + .cloned() + .ok_or_else(|| Error::MissingConfig(key.to_string())) +} +``` + +## Error Type Conversion + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum MyError { + #[error("io error")] + Io(#[from] std::io::Error), // Auto From impl + + #[error("parse error")] + Parse(#[from] serde_json::Error), // Auto From impl +} + +fn process() -> Result<(), MyError> { + // ? automatically converts io::Error to MyError via From + let content = std::fs::read_to_string("file.txt")?; + + // ? automatically converts serde_json::Error to MyError + let data: Data = serde_json::from_str(&content)?; + + Ok(()) +} +``` + +## In main() + +```rust +// Option 1: Return Result from main +fn main() -> Result<(), Box> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} + +// Option 2: Handle in main, exit on error +fn main() { + if let Err(e) = run() { + eprintln!("Error: {:#}", e); + std::process::exit(1); + } +} + +fn run() -> anyhow::Result<()> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} +``` + +## See Also + +- [err-context-chain](err-context-chain.md) - Add context with .context() +- [err-from-impl](err-from-impl.md) - Use #[from] for automatic conversion +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-result-over-panic.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-result-over-panic.md new file mode 100644 index 00000000..4e294d2e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-result-over-panic.md @@ -0,0 +1,130 @@ +# err-result-over-panic + +> Return `Result` instead of panicking for recoverable errors + +## Why It Matters + +Panics unwind the stack and crash the thread (or program). They're unrecoverable from the caller's perspective. `Result` gives callers the ability to decide how to handle errors—retry, fallback, propagate, or log. Libraries should almost never panic; applications should minimize panics to truly unrecoverable situations. + +## Bad + +```rust +fn parse_config(path: &str) -> Config { + let content = std::fs::read_to_string(path) + .expect("Failed to read config"); // Crashes on missing file + + serde_json::from_str(&content) + .expect("Invalid config format") // Crashes on bad JSON +} + +fn divide(a: i32, b: i32) -> i32 { + if b == 0 { + panic!("Division by zero!"); // Crashes the program + } + a / b +} +``` + +Caller has no chance to recover or provide a fallback. + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file: {0}")] + Io(#[from] std::io::Error), + #[error("Invalid config format: {0}")] + Parse(#[from] serde_json::Error), +} + +fn parse_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + let config = serde_json::from_str(&content)?; + Ok(config) +} + +fn divide(a: i32, b: i32) -> Result { + if b == 0 { + return Err("Division by zero"); + } + Ok(a / b) +} + +// Caller decides how to handle +match parse_config("app.json") { + Ok(config) => run_app(config), + Err(e) => { + eprintln!("Using default config: {}", e); + run_app(Config::default()) + } +} +``` + +## When Panic IS Appropriate + +```rust +// 1. Bug in the program (invariant violation) +fn get_cached_value(&self, key: &str) -> &Value { + self.cache.get(key).expect("BUG: key was verified to exist") +} + +// 2. Setup/initialization that can't reasonably fail +fn main() { + let config = Config::load().expect("Failed to load required config"); + // Can't run without config, panic is reasonable +} + +// 3. Tests +#[test] +fn test_parse() { + let result = parse("valid input").unwrap(); // unwrap OK in tests + assert_eq!(result, expected); +} + +// 4. Examples and prototypes +fn main() { + // Quick prototype, panic is fine + let data = fetch_data().unwrap(); +} +``` + +## Panic vs Result Decision Guide + +| Situation | Use | +|-----------|-----| +| File not found | `Result` | +| Network error | `Result` | +| Invalid user input | `Result` | +| Parse error | `Result` | +| Index out of bounds (from user data) | `Result` | +| Index out of bounds (internal bug) | Panic | +| Violated internal invariant | Panic | +| Unimplemented code path | Panic (`unimplemented!()`) | +| Impossible state reached | Panic (`unreachable!()`) | + +## Library vs Application + +```rust +// Library: NEVER panic on user input +pub fn parse(input: &str) -> Result { + // Always return Result +} + +// Application: Can panic at top level for critical failures +fn main() { + if let Err(e) = run() { + eprintln!("Fatal error: {}", e); + std::process::exit(1); + } +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Define error types for libraries +- [err-anyhow-app](./err-anyhow-app.md) - Ergonomic errors for applications +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoid unwrap in production code +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - When unwrap is acceptable diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-source-chain.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-source-chain.md new file mode 100644 index 00000000..c6390c13 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-source-chain.md @@ -0,0 +1,155 @@ +# err-source-chain + +> Preserve error chains with `#[source]` or `source()` method + +## Why It Matters + +Errors often have underlying causes. Preserving the error chain (via `source()` method) allows logging frameworks and error reporters to show the full context: "config parse failed → JSON syntax error at line 5 → unexpected token". Without chaining, you lose valuable debugging information. + +## Bad + +```rust +#[derive(Debug)] +enum ConfigError { + ParseFailed(String), // Lost the original serde_json::Error +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::ParseFailed(e.to_string()))?; // Chain lost! + + serde_json::from_str(&content) + .map_err(|e| ConfigError::ParseFailed(e.to_string()))? // No source +} + +// Error output: "Parse failed: invalid type: ..." +// Missing: which file? what line? what was the parent error? +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file '{path}'")] + ReadFailed { + path: String, + #[source] // Preserves the error chain + source: std::io::Error, + }, + + #[error("Failed to parse config file '{path}'")] + ParseFailed { + path: String, + #[source] // Original parse error preserved + source: serde_json::Error, + }, +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|source| ConfigError::ReadFailed { + path: path.to_string(), + source, // Chain preserved + })?; + + serde_json::from_str(&content) + .map_err(|source| ConfigError::ParseFailed { + path: path.to_string(), + source, + }) +} +``` + +## Manual source() Implementation + +```rust +use std::error::Error; + +#[derive(Debug)] +struct MyError { + message: String, + source: Option>, +} + +impl std::fmt::Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl Error for MyError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_ref().map(|e| e.as_ref() as &(dyn Error + 'static)) + } +} +``` + +## Walking the Error Chain + +```rust +fn print_error_chain(error: &dyn std::error::Error) { + eprintln!("Error: {}", error); + + let mut source = error.source(); + while let Some(err) = source { + eprintln!("Caused by: {}", err); + source = err.source(); + } +} + +// With anyhow, use {:?} for full chain +let result: anyhow::Result<()> = do_something(); +if let Err(e) = result { + eprintln!("{:?}", e); // Prints full chain with backtraces +} +``` + +## anyhow Context + +```rust +use anyhow::{Context, Result}; + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read '{}'", path))?; + + let config: Config = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse '{}'", path))?; + + Ok(config) +} + +// Output: +// Error: Failed to parse 'config.json' +// Caused by: expected `:` at line 5 column 10 +``` + +## #[from] vs #[source] + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum MyError { + // #[from] = implements From + sets source + #[error("IO error")] + Io(#[from] std::io::Error), + + // #[source] = only sets source (no From impl) + #[error("Parse error in file '{path}'")] + Parse { + path: String, + #[source] + source: serde_json::Error, + }, +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - thiserror for error definitions +- [err-context-chain](./err-context-chain.md) - Adding context to errors +- [err-from-impl](./err-from-impl.md) - From implementations for ? diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-thiserror-lib.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-thiserror-lib.md new file mode 100644 index 00000000..6a7d0c8c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-thiserror-lib.md @@ -0,0 +1,171 @@ +# err-thiserror-lib + +> Use `thiserror` for library error types + +## Why It Matters + +Libraries should expose typed, matchable errors so users can handle specific error conditions. `thiserror` generates `Error` trait implementations with minimal boilerplate, creating ergonomic error types that are easy to match against. + +## Bad + +```rust +// String errors - not matchable +fn parse(input: &str) -> Result { + Err("parse error".to_string()) +} + +// Box - not matchable +fn load(path: &Path) -> Result> { + Err(Box::new(std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"))) +} + +// Manual implementation - verbose +#[derive(Debug)] +enum MyError { + Io(std::io::Error), + Parse(String), +} + +impl std::fmt::Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MyError::Io(e) => write!(f, "io error: {}", e), + MyError::Parse(s) => write!(f, "parse error: {}", s), + } + } +} + +impl std::error::Error for MyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + MyError::Io(e) => Some(e), + MyError::Parse(_) => None, + } + } +} +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ParseError { + #[error("invalid syntax at line {line}: {message}")] + Syntax { line: usize, message: String }, + + #[error("unexpected end of file")] + UnexpectedEof, + + #[error("invalid utf-8 encoding")] + Utf8(#[from] std::str::Utf8Error), + + #[error("io error reading input")] + Io(#[from] std::io::Error), +} + +// Usage +fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(ParseError::UnexpectedEof); + } + // ... +} + +// Users can match specific errors +match parse(input) { + Ok(ast) => process(ast), + Err(ParseError::Syntax { line, message }) => { + eprintln!("Syntax error on line {}: {}", line, message); + } + Err(ParseError::UnexpectedEof) => { + eprintln!("File ended unexpectedly"); + } + Err(e) => eprintln!("Error: {}", e), +} +``` + +## Key Attributes + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MyError { + // Simple message + #[error("operation failed")] + Failed, + + // Interpolated fields + #[error("invalid value: {0}")] + InvalidValue(String), + + // Named fields + #[error("connection to {host}:{port} failed")] + Connection { host: String, port: u16 }, + + // Automatic From impl with #[from] + #[error("database error")] + Database(#[from] sqlx::Error), + + // Source without From (manual conversion needed) + #[error("validation failed")] + Validation { + #[source] + cause: ValidationError, + field: String, + }, + + // Transparent - delegates Display and source to inner + #[error(transparent)] + Other(#[from] anyhow::Error), +} +``` + +## Error Chaining + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("failed to read config file")] + Read(#[source] std::io::Error), + + #[error("failed to parse config")] + Parse(#[source] toml::de::Error), + + #[error("invalid config value for '{key}'")] + InvalidValue { + key: String, + #[source] + cause: ValueError, + }, +} + +// Error chain is preserved +fn load_config(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(ConfigError::Read)?; + + let config: Config = toml::from_str(&content) + .map_err(ConfigError::Parse)?; + + Ok(config) +} +``` + +## Library vs Application + +| Context | Crate | Why | +|---------|-------|-----| +| Library | `thiserror` | Typed errors users can match | +| Application | `anyhow` | Easy error handling with context | +| Both | `thiserror` for public API, `anyhow` internally | Best of both | + +## See Also + +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications +- [err-from-impl](err-from-impl.md) - Use #[from] for automatic conversion +- [err-source-chain](err-source-chain.md) - Use #[source] to chain errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-cargo-metadata.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-cargo-metadata.md new file mode 100644 index 00000000..8e5b16aa --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-cargo-metadata.md @@ -0,0 +1,138 @@ +# lint-cargo-metadata + +> Enable clippy::cargo for published crates + +## Why It Matters + +The `clippy::cargo` lint group checks Cargo.toml for issues that affect publishing and dependency management. For crates intended for crates.io, these checks help ensure a professional, well-configured package. + +## Configuration + +```toml +# Cargo.toml +[lints.clippy] +cargo = "warn" +``` + +Or in code: + +```rust +#![warn(clippy::cargo)] +``` + +## What It Catches + +### Missing Metadata + +```toml +# WARN: missing package.description +# WARN: missing package.license or package.license-file +# WARN: missing package.repository +[package] +name = "my-crate" +version = "0.1.0" +``` + +### Dependency Issues + +```toml +# WARN: feature used but not defined +# WARN: dependency version not specified +[dependencies] +serde = "*" # Bad: any version +tokio = { git = "..." } # WARN for published crates +``` + +### Feature Issues + +```toml +# WARN: negative_feature_names +[features] +no-std = [] # Should be: std = [] (opt-out vs opt-in) + +# WARN: redundant_feature_names +[features] +default = ["feature-a"] +feature-a = [] # Feature name matches crate name +``` + +## Notable Lints + +| Lint | Issue | +|------|-------| +| `cargo_common_metadata` | Missing description/license/repository | +| `multiple_crate_versions` | Same crate at different versions | +| `negative_feature_names` | Features like `no-std` instead of `std` | +| `redundant_feature_names` | Feature same as crate name | +| `wildcard_dependencies` | Using `*` for version | + +## Complete Cargo.toml + +```toml +[package] +name = "my-crate" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +# Required for cargo lint satisfaction +description = "A short description of what this crate does" +license = "MIT OR Apache-2.0" +repository = "https://github.com/user/my-crate" + +# Recommended +documentation = "https://docs.rs/my-crate" +readme = "README.md" +keywords = ["keyword1", "keyword2"] +categories = ["category-slug"] + +[dependencies] +# Specific versions, not wildcards +serde = "1.0" +tokio = { version = "1.0", features = ["full"] } + +[features] +default = ["std"] +std = [] # Opt-out, not no-std opt-in + +[lints.clippy] +cargo = "warn" +``` + +## Multiple Crate Versions + +``` +# WARN: multiple versions of `syn` in dependency tree +# syn v1.0.109 +# syn v2.0.48 +``` + +Fix by updating dependencies or using `[patch]`: + +```toml +[patch.crates-io] +old-dep = { git = "...", branch = "syn-2" } +``` + +## When to Disable + +For internal/unpublished crates: + +```toml +[lints.clippy] +cargo = "allow" # Not publishing, metadata not needed +``` + +Or selectively: + +```toml +[lints.clippy] +cargo = "warn" +multiple_crate_versions = "allow" # Acceptable in this project +``` + +## See Also + +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Cargo.toml metadata +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace dependencies +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-deny-correctness.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-deny-correctness.md new file mode 100644 index 00000000..bf6074d9 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-deny-correctness.md @@ -0,0 +1,107 @@ +# lint-deny-correctness + +> `#![deny(clippy::correctness)]` + +## Why It Matters + +Clippy's correctness lints catch code that is outright wrong - logic errors, undefined behavior, or code that doesn't do what you think. These should always be errors, not warnings. + +## Setup + +```rust +// At the top of lib.rs or main.rs +#![deny(clippy::correctness)] + +// Or in Cargo.toml for workspace-wide +[lints.clippy] +correctness = "deny" +``` + +## What It Catches + +```rust +// Infinite loop (iter::repeat without take) +for x in std::iter::repeat(1) { // ERROR: infinite iterator + println!("{}", x); +} + +// Comparison to NaN (always false) +if x == f64::NAN { // ERROR: NaN != NaN always + // This never executes +} + +// Use after free patterns +let r; +{ + let x = 5; + r = &x; // ERROR: x dropped here +} +println!("{}", r); + +// Wrong equality check +if x = 5 { // ERROR: assignment in condition (should be ==) +} + +// Useless comparisons +if x >= 0 && x < 0 { // ERROR: impossible condition +} +``` + +## Important Correctness Lints + +```rust +// approx_constant - using imprecise PI, E values +let pi = 3.14; // Use std::f64::consts::PI + +// invalid_regex - regex that won't compile +let re = Regex::new("["); // Invalid regex + +// iter_next_loop - using .next() in for loop incorrectly +for x in iter.next() { // Should be: for x in iter + +// never_loop - loop that never actually loops +loop { + break; // Always breaks immediately +} + +// nonsensical_open_options - impossible file options +File::options().read(false).write(false).open("f"); + +// unit_cmp - comparing unit type () +if foo() == bar() { } // Both return (), always true +``` + +## Full Recommended Lints + +```rust +#![deny(clippy::correctness)] +#![warn(clippy::suspicious)] +#![warn(clippy::style)] +#![warn(clippy::complexity)] +#![warn(clippy::perf)] + +// For published crates +#![warn(missing_docs)] +#![warn(clippy::cargo)] +``` + +## Running Clippy + +```bash +# Basic check +cargo clippy + +# With all warnings as errors +cargo clippy -- -D warnings + +# Check specific lint category +cargo clippy -- -W clippy::correctness + +# In CI (fail on warnings) +cargo clippy -- -D warnings -D clippy::correctness +``` + +## See Also + +- [lint-warn-suspicious](lint-warn-suspicious.md) - Warn on suspicious code +- [lint-warn-perf](lint-warn-perf.md) - Warn on performance issues diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-missing-docs.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-missing-docs.md new file mode 100644 index 00000000..17fa2627 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-missing-docs.md @@ -0,0 +1,154 @@ +# lint-missing-docs + +> Warn on missing documentation for public items + +## Why It Matters + +The `missing_docs` lint ensures all public API items are documented. For libraries, documentation IS the user interface. Missing docs mean users can't understand your API without reading source code. + +## Configuration + +```rust +// In lib.rs +#![warn(missing_docs)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.rust] +missing_docs = "warn" +``` + +For strict enforcement: + +```rust +#![deny(missing_docs)] +``` + +## What It Catches + +```rust +#![warn(missing_docs)] + +pub struct User { // WARN: missing documentation for a struct + pub name: String, // WARN: missing documentation for a field + pub age: u32, // WARN: missing documentation for a field +} + +pub fn process() { } // WARN: missing documentation for a function + +pub trait Handler { // WARN: missing documentation for a trait + fn handle(&self); // WARN: missing documentation for a method +} +``` + +## Good + +```rust +#![warn(missing_docs)] + +//! User management module. + +/// Represents a registered user in the system. +pub struct User { + /// The user's display name. + pub name: String, + /// The user's age in years. + pub age: u32, +} + +/// Processes pending user requests. +/// +/// # Examples +/// +/// ``` +/// process(); +/// ``` +pub fn process() { } + +/// Handler trait for request processing. +pub trait Handler { + /// Handle an incoming request. + fn handle(&self); +} +``` + +## Private Items + +`missing_docs` only applies to `pub` items. Private items don't trigger warnings: + +```rust +#![warn(missing_docs)] + +struct Internal { } // No warning - private + +pub struct Public { } // WARN - public, needs docs +``` + +## Allow for Specific Items + +```rust +#![warn(missing_docs)] + +/// Documented module. +pub mod api { + /// Documented struct. + pub struct Config { } + + #[allow(missing_docs)] + pub mod internal { + // Internal API, docs not required + pub struct Helper { } + } +} +``` + +## Gradual Adoption + +For existing codebases, start with `warn` and fix incrementally: + +```rust +// Phase 1: Warn, fix critical items +#![warn(missing_docs)] + +// Phase 2: After cleanup, deny +#![deny(missing_docs)] +``` + +## Combining with doc Attributes + +```rust +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![warn(rustdoc::private_intra_doc_links)] +``` + +## Workspace Configuration + +```toml +# In workspace Cargo.toml +[workspace.lints.rust] +missing_docs = "warn" + +# Member crates inherit +[lints] +workspace = true +``` + +## What to Document + +| Item | Doc Focus | +|------|-----------| +| Structs | Purpose, usage example | +| Struct fields | What it represents | +| Enums | When to use each variant | +| Functions | What it does, params, return | +| Traits | Contract and expectations | +| Modules | What the module provides | + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documentation patterns +- [lint-unsafe-doc](./lint-unsafe-doc.md) - Unsafe documentation +- [doc-examples-section](./doc-examples-section.md) - Adding examples diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-pedantic-selective.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-pedantic-selective.md new file mode 100644 index 00000000..896e9fcf --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-pedantic-selective.md @@ -0,0 +1,118 @@ +# lint-pedantic-selective + +> Enable clippy::pedantic selectively + +## Why It Matters + +The `clippy::pedantic` group contains opinionated lints that aren't universally applicable. Enabling it wholesale produces noise; selectively enabling useful pedantic lints improves code quality without false positives. + +## Bad + +```rust +// Too noisy - will fight you constantly +#![warn(clippy::pedantic)] +``` + +## Good + +```toml +# Cargo.toml - cherry-pick useful pedantic lints +[lints.clippy] +# Enable pedantic as baseline +pedantic = "warn" + +# Disable noisy ones +missing_errors_doc = "allow" # Document errors separately +missing_panics_doc = "allow" # Document panics separately +module_name_repetitions = "allow" # Allow Foo::FooError pattern +too_many_lines = "allow" # Function length varies +must_use_candidate = "allow" # Too many suggestions +``` + +## Recommended Pedantic Lints + +| Lint | Why Enable | +|------|-----------| +| `doc_markdown` | Catch unmarked code in docs | +| `match_wildcard_for_single_variants` | Explicit variant matching | +| `semicolon_if_nothing_returned` | Consistent semicolons | +| `string_add_assign` | Use `+=` for string concatenation | +| `unnested_or_patterns` | Simplify match patterns | +| `unused_self` | Catch methods that should be functions | +| `used_underscore_binding` | Warn on using `_var` | +| `wildcard_imports` | Avoid glob imports | + +## Often Disabled + +| Lint | Why Disable | +|------|-------------| +| `missing_errors_doc` | Handle with `#[doc]` policy | +| `missing_panics_doc` | Handle with `#[doc]` policy | +| `module_name_repetitions` | Sometimes intentional | +| `must_use_candidate` | Too aggressive | +| `too_many_lines` | Arbitrary threshold | +| `struct_excessive_bools` | Valid for config structs | + +## Full Configuration + +```toml +# Cargo.toml +[lints.clippy] +# Start with pedantic +pedantic = "warn" + +# Keep these +doc_markdown = "warn" +match_wildcard_for_single_variants = "warn" +semicolon_if_nothing_returned = "warn" +unused_self = "warn" +wildcard_imports = "warn" + +# Disable these +missing_errors_doc = "allow" +missing_panics_doc = "allow" +module_name_repetitions = "allow" +must_use_candidate = "allow" +too_many_lines = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +``` + +## Alternative: Explicit Opt-in + +```toml +# Only enable specific lints, not the group +[lints.clippy] +# From pedantic, only these: +doc_markdown = "warn" +semicolon_if_nothing_returned = "warn" +unused_self = "warn" +wildcard_imports = "warn" +``` + +## Module-Level Overrides + +```rust +// Allow specific lint for a module +#![allow(clippy::module_name_repetitions)] + +// Or for specific items +#[allow(clippy::too_many_arguments)] +fn complex_function(/* many args */) { } +``` + +## Team Consensus + +Pedantic lints are style choices. Agree as a team: + +1. Enable `pedantic` as baseline +2. Run `cargo clippy` on codebase +3. Discuss each warning category +4. Disable ones that don't fit your style +5. Document decisions in `clippy.toml` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-rustfmt-check.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-rustfmt-check.md new file mode 100644 index 00000000..6c7f9e6a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-rustfmt-check.md @@ -0,0 +1,157 @@ +# lint-rustfmt-check + +> Run cargo fmt --check in CI + +## Why It Matters + +Consistent formatting eliminates style debates and makes diffs cleaner. Running `cargo fmt --check` in CI ensures all code follows the same format. This catches formatting issues before merge, not after. + +## CI Configuration + +### GitHub Actions + +```yaml +name: CI + +on: [push, pull_request] + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check +``` + +### GitLab CI + +```yaml +fmt: + image: rust:latest + script: + - rustup component add rustfmt + - cargo fmt --all --check +``` + +### Pre-commit Hook + +```bash +#!/bin/sh +# .git/hooks/pre-commit +cargo fmt --all --check +``` + +## Configuration + +Create `rustfmt.toml` for custom settings: + +```toml +# rustfmt.toml +edition = "2021" +max_width = 100 +use_small_heuristics = "Max" +imports_granularity = "Module" +group_imports = "StdExternalCrate" +reorder_imports = true +``` + +## Common Options + +| Option | Default | Description | +|--------|---------|-------------| +| `max_width` | 100 | Maximum line width | +| `tab_spaces` | 4 | Spaces per indent | +| `edition` | "2015" | Rust edition | +| `use_small_heuristics` | "Default" | Layout heuristics | +| `imports_granularity` | "Preserve" | Import grouping | +| `group_imports` | "Preserve" | Import ordering | + +## Running Locally + +```bash +# Check formatting (doesn't modify files) +cargo fmt --all --check + +# Apply formatting +cargo fmt --all + +# Format specific file +cargo fmt -- src/main.rs + +# Check with verbose output +cargo fmt --all --check -- --verbose +``` + +## Workspace Formatting + +```bash +# Format all workspace members +cargo fmt --all + +# Format specific package +cargo fmt -p my-package +``` + +## Ignoring Files + +In `rustfmt.toml`: + +```toml +# Skip generated files +ignore = [ + "src/generated/*", + "build.rs", +] +``` + +Or in code: + +```rust +#[rustfmt::skip] +mod generated_code; + +#[rustfmt::skip] +const MATRIX: [[i32; 4]; 4] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], +]; +``` + +## Nightly Features + +Some options require nightly: + +```toml +# rustfmt.toml (nightly only) +unstable_features = true +imports_granularity = "Crate" +wrap_comments = true +format_code_in_doc_comments = true +``` + +```bash +# Use nightly rustfmt +cargo +nightly fmt +``` + +## IDE Integration + +Most IDEs format on save. Configure to use project `rustfmt.toml`: + +```json +// VS Code settings.json +{ + "rust-analyzer.rustfmt.extraArgs": ["--config-path", "./rustfmt.toml"] +} +``` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style lints +- [lint-pedantic-selective](./lint-pedantic-selective.md) - Pedantic lints +- [name-funcs-snake](./name-funcs-snake.md) - Naming conventions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-unsafe-doc.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-unsafe-doc.md new file mode 100644 index 00000000..87112edf --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-unsafe-doc.md @@ -0,0 +1,133 @@ +# lint-unsafe-doc + +> Require documentation for unsafe blocks + +## Why It Matters + +The `undocumented_unsafe_blocks` lint ensures every unsafe block has a `// SAFETY:` comment explaining why the operation is sound. Unsafe code is the source of most memory safety bugs—documenting invariants catches mistakes and helps reviewers. + +## Configuration + +```rust +#![warn(clippy::undocumented_unsafe_blocks)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "warn" +``` + +For strict enforcement: + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "deny" +``` + +## Bad + +```rust +pub fn read_data(ptr: *const u8, len: usize) -> &[u8] { + unsafe { + std::slice::from_raw_parts(ptr, len) // WARN: undocumented + } +} + +impl Buffer { + pub fn get_unchecked(&self, index: usize) -> &u8 { + unsafe { self.data.get_unchecked(index) } // WARN + } +} +``` + +## Good + +```rust +pub fn read_data(ptr: *const u8, len: usize) -> &[u8] { + // SAFETY: Caller guarantees: + // - ptr is valid for reads of len bytes + // - ptr is properly aligned for u8 + // - the memory is initialized + // - no mutable references exist to this memory + unsafe { + std::slice::from_raw_parts(ptr, len) + } +} + +impl Buffer { + pub fn get_unchecked(&self, index: usize) -> &u8 { + debug_assert!(index < self.len(), "index out of bounds"); + // SAFETY: We verified index < len in debug builds. + // Callers must ensure index is within bounds. + unsafe { self.data.get_unchecked(index) } + } +} +``` + +## SAFETY Comment Format + +```rust +// SAFETY: +unsafe { + // ... +} +``` + +The comment should explain: +1. **What invariants are upheld** - preconditions that make this safe +2. **Why the invariants hold** - how you know they're satisfied +3. **What could go wrong** - if invariants are violated + +## Examples by Category + +### Pointer Operations + +```rust +// SAFETY: ptr was obtained from Box::into_raw, so it's valid +// and properly aligned. We're taking back ownership. +let boxed = unsafe { Box::from_raw(ptr) }; +``` + +### Unchecked Operations + +```rust +// SAFETY: We just checked that i < self.len() above. +// The bounds check cannot be elided by the optimizer +// because len() is not inlined. +unsafe { self.data.get_unchecked(i) } +``` + +### FFI Calls + +```rust +// SAFETY: libc::getenv is safe to call with a null-terminated +// string. We ensure null termination with CString::new. +// The returned pointer is valid for the lifetime of the environment. +let value = unsafe { libc::getenv(key.as_ptr()) }; +``` + +### Trait Implementations + +```rust +// SAFETY: MyType contains no pointers or interior mutability, +// and all bit patterns are valid MyType values. +unsafe impl Send for MyType {} +unsafe impl Sync for MyType {} +``` + +## Related Lints + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "warn" +# Also consider: +multiple_unsafe_ops_per_block = "warn" # One operation per block +``` + +## See Also + +- [doc-safety-section](./doc-safety-section.md) - `# Safety` in docs +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints +- [type-repr-transparent](./type-repr-transparent.md) - FFI safety diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-complexity.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-complexity.md new file mode 100644 index 00000000..dd88b9c0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-complexity.md @@ -0,0 +1,131 @@ +# lint-warn-complexity + +> Enable clippy::complexity for simpler code + +## Why It Matters + +The `clippy::complexity` lint group identifies unnecessarily complex code that can be simplified. Complex code is harder to read, maintain, and often hides bugs. Clippy suggests cleaner alternatives. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::complexity)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +complexity = "warn" +``` + +## What It Catches + +### Unnecessary Complexity + +```rust +// WARN: Overly complex boolean expression +if !(x == 0) { } // Use: if x != 0 { } + +// WARN: Manual implementation of Option::map +match option { + Some(x) => Some(x + 1), + None => None, +} // Use: option.map(|x| x + 1) + +// WARN: Unnecessary filter before count +iter.filter(|x| predicate(x)).count() // Could simplify if only counting +``` + +### Redundant Operations + +```rust +// WARN: Redundant allocation +let s = format!("literal"); // Use: "literal".to_string() or just "literal" + +// WARN: Unnecessarily complicated match +match result { + Ok(ok) => Ok(ok), + Err(err) => Err(err), +} // Just use: result + +// WARN: Box::new in return position +fn make_error() -> Box { + Box::new(MyError) // Could use: MyError.into() +} +``` + +### Overly Verbose Code + +```rust +// WARN: bind_instead_of_map +option.and_then(|x| Some(x + 1)) // Use: option.map(|x| x + 1) + +// WARN: clone_on_copy +let y = x.clone(); // Where x is Copy type, just use: let y = x; + +// WARN: useless_let_if_seq +let result; +if condition { + result = 1; +} else { + result = 2; +} +// Use: let result = if condition { 1 } else { 2 }; +``` + +## Notable Lints in This Group + +| Lint | Simplification | +|------|---------------| +| `bind_instead_of_map` | Use `map` instead of `and_then(Some(...))` | +| `bool_comparison` | `if x == true` → `if x` | +| `clone_on_copy` | Remove `.clone()` for Copy types | +| `filter_next` | Use `.find()` instead | +| `option_map_unit_fn` | Use `if let` instead | +| `search_is_some` | Use `.any()` or `.contains()` | +| `unnecessary_cast` | Remove redundant casts | +| `useless_conversion` | Remove `.into()` when types match | + +## Examples + +```rust +// Before (complexity warnings) +fn find_positive(nums: &[i32]) -> Option { + let filtered: Vec<_> = nums.iter() + .cloned() + .filter(|x| *x > 0) + .collect(); + if filtered.len() == 0 { + None + } else { + Some(filtered[0]) + } +} + +// After (simplified) +fn find_positive(nums: &[i32]) -> Option { + nums.iter() + .copied() + .find(|&x| x > 0) +} +``` + +## Cognitive Load + +Complex code isn't just longer—it's harder to understand: + +```rust +// High cognitive load +let value = if x.is_some() { x.unwrap() } else { y.unwrap_or(z) }; + +// Lower cognitive load +let value = x.unwrap_or_else(|| y.unwrap_or(z)); +``` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-perf](./lint-warn-perf.md) - Performance warnings +- [lint-pedantic-selective](./lint-pedantic-selective.md) - Pedantic lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-perf.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-perf.md new file mode 100644 index 00000000..93ee4544 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-perf.md @@ -0,0 +1,136 @@ +# lint-warn-perf + +> Enable clippy::perf for performance improvements + +## Why It Matters + +The `clippy::perf` lint group catches performance anti-patterns—inefficient allocations, unnecessary copies, suboptimal API usage. While not all performance issues are critical, avoiding obvious inefficiencies is good practice. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::perf)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +perf = "warn" +``` + +## What It Catches + +### Unnecessary Allocations + +```rust +// WARN: Unnecessary to_string before into +fn take_string(s: impl Into) { } +take_string("hello".to_string()); // Just use: "hello" + +// WARN: Box::new in return with deref coercion +fn make_trait() -> Box { + Box::new(concrete) // Could use Into +} + +// WARN: Unnecessary vec! for iteration +for x in vec![1, 2, 3] { } // Use array: [1, 2, 3] +``` + +### Inefficient Operations + +```rust +// WARN: Single-character string patterns +s.starts_with("x") // Use char: 'x' +s.contains("a") // Use char: 'a' + +// WARN: iter().nth(0) instead of first() +iter.nth(0) // Use: iter.first() or iter.next() + +// WARN: Manual saturating arithmetic +if x > i32::MAX - y { i32::MAX } else { x + y } +// Use: x.saturating_add(y) +``` + +### Collection Inefficiencies + +```rust +// WARN: extend with a single element +vec.extend(std::iter::once(item)); // Use: vec.push(item) + +// WARN: Inefficient to_vec +slice.iter().cloned().collect::>() // Use: slice.to_vec() + +// WARN: Manual string concatenation +let s = format!("{}{}", a, b); // When both are &str, use: a.to_owned() + b +``` + +## Notable Lints in This Group + +| Lint | Improvement | +|------|-------------| +| `box_collection` | Use `Vec` not `Box>` | +| `iter_nth` | Use `.get(n)` or `.next()` | +| `large_enum_variant` | Box large variants | +| `manual_memcpy` | Use slice copy methods | +| `redundant_allocation` | Remove double boxing | +| `single_char_pattern` | Use `char` not `&str` | +| `slow_vector_initialization` | Use `vec![0; n]` | +| `unnecessary_to_owned` | Remove redundant `.to_owned()` | + +## Examples + +```rust +// Before (perf warnings) +fn process(input: &str) -> String { + let parts: Vec<_> = input.split(",").collect(); + let mut result = String::new(); + for part in parts.iter() { + if part.starts_with(" ") { + result = result + &part.trim().to_string(); + } + } + result +} + +// After (optimized) +fn process(input: &str) -> String { + input.split(',') + .filter(|part| part.starts_with(' ')) + .map(str::trim) + .collect() +} +``` + +## Allocation Patterns + +```rust +// Unnecessary allocation +let vec: Vec = vec![]; // Creates capacity +let vec: Vec = Vec::new(); // No allocation + +// Pre-allocation +let mut vec = Vec::with_capacity(100); // One allocation +for i in 0..100 { + vec.push(i); // No reallocation +} +``` + +## String Patterns + +```rust +// Slow: str pattern +s.contains("x"); +s.find("y"); + +// Fast: char pattern +s.contains('x'); +s.find('y'); +``` + +## See Also + +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation +- [perf-profile-first](./perf-profile-first.md) - Profile before optimizing diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-style.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-style.md new file mode 100644 index 00000000..4e017cd1 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-style.md @@ -0,0 +1,135 @@ +# lint-warn-style + +> Enable clippy::style for idiomatic code + +## Why It Matters + +The `clippy::style` lint group enforces idiomatic Rust patterns. While not bugs, style violations make code harder to read and maintain. Consistent style helps teams work together and makes code easier to review. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::style)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +style = "warn" +``` + +## What It Catches + +### Redundant Code + +```rust +// WARN: Redundant clone on Copy type +let x = 5; +let y = x.clone(); // Just use: let y = x; + +// WARN: Redundant closure +iter.map(|x| foo(x)) // Just use: iter.map(foo) + +// WARN: Redundant pattern matching +match result { + Ok(x) => Ok(x), + Err(e) => Err(e), +} // Just return result +``` + +### Non-Idiomatic Patterns + +```rust +// WARN: Should use if let +match option { + Some(x) => do_something(x), + None => {}, +} +// Better: if let Some(x) = option { do_something(x) } + +// WARN: Should use or_else +let value = if option.is_some() { + option.unwrap() +} else { + default() +}; +// Better: option.unwrap_or_else(default) + +// WARN: Collapsible if statements +if condition1 { + if condition2 { + do_something(); + } +} +// Better: if condition1 && condition2 { do_something() } +``` + +### Naming Issues + +```rust +// WARN: Function should not start with 'is_' returning non-bool +fn is_valid() -> i32 { 0 } // Misleading name + +// WARN: Method should not be named 'new' without returning Self +impl Foo { + fn new() -> Bar { Bar } // Confusing +} +``` + +## Notable Lints in This Group + +| Lint | Better Pattern | +|------|---------------| +| `len_zero` | Use `is_empty()` instead of `len() == 0` | +| `redundant_field_names` | Use shorthand `{ x }` not `{ x: x }` | +| `unused_unit` | Remove `-> ()` and trailing `()` | +| `collapsible_if` | Combine nested ifs with `&&` | +| `single_match` | Use `if let` instead | +| `match_like_matches_macro` | Use `matches!()` macro | +| `needless_return` | Remove explicit `return` at end | +| `question_mark` | Use `?` instead of `match` | + +## Examples + +```rust +// Before (style warnings) +fn process(data: Vec) -> Option { + if data.len() == 0 { + return None; + } + let first = match data.first() { + Some(x) => x, + None => return None, + }; + return Some(*first); +} + +// After (idiomatic) +fn process(data: Vec) -> Option { + if data.is_empty() { + return None; + } + let first = data.first()?; + Some(*first) +} +``` + +## Selective Allowance + +Some style lints may conflict with team preferences: + +```rust +// If your team prefers explicit returns +#[allow(clippy::needless_return)] +fn explicit_return() -> i32 { + return 42; +} +``` + +## See Also + +- [lint-warn-suspicious](./lint-warn-suspicious.md) - Suspicious patterns +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [lint-rustfmt-check](./lint-rustfmt-check.md) - Formatting checks diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-suspicious.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-suspicious.md new file mode 100644 index 00000000..2172ebca --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-suspicious.md @@ -0,0 +1,122 @@ +# lint-warn-suspicious + +> Enable clippy::suspicious for likely bugs + +## Why It Matters + +The `clippy::suspicious` lint group catches code patterns that are syntactically valid but almost always wrong. These are potential bugs that deserve investigation. Enabling this group as a warning helps catch mistakes early. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::suspicious)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +suspicious = "warn" +``` + +Or in `clippy.toml`: + +```toml +warn = ["clippy::suspicious"] +``` + +## What It Catches + +### Suspicious Arithmetic + +```rust +// WARN: Suspicious use of + in a << expression +let bits = 1 << 4 + 1; // Probably meant (1 << 4) + 1 or 1 << (4 + 1) + +// WARN: Suspicious use of | in a + expression +let value = x | 1 + y; // Probably meant (x | 1) + y or x | (1 + y) +``` + +### Suspicious Comparisons + +```rust +// WARN: Almost swapped operands in a comparison +if 5 < x && x < 3 { } // Impossible condition + +// WARN: Suspicious assignment in a condition +if (x = 5) { } // Probably meant x == 5 +``` + +### Suspicious Method Calls + +```rust +// WARN: Suspicious map usage +let _: Vec<_> = vec.iter().map(|x| { + println!("{}", x); // Side effect in map + x +}).collect(); // Use for_each instead + +// WARN: Suspicious string formatting +let s = format!("{}", format!("{}", x)); // Redundant nested format +``` + +### Suspicious Casts + +```rust +// WARN: Suspicious use of not on a bool +let inverted = !x as i32; // Did you mean (!x) as i32 or !(x as i32)? + +// WARN: Cast of float to int may lose precision +let n = 3.14_f64 as i32; // May want .round() first +``` + +## Notable Lints in This Group + +| Lint | Description | +|------|-------------| +| `suspicious_arithmetic_impl` | Unusual operator in arithmetic trait | +| `suspicious_assignment_formatting` | Looks like typo in assignment | +| `suspicious_else_formatting` | Else on wrong line | +| `suspicious_map` | Map with side effects | +| `suspicious_op_assign_impl` | Unusual op-assign implementation | +| `suspicious_splitn` | splitn that can't produce n parts | +| `suspicious_unary_op_formatting` | Confusing unary operator spacing | + +## Example Catches + +```rust +// Caught: Suspicious double negation +let value = --x; // In Rust, this is -(-x), not pre-decrement + +// Caught: Suspicious modulo +let remainder = x % 1; // Always 0 for integers + +// Caught: Suspicious else formatting +if condition { + do_something(); +} +else { // Weird formatting, might be a mistake + do_other(); +} +``` + +## When to Allow + +Rarely. If you need to suppress, document why: + +```rust +#[allow(clippy::suspicious_arithmetic_impl)] +impl Mul for Matrix { + // Custom matrix multiplication using + for reduction step + fn mul(self, rhs: Self) -> Self::Output { + // ... + } +} +``` + +## See Also + +- [lint-deny-correctness](./lint-deny-correctness.md) - Deny definite bugs +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-workspace-lints.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-workspace-lints.md new file mode 100644 index 00000000..67944ea8 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-workspace-lints.md @@ -0,0 +1,172 @@ +# lint-workspace-lints + +> Configure lints at workspace level for consistent enforcement + +## Why It Matters + +Without centralized lint configuration, each crate develops its own standards (or none). Workspace-level lints (Rust 1.74+) ensure consistent code quality across all crates. Denied lints catch issues in CI before they reach production. + +## Bad + +```toml +# crate-a/Cargo.toml - strict +[lints.clippy] +unwrap_used = "deny" + +# crate-b/Cargo.toml - lenient +# No lint config + +# crate-c/Cargo.toml - different +[lints.clippy] +unwrap_used = "warn" + +# Inconsistent enforcement, some issues slip through +``` + +## Good + +```toml +# Root Cargo.toml +[workspace.lints.rust] +unsafe_code = "deny" +missing_docs = "warn" + +[workspace.lints.clippy] +# Correctness +unwrap_used = "deny" +expect_used = "warn" +panic = "deny" + +# Style +needless_pass_by_value = "warn" +redundant_clone = "warn" + +# Complexity +cognitive_complexity = "warn" + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" + +# crate-a/Cargo.toml +[lints] +workspace = true + +# crate-b/Cargo.toml +[lints] +workspace = true +``` + +## Recommended Lint Configuration + +```toml +# Root Cargo.toml +[workspace.lints.rust] +# Safety +unsafe_code = "deny" +missing_debug_implementations = "warn" + +# Quality +unused_results = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +# === Correctness (deny) === +correctness = { level = "deny", priority = -1 } + +# === Suspicious (deny) === +suspicious = { level = "deny", priority = -1 } + +# === Style (warn) === +style = { level = "warn", priority = -1 } + +# === Complexity (warn) === +complexity = { level = "warn", priority = -1 } + +# === Perf (warn) === +perf = { level = "warn", priority = -1 } + +# === Pedantic (selective) === +# Not all pedantic lints are useful +doc_markdown = "warn" +needless_pass_by_value = "warn" +redundant_closure_for_method_calls = "warn" +semicolon_if_nothing_returned = "warn" + +# === Nursery (selective) === +cognitive_complexity = "warn" +useless_let_if_seq = "warn" + +# === Restriction (selective) === +unwrap_used = "deny" +expect_used = "warn" +dbg_macro = "warn" +print_stdout = "warn" # Use logging instead +todo = "warn" + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" +private_intra_doc_links = "warn" +``` + +## Per-Crate Overrides + +```toml +# crate-with-binary/Cargo.toml +[lints] +workspace = true + +# Binary entry point can use unwrap +[lints.clippy] +unwrap_used = "allow" + +# test-utils/Cargo.toml +[lints] +workspace = true + +# Test utilities can print +[lints.clippy] +print_stdout = "allow" +``` + +## CI Integration + +```yaml +# .github/workflows/ci.yml +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Rustdoc + run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps +``` + +## Lint Categories + +```toml +# Category-level configuration +[workspace.lints.clippy] +# All lints in category at once +correctness = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +style = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } + +# Then override specific lints (higher priority) +missing_errors_doc = "allow" # Override pedantic +``` + +## See Also + +- [lint-deny-correctness](./lint-deny-correctness.md) - Critical lints +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace configuration +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - unwrap lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arena-allocator.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arena-allocator.md new file mode 100644 index 00000000..b5d32c3b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arena-allocator.md @@ -0,0 +1,168 @@ +# mem-arena-allocator + +> Use arena allocators for batch allocations + +## Why It Matters + +Arena allocators (bump allocators) allocate memory from a contiguous region, making allocation extremely fast (just bump a pointer). All allocations are freed at once when the arena is dropped. Perfect for request-scoped or parse-tree allocations. + +## Bad + +```rust +// Many small allocations during parsing +fn parse(input: &str) -> Vec { + let mut nodes = Vec::new(); + for token in tokenize(input) { + nodes.push(Box::new(Node::new(token))); // Heap alloc per node! + } + nodes +} + +// Per-request allocations add up +fn handle_request(req: Request) -> Response { + let headers = parse_headers(&req); // Allocates + let body = parse_body(&req); // Allocates + let response = generate_response(); // Allocates + // All freed individually at end + response +} +``` + +## Good + +```rust +use bumpalo::Bump; + +// All nodes allocated from same arena +fn parse<'a>(input: &str, arena: &'a Bump) -> Vec<&'a Node> { + let mut nodes = Vec::new(); + for token in tokenize(input) { + let node = arena.alloc(Node::new(token)); // Fast bump! + nodes.push(node); + } + nodes +} // Arena freed all at once + +// Per-request arena +fn handle_request(req: Request) -> Response { + let arena = Bump::new(); + + let headers = parse_headers(&req, &arena); + let body = parse_body(&req, &arena); + let response = generate_response(&arena); + + // Convert to owned response before arena drops + response.to_owned() +} // All request memory freed instantly +``` + +## Thread-Local Scratch Arena Pattern + +```rust +use bumpalo::Bump; +use std::cell::RefCell; + +thread_local! { + static SCRATCH: RefCell = RefCell::new(Bump::with_capacity(4 * 1024)); +} + +fn with_scratch(f: impl FnOnce(&Bump) -> T) -> T { + SCRATCH.with(|scratch| { + let arena = scratch.borrow(); + let result = f(&arena); + result + }) +} + +fn reset_scratch() { + SCRATCH.with(|scratch| { + scratch.borrow_mut().reset(); + }); +} + +// Usage +fn process_batch(items: &[Item]) -> Vec { + with_scratch(|arena| { + let temp_data: Vec<&TempData> = items + .iter() + .map(|item| arena.alloc(compute_temp(item))) + .collect(); + + // Use temp_data... + let result = finalize(&temp_data); + + reset_scratch(); // Reuse arena memory + result + }) +} +``` + +## Evidence from ROC Compiler + +```rust +// https://github.com/roc-lang/roc/blob/main/crates/compiler/solve/src/to_var.rs +std::thread_local! { + static SCRATCHPAD: RefCell> = + RefCell::new(Some(bumpalo::Bump::with_capacity(4 * 1024))); +} + +fn take_scratchpad() -> bumpalo::Bump { + SCRATCHPAD.with(|f| f.take().unwrap()) +} + +fn put_scratchpad(scratchpad: bumpalo::Bump) { + SCRATCHPAD.with(|f| { + f.replace(Some(scratchpad)); + }); +} +``` + +## Bumpalo Collections + +```rust +use bumpalo::Bump; +use bumpalo::collections::{Vec, String}; + +fn process<'a>(arena: &'a Bump, input: &str) -> Vec<'a, String<'a>> { + let mut results = Vec::new_in(arena); + + for word in input.split_whitespace() { + let mut s = String::new_in(arena); + s.push_str(word); + s.push_str("_processed"); + results.push(s); + } + + results // All allocated in arena +} +``` + +## When to Use Arenas + +| Situation | Use Arena? | +|-----------|-----------| +| Parsing (AST nodes) | Yes | +| Request handling | Yes | +| Batch processing | Yes | +| Long-lived data | No | +| Data escaping scope | No (or copy out) | +| Simple programs | Overkill | + +## Performance Impact + +```rust +// Benchmarks from production systems: +// - Individual allocations: ~25-50ns each +// - Arena bump: ~1-2ns each (20-50x faster) +// - Arena reset: O(1) regardless of allocation count + +// Memory overhead: +// - Arena wastes some memory (unused capacity) +// - But eliminates per-allocation metadata overhead +``` + +## See Also + +- [mem-with-capacity](mem-with-capacity.md) - Pre-allocate when size is known +- [mem-reuse-collections](mem-reuse-collections.md) - Reuse collections with clear() +- [opt-profile-first](perf-profile-first.md) - Profile to verify benefit diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arrayvec.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arrayvec.md new file mode 100644 index 00000000..76cecaf5 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arrayvec.md @@ -0,0 +1,142 @@ +# mem-arrayvec + +> Use `ArrayVec` for fixed-capacity collections that never heap-allocate + +## Why It Matters + +`ArrayVec` from the `arrayvec` crate provides Vec-like API with a compile-time maximum capacity, storing all elements inline on the stack. Unlike `SmallVec` which can spill to heap, `ArrayVec` guarantees no heap allocation—if you exceed capacity, it returns an error or panics. This is ideal for embedded systems, real-time code, or when you have a hard upper bound. + +## Bad + +```rust +// Vec always heap-allocates, even for small collections +fn parse_options(input: &str) -> Vec
() == 32); +``` + +## Testing Size Stability + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn critical_types_have_expected_sizes() { + // Document expected sizes in tests too + assert_eq!(std::mem::size_of::(), 48); + assert_eq!(std::mem::size_of::(), 64); + assert_eq!(std::mem::size_of::
(), 32); + } + + #[test] + fn cache_line_aligned() { + // Verify cache-friendly sizing + assert!(std::mem::size_of::() <= 64); + } +} +``` + +## When to Assert + +```rust +// ✅ Types stored in large collections +struct Node { /* ... */ } +const _: () = assert!(std::mem::size_of::() <= 64); + +// ✅ Types used in FFI / binary protocols +#[repr(C)] +struct WireFormat { /* ... */ } +const _: () = assert!(std::mem::size_of::() == 256); + +// ✅ Performance-critical types +struct HotPath { /* ... */ } +const _: () = assert!(std::mem::size_of::() <= 128); + +// ❌ Skip for rarely-instantiated types +struct AppConfig { /* many fields */ } +// Size doesn't matter, only one instance +``` + +## Cargo.toml + +```toml +[dependencies] +static_assertions = "1.1" +``` + +## See Also + +- [mem-smaller-integers](./mem-smaller-integers.md) - Choosing appropriate integer sizes +- [mem-box-large-variant](./mem-box-large-variant.md) - Managing enum variant sizes +- [opt-cache-friendly](./opt-cache-friendly.md) - Cache line considerations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-avoid-format.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-avoid-format.md new file mode 100644 index 00000000..8c5ff90c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-avoid-format.md @@ -0,0 +1,147 @@ +# mem-avoid-format + +> Avoid `format!()` when string literals work + +## Why It Matters + +`format!()` always allocates a new String, even for constant text. In hot paths, these allocations add up. Use string literals, `write!()`, or pre-allocated buffers instead. + +## Bad + +```rust +// Allocates every time, even for static text +fn get_error_message() -> String { + format!("An error occurred") // Unnecessary allocation! +} + +// Allocates in a loop +for item in items { + log::info!("{}", format!("Processing item: {}", item)); // Double work! +} + +// format! in hot path +fn classify(n: i32) -> String { + if n > 0 { + format!("positive") // Allocates! + } else if n < 0 { + format!("negative") // Allocates! + } else { + format!("zero") // Allocates! + } +} +``` + +## Good + +```rust +// Return &'static str for constants +fn get_error_message() -> &'static str { + "An error occurred" // No allocation +} + +// Use format args directly +for item in items { + log::info!("Processing item: {}", item); // No intermediate String +} + +// Return Cow for mixed static/dynamic +use std::borrow::Cow; + +fn classify(n: i32) -> Cow<'static, str> { + if n > 0 { + Cow::Borrowed("positive") // No allocation + } else if n < 0 { + Cow::Borrowed("negative") // No allocation + } else { + Cow::Borrowed("zero") // No allocation + } +} + +// Or just &'static str if always static +fn classify_str(n: i32) -> &'static str { + if n > 0 { "positive" } + else if n < 0 { "negative" } + else { "zero" } +} +``` + +## Use write!() for Output + +```rust +use std::io::Write; + +// Bad: Allocate then write +fn bad_log(writer: &mut impl Write, msg: &str, code: u32) { + let formatted = format!("[ERROR {}] {}", code, msg); // Allocation! + writer.write_all(formatted.as_bytes()).unwrap(); +} + +// Good: Write directly +fn good_log(writer: &mut impl Write, msg: &str, code: u32) { + write!(writer, "[ERROR {}] {}", code, msg).unwrap(); // No allocation! +} +``` + +## Pre-allocate for Multiple Appends + +```rust +// Bad: Multiple allocations +fn build_message(parts: &[&str]) -> String { + let mut result = String::new(); + for part in parts { + result = format!("{}{}\n", result, part); // Allocates each iteration! + } + result +} + +// Good: Pre-allocate +fn build_message(parts: &[&str]) -> String { + let total_len: usize = parts.iter().map(|p| p.len() + 1).sum(); + let mut result = String::with_capacity(total_len); + for part in parts { + result.push_str(part); + result.push('\n'); + } + result +} + +// Good: Use join +fn build_message(parts: &[&str]) -> String { + parts.join("\n") +} +``` + +## CompactString for Small Strings + +```rust +use compact_str::CompactString; + +// Stack-allocated for strings <= 24 bytes +fn format_code(code: u32) -> CompactString { + compact_str::format_compact!("ERR-{:04}", code) + // Stack-allocated if result is small enough +} +``` + +## When format!() Is Fine + +```rust +// Rare/cold paths - clarity over micro-optimization +fn log_startup_message() { + println!("{}", format!("Starting {} v{}", APP_NAME, VERSION)); +} + +// When you need an owned String anyway +fn create_user_greeting(name: &str) -> String { + format!("Hello, {}!", name) // Need owned String +} + +// Error messages (already on error path) +return Err(format!("Invalid value: {}", value).into()); +``` + +## See Also + +- [mem-write-over-format](mem-write-over-format.md) - Use write!() instead of format!() +- [mem-with-capacity](mem-with-capacity.md) - Pre-allocate strings +- [own-cow-conditional](own-cow-conditional.md) - Use Cow for mixed static/dynamic diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-box-large-variant.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-box-large-variant.md new file mode 100644 index 00000000..8e2689e8 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-box-large-variant.md @@ -0,0 +1,158 @@ +# mem-box-large-variant + +> Box large enum variants to reduce overall enum size + +## Why It Matters + +An enum's size is determined by its largest variant. If one variant contains a large struct while others are small, every instance of the enum pays for the largest variant's size. Boxing the large variant puts that data on the heap, keeping the enum itself small. This can significantly reduce memory usage and improve cache performance. + +## Bad + +```rust +enum Message { + Quit, // 0 bytes of data + Move { x: i32, y: i32 }, // 8 bytes + Text(String), // 24 bytes + Image { + data: [u8; 1024], // 1024 bytes - forces entire enum to ~1032 bytes! + width: u32, + height: u32 + }, +} + +// Every Message is ~1032 bytes, even Quit and Move +let messages: Vec = vec![ + Message::Quit, // Wastes ~1032 bytes + Message::Quit, // Wastes ~1032 bytes + Message::Move { x: 0, y: 0 }, // Wastes ~1024 bytes +]; +``` + +## Good + +```rust +struct ImageData { + data: [u8; 1024], + width: u32, + height: u32, +} + +enum Message { + Quit, + Move { x: i32, y: i32 }, + Text(String), + Image(Box), // Now just 8 bytes (pointer) +} + +// Message is now ~32 bytes (String variant is largest) +let messages: Vec = vec![ + Message::Quit, // Uses ~32 bytes + Message::Quit, // Uses ~32 bytes + Message::Move { x: 0, y: 0 }, // Uses ~32 bytes +]; +``` + +## Check Enum Sizes + +```rust +use std::mem::size_of; + +// Before boxing +enum BadEvent { + Click { x: u32, y: u32 }, // 8 bytes + KeyPress(char), // 4 bytes + LargeData([u8; 256]), // 256 bytes +} +println!("BadEvent: {} bytes", size_of::()); // ~264 bytes + +// After boxing +enum GoodEvent { + Click { x: u32, y: u32 }, + KeyPress(char), + LargeData(Box<[u8; 256]>), // 8 bytes (pointer) +} +println!("GoodEvent: {} bytes", size_of::()); // ~16 bytes +``` + +## Clippy Lint + +```toml +[lints.clippy] +large_enum_variant = "warn" # Warns when variants differ significantly +``` + +```rust +// Clippy will suggest: +// warning: large size difference between variants +// help: consider boxing the large fields to reduce the total size +``` + +## When to Box + +| Largest Variant | Other Variants | Action | +|-----------------|----------------|--------| +| < 64 bytes | Similar size | Don't box | +| > 128 bytes | Much smaller | Box the large variant | +| > 256 bytes | Any | Definitely box | + +## Recursive Types Require Boxing + +```rust +// Won't compile - infinite size +enum List { + Cons(i32, List), + Nil, +} + +// Must box recursive variant +enum List { + Cons(i32, Box), // Now finite size + Nil, +} + +// Same for ASTs +enum Expr { + Number(i64), + BinOp { + op: Op, + left: Box, // Recursive - must box + right: Box, + }, +} +``` + +## Pattern Matching with Boxed Variants + +```rust +enum Event { + Small(u32), + Large(Box), +} + +fn handle(event: Event) { + match event { + Event::Small(n) => println!("Small: {}", n), + Event::Large(data) => { + // data is Box, dereference to access + println!("Large: {} bytes", data.size); + } + } +} + +// Or match on reference +fn handle_ref(event: &Event) { + match event { + Event::Small(n) => println!("Small: {}", n), + Event::Large(data) => { + // data is &Box, auto-derefs + println!("Large: {} bytes", data.size); + } + } +} +``` + +## See Also + +- [own-move-large](./own-move-large.md) - Boxing large types for cheap moves +- [mem-smallvec](./mem-smallvec.md) - Alternative for inline small collections +- [lint-deny-correctness](./lint-deny-correctness.md) - Enabling clippy lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-boxed-slice.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-boxed-slice.md new file mode 100644 index 00000000..fa91c9ea --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-boxed-slice.md @@ -0,0 +1,139 @@ +# mem-boxed-slice + +> Use `Box<[T]>` instead of `Vec` for fixed-size heap data + +## Why It Matters + +`Vec` stores three words: pointer, length, and capacity. When you know a collection won't grow, `Box<[T]>` stores only pointer and length (2 words), saving 8 bytes per instance. More importantly, it communicates intent: "this data is fixed-size." For large numbers of fixed collections, this adds up. + +## Bad + +```rust +struct Document { + // Vec signals "might grow" but we never push after creation + paragraphs: Vec, // 24 bytes: ptr + len + capacity +} + +fn load_document(data: &[u8]) -> Document { + let paragraphs: Vec = parse_paragraphs(data); + // paragraphs has capacity >= len, wasting the capacity field + Document { paragraphs } +} +``` + +## Good + +```rust +struct Document { + // Box<[T]> signals "fixed size" - clear intent + paragraphs: Box<[Paragraph]>, // 16 bytes: ptr + len (as fat pointer) +} + +fn load_document(data: &[u8]) -> Document { + let paragraphs: Vec = parse_paragraphs(data); + Document { + paragraphs: paragraphs.into_boxed_slice() // Shrinks + converts + } +} +``` + +## Memory Layout + +```rust +use std::mem::size_of; + +// Vec: 24 bytes on 64-bit +assert_eq!(size_of::>(), 24); // ptr(8) + len(8) + cap(8) + +// Box<[T]>: 16 bytes (fat pointer) +assert_eq!(size_of::>(), 16); // ptr(8) + len(8) + +// Savings per instance: 8 bytes +// For 1 million instances: 8 MB saved +``` + +## Conversion Patterns + +```rust +// Vec to Box<[T]> +let vec: Vec = vec![1, 2, 3, 4, 5]; +let boxed: Box<[i32]> = vec.into_boxed_slice(); + +// Box<[T]> back to Vec (if you need to grow) +let vec_again: Vec = boxed.into_vec(); + +// From iterator +let boxed: Box<[i32]> = (0..100).collect::>().into_boxed_slice(); + +// Shrink Vec first if it has excess capacity +let mut vec = Vec::with_capacity(1000); +vec.extend(0..10); +vec.shrink_to_fit(); // Reduce capacity to length +let boxed = vec.into_boxed_slice(); // Now no wasted allocation +``` + +## When to Use What + +| Type | Use When | +|------|----------| +| `Vec` | Collection may grow/shrink | +| `Box<[T]>` | Fixed-size, heap-allocated, many instances | +| `[T; N]` | Fixed-size, stack-allocated, size known at compile time | +| `&[T]` | Borrowed view, don't need ownership | + +## Box for Immutable Strings + +Same principle applies to strings: + +```rust +use std::mem::size_of; + +// String: 24 bytes (like Vec) +assert_eq!(size_of::(), 24); + +// Box: 16 bytes +assert_eq!(size_of::>(), 16); + +// For immutable strings +struct Name { + value: Box, // Saves 8 bytes vs String +} + +impl Name { + fn new(s: &str) -> Self { + Name { value: s.into() } // &str -> Box + } +} + +// Or from String +let s = String::from("hello"); +let boxed: Box = s.into_boxed_str(); +``` + +## Real-World Example + +```rust +// Cache with millions of entries +struct Cache { + // 8 bytes saved per entry adds up + entries: HashMap>, +} + +impl Cache { + fn insert(&mut self, key: Key, data: Vec) { + // Convert to boxed slice for storage + self.entries.insert(key, data.into_boxed_slice()); + } + + fn get(&self, key: &Key) -> Option<&[u8]> { + // Returns regular slice reference + self.entries.get(key).map(|b| b.as_ref()) + } +} +``` + +## See Also + +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocating when size is known +- [own-slice-over-vec](./own-slice-over-vec.md) - Using slices in function parameters +- [mem-compact-string](./mem-compact-string.md) - Compact string alternatives diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-clone-from.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-clone-from.md new file mode 100644 index 00000000..d19cf3fe --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-clone-from.md @@ -0,0 +1,147 @@ +# mem-clone-from + +> Use `clone_from()` to reuse allocations when repeatedly cloning + +## Why It Matters + +`x = y.clone()` drops x's allocation and creates a new one from y. `x.clone_from(&y)` reuses x's existing allocation if possible, avoiding the allocation overhead. For repeatedly cloning into the same variable (loops, buffers), this can significantly reduce allocator pressure. + +## Bad + +```rust +let mut buffer = String::with_capacity(1024); + +for source in sources { + buffer = source.clone(); // Drops old allocation, allocates new + process(&buffer); +} + +// Each iteration: +// 1. Drops buffer's 1024-byte allocation +// 2. Allocates new memory for source.clone() +// Allocator thrashing! +``` + +## Good + +```rust +let mut buffer = String::with_capacity(1024); + +for source in sources { + buffer.clone_from(source); // Reuses allocation if capacity sufficient + process(&buffer); +} + +// If source.len() <= 1024, no allocation happens +// Just copies bytes into existing buffer +``` + +## How clone_from Works + +```rust +impl Clone for String { + fn clone(&self) -> Self { + // Always allocates new memory + String::from(self.as_str()) + } + + fn clone_from(&mut self, source: &Self) { + // Reuse existing capacity if possible + self.clear(); + self.push_str(source); // Only reallocates if capacity insufficient + } +} +``` + +## Types That Benefit + +```rust +// String - reuses capacity +let mut s = String::with_capacity(100); +s.clone_from(&other_string); + +// Vec - reuses capacity +let mut v: Vec = Vec::with_capacity(1000); +v.clone_from(&other_vec); + +// HashMap - reuses buckets +let mut map = HashMap::with_capacity(100); +map.clone_from(&other_map); + +// PathBuf - reuses capacity +let mut path = PathBuf::with_capacity(256); +path.clone_from(&other_path); +``` + +## Benchmarking the Difference + +```rust +use criterion::{black_box, criterion_group, Criterion}; + +fn bench_clone_patterns(c: &mut Criterion) { + let source = "x".repeat(1000); + + c.bench_function("clone assignment", |b| { + let mut buffer = String::new(); + b.iter(|| { + buffer = black_box(&source).clone(); + }); + }); + + c.bench_function("clone_from", |b| { + let mut buffer = String::with_capacity(1000); + b.iter(|| { + buffer.clone_from(black_box(&source)); + }); + }); +} +// clone_from is typically 2-3x faster for this pattern +``` + +## Custom Implementations + +When implementing Clone for your types: + +```rust +#[derive(Debug)] +struct Buffer { + data: Vec, + metadata: Metadata, +} + +impl Clone for Buffer { + fn clone(&self) -> Self { + Buffer { + data: self.data.clone(), + metadata: self.metadata.clone(), + } + } + + // Optimize clone_from to reuse vec capacity + fn clone_from(&mut self, source: &Self) { + self.data.clone_from(&source.data); // Reuses allocation + self.metadata = source.metadata.clone(); + } +} +``` + +## When NOT Needed + +```rust +// Single clone - no benefit +let copy = original.clone(); // Can't reuse, no prior allocation + +// Small Copy types - no allocation anyway +let x: i32 = y; // Not even Clone, just Copy + +// Immutable context +fn process(data: &String) { + // Can't use clone_from - would need &mut self +} +``` + +## See Also + +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocating capacity +- [mem-reuse-collections](./mem-reuse-collections.md) - Reusing collection allocations +- [own-clone-explicit](./own-clone-explicit.md) - When Clone is appropriate diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-compact-string.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-compact-string.md new file mode 100644 index 00000000..a136de39 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-compact-string.md @@ -0,0 +1,149 @@ +# mem-compact-string + +> Use compact string types for memory-constrained string storage + +## Why It Matters + +Standard `String` is 24 bytes (pointer + length + capacity). For applications storing millions of short strings, this overhead dominates. Compact string libraries like `compact_str`, `smartstring`, or `ecow` store small strings inline (no heap allocation) and use optimized layouts for larger strings. + +## Bad + +```rust +struct User { + id: u64, + // Most usernames are < 24 chars, but String is always 24 bytes + heap + username: String, + email: String, +} + +// 1 million users = 24 bytes * 2 * 1M = 48MB just for String metadata +// Plus all the heap allocations for actual content +``` + +## Good + +```rust +use compact_str::CompactString; + +struct User { + id: u64, + // CompactString: 24 bytes, but strings ≤ 24 chars are inline (no heap) + username: CompactString, + email: CompactString, +} + +// Most usernames fit inline = zero heap allocations +// Same memory footprint as String but way fewer allocations +``` + +## Compact String Libraries + +### compact_str + +```rust +use compact_str::CompactString; + +// Inline storage for strings ≤ 24 bytes +let small: CompactString = "hello".into(); // No heap allocation + +// Automatic heap fallback for larger strings +let large: CompactString = "x".repeat(100).into(); + +// String-like API +let mut s = CompactString::new("hello"); +s.push_str(" world"); +assert_eq!(s.as_str(), "hello world"); + +// Format macro +use compact_str::format_compact; +let s = format_compact!("value: {}", 42); +``` + +### smartstring + +```rust +use smartstring::{SmartString, LazyCompact}; + +// Default is LazyCompact: 24 bytes inline capacity +let s: SmartString = "short string".into(); + +// Compact mode: 23 bytes inline on 64-bit +use smartstring::Compact; +let s: SmartString = "hello".into(); +``` + +### ecow (copy-on-write) + +```rust +use ecow::EcoString; + +// Clone is O(1) - shares underlying data +let s1: EcoString = "shared data".into(); +let s2 = s1.clone(); // Cheap, shares allocation + +// Copy-on-write: only allocates on mutation +let mut s3 = s1.clone(); +s3.push_str(" modified"); // Now allocates +``` + +## Memory Comparison + +```rust +use std::mem::size_of; + +// All 24 bytes, but different inline capacities +assert_eq!(size_of::(), 24); +assert_eq!(size_of::(), 24); +assert_eq!(size_of::(), 24); +assert_eq!(size_of::(), 16); // Even smaller! +``` + +## Inline Capacity + +| Type | Size | Inline Capacity | +|------|------|-----------------| +| `String` | 24 | 0 (always heap) | +| `CompactString` | 24 | 24 bytes | +| `SmartString` | 24 | 23 bytes | +| `EcoString` | 16 | 15 bytes | + +## When to Use + +```rust +// ✅ Good: Many short strings in memory +struct Dictionary { + words: Vec, // Millions of short words +} + +// ✅ Good: Frequently cloned strings +struct Template { + parts: Vec, // O(1) clone +} + +// ❌ Don't: Hot path string manipulation +fn transform(s: &str) -> String { + // Standard String is optimized for manipulation + s.to_uppercase() +} + +// ❌ Don't: API boundaries (prefer &str or String for interop) +pub fn public_api(input: CompactString) { } // Forces dependency +pub fn public_api(input: impl Into) { } // Better +``` + +## Cargo.toml + +```toml +[dependencies] +compact_str = "0.7" +# or +smartstring = "1.0" +# or +ecow = "0.2" +``` + +## See Also + +- [mem-boxed-slice](./mem-boxed-slice.md) - Box for immutable strings +- [own-cow-conditional](./own-cow-conditional.md) - Cow for borrow-or-own +- [mem-smallvec](./mem-smallvec.md) - Similar concept for Vec diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-reuse-collections.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-reuse-collections.md new file mode 100644 index 00000000..b307779e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-reuse-collections.md @@ -0,0 +1,174 @@ +# mem-reuse-collections + +> Clear and reuse collections instead of creating new ones in loops + +## Why It Matters + +Creating new `Vec`, `String`, or `HashMap` instances in hot loops generates significant allocator pressure. Clearing a collection and reusing it keeps the existing capacity, avoiding repeated allocation/deallocation cycles. This is especially impactful for frequently-executed code paths. + +## Bad + +```rust +fn process_batches(batches: &[Batch]) -> Vec { + let mut results = Vec::new(); + + for batch in batches { + let mut temp = Vec::new(); // Allocates every iteration + + for item in &batch.items { + temp.push(transform(item)); + } + + results.push(aggregate(&temp)); + // temp dropped here, deallocation + } + + results +} + +fn format_lines(items: &[Item]) -> String { + let mut output = String::new(); + + for item in items { + let line = format!("{}: {}", item.name, item.value); // Allocates + output.push_str(&line); + output.push('\n'); + } + + output +} +``` + +## Good + +```rust +fn process_batches(batches: &[Batch]) -> Vec { + let mut results = Vec::with_capacity(batches.len()); + let mut temp = Vec::new(); // Allocate once outside loop + + for batch in batches { + temp.clear(); // Reuse allocation, just reset length + + for item in &batch.items { + temp.push(transform(item)); + } + + results.push(aggregate(&temp)); + // temp keeps its capacity for next iteration + } + + results +} + +fn format_lines(items: &[Item]) -> String { + use std::fmt::Write; + + let mut output = String::new(); + let mut line = String::new(); // Reusable buffer + + for item in items { + line.clear(); + write!(&mut line, "{}: {}", item.name, item.value).unwrap(); + output.push_str(&line); + output.push('\n'); + } + + output +} +``` + +## Clear vs Drain vs New + +```rust +let mut vec = vec![1, 2, 3, 4, 5]; + +// clear(): keeps capacity, O(n) for Drop types +vec.clear(); +assert_eq!(vec.len(), 0); +assert!(vec.capacity() >= 5); + +// drain(): returns iterator, clears after iteration +let drained: Vec<_> = vec.drain(..).collect(); + +// truncate(): keeps first n elements +vec.truncate(2); + +// Creating new: loses all capacity +vec = Vec::new(); // Capacity gone +``` + +## HashMap Reuse + +```rust +use std::collections::HashMap; + +fn count_words_per_line(lines: &[&str]) -> Vec> { + let mut results = Vec::with_capacity(lines.len()); + let mut counts = HashMap::new(); // Reuse across iterations + + for line in lines { + counts.clear(); // Keeps bucket allocation + + for word in line.split_whitespace() { + *counts.entry(word.to_string()).or_insert(0) += 1; + } + + results.push(counts.clone()); + } + + results +} +``` + +## BufWriter Pattern + +```rust +use std::io::{BufWriter, Write}; + +fn write_many_records(records: &[Record], mut output: impl Write) -> std::io::Result<()> { + // BufWriter reuses its internal buffer + let mut writer = BufWriter::with_capacity(8192, &mut output); + let mut line = String::with_capacity(256); // Reusable formatting buffer + + for record in records { + line.clear(); + format_record(record, &mut line); + writer.write_all(line.as_bytes())?; + writer.write_all(b"\n")?; + } + + writer.flush() +} +``` + +## When to Create Fresh + +```rust +// When ownership transfer is needed +fn produce_results() -> Vec> { + let mut results = Vec::new(); + + for batch in batches { + let processed: Vec = batch.process(); // Ownership transferred + results.push(processed); // Moved into results + } + + results // Each inner Vec is independent +} + +// When thread safety requires it +std::thread::scope(|s| { + for _ in 0..4 { + s.spawn(|| { + let local_buffer = Vec::new(); // Thread-local, can't share + // ... + }); + } +}); +``` + +## See Also + +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocating capacity +- [mem-clone-from](./mem-clone-from.md) - Reusing allocations when cloning +- [mem-write-over-format](./mem-write-over-format.md) - Avoiding format! allocations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smaller-integers.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smaller-integers.md new file mode 100644 index 00000000..d4c5581d --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smaller-integers.md @@ -0,0 +1,159 @@ +# mem-smaller-integers + +> Use appropriately-sized integers to reduce memory footprint + +## Why It Matters + +Using `i64` when `i16` suffices wastes 6 bytes per value. In arrays, vectors, and structs with millions of instances, this waste compounds dramatically. Choosing the smallest integer type that fits your domain reduces memory usage and improves cache utilization. + +## Bad + +```rust +struct Pixel { + r: u64, // Color channels 0-255 = 8 bits needed + g: u64, // Using 64 bits = 8x waste + b: u64, + a: u64, +} +// Size: 32 bytes per pixel + +struct HttpStatus { + code: i32, // HTTP codes 100-599 = 10 bits needed + version: i32, // HTTP 1.0, 1.1, 2, 3 = 2 bits needed +} +// Size: 8 bytes per status + +struct GeoPoint { + lat: f64, // -90 to 90 + lon: f64, // -180 to 180 +} +// Often f32 precision is sufficient for display +``` + +## Good + +```rust +struct Pixel { + r: u8, + g: u8, + b: u8, + a: u8, +} +// Size: 4 bytes per pixel (8x smaller!) + +struct HttpStatus { + code: u16, // 100-599 fits in u16 + version: u8, // 1, 2, 3 fits in u8 +} +// Size: 3 bytes (+ 1 padding = 4 bytes) + +struct GeoPoint { + lat: f32, // ~7 decimal digits precision + lon: f32, // Sufficient for most geo applications +} +// Size: 8 bytes vs 16 bytes +``` + +## Integer Size Reference + +| Type | Range | Use For | +|------|-------|---------| +| `u8` | 0 to 255 | Bytes, small counts, flags | +| `i8` | -128 to 127 | Small signed values | +| `u16` | 0 to 65,535 | Port numbers, small indices | +| `i16` | -32,768 to 32,767 | Audio samples | +| `u32` | 0 to 4 billion | Array indices, timestamps (seconds) | +| `i32` | ±2 billion | General integers, file offsets | +| `u64` | 0 to 18 quintillion | Large counts, nanosecond timestamps | +| `usize` | Platform-dependent | Array indexing (required by Rust) | + +## Struct Packing + +```rust +use std::mem::size_of; + +// Poor ordering - 24 bytes due to padding +struct Wasteful { + a: u8, // 1 byte + 7 padding + b: u64, // 8 bytes + c: u8, // 1 byte + 7 padding +} +assert_eq!(size_of::(), 24); + +// Better ordering - 16 bytes +struct Efficient { + b: u64, // 8 bytes (aligned) + a: u8, // 1 byte + c: u8, // 1 byte + 6 padding +} +assert_eq!(size_of::(), 16); + +// Even better with smaller types - 10 bytes +struct Compact { + b: u32, // 4 bytes (if u32 suffices) + a: u8, // 1 byte + c: u8, // 1 byte +} +assert_eq!(size_of::(), 8); // With padding +``` + +## Conversion Safety + +```rust +// Safe: always succeeds (widening) +let small: u8 = 42; +let big: u32 = small.into(); + +// Fallible: may overflow (narrowing) +let big: u32 = 1000; +let small: u8 = big.try_into().expect("value out of range"); + +// Or use checked conversion +if let Ok(small) = u8::try_from(big) { + use_small(small); +} else { + handle_overflow(); +} +``` + +## Bitflags for Boolean Sets + +```rust +use bitflags::bitflags; + +// Instead of 8 separate bool fields (8 bytes minimum) +bitflags! { + struct Permissions: u8 { + const READ = 0b0000_0001; + const WRITE = 0b0000_0010; + const EXECUTE = 0b0000_0100; + const DELETE = 0b0000_1000; + } +} +// All 8 flags in 1 byte! + +let perms = Permissions::READ | Permissions::WRITE; +if perms.contains(Permissions::READ) { + // ... +} +``` + +## NonZero Types for Option Optimization + +```rust +use std::num::NonZeroU64; + +// Option = 16 bytes (no null pointer optimization) +assert_eq!(size_of::>(), 16); + +// Option = 8 bytes (0 represents None) +assert_eq!(size_of::>(), 8); + +let id: Option = NonZeroU64::new(42); +``` + +## See Also + +- [mem-box-large-variant](./mem-box-large-variant.md) - Optimizing enum sizes +- [mem-assert-type-size](./mem-assert-type-size.md) - Compile-time size checks +- [type-newtype-ids](./type-newtype-ids.md) - Type safety for integer IDs diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smallvec.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smallvec.md new file mode 100644 index 00000000..3c42cf50 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smallvec.md @@ -0,0 +1,138 @@ +# mem-smallvec + +> Use `SmallVec` for usually-small collections + +## Why It Matters + +`SmallVec<[T; N]>` stores up to N elements inline (on the stack), only allocating on the heap when the size exceeds N. This eliminates heap allocations for the common case while still allowing growth when needed. + +## Bad + +```rust +// Always heap-allocates, even for 1-2 elements +fn get_path_components(path: &str) -> Vec<&str> { + path.split('/').collect() // Usually 2-4 components +} + +// Always heap-allocates for error list +fn validate(input: &Input) -> Vec { + let mut errors = Vec::new(); // Usually 0-3 errors + // validation logic... + errors +} +``` + +## Good + +```rust +use smallvec::{smallvec, SmallVec}; + +// Stack-allocated for typical paths (1-8 components) +fn get_path_components(path: &str) -> SmallVec<[&str; 8]> { + path.split('/').collect() +} + +// Stack-allocated for typical error counts +fn validate(input: &Input) -> SmallVec<[ValidationError; 4]> { + let mut errors = SmallVec::new(); + // validation logic... + errors +} + +// Using smallvec! macro +let v: SmallVec<[i32; 4]> = smallvec![1, 2, 3]; +``` + +## Choosing Capacity N + +```rust +// Measure your actual data distribution! +// Guidelines: + +// Path components: 4-8 (most paths are shallow) +type PathParts<'a> = SmallVec<[&'a str; 8]>; + +// Function arguments: 4-8 (most functions have few args) +type Args = SmallVec<[Arg; 8]>; + +// AST children: 2-4 (binary ops, if/else, etc.) +type Children = SmallVec<[Node; 4]>; + +// Error accumulation: 2-4 (most inputs have few errors) +type Errors = SmallVec<[Error; 4]>; + +// Attribute lists: 4-8 (most items have few attributes) +type Attrs = SmallVec<[Attribute; 8]>; +``` + +## Evidence from rust-analyzer + +```rust +// https://github.com/rust-lang/rust/blob/main/compiler/rustc_expand/src/base.rs +macro_rules! make_stmts_default { + ($me:expr) => { + $me.make_expr().map(|e| { + smallvec![ast::Stmt { + id: ast::DUMMY_NODE_ID, + span: e.span, + kind: ast::StmtKind::Expr(e), + }] + }) + } +} +``` + +## Trade-offs + +```rust +// SmallVec is slightly larger than Vec +use std::mem::size_of; +// Vec: 24 bytes (ptr + len + cap) +// SmallVec<[i32; 4]>: 32 bytes (inline storage + len + discriminant) + +// SmallVec has branching overhead on every operation +// (must check if inline or heap) + +// Profile to verify benefit! +``` + +## When to Use SmallVec vs Alternatives + +| Situation | Use | +|-----------|-----| +| Usually small, sometimes large | `SmallVec<[T; N]>` | +| Always small, fixed max | `ArrayVec` | +| Rarely grows past initial | `Vec::with_capacity` | +| No `unsafe` allowed | `TinyVec` | +| Often empty | `ThinVec` | + +## ArrayVec Alternative + +```rust +use arrayvec::ArrayVec; + +// Fixed maximum capacity, never heap allocates +// Panics if you exceed capacity +fn parse_rgb(s: &str) -> ArrayVec { + let mut components = ArrayVec::new(); + for part in s.split(',').take(3) { + components.push(part.parse().unwrap()); + } + components +} +``` + +## TinyVec (No Unsafe) + +```rust +use tinyvec::{tiny_vec, TinyVec}; + +// Same concept as SmallVec but 100% safe code +let v: TinyVec<[i32; 4]> = tiny_vec![1, 2, 3]; +``` + +## See Also + +- [mem-arrayvec](mem-arrayvec.md) - Use ArrayVec for fixed-max collections +- [mem-with-capacity](mem-with-capacity.md) - Pre-allocate when size is known +- [mem-thinvec](mem-thinvec.md) - Use ThinVec for often-empty vectors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-thinvec.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-thinvec.md new file mode 100644 index 00000000..a0b49360 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-thinvec.md @@ -0,0 +1,142 @@ +# mem-thinvec + +> Use `ThinVec` for nullable collections with minimal overhead + +## Why It Matters + +Standard `Vec` is 24 bytes even when empty. `ThinVec` from Mozilla's `thin_vec` crate uses a single pointer (8 bytes), storing length and capacity inline with the heap allocation. For Option> patterns or structs with many optional vecs, this significantly reduces memory overhead. + +## Bad + +```rust +struct TreeNode { + value: i32, + // Each node pays 24 bytes for children, even leaves + children: Vec, // Most nodes are leaves with empty Vec +} + +// Or using Option> +struct SparseData { + // Option = 24 bytes (Vec is never null-pointer optimized) + tags: Option>, + metadata: Option>, + // 48 bytes for usually-None fields +} +``` + +## Good + +```rust +use thin_vec::ThinVec; + +struct TreeNode { + value: i32, + // Empty ThinVec is just a null pointer - 8 bytes + children: ThinVec, +} + +struct SparseData { + // ThinVec empty = 8 bytes each + tags: ThinVec, + metadata: ThinVec, + // 16 bytes vs 48 bytes +} +``` + +## Memory Layout + +```rust +use std::mem::size_of; + +// Standard Vec: always 24 bytes +assert_eq!(size_of::>(), 24); +assert_eq!(size_of::>>(), 24); // No NPO benefit + +// ThinVec: 8 bytes (one pointer) +use thin_vec::ThinVec; +assert_eq!(size_of::>(), 8); +assert_eq!(size_of::>>(), 8); // Option is free! +``` + +## ThinVec vs Vec + +| Feature | `Vec` | `ThinVec` | +|---------|----------|--------------| +| Size (empty) | 24 bytes | 8 bytes | +| Size (non-empty) | 24 bytes | 8 bytes (header on heap) | +| Option optimization | No | Yes | +| Cache locality | Better (len/cap on stack) | Worse (len/cap on heap) | +| Iteration speed | Faster | Slightly slower | +| API compatibility | Full | Vec-like | + +## When to Use ThinVec + +```rust +// ✅ Good: Many instances, often empty +struct SparseGraph { + nodes: Vec, + // Most edges lists are empty or small + edges: Vec>, // Saves 16 bytes per node +} + +// ✅ Good: Nullable collection field +struct Document { + content: String, + attachments: ThinVec, // Often empty +} + +// ❌ Avoid: Hot loops, performance-critical iteration +fn process_hot_path(data: &ThinVec) { + // Every length check goes through pointer indirection + for item in data { // Vec would be faster here + process(item); + } +} + +// ❌ Avoid: Few instances +fn main() { + let single_vec: ThinVec = ThinVec::new(); + // Saving 16 bytes once is meaningless +} +``` + +## API Compatibility + +```rust +use thin_vec::{ThinVec, thin_vec}; + +// Constructor macro +let v: ThinVec = thin_vec![1, 2, 3]; + +// Familiar Vec-like API +let mut v = ThinVec::new(); +v.push(1); +v.push(2); +v.extend([3, 4, 5]); +v.pop(); + +// Iteration +for item in &v { + println!("{}", item); +} + +// Slicing +let slice: &[i32] = &v[..]; + +// Conversion +let vec: Vec = v.into(); +let thin: ThinVec = vec.into(); +``` + +## Cargo.toml + +```toml +[dependencies] +thin-vec = "0.2" +``` + +## See Also + +- [mem-smallvec](./mem-smallvec.md) - Stack-allocated small vecs +- [mem-boxed-slice](./mem-boxed-slice.md) - Fixed-size heap slices +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation strategies diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-with-capacity.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-with-capacity.md new file mode 100644 index 00000000..b94a9f21 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-with-capacity.md @@ -0,0 +1,156 @@ +# mem-with-capacity + +> Use `with_capacity()` when size is known + +## Why It Matters + +When you know (or can estimate) the final size of a collection, pre-allocating avoids multiple reallocations as it grows. Each reallocation copies all existing elements, so avoiding them can dramatically improve performance. + +## Bad + +```rust +// Vec starts at capacity 0, reallocates at 4, 8, 16, 32... +let mut results = Vec::new(); +for i in 0..1000 { + results.push(process(i)); // ~10 reallocations! +} + +// String grows similarly +let mut output = String::new(); +for word in words { + output.push_str(word); + output.push(' '); +} + +// HashMap default capacity is small +let mut map = HashMap::new(); +for (k, v) in pairs { // Many reallocations + map.insert(k, v); +} +``` + +## Good + +```rust +// Pre-allocate exact size +let mut results = Vec::with_capacity(1000); +for i in 0..1000 { + results.push(process(i)); // Zero reallocations! +} + +// Or use collect with size hint (iterator provides capacity) +let results: Vec<_> = (0..1000).map(process).collect(); + +// Pre-allocate string +let estimated_len = words.iter().map(|w| w.len() + 1).sum(); +let mut output = String::with_capacity(estimated_len); +for word in words { + output.push_str(word); + output.push(' '); +} + +// Pre-allocate HashMap +let mut map = HashMap::with_capacity(pairs.len()); +for (k, v) in pairs { + map.insert(k, v); +} +``` + +## Collection Capacity Methods + +```rust +// Vec +let mut v = Vec::with_capacity(100); +v.reserve(50); // Ensure at least 50 more slots +v.reserve_exact(50); // Ensure exactly 50 more (no extra) +v.shrink_to_fit(); // Release unused capacity + +// String +let mut s = String::with_capacity(100); +s.reserve(50); + +// HashMap / HashSet +let mut m = HashMap::with_capacity(100); +m.reserve(50); + +// VecDeque +let mut d = VecDeque::with_capacity(100); +``` + +## Estimating Capacity + +```rust +// From iterator length +fn collect_results(items: &[Item]) -> Vec { + let mut results = Vec::with_capacity(items.len()); + for item in items { + results.push(process(item)); + } + results +} + +// From filter estimate (if ~10% pass filter) +fn filter_valid(items: &[Item]) -> Vec<&Item> { + let mut valid = Vec::with_capacity(items.len() / 10); + for item in items { + if item.is_valid() { + valid.push(item); + } + } + valid +} + +// String from parts +fn join_with_sep(parts: &[&str], sep: &str) -> String { + let total_len: usize = parts.iter().map(|p| p.len()).sum(); + let sep_len = if parts.is_empty() { 0 } else { sep.len() * (parts.len() - 1) }; + + let mut result = String::with_capacity(total_len + sep_len); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + result.push_str(sep); + } + result.push_str(part); + } + result +} +``` + +## Evidence from Production Code + +From fd (file finder): +```rust +// https://github.com/sharkdp/fd/blob/master/src/walk.rs +struct ReceiverBuffer<'a, W> { + buffer: Vec, + // ... +} + +impl<'a, W: Write> ReceiverBuffer<'a, W> { + fn new(...) -> Self { + Self { + buffer: Vec::with_capacity(MAX_BUFFER_LENGTH), + // ... + } + } +} +``` + +## When to Skip + +```rust +// Unknown size, small expected +let mut small: Vec = Vec::new(); // OK for small collections + +// Using collect() with good size_hint +let v: Vec<_> = iter.collect(); // collect() uses size_hint + +// Capacity overhead exceeds benefit +let mut rarely_used = Vec::new(); // OK if rarely grown +``` + +## See Also + +- [mem-reuse-collections](mem-reuse-collections.md) - Reuse collections with clear() +- [mem-smallvec](mem-smallvec.md) - Use SmallVec for usually-small collections +- [perf-extend-batch](perf-extend-batch.md) - Use extend() for batch insertions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-write-over-format.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-write-over-format.md new file mode 100644 index 00000000..efa80f64 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-write-over-format.md @@ -0,0 +1,172 @@ +# mem-write-over-format + +> Use `write!()` into existing buffers instead of `format!()` allocations + +## Why It Matters + +`format!()` always allocates a new `String`. In hot paths or loops, these allocations add up. `write!()` writes directly into an existing buffer, reusing its capacity. For high-frequency formatting operations, this can eliminate significant allocator overhead. + +## Bad + +```rust +fn log_event(event: &Event, output: &mut Vec) { + // format! allocates a new String every call + let line = format!( + "[{}] {}: {}\n", + event.timestamp, + event.level, + event.message + ); + output.extend_from_slice(line.as_bytes()); +} + +fn build_response(items: &[Item]) -> String { + let mut result = String::new(); + + for item in items { + // format! allocates for each item + result.push_str(&format!("{}: {}\n", item.name, item.value)); + } + + result +} +``` + +## Good + +```rust +use std::fmt::Write; + +fn log_event(event: &Event, output: &mut Vec) { + use std::io::Write; + // write! to Vec directly, no intermediate allocation + write!( + output, + "[{}] {}: {}\n", + event.timestamp, + event.level, + event.message + ).unwrap(); +} + +fn build_response(items: &[Item]) -> String { + use std::fmt::Write; + + let mut result = String::with_capacity(items.len() * 64); + + for item in items { + // write! into existing String, reuses capacity + write!(&mut result, "{}: {}\n", item.name, item.value).unwrap(); + } + + result +} +``` + +## Write Trait Varieties + +```rust +// std::fmt::Write - for String, &mut String +use std::fmt::Write as FmtWrite; +let mut s = String::new(); +write!(&mut s, "Hello {}", 42).unwrap(); + +// std::io::Write - for Vec, File, TcpStream, etc. +use std::io::Write as IoWrite; +let mut v: Vec = Vec::new(); +write!(&mut v, "Hello {}", 42).unwrap(); + +// Both can fail in principle, but String/Vec never fail +// Still need .unwrap() due to Result return type +``` + +## Reusable Formatting Buffer + +```rust +use std::fmt::Write; + +struct Formatter { + buffer: String, +} + +impl Formatter { + fn new() -> Self { + Self { buffer: String::with_capacity(1024) } + } + + fn format_event(&mut self, event: &Event) -> &str { + self.buffer.clear(); // Reuse allocation + write!( + &mut self.buffer, + "[{}] {}", + event.timestamp, + event.message + ).unwrap(); + &self.buffer + } +} + +// Usage +let mut formatter = Formatter::new(); +for event in events { + let formatted = formatter.format_event(event); + send_log(formatted); +} +``` + +## writeln! for Lines + +```rust +use std::fmt::Write; + +let mut output = String::new(); + +// writeln! adds newline automatically +writeln!(&mut output, "Line 1: {}", value1).unwrap(); +writeln!(&mut output, "Line 2: {}", value2).unwrap(); + +// Equivalent to +write!(&mut output, "Line 1: {}\n", value1).unwrap(); +``` + +## When format! Is Fine + +```rust +// One-time formatting, not in loop +let message = format!("Starting server on port {}", port); +log::info!("{}", message); + +// Return value (can't return reference to local buffer) +fn describe(item: &Item) -> String { + format!("{}: {}", item.name, item.value) // Must allocate +} + +// Debug/error paths (not hot) +if condition { + panic!("Unexpected: {}", format!("details: {:?}", debug_info)); +} +``` + +## Benchmark Difference + +```rust +// format! in loop: ~500ns per iteration (allocation heavy) +for i in 0..1000 { + let s = format!("item-{}", i); + process(&s); +} + +// write! with reuse: ~50ns per iteration (no allocation) +let mut buf = String::with_capacity(32); +for i in 0..1000 { + buf.clear(); + write!(&mut buf, "item-{}", i).unwrap(); + process(&buf); +} +``` + +## See Also + +- [mem-avoid-format](./mem-avoid-format.md) - General format! avoidance patterns +- [mem-reuse-collections](./mem-reuse-collections.md) - Reusing buffers in loops +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocating string capacity diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-zero-copy.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-zero-copy.md new file mode 100644 index 00000000..b4450f76 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-zero-copy.md @@ -0,0 +1,164 @@ +# mem-zero-copy + +> Use zero-copy patterns with slices and `Bytes` + +## Why It Matters + +Zero-copy means working with data without copying it. Instead of allocating new memory and copying bytes, you work with references to the original data. This dramatically reduces memory usage and improves performance, especially for large data. + +## Bad + +```rust +// Copies every line into a new String +fn get_lines(data: &str) -> Vec { + data.lines() + .map(|line| line.to_string()) // Allocates! + .collect() +} + +// Copies the entire buffer +fn process_packet(buffer: &[u8]) -> Vec { + let header = buffer[0..16].to_vec(); // Copy! + let body = buffer[16..].to_vec(); // Copy! + // Process... + [header, body].concat() // Another copy! +} +``` + +## Good + +```rust +// Zero-copy: returns references to original data +fn get_lines(data: &str) -> Vec<&str> { + data.lines().collect() // Just pointers! +} + +// Zero-copy with slices +fn process_packet(buffer: &[u8]) -> (&[u8], &[u8]) { + let header = &buffer[0..16]; // Just a pointer + length + let body = &buffer[16..]; // Just a pointer + length + (header, body) +} +``` + +## Using bytes::Bytes + +```rust +use bytes::Bytes; + +// Bytes provides zero-copy slicing with reference counting +let data = Bytes::from("hello world"); + +// Slicing doesn't copy - just increments refcount +let hello = data.slice(0..5); // Zero-copy! +let world = data.slice(6..11); // Zero-copy! + +// Both hello and world share the underlying allocation +// Memory is freed when all references are dropped +``` + +## Real-World Pattern from Deno + +```rust +// https://github.com/denoland/deno/blob/main/ext/http/lib.rs +fn method_to_cow(method: &http::Method) -> Cow<'static, str> { + match *method { + Method::GET => Cow::Borrowed("GET"), // Zero-copy + Method::POST => Cow::Borrowed("POST"), // Zero-copy + Method::PUT => Cow::Borrowed("PUT"), // Zero-copy + _ => Cow::Owned(method.to_string()), // Only copies for rare methods + } +} +``` + +## Zero-Copy Parsing + +```rust +// Bad: Copies each parsed field +struct ParsedBad { + name: String, + value: String, +} + +fn parse_bad(input: &str) -> ParsedBad { + let (name, value) = input.split_once('=').unwrap(); + ParsedBad { + name: name.to_string(), // Copy! + value: value.to_string(), // Copy! + } +} + +// Good: References into original string +struct Parsed<'a> { + name: &'a str, + value: &'a str, +} + +fn parse_good(input: &str) -> Parsed<'_> { + let (name, value) = input.split_once('=').unwrap(); + Parsed { name, value } // Zero-copy! +} +``` + +## Combining with Cow + +```rust +use std::borrow::Cow; + +// Zero-copy when possible, copy when needed +fn normalize<'a>(input: &'a str) -> Cow<'a, str> { + if input.contains('\t') { + // Must copy to modify + Cow::Owned(input.replace('\t', " ")) + } else { + // Zero-copy reference + Cow::Borrowed(input) + } +} +``` + +## memchr for Fast Searching + +```rust +use memchr::memchr; + +// Fast byte search using SIMD +fn find_newline(data: &[u8]) -> Option { + memchr(b'\n', data) // SIMD-accelerated, no allocation +} + +// Find all occurrences +use memchr::memchr_iter; + +fn count_newlines(data: &[u8]) -> usize { + memchr_iter(b'\n', data).count() +} +``` + +## When Zero-Copy Isn't Possible + +```rust +// Need to modify data - must copy +fn uppercase(s: &str) -> String { + s.to_uppercase() // Creates new String +} + +// Need data to outlive source +fn store_for_later(s: &str) -> String { + s.to_string() // Must copy for ownership +} + +// Cross-thread transfer (without Arc) +fn send_to_thread(data: &[u8]) { + let owned = data.to_vec(); // Must copy + std::thread::spawn(move || { + process(&owned); + }); +} +``` + +## See Also + +- [own-cow-conditional](own-cow-conditional.md) - Use Cow for conditional ownership +- [own-borrow-over-clone](own-borrow-over-clone.md) - Prefer borrowing over cloning +- [mem-arena-allocator](mem-arena-allocator.md) - Arena allocators for batch operations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-acronym-word.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-acronym-word.md new file mode 100644 index 00000000..b65ac70b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-acronym-word.md @@ -0,0 +1,99 @@ +# name-acronym-word + +> Treat acronyms as words in identifiers: `HttpServer`, not `HTTPServer` + +## Why It Matters + +When acronyms are written in ALL CAPS within identifiers, word boundaries become unclear: is `HTTPSHandler` "HTTPS Handler" or "HTTP SHandler"? Treating acronyms as words (`HttpsHandler`) maintains clear word boundaries and follows Rust convention. The standard library uses this consistently. + +## Bad + +```rust +// ALL CAPS acronyms - unclear word boundaries +struct HTTPServer { ... } // HTTP + Server or H + TTP + Server? +struct TCPIPConnection { ... } // TCP + IP? Or other splits? +struct JSONParser { ... } +struct XMLHTTPRequest { ... } // Very confusing + +fn parseJSON(input: &str) { ... } +fn connectTCP(addr: &str) { ... } +``` + +## Good + +```rust +// Acronyms as words - clear boundaries +struct HttpServer { ... } // Http + Server +struct TcpIpConnection { ... } // Tcp + Ip + Connection +struct JsonParser { ... } +struct XmlHttpRequest { ... } + +fn parse_json(input: &str) { ... } +fn connect_tcp(addr: &str) { ... } + +// More examples +struct Uuid { ... } // Not UUID +struct Uri { ... } // Not URI +struct Url { ... } // Not URL +struct Html { ... } // Not HTML +struct Css { ... } // Not CSS +struct Api { ... } // Not API +``` + +## Standard Library Examples + +```rust +// std uses acronyms as words +std::net::TcpStream // Not TCPStream +std::net::TcpListener // Not TCPListener +std::net::UdpSocket // Not UDPSocket +std::net::IpAddr // Not IPAddr +std::io::IoError // Not IOError (though Io is acceptable too) +``` + +## Two-Letter Acronyms + +```rust +// Two-letter acronyms can go either way +struct Io { ... } // or IO - both acceptable +struct Id { ... } // or ID - both acceptable + +// Preference: treat as word for consistency +struct IoHandler { ... } // Preferred +struct IdGenerator { ... } // Preferred +``` + +## In snake_case + +```rust +// Acronyms become lowercase in snake_case +fn parse_json() { ... } +fn connect_tcp() { ... } +fn generate_uuid() { ... } +fn fetch_http() { ... } +fn encode_url() { ... } + +// Variables +let json_response = fetch_json(); +let tcp_connection = connect_tcp(); +let user_id = generate_uuid(); +``` + +## Mixed Cases + +```rust +// When acronym is part of compound +struct HttpsConnection { ... } // Https (not HTTPS) +struct Utf8String { ... } // Utf8 (not UTF8) +struct Base64Encoder { ... } // Base64 as word + +// Multiple acronyms +struct JsonApiClient { ... } // Json + Api + Client +struct RestApiHandler { ... } // Rest + Api + Handler +``` + +## See Also + +- [name-types-camel](./name-types-camel.md) - Type naming conventions +- [name-funcs-snake](./name-funcs-snake.md) - Function naming conventions +- [name-consts-screaming](./name-consts-screaming.md) - Constant naming diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-as-free.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-as-free.md new file mode 100644 index 00000000..d4ddc421 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-as-free.md @@ -0,0 +1,104 @@ +# name-as-free + +> `as_` prefix: free reference conversion + +## Why It Matters + +Consistent naming helps users understand API cost. `as_` prefix signals a free (O(1), no allocation) conversion that returns a reference. This convention is used throughout the standard library. + +## The Convention + +| Prefix | Cost | Ownership | Example | +|--------|------|-----------|---------| +| `as_` | Free | `&T -> &U` | `str::as_bytes()` | +| `to_` | Expensive | `&T -> U` | `str::to_lowercase()` | +| `into_` | Variable | `T -> U` | `String::into_bytes()` | + +## Examples + +```rust +impl MyString { + // as_ - free reference conversion + pub fn as_str(&self) -> &str { + &self.inner + } + + pub fn as_bytes(&self) -> &[u8] { + self.inner.as_bytes() + } +} + +impl Wrapper { + // as_ - returns reference to inner + pub fn as_inner(&self) -> &T { + &self.inner + } + + pub fn as_inner_mut(&mut self) -> &mut T { + &mut self.inner + } +} +``` + +## Standard Library Examples + +```rust +// String +let s = String::from("hello"); +let bytes: &[u8] = s.as_bytes(); // Free, returns &[u8] +let str_ref: &str = s.as_str(); // Free, returns &str + +// Vec +let v = vec![1, 2, 3]; +let slice: &[i32] = v.as_slice(); // Free, returns &[i32] + +// Path +let p = PathBuf::from("/home"); +let path: &Path = p.as_path(); // Free, returns &Path + +// OsString +let os = OsString::from("hello"); +let os_str: &OsStr = os.as_os_str(); // Free, returns &OsStr +``` + +## Bad + +```rust +impl MyType { + // BAD: as_ but allocates + pub fn as_string(&self) -> String { + format!("{}", self.value) // Allocates! Should be to_string() + } + + // BAD: as_ but expensive + pub fn as_processed(&self) -> &ProcessedData { + // Actually does expensive computation + } +} +``` + +## Good + +```rust +impl MyType { + // GOOD: Free reference + pub fn as_str(&self) -> &str { + &self.inner + } + + // GOOD: to_ signals allocation + pub fn to_string(&self) -> String { + format!("{}", self.value) + } + + // GOOD: into_ signals ownership transfer + pub fn into_inner(self) -> Inner { + self.inner + } +} +``` + +## See Also + +- [name-to-expensive](name-to-expensive.md) - `to_` prefix for expensive conversions +- [name-into-ownership](name-into-ownership.md) - `into_` prefix for ownership transfer diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-consts-screaming.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-consts-screaming.md new file mode 100644 index 00000000..dcb2cfae --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-consts-screaming.md @@ -0,0 +1,94 @@ +# name-consts-screaming + +> Use `SCREAMING_SNAKE_CASE` for constants and statics + +## Why It Matters + +Constants and statics are special—they're known at compile time and have program-wide lifetime. `SCREAMING_SNAKE_CASE` makes them visually distinct from runtime variables. This convention is enforced by the compiler and universally expected. + +## Bad + +```rust +// lowercase/camelCase constants - compiler warns +const maxConnections: u32 = 100; // warning +const default_timeout: u64 = 30; // warning +static globalCounter: AtomicU64 = AtomicU64::new(0); // warning +``` + +## Good + +```rust +// SCREAMING_SNAKE_CASE for constants +const MAX_CONNECTIONS: u32 = 100; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const BUFFER_SIZE: usize = 4096; + +// SCREAMING_SNAKE_CASE for statics +static GLOBAL_COUNTER: AtomicU64 = AtomicU64::new(0); +static CONFIG: OnceLock = OnceLock::new(); + +// Type-level constants in impl blocks +impl Buffer { + const INITIAL_CAPACITY: usize = 1024; + const MAX_CAPACITY: usize = 1024 * 1024; +} +``` + +## Associated Constants + +```rust +trait Limit { + const MAX: usize; + const MIN: usize; +} + +impl Limit for SmallBuffer { + const MAX: usize = 256; + const MIN: usize = 16; +} + +// Generic associated constants +struct Container { + data: Vec, +} + +impl Container { + const EMPTY: Self = Self { data: Vec::new() }; +} +``` + +## Environment and Config + +```rust +// Environment variable names +const ENV_DATABASE_URL: &str = "DATABASE_URL"; +const ENV_LOG_LEVEL: &str = "LOG_LEVEL"; + +// Configuration keys +const CONFIG_TIMEOUT_SECONDS: &str = "timeout_seconds"; +const CONFIG_MAX_RETRIES: &str = "max_retries"; +``` + +## Lazy Static / OnceLock + +```rust +use std::sync::OnceLock; + +// Global configuration +static CONFIG: OnceLock = OnceLock::new(); + +// Compiled regex +static EMAIL_REGEX: OnceLock = OnceLock::new(); + +fn get_email_regex() -> &'static Regex { + EMAIL_REGEX.get_or_init(|| { + Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap() + }) +} +``` + +## See Also + +- [name-funcs-snake](./name-funcs-snake.md) - Function/variable naming +- [name-types-camel](./name-types-camel.md) - Type naming +- [type-newtype-ids](./type-newtype-ids.md) - Type-safe constants diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-crate-no-rs.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-crate-no-rs.md new file mode 100644 index 00000000..28c2ba05 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-crate-no-rs.md @@ -0,0 +1,78 @@ +# name-crate-no-rs + +> Don't suffix crate names with `-rs` or `-rust` + +## Why It Matters + +Adding `-rs` or `-rust` to crate names is redundant—you're already on crates.io, it's obviously Rust. These suffixes waste characters, clutter the namespace, and can make crate names harder to type. The Rust community discourages this pattern. + +## Bad + +```toml +# Cargo.toml +[package] +name = "json-parser-rs" # Redundant -rs +name = "my-lib-rust" # Redundant -rust +name = "http-client-rs" # We know it's Rust +name = "rust-sqlite" # rust- prefix equally bad +``` + +## Good + +```toml +# Cargo.toml +[package] +name = "json-parser" +name = "my-lib" +name = "http-client" +name = "sqlite-wrapper" + +# Real crate examples (no -rs): +# serde (not serde-rs) +# tokio (not tokio-rs) +# reqwest (not reqwest-rs) +# clap (not clap-rs) +``` + +## When Context Is Needed + +```toml +# If you're porting a library from another language: +name = "python-ast" # Describes what it's for, not what it's written in + +# If you're providing bindings: +name = "openssl" # The Rust crate IS the Rust interface + +# Platform-specific: +name = "windows-sys" # Platform, not language +``` + +## Repository Naming + +``` +# GitHub repos don't need -rs either +github.com/user/my-library # Good +github.com/user/my-library-rs # Unnecessary + +# Though some do for disambiguation from other language versions +github.com/rust-lang/rust # The rust repo itself uses "rust" +``` + +## Exceptions + +```toml +# Rare cases where disambiguation matters: +# - If there's a widely-known non-Rust project with the same name +# - Official Rust project repositories (rust-lang org) + +# But even then, consider alternatives: +name = "fancy-lib" # Instead of fancy-rs +name = "better-json" # Instead of json-rust +name = "my-serde-impl" # Instead of serde-rs-fork +``` + +## See Also + +- [proj-workspace-deps](./proj-workspace-deps.md) - Cargo configuration +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Package metadata +- [name-funcs-snake](./name-funcs-snake.md) - Naming conventions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-funcs-snake.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-funcs-snake.md new file mode 100644 index 00000000..e2d4226b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-funcs-snake.md @@ -0,0 +1,76 @@ +# name-funcs-snake + +> Use `snake_case` for functions, methods, variables, and modules + +## Why It Matters + +Rust uses `snake_case` for "value-level" names—functions, methods, variables, modules. This convention is enforced by the compiler and distinguishes runtime entities from types. Consistent naming makes code scannable and predictable. + +## Bad + +```rust +// CamelCase functions - compiler warns +fn calculateTotal() -> f64 { ... } // warning: function `calculateTotal` should have a snake case name +fn getUserName() -> String { ... } // warning + +// Inconsistent naming +fn get_user() -> User { ... } +fn fetchOrder() -> Order { ... } // Mixed conventions +``` + +## Good + +```rust +// snake_case for functions +fn calculate_total() -> f64 { ... } +fn get_user_name() -> String { ... } +fn fetch_order() -> Order { ... } + +// snake_case for methods +impl User { + fn full_name(&self) -> String { ... } + fn is_active(&self) -> bool { ... } + fn set_email(&mut self, email: &str) { ... } +} + +// snake_case for variables +let user_count = 42; +let max_connections = 100; +let is_valid = true; + +// snake_case for modules +mod user_service; +mod http_client; +mod json_parser; +``` + +## Acronyms in snake_case + +```rust +// Lowercase acronyms in snake_case +fn parse_json() -> Json { ... } // Not parse_JSON +fn connect_tcp() -> TcpStream { ... } // Not connect_TCP +fn generate_uuid() -> Uuid { ... } // Not generate_UUID + +let http_response = fetch(); +let json_data = parse(); +``` + +## Local Variables + +```rust +fn process_data(input_data: &[u8]) -> Result { + let raw_bytes = input_data; + let decoded_string = decode(raw_bytes)?; + let parsed_value = parse(&decoded_string)?; + let final_result = transform(parsed_value)?; + + Ok(final_result) +} +``` + +## See Also + +- [name-types-camel](./name-types-camel.md) - Type naming +- [name-consts-screaming](./name-consts-screaming.md) - Constant naming +- [name-lifetime-short](./name-lifetime-short.md) - Lifetime naming diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-into-ownership.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-into-ownership.md new file mode 100644 index 00000000..320003af --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-into-ownership.md @@ -0,0 +1,123 @@ +# name-into-ownership + +> Use `into_` prefix for ownership-consuming conversions + +## Why It Matters + +The `into_` prefix signals "this method consumes self and returns something else." The original value is moved and no longer usable. This ownership transfer is usually cheap (no allocation), but the caller loses access to the original. Clear naming prevents "use after move" confusion. + +## Bad + +```rust +impl Wrapper { + // Misleading: doesn't indicate ownership transfer + fn get_inner(self) -> Inner { + self.inner + } + + // Misleading: suggests borrowing + fn as_inner(self) -> Inner { // Takes self by value! + self.inner + } +} +``` + +## Good + +```rust +impl Wrapper { + // into_ clearly shows ownership transfer + fn into_inner(self) -> Inner { + self.inner + } +} + +// Usage is clear +let wrapper = Wrapper::new(inner); +let inner = wrapper.into_inner(); // wrapper is consumed +// wrapper.foo(); // Error: use of moved value +``` + +## Standard Library Examples + +```rust +// All consume self and return owned data +let string: String = "hello".to_string(); +let bytes: Vec = string.into_bytes(); // String consumed + +let path = PathBuf::from("/foo"); +let os_string: OsString = path.into_os_string(); // PathBuf consumed + +let boxed: Box<[i32]> = vec![1, 2, 3].into_boxed_slice(); // Vec consumed + +let vec: Vec = boxed.into_vec(); // Box consumed +``` + +## into_iter() Pattern + +```rust +let vec = vec![1, 2, 3]; + +// into_iter consumes the collection +for item in vec.into_iter() { // or just: for item in vec + // item is i32, not &i32 +} +// vec is consumed, can't use anymore + +// Contrast with iter() which borrows +let vec = vec![1, 2, 3]; +for item in vec.iter() { + // item is &i32 +} +// vec still usable +``` + +## IntoIterator Trait + +```rust +impl IntoIterator for MyCollection { + type Item = Element; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.elements.into_iter() // Consumes self + } +} +``` + +## Conversion Prefix Summary + +```rust +struct Buffer { + data: Vec, + name: String, +} + +impl Buffer { + // as_ : free borrow, returns reference + fn as_slice(&self) -> &[u8] { + &self.data + } + + // to_ : allocates, creates new value + fn to_vec(&self) -> Vec { + self.data.clone() + } + + // into_ : consumes self, usually cheap + fn into_inner(self) -> Vec { + self.data + } + + // into_ : can destructure into parts + fn into_parts(self) -> (Vec, String) { + (self.data, self.name) + } +} +``` + +## See Also + +- [name-as-free](./name-as-free.md) - Borrowing conversions +- [name-to-expensive](./name-to-expensive.md) - Allocating conversions +- [api-from-not-into](./api-from-not-into.md) - From trait implementation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-is-has-bool.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-is-has-bool.md new file mode 100644 index 00000000..4cc267f2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-is-has-bool.md @@ -0,0 +1,127 @@ +# name-is-has-bool + +> Use `is_`, `has_`, `can_`, `should_` prefixes for boolean-returning methods + +## Why It Matters + +Boolean methods answer yes/no questions. Prefixes like `is_`, `has_`, `can_` make the question explicit, so code reads naturally: `if user.is_active()`, `if buffer.has_remaining()`. Without prefixes, boolean methods are ambiguous and require reading documentation. + +## Bad + +```rust +impl User { + // Unclear: does this check or set? + fn active(&self) -> bool { ... } + + // Unclear: does this delete or check? + fn deleted(&self) -> bool { ... } + + // Unclear return type + fn admin(&self) -> bool { ... } +} + +// Reading code is confusing +if user.active() { ... } // Is this checking or activating? +``` + +## Good + +```rust +impl User { + // Clear: answers "is the user active?" + fn is_active(&self) -> bool { ... } + + // Clear: answers "is the user deleted?" + fn is_deleted(&self) -> bool { ... } + + // Clear: answers "is the user an admin?" + fn is_admin(&self) -> bool { ... } + + // Clear: answers "does the user have permission X?" + fn has_permission(&self, perm: Permission) -> bool { ... } + + // Clear: answers "can the user edit?" + fn can_edit(&self) -> bool { ... } +} + +// Reads naturally +if user.is_active() && user.has_permission(Permission::Write) { + // ... +} +``` + +## Common Prefixes + +| Prefix | Use For | Example | +|--------|---------|---------| +| `is_` | State/property check | `is_empty()`, `is_valid()`, `is_some()` | +| `has_` | Possession/containment | `has_key()`, `has_children()`, `has_remaining()` | +| `can_` | Capability/permission | `can_read()`, `can_write()`, `can_execute()` | +| `should_` | Recommendation/policy | `should_retry()`, `should_cache()` | +| `needs_` | Requirement | `needs_update()`, `needs_auth()` | +| `will_` | Future action | `will_block()`, `will_overflow()` | + +## Standard Library Examples + +```rust +// is_ prefix +vec.is_empty() +option.is_some() +option.is_none() +result.is_ok() +result.is_err() +char.is_alphabetic() +str.is_ascii() +path.is_file() +path.is_dir() + +// has_ prefix (less common in std) +iterator.has_next() // conceptual + +// Checking methods +str.contains("foo") // Not is_ because takes argument +str.starts_with("bar") // Descriptive verb phrase +str.ends_with("baz") +``` + +## Negation + +```rust +// Prefer positive form with caller negation +if !user.is_active() { ... } + +// Rather than negative method +if user.is_inactive() { ... } // Avoid double negatives: !is_inactive() + +// Exception: when negative is the common case +fn is_empty(&self) -> bool { ... } // Checking for empty is common +fn is_not_empty(&self) -> bool { ... } // Rarely needed, use !is_empty() +``` + +## Boolean Fields + +```rust +struct Config { + // Field names can omit prefix + enabled: bool, + verbose: bool, + debug: bool, +} + +impl Config { + // But methods should have prefix + fn is_enabled(&self) -> bool { + self.enabled + } + + fn is_verbose(&self) -> bool { + self.verbose + } +} +``` + +## See Also + +- [name-no-get-prefix](./name-no-get-prefix.md) - Getter naming +- [name-funcs-snake](./name-funcs-snake.md) - Function naming +- [api-must-use](./api-must-use.md) - Boolean functions should be checked diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-convention.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-convention.md new file mode 100644 index 00000000..b86194f5 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-convention.md @@ -0,0 +1,129 @@ +# name-iter-convention + +> Use iter/iter_mut/into_iter for iterator methods + +## Why It Matters + +Rust has a standard convention for iterator method names that signals ownership semantics. Following this convention makes APIs predictable and enables the `for item in collection` syntax to work correctly. + +## The Three Iterator Methods + +| Method | Returns | Ownership | +|--------|---------|-----------| +| `iter()` | `impl Iterator` | Borrows collection | +| `iter_mut()` | `impl Iterator` | Mutably borrows | +| `into_iter()` | `impl Iterator` | Consumes collection | + +## Implementation + +```rust +struct MyCollection { + items: Vec, +} + +impl MyCollection { + /// Returns an iterator over references. + fn iter(&self) -> impl Iterator { + self.items.iter() + } + + /// Returns an iterator over mutable references. + fn iter_mut(&mut self) -> impl Iterator { + self.items.iter_mut() + } +} + +// IntoIterator trait for into_iter() +impl IntoIterator for MyCollection { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.items.into_iter() + } +} + +// Also implement for references +impl<'a, T> IntoIterator for &'a MyCollection { + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.items.iter() + } +} + +impl<'a, T> IntoIterator for &'a mut MyCollection { + type Item = &'a mut T; + type IntoIter = std::slice::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.items.iter_mut() + } +} +``` + +## Usage + +```rust +let collection = MyCollection { items: vec![1, 2, 3] }; + +// Explicit methods +for x in collection.iter() { } // Borrows +for x in collection.iter_mut() { } // Mutably borrows + +// IntoIterator enables for loop syntax +for x in &collection { } // Calls (&collection).into_iter() +for x in &mut collection { } // Calls (&mut collection).into_iter() +for x in collection { } // Consumes, calls collection.into_iter() +``` + +## Bad + +```rust +impl MyCollection { + // Non-standard names + fn elements(&self) -> impl Iterator { } // Should be iter() + fn get_items(&self) -> impl Iterator { } // Should be iter() + fn iterate(&self) -> impl Iterator { } // Should be iter() + fn as_iter(&self) -> impl Iterator { } // Should be iter() +} +``` + +## Additional Iterator Methods + +```rust +impl MyCollection { + // Filter by predicate + fn iter_valid(&self) -> impl Iterator { + self.iter().filter(|x| x.is_valid()) + } + + // Specific slice + fn iter_range(&self, start: usize, end: usize) -> impl Iterator { + self.items[start..end].iter() + } +} +``` + +## Standard Library Examples + +```rust +// Vec, slice, arrays +vec.iter() // &T +vec.iter_mut() // &mut T +vec.into_iter() // T + +// HashMap +map.iter() // (&K, &V) +map.iter_mut() // (&K, &mut V) +map.into_iter() // (K, V) +map.keys() // &K +map.values() // &V +``` + +## See Also + +- [name-iter-type-match](./name-iter-type-match.md) - Iterator type naming +- [name-iter-method](./name-iter-method.md) - Iterator method names +- [perf-iter-over-index](./perf-iter-over-index.md) - Prefer iterators diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-method.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-method.md new file mode 100644 index 00000000..e7befe2c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-method.md @@ -0,0 +1,131 @@ +# name-iter-method + +> Name iterator methods `iter()`, `iter_mut()`, and `into_iter()` consistently + +## Why It Matters + +Rust has a strong convention for iterator method names. Following these conventions makes your types work predictably with `for` loops and iterator adapters. Users expect `iter()` for shared references, `iter_mut()` for mutable references, and `into_iter()` for owned iteration. + +## Bad + +```rust +struct Collection { + items: Vec, +} + +impl Collection { + // Non-standard names - confusing + fn elements(&self) -> impl Iterator { + self.items.iter() + } + + fn get_iterator(&self) -> impl Iterator { + self.items.iter() + } + + fn to_iter(self) -> impl Iterator { + self.items.into_iter() + } +} +``` + +## Good + +```rust +struct Collection { + items: Vec, +} + +impl Collection { + /// Returns an iterator over references. + fn iter(&self) -> impl Iterator { + self.items.iter() + } + + /// Returns an iterator over mutable references. + fn iter_mut(&mut self) -> impl Iterator { + self.items.iter_mut() + } +} + +// Implement IntoIterator for for-loop support +impl IntoIterator for Collection { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.items.into_iter() + } +} + +impl<'a, T> IntoIterator for &'a Collection { + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.items.iter() + } +} + +impl<'a, T> IntoIterator for &'a mut Collection { + type Item = &'a mut T; + type IntoIter = std::slice::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.items.iter_mut() + } +} +``` + +## Iterator Convention Summary + +| Method | Receiver | Yields | Use Case | +|--------|----------|--------|----------| +| `iter()` | `&self` | `&T` | Read-only iteration | +| `iter_mut()` | `&mut self` | `&mut T` | In-place modification | +| `into_iter()` | `self` | `T` | Consuming iteration | + +## For Loop Integration + +```rust +let col = Collection { items: vec![1, 2, 3] }; + +// These all work with proper IntoIterator impls +for item in &col { // Calls (&col).into_iter() -> iter() + println!("{}", item); // &i32 +} + +for item in &mut col { // Calls (&mut col).into_iter() -> iter_mut() + *item += 1; // &mut i32 +} + +for item in col { // Calls col.into_iter() + process(item); // i32, consumes col +} +``` + +## Additional Iterator Methods + +```rust +impl Collection { + // Domain-specific iterators follow similar patterns + + /// Iterates over keys (for map-like structures). + fn keys(&self) -> impl Iterator { ... } + + /// Iterates over values. + fn values(&self) -> impl Iterator { ... } + + /// Iterates over mutable values. + fn values_mut(&mut self) -> impl Iterator { ... } + + /// Drains elements, leaving container empty. + fn drain(&mut self) -> impl Iterator { ... } +} +``` + +## See Also + +- [name-as-free](./name-as-free.md) - Conversion naming conventions +- [api-extension-trait](./api-extension-trait.md) - Iterator extensions +- [api-common-traits](./api-common-traits.md) - Standard trait implementations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-type-match.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-type-match.md new file mode 100644 index 00000000..b6db3be7 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-type-match.md @@ -0,0 +1,142 @@ +# name-iter-type-match + +> Name iterator types after their source method + +## Why It Matters + +Iterator types should match the method that creates them. `iter()` returns `Iter`, `into_iter()` returns `IntoIter`, `keys()` returns `Keys`. This naming pattern is established by the standard library and makes types predictable. + +## Standard Library Pattern + +```rust +// Vec +impl Vec { + fn iter(&self) -> Iter<'_, T> { } // Returns Iter + fn iter_mut(&mut self) -> IterMut<'_, T> { } // Returns IterMut +} + +impl IntoIterator for Vec { + type IntoIter = IntoIter; // Returns IntoIter +} + +// HashMap +impl HashMap { + fn iter(&self) -> Iter<'_, K, V> { } + fn keys(&self) -> Keys<'_, K, V> { } // Returns Keys + fn values(&self) -> Values<'_, K, V> { } // Returns Values + fn drain(&mut self) -> Drain<'_, K, V> { } // Returns Drain +} +``` + +## Implementation + +```rust +mod my_collection { + pub struct MyCollection { + items: Vec, + } + + // Iterator types in same module + pub struct Iter<'a, T> { + inner: std::slice::Iter<'a, T>, + } + + pub struct IterMut<'a, T> { + inner: std::slice::IterMut<'a, T>, + } + + pub struct IntoIter { + inner: std::vec::IntoIter, + } + + impl MyCollection { + pub fn iter(&self) -> Iter<'_, T> { + Iter { inner: self.items.iter() } + } + + pub fn iter_mut(&mut self) -> IterMut<'_, T> { + IterMut { inner: self.items.iter_mut() } + } + } + + impl IntoIterator for MyCollection { + type Item = T; + type IntoIter = IntoIter; + + fn into_iter(self) -> IntoIter { + IntoIter { inner: self.items.into_iter() } + } + } + + // Implement Iterator for each type + impl<'a, T> Iterator for Iter<'a, T> { + type Item = &'a T; + fn next(&mut self) -> Option { + self.inner.next() + } + } + + impl<'a, T> Iterator for IterMut<'a, T> { + type Item = &'a mut T; + fn next(&mut self) -> Option { + self.inner.next() + } + } + + impl Iterator for IntoIter { + type Item = T; + fn next(&mut self) -> Option { + self.inner.next() + } + } +} +``` + +## Naming Convention + +| Method | Iterator Type | +|--------|---------------| +| `iter()` | `Iter` | +| `iter_mut()` | `IterMut` | +| `into_iter()` | `IntoIter` | +| `keys()` | `Keys` | +| `values()` | `Values` | +| `values_mut()` | `ValuesMut` | +| `drain()` | `Drain` | +| `chunks()` | `Chunks` | +| `windows()` | `Windows` | + +## Custom Iterator Methods + +```rust +impl Graph { + // Method name -> Type name + fn nodes(&self) -> Nodes<'_> { } // Custom: Nodes + fn edges(&self) -> Edges<'_> { } // Custom: Edges + fn neighbors(&self, node: NodeId) -> Neighbors<'_> { } // Custom: Neighbors +} + +pub struct Nodes<'a> { /* ... */ } +pub struct Edges<'a> { /* ... */ } +pub struct Neighbors<'a> { /* ... */ } +``` + +## Bad + +```rust +// Mismatched names +impl MyCollection { + fn iter(&self) -> MyCollectionIterator<'_, T> { } // Should be Iter + fn keys(&self) -> KeyIterator<'_, K> { } // Should be Keys +} + +// Generic names that don't match method +pub struct Iterator; // Conflicts with std::iter::Iterator +pub struct I; // Too cryptic +``` + +## See Also + +- [name-iter-convention](./name-iter-convention.md) - iter/iter_mut/into_iter +- [name-iter-method](./name-iter-method.md) - Iterator method names +- [api-common-traits](./api-common-traits.md) - Implementing common traits diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-lifetime-short.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-lifetime-short.md new file mode 100644 index 00000000..e65d281c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-lifetime-short.md @@ -0,0 +1,86 @@ +# name-lifetime-short + +> Use short, conventional lifetime names: `'a`, `'b`, `'de`, `'src` + +## Why It Matters + +Lifetime parameters are ubiquitous in Rust signatures. Short names like `'a` keep signatures readable. For domain-specific lifetimes, descriptive but short names like `'src` or `'de` communicate intent without clutter. The Rust community has established conventions that aid recognition. + +## Bad + +```rust +// Overly verbose lifetimes +fn parse<'input_lifetime, 'output_lifetime>( + input: &'input_lifetime str +) -> Result<&'output_lifetime str, Error> { ... } + +// Meaningless long names +struct Parser<'parser_instance_lifetime> { + source: &'parser_instance_lifetime str, +} +``` + +## Good + +```rust +// Standard short lifetimes +fn parse<'a>(input: &'a str) -> Result<&'a str, Error> { ... } + +struct Parser<'a> { + source: &'a str, +} + +// Multiple lifetimes: 'a, 'b, 'c +fn merge<'a, 'b>(first: &'a str, second: &'b str) -> String { ... } + +// Descriptive when clarity helps +fn deserialize<'de>(input: &'de [u8]) -> Result, Error> { ... } +``` + +## Common Lifetime Conventions + +| Lifetime | Convention | Example | +|----------|------------|---------| +| `'a` | Generic, first lifetime | `fn foo<'a>(x: &'a str)` | +| `'b` | Generic, second lifetime | `fn bar<'a, 'b>(x: &'a T, y: &'b U)` | +| `'de` | Deserialization | serde's `Deserialize<'de>` | +| `'src` | Source code/input | `struct Lexer<'src>` | +| `'ctx` | Context | `struct Query<'ctx>` | +| `'input` | Input data | `struct Parser<'input>` | +| `'static` | Static lifetime | `&'static str` | + +## Elision Preferred + +```rust +// Let elision work when possible +fn first_word(s: &str) -> &str { // Not fn first_word<'a>(s: &'a str) -> &'a str + s.split_whitespace().next().unwrap_or("") +} + +impl User { + fn name(&self) -> &str { // Elision handles this + &self.name + } +} +``` + +## Serde Convention + +```rust +use serde::{Deserialize, Serialize}; + +// 'de is the standard serde lifetime for borrowed data +#[derive(Deserialize)] +struct Request<'de> { + #[serde(borrow)] + name: &'de str, + #[serde(borrow)] + tags: Vec<&'de str>, +} +``` + +## See Also + +- [own-lifetime-elision](./own-lifetime-elision.md) - When to omit lifetimes +- [name-type-param-single](./name-type-param-single.md) - Type parameter naming +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Borrowing patterns diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-no-get-prefix.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-no-get-prefix.md new file mode 100644 index 00000000..c2835c6f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-no-get-prefix.md @@ -0,0 +1,154 @@ +# name-no-get-prefix + +> Omit get_ prefix for simple getters + +## Why It Matters + +Rust convention omits the `get_` prefix for simple field access. Methods like `len()`, `name()`, `value()` are cleaner than `get_len()`, `get_name()`, `get_value()`. This follows the principle of making the common case concise. + +The `get` prefix is reserved for methods that DO something beyond simple field access. + +## Bad + +```rust +struct User { + name: String, + age: u32, +} + +impl User { + fn get_name(&self) -> &str { // Verbose + &self.name + } + + fn get_age(&self) -> u32 { // Verbose + self.age + } + + fn get_is_adult(&self) -> bool { // Doubly verbose + self.age >= 18 + } +} + +let name = user.get_name(); +let age = user.get_age(); +``` + +## Good + +```rust +struct User { + name: String, + age: u32, +} + +impl User { + fn name(&self) -> &str { // Clean + &self.name + } + + fn age(&self) -> u32 { // Clean + self.age + } + + fn is_adult(&self) -> bool { // Boolean uses is_ prefix + self.age >= 18 + } +} + +let name = user.name(); +let age = user.age(); +``` + +## When get_ IS Appropriate + +Use `get` when the method does more than simple access: + +```rust +impl HashMap { + // Returns Option - not just field access + fn get(&self, key: &K) -> Option<&V> { } + + // Mutable variant + fn get_mut(&mut self, key: &K) -> Option<&mut V> { } +} + +impl Vec { + // Returns Option - bounds checked + fn get(&self, index: usize) -> Option<&T> { } +} + +impl Context { + // Does computation/lookup, not just field access + fn get_config(&self) -> Config { + self.configs.get(&self.current_env).cloned().unwrap_or_default() + } +} +``` + +## Standard Library Examples + +```rust +// No get_ prefix +String::len() +Vec::len() +Vec::capacity() +Vec::is_empty() +Path::file_name() +Option::is_some() +Result::is_ok() + +// With get - returns Option or does lookup +Vec::get(index) +HashMap::get(key) +BTreeMap::get(key) +``` + +## Pattern: Getter/Setter Pairs + +```rust +impl Config { + // Getter: no prefix + fn timeout(&self) -> Duration { + self.timeout + } + + // Setter: use set_ prefix + fn set_timeout(&mut self, timeout: Duration) { + self.timeout = timeout; + } +} +``` + +## Pattern: Builder Methods + +```rust +impl ConfigBuilder { + // Builder methods: no get_, no set_ + fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + fn retries(mut self, retries: u32) -> Self { + self.retries = retries; + self + } +} +``` + +## Decision Guide + +| Pattern | Naming | +|---------|--------| +| Simple field access | `name()`, `value()`, `len()` | +| Boolean property | `is_valid()`, `has_items()` | +| Fallible access | `get()`, `get_mut()` | +| Setter | `set_name()`, `set_value()` | +| Builder | `name()`, `value()` (consuming self) | + +## See Also + +- [name-is-has-bool](./name-is-has-bool.md) - Boolean naming +- [name-is-has-bool](./name-is-has-bool.md) - Boolean naming +- [api-builder-pattern](./api-builder-pattern.md) - Builder pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-to-expensive.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-to-expensive.md new file mode 100644 index 00000000..66287cb9 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-to-expensive.md @@ -0,0 +1,118 @@ +# name-to-expensive + +> Use `to_` prefix for expensive conversions that allocate or compute + +## Why It Matters + +The `to_` prefix signals "this conversion has a cost"—typically allocation, cloning, or computation. Callers know to consider caching the result or avoiding repeated calls. This contrasts with `as_` (free reference conversion) and `into_` (ownership transfer). + +## Bad + +```rust +impl Name { + // Misleading: suggests expensive operation + fn as_uppercase(&self) -> String { + self.0.to_uppercase() // Allocates! + } + + // Misleading: suggests cheap reference + fn get_string(&self) -> String { + self.0.clone() // Allocates! + } +} +``` + +## Good + +```rust +impl Name { + // to_ = allocates/computes + fn to_uppercase(&self) -> String { + self.0.to_uppercase() + } + + // to_ = creates new value + fn to_string(&self) -> String { + self.0.clone() + } + + // as_ = free reference (cheap) + fn as_str(&self) -> &str { + &self.0 + } +} +``` + +## Standard Library Examples + +```rust +// to_ methods - all allocate or compute +let s: String = slice.to_vec(); // Allocates Vec +let s: String = "hello".to_string(); // Allocates String +let s: String = "HELLO".to_lowercase(); // Allocates new String +let s: String = path.to_string_lossy().into_owned(); // May allocate + +// Contrast with as_ methods - all are free +let slice: &[u8] = s.as_bytes(); // Just reinterpret +let str_ref: &str = string.as_str(); // Just reference +let path: &Path = Path::new("foo"); // Just reference +``` + +## Conversion Method Prefixes + +| Prefix | Cost | Ownership | Example | +|--------|------|-----------|---------| +| `as_` | Free (O(1)) | Borrows `&T` | `as_str()`, `as_bytes()` | +| `to_` | Allocates/Computes | Creates new | `to_string()`, `to_vec()` | +| `into_` | Usually free | Takes ownership | `into_inner()`, `into_vec()` | + +## Custom Types + +```rust +struct Email(String); + +impl Email { + // Cheap: just returns reference + fn as_str(&self) -> &str { + &self.0 + } + + // Expensive: allocates + fn to_lowercase(&self) -> Email { + Email(self.0.to_lowercase()) + } + + // Expensive: allocates + fn to_display_format(&self) -> String { + format!("<{}>", self.0) + } + + // Ownership transfer: usually cheap + fn into_string(self) -> String { + self.0 + } +} +``` + +## to_owned() Pattern + +```rust +// to_owned() for getting owned version of borrowed data +let borrowed: &str = "hello"; +let owned: String = borrowed.to_owned(); // Allocates + +let borrowed: &[i32] = &[1, 2, 3]; +let owned: Vec = borrowed.to_owned(); // Allocates + +// ToOwned trait +trait ToOwned { + type Owned; + fn to_owned(&self) -> Self::Owned; +} +``` + +## See Also + +- [name-as-free](./name-as-free.md) - Free reference conversions +- [name-into-ownership](./name-into-ownership.md) - Ownership transfer +- [own-cow-conditional](./own-cow-conditional.md) - Avoiding unnecessary allocations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-type-param-single.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-type-param-single.md new file mode 100644 index 00000000..6d12502c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-type-param-single.md @@ -0,0 +1,92 @@ +# name-type-param-single + +> Use single uppercase letters for type parameters: `T`, `E`, `K`, `V` + +## Why It Matters + +Generic type parameters conventionally use single uppercase letters. This keeps signatures concise and follows established conventions that readers instantly recognize. `T` for "type", `E` for "error", `K` for "key", `V` for "value" are universal in Rust. + +## Bad + +```rust +// Verbose type parameters +struct Container { + items: Vec, +} + +fn process(input: InputType) -> OutputType { ... } + +// Lowercase - looks like lifetime +struct Wrapper { ... } // Confusing +``` + +## Good + +```rust +// Single uppercase letters +struct Container { + items: Vec, +} + +fn process(input: I) -> O { ... } + +// Standard conventions +struct HashMap { ... } // K=Key, V=Value +enum Result { ... } // T=Type, E=Error +enum Option { ... } // T=Type +struct Ref<'a, T> { ... } // Lifetime + Type +``` + +## Standard Type Parameter Names + +| Parameter | Meaning | Example | +|-----------|---------|---------| +| `T` | Type (generic) | `Vec` | +| `E` | Error | `Result` | +| `K` | Key | `HashMap` | +| `V` | Value | `HashMap` | +| `I` | Input / Item | `Iterator` | +| `O` | Output | `Fn(I) -> O` | +| `R` | Return / Result | `fn() -> R` | +| `S` | State | `StateMachine` | +| `A` | Allocator | `Vec` | +| `F` | Function | `map(f: F)` | + +## Multiple Type Parameters + +```rust +// Use related letters +fn transform(input: I) -> Result +where + I: Input, + O: Output, + E: Error, +{ ... } + +// Or sequential: T, U, V +fn combine(a: T, b: U) -> V { ... } + +// Descriptive only when many parameters need clarity +struct Query { ... } +``` + +## Trait Bounds + +```rust +// Keep type params short, move complexity to where clause +fn process(value: T) -> Result +where + T: Clone + Debug + Send + Sync, + E: Error + From, +{ ... } + +// Not inline +fn process>(value: T) -> Result +// Too long! +``` + +## See Also + +- [name-lifetime-short](./name-lifetime-short.md) - Lifetime parameter naming +- [name-types-camel](./name-types-camel.md) - Concrete type naming +- [type-generic-bounds](./type-generic-bounds.md) - Trait bounds diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-types-camel.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-types-camel.md new file mode 100644 index 00000000..acbb70c4 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-types-camel.md @@ -0,0 +1,65 @@ +# name-types-camel + +> Use `UpperCamelCase` for types, traits, and enum names + +## Why It Matters + +Rust's naming conventions are enforced by the compiler and linter. Consistent naming makes code immediately recognizable—you know `HttpClient` is a type, `send_request` is a function. Violating conventions triggers warnings and makes code harder to read. + +## Bad + +```rust +// Lowercase types - compiler warns +struct http_client { ... } // warning: type `http_client` should have an upper camel case name +trait serializable { ... } // warning +enum response_type { ... } // warning + +// Screaming case for types +struct HTTP_CLIENT { ... } // Not idiomatic +``` + +## Good + +```rust +// UpperCamelCase for all types +struct HttpClient { ... } +trait Serializable { ... } +enum ResponseType { ... } + +// Compound words +struct TcpConnection { ... } +struct IoError { ... } +struct FileReader { ... } + +// Generic types +struct HashMap { ... } +struct Result { ... } +``` + +## Acronyms + +```rust +// Treat acronyms as words (capitalize first letter only) +struct HttpServer { ... } // Not HTTPServer +struct JsonParser { ... } // Not JSONParser +struct Uuid { ... } // Not UUID +struct TcpStream { ... } // Not TCPStream + +// Exception: Two-letter acronyms can be all caps +struct IOError { ... } // Acceptable +struct IoError { ... } // Also acceptable (preferred) +``` + +## Type Aliases + +```rust +// Type aliases also use UpperCamelCase +type Result = std::result::Result; +type BoxedFuture<'a, T> = Pin + Send + 'a>>; +``` + +## See Also + +- [name-variants-camel](./name-variants-camel.md) - Enum variant naming +- [name-funcs-snake](./name-funcs-snake.md) - Function naming +- [name-acronym-word](./name-acronym-word.md) - Acronym handling diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-variants-camel.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-variants-camel.md new file mode 100644 index 00000000..4b471efa --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-variants-camel.md @@ -0,0 +1,101 @@ +# name-variants-camel + +> Use `UpperCamelCase` for enum variants + +## Why It Matters + +Enum variants follow the same naming convention as types—`UpperCamelCase`. This distinguishes them from fields, variables, and functions. The compiler warns on violations, and consistent naming helps readers instantly recognize variant names. + +## Bad + +```rust +enum Status { + pending, // warning: variant `pending` should have an upper camel case name + in_progress, // warning + COMPLETED, // Not idiomatic +} + +enum Color { + RED, // Screaming case - not Rust style + GREEN, + BLUE, +} +``` + +## Good + +```rust +enum Status { + Pending, + InProgress, + Completed, + Failed, +} + +enum Color { + Red, + Green, + Blue, + Custom(u8, u8, u8), +} + +enum HttpMethod { + Get, + Post, + Put, + Delete, + Patch, +} +``` + +## Variants with Data + +```rust +enum Message { + // Unit variant + Quit, + + // Tuple variant + Move(i32, i32), + + // Struct variant + Write { text: String }, + + // Named fields + ChangeColor { + red: u8, + green: u8, + blue: u8, + }, +} +``` + +## Variant Naming Tips + +```rust +// Be specific +enum Error { + NotFound, // Good: specific + PermissionDenied, // Good: specific + Error, // Bad: vague +} + +// Avoid redundant type name in variant +enum ConnectionState { + Connected, // Good + Disconnected, // Good + ConnectionError, // Bad: redundant "Connection" +} + +// Use None/Some pattern for Option-like enums +enum MaybeValue { + Some(T), + None, +} +``` + +## See Also + +- [name-types-camel](./name-types-camel.md) - Type naming +- [api-non-exhaustive](./api-non-exhaustive.md) - Forward-compatible enums +- [type-enum-states](./type-enum-states.md) - State machine enums diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-bounds-check.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-bounds-check.md new file mode 100644 index 00000000..4087d2d5 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-bounds-check.md @@ -0,0 +1,161 @@ +# opt-bounds-check + +> Use iterators and patterns that eliminate bounds checks in hot paths + +## Why It Matters + +Rust's safety guarantees require bounds checking on array/slice indexing. In tight loops, these checks can cause measurable overhead (branch mispredictions, preventing vectorization). Patterns like iterators, `get_unchecked`, and index splitting can eliminate these checks while maintaining safety. + +## Bad + +```rust +fn sum_products(a: &[f64], b: &[f64]) -> f64 { + let mut sum = 0.0; + for i in 0..a.len() { + sum += a[i] * b[i]; // Two bounds checks per iteration + } + sum +} + +fn apply_filter(data: &mut [u8], kernel: &[u8; 3]) { + for i in 1..data.len() - 1 { + // Three bounds checks per iteration + data[i] = (data[i - 1] + data[i] + data[i + 1]) / 3; + } +} +``` + +## Good + +```rust +fn sum_products(a: &[f64], b: &[f64]) -> f64 { + // Iterator zips - no bounds checks, vectorizes well + a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() +} + +fn apply_filter(data: &mut [u8]) { + // Windows pattern - no bounds checks + for window in data.windows(3) { + // window[0], window[1], window[2] are all valid + } + + // Or use chunks + for chunk in data.chunks_exact(4) { + process_simd(chunk); + } +} +``` + +## Iterator Patterns + +```rust +// All of these avoid bounds checks: + +// zip - parallel iteration +for (a, b) in xs.iter().zip(ys.iter()) { ... } + +// enumerate - index + value +for (i, x) in data.iter().enumerate() { ... } + +// windows - sliding window +for window in data.windows(3) { ... } + +// chunks - fixed-size groups +for chunk in data.chunks(4) { ... } +for chunk in data.chunks_exact(4) { ... } // Guarantees exact size + +// split_at - divide slice +let (left, right) = data.split_at(mid); +``` + +## Split for Parallel Access + +```rust +fn parallel_sum(data: &[i32]) -> i32 { + // Split into independent chunks + let (left, right) = data.split_at(data.len() / 2); + + // Process chunks without bounds checks + let sum_left: i32 = left.iter().sum(); + let sum_right: i32 = right.iter().sum(); + + sum_left + sum_right +} +``` + +## get_unchecked for Proven Safety + +```rust +fn matrix_multiply(a: &[f64], b: &[f64], c: &mut [f64], n: usize) { + assert!(a.len() >= n * n); + assert!(b.len() >= n * n); + assert!(c.len() >= n * n); + + for i in 0..n { + for j in 0..n { + let mut sum = 0.0; + for k in 0..n { + // SAFETY: bounds verified by asserts above + unsafe { + sum += a.get_unchecked(i * n + k) + * b.get_unchecked(k * n + j); + } + } + // SAFETY: bounds verified by asserts above + unsafe { + *c.get_unchecked_mut(i * n + j) = sum; + } + } + } +} +``` + +## Slice Patterns + +```rust +fn process_header(data: &[u8]) -> Option
{ + // Slice pattern - single length check, no per-field checks + let [a, b, c, d, rest @ ..] = data else { + return None; + }; + + Some(Header { + magic: *a, + version: *b, + flags: u16::from_le_bytes([*c, *d]), + payload: rest, + }) +} +``` + +## Verify Bounds Check Elimination + +```bash +# Check generated assembly +cargo asm --release my_crate::hot_function + +# Look for 'cmp' and 'ja'/'jbe' instructions near array access +# If eliminated, you'll see direct memory access +``` + +## When to Accept Bounds Checks + +```rust +// Random access patterns - checks unavoidable +fn random_lookup(data: &[u8], indices: &[usize]) -> Vec { + indices.iter() + .filter_map(|&i| data.get(i).copied()) // Checked, but necessary + .collect() +} + +// Infrequent access - overhead negligible +fn get_config(&self, key: &str) -> Option<&Value> { + self.config.get(key) // Fine, not hot path +} +``` + +## See Also + +- [opt-simd-portable](./opt-simd-portable.md) - SIMD requires unchecked access +- [opt-cache-friendly](./opt-cache-friendly.md) - Cache-efficient patterns +- [perf-profile-first](./perf-profile-first.md) - Identify actual hot paths diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cache-friendly.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cache-friendly.md new file mode 100644 index 00000000..fbfb199c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cache-friendly.md @@ -0,0 +1,187 @@ +# opt-cache-friendly + +> Organize data for cache-efficient access patterns + +## Why It Matters + +Cache misses are expensive—a L3 cache miss costs ~100+ cycles vs ~4 cycles for L1 hit. Data layout and access patterns determine cache efficiency. Arrays of structs (AoS) vs structs of arrays (SoA), memory locality, and access patterns can make order-of-magnitude performance differences. + +## Bad + +```rust +// Array of Structs (AoS) - poor cache use when accessing one field +struct Particle { + position: [f32; 3], // 12 bytes + velocity: [f32; 3], // 12 bytes + mass: f32, // 4 bytes + id: u64, // 8 bytes + flags: u8, // 1 byte + padding + // Total: 40 bytes per particle +} + +fn update_positions(particles: &mut [Particle], dt: f32) { + for p in particles { + // Access position and velocity - 24 bytes + // But loads 40-byte struct per particle + // 16 bytes wasted per cache line load + p.position[0] += p.velocity[0] * dt; + p.position[1] += p.velocity[1] * dt; + p.position[2] += p.velocity[2] * dt; + } +} +``` + +## Good + +```rust +// Struct of Arrays (SoA) - cache-efficient for field access +struct Particles { + positions_x: Vec, + positions_y: Vec, + positions_z: Vec, + velocities_x: Vec, + velocities_y: Vec, + velocities_z: Vec, + masses: Vec, + ids: Vec, + flags: Vec, +} + +fn update_positions(p: &mut Particles, dt: f32) { + // Access contiguous memory - perfect cache utilization + for (px, vx) in p.positions_x.iter_mut().zip(&p.velocities_x) { + *px += vx * dt; + } + for (py, vy) in p.positions_y.iter_mut().zip(&p.velocities_y) { + *py += vy * dt; + } + for (pz, vz) in p.positions_z.iter_mut().zip(&p.velocities_z) { + *pz += vz * dt; + } +} +``` + +## Hot/Cold Splitting + +```rust +// Separate frequently and rarely accessed fields +struct EntityHot { + position: [f32; 3], + velocity: [f32; 3], + // Hot data - accessed every frame +} + +struct EntityCold { + name: String, + creation_time: Instant, + metadata: HashMap, + // Cold data - rarely accessed +} + +struct Entities { + hot: Vec, + cold: Vec, +} + +// Hot loop touches only hot data +fn update(entities: &mut Entities, dt: f32) { + for e in &mut entities.hot { + e.position[0] += e.velocity[0] * dt; + // Cold data stays out of cache + } +} +``` + +## Prefetching + +```rust +// Process in cache-line-sized chunks +const CACHE_LINE: usize = 64; + +fn process_with_prefetch(data: &mut [u8]) { + for chunk in data.chunks_mut(CACHE_LINE) { + // Prefetch next chunk while processing current + // (automatic in many cases, manual for complex patterns) + process_chunk(chunk); + } +} + +// Matrix multiplication - block for cache +fn matmul_blocked(a: &[f64], b: &[f64], c: &mut [f64], n: usize) { + const BLOCK: usize = 32; // Fits in L1 cache + + for i0 in (0..n).step_by(BLOCK) { + for j0 in (0..n).step_by(BLOCK) { + for k0 in (0..n).step_by(BLOCK) { + // Process BLOCK x BLOCK tile + for i in i0..min(i0 + BLOCK, n) { + for j in j0..min(j0 + BLOCK, n) { + // Inner loop operates on cached data + } + } + } + } + } +} +``` + +## Avoid Pointer Chasing + +```rust +// Bad: linked list - random memory access +struct Node { + value: i32, + next: Option>, +} + +fn sum_linked(head: &Node) -> i32 { + // Each node is a cache miss +} + +// Good: contiguous vector +fn sum_vector(data: &[i32]) -> i32 { + data.iter().sum() // Sequential access, prefetcher happy +} + +// Good: if graph needed, use indices +struct Graph { + values: Vec, + edges: Vec, // Indices into values +} +``` + +## Memory Layout Attributes + +```rust +// Ensure cache-line alignment +#[repr(C, align(64))] +struct CacheAligned { + data: [u8; 64], +} + +// Prevent false sharing in concurrent code +#[repr(C, align(64))] +struct PaddedCounter { + value: AtomicU64, + _pad: [u8; 56], +} +``` + +## Measuring Cache Performance + +```bash +# Linux perf +perf stat -e cache-references,cache-misses ./my_program + +# Detailed cache analysis +perf stat -e L1-dcache-loads,L1-dcache-load-misses,LLC-loads,LLC-load-misses ./my_program + +# Cachegrind +valgrind --tool=cachegrind ./my_program +``` + +## See Also + +- [mem-smaller-integers](./mem-smaller-integers.md) - Smaller data fits more in cache +- [mem-box-large-variant](./mem-box-large-variant.md) - Keep enum sizes small +- [opt-bounds-check](./opt-bounds-check.md) - Sequential access patterns diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-codegen-units.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-codegen-units.md new file mode 100644 index 00000000..eedda684 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-codegen-units.md @@ -0,0 +1,142 @@ +# opt-codegen-units + +> Set `codegen-units = 1` for maximum optimization in release builds + +## Why It Matters + +By default, Cargo splits code into multiple codegen units for parallel compilation. This speeds up builds but prevents some cross-unit optimizations. Setting `codegen-units = 1` allows LLVM to optimize across the entire crate, potentially improving runtime performance by 5-20% at the cost of slower builds. + +## Bad + +```toml +# Cargo.toml - default settings +[profile.release] +# codegen-units defaults to 16 +# Fast to compile, but misses optimization opportunities +``` + +## Good + +```toml +# Cargo.toml - optimized for runtime performance +[profile.release] +codegen-units = 1 # Single unit = better optimization +lto = true # Link-time optimization +opt-level = 3 # Maximum optimization +``` + +## What codegen-units Affects + +| Codegen Units | Compile Time | Runtime Performance | Memory Use | +|---------------|--------------|---------------------|------------| +| 16 (default) | Faster | Baseline | Lower | +| 4-8 | Moderate | Slightly better | Moderate | +| 1 | Slower | Best | Higher | + +## How It Works + +```rust +// With codegen-units = 16: +// - Crate split into 16 independent compilation units +// - Compiled in parallel +// - Limited visibility between units for optimization + +// With codegen-units = 1: +// - Entire crate in single unit +// - LLVM sees all code at once +// - Can inline across module boundaries +// - Better dead code elimination +// - Better constant propagation +``` + +## Full Release Profile + +```toml +[profile.release] +# Maximum runtime performance +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" # Smaller binary, slight perf gain +strip = true # Smaller binary + +[profile.release-with-debug] +# Performance with debugging ability +inherits = "release" +debug = true # Keep debug symbols +strip = false + +[profile.bench] +# For benchmarking +inherits = "release" +``` + +## Build Time Trade-offs + +```bash +# Default release build (fast compile) +cargo build --release +# Time: ~30s + +# Optimized release build (slow compile, fast runtime) +# With codegen-units = 1, lto = "fat" +cargo build --release +# Time: ~2-5min, but potentially 10-20% faster binary +``` + +## Per-Profile Configuration + +```toml +# Fast debug builds +[profile.dev] +codegen-units = 256 # Maximum parallelism + +# Fast CI builds +[profile.ci] +inherits = "release" +codegen-units = 16 # Balance compile time vs runtime +lto = "thin" # Faster than "fat" + +# Production release +[profile.production] +inherits = "release" +codegen-units = 1 +lto = "fat" +``` + +## When to Use What + +```rust +// codegen-units = 16 (default) +// - Development builds +// - CI where compile time matters +// - When runtime performance isn't critical + +// codegen-units = 1 +// - Production deployments +// - Performance-critical applications +// - Final releases +// - Benchmarking +``` + +## Measuring Impact + +```bash +# Build with different settings +cargo build --release + +# Benchmark +cargo bench + +# Compare binary sizes +ls -lh target/release/my_binary + +# Profile runtime +perf stat ./target/release/my_binary +``` + +## See Also + +- [opt-lto-release](./opt-lto-release.md) - Link-time optimization +- [opt-pgo-profile](./opt-pgo-profile.md) - Profile-guided optimization +- [opt-target-cpu](./opt-target-cpu.md) - CPU-specific optimization diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cold-unlikely.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cold-unlikely.md new file mode 100644 index 00000000..85128e7b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cold-unlikely.md @@ -0,0 +1,152 @@ +# opt-cold-unlikely + +> Mark unlikely code paths with `#[cold]` to help compiler optimization + +## Why It Matters + +The `#[cold]` attribute tells the compiler that a function is rarely called. The compiler uses this to optimize code layout—keeping cold code away from hot code improves instruction cache utilization. Combined with branch layout optimization, this can measurably improve performance. + +## Bad + +```rust +// All branches treated equally +fn validate(input: &str) -> Result { + if input.is_empty() { + return Err(ValidationError::Empty); // Rare + } + + if input.len() > 1000 { + return Err(ValidationError::TooLong); // Rare + } + + if !input.is_ascii() { + return Err(ValidationError::NonAscii); // Rare + } + + // This is the common case + Ok(parse_data(input)) +} +``` + +## Good + +```rust +fn validate(input: &str) -> Result { + if input.is_empty() { + return cold_empty_error(); + } + + if input.len() > 1000 { + return cold_too_long_error(); + } + + if !input.is_ascii() { + return cold_non_ascii_error(); + } + + Ok(parse_data(input)) +} + +#[cold] +fn cold_empty_error() -> Result { + Err(ValidationError::Empty) +} + +#[cold] +fn cold_too_long_error() -> Result { + Err(ValidationError::TooLong) +} + +#[cold] +fn cold_non_ascii_error() -> Result { + Err(ValidationError::NonAscii) +} +``` + +## What #[cold] Does + +1. **Code placement**: Cold functions are placed in separate code sections, away from hot code +2. **Branch prediction**: Compiler generates branch hints favoring the non-cold path +3. **Inlining decisions**: Cold functions are not inlined into hot paths +4. **Optimization budget**: Compiler spends less effort optimizing cold code + +## Common Cold Patterns + +```rust +// Error handling +#[cold] +fn handle_error(e: E) -> ! { + eprintln!("Fatal error: {}", e); + std::process::exit(1); +} + +// Logging rare events +#[cold] +fn log_rare_event(event: &Event) { + log::warn!("Rare event occurred: {:?}", event); +} + +// Fallback paths +#[cold] +fn slow_fallback(data: &Data) -> Output { + // This path should rarely be taken + compute_slowly(data) +} + +// Panic handlers +#[cold] +fn panic_invalid_state(state: &State) -> ! { + panic!("Invalid state: {:?}", state); +} +``` + +## Assertions and Invariants + +```rust +fn get_unchecked(&self, index: usize) -> &T { + if index >= self.len { + cold_bounds_panic(index, self.len); + } + unsafe { &*self.ptr.add(index) } +} + +#[cold] +#[inline(never)] +fn cold_bounds_panic(index: usize, len: usize) -> ! { + panic!("index out of bounds: the len is {} but the index is {}", len, index); +} +``` + +## Combining with #[inline(never)] + +```rust +// Usually combine both for maximum effect +#[cold] +#[inline(never)] +fn error_path() -> Error { + // Complex error construction stays out of hot code + Error { + backtrace: Backtrace::capture(), + context: gather_context(), + } +} +``` + +## Measuring Impact + +```rust +// Check code layout with objdump +// objdump -d target/release/binary | less + +// Look for .cold sections +// nm target/release/binary | grep cold + +// Profile to verify improvement +// perf stat -e cache-misses,cache-references ./binary +``` + +## See Also + +- [opt-inline-never-cold](./opt-inline-never-cold.md) - Combining with inline(never) +- [opt-likely-hint](./opt-likely-hint.md) - Branch prediction hints +- [err-result-over-panic](./err-result-over-panic.md) - Error handling diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-always-rare.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-always-rare.md new file mode 100644 index 00000000..981cc06b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-always-rare.md @@ -0,0 +1,141 @@ +# opt-inline-always-rare + +> Use `#[inline(always)]` sparingly—only for critical hot paths proven by profiling + +## Why It Matters + +`#[inline(always)]` forces the compiler to inline a function regardless of heuristics. Overuse increases binary size, hurts instruction cache, and can slow down code. The compiler is usually smarter about inlining than humans. Reserve this for measured hot paths where benchmarks prove a benefit. + +## Bad + +```rust +// Annotating everything - trusting intuition over data +#[inline(always)] +pub fn get_name(&self) -> &str { + &self.name +} + +#[inline(always)] +pub fn calculate_tax(amount: f64) -> f64 { + amount * 0.1 +} + +#[inline(always)] +fn helper(x: i32) -> i32 { + x + 1 +} + +// Result: bloated binary, poor cache utilization +``` + +## Good + +```rust +// Let compiler decide for most functions +pub fn get_name(&self) -> &str { + &self.name +} + +pub fn calculate_tax(amount: f64) -> f64 { + amount * 0.1 +} + +// Only force inline for proven hot paths +impl Hasher for MyHasher { + // Hasher::write is called millions of times in tight loops + // Profiling showed 15% improvement from forced inlining + #[inline(always)] + fn write(&mut self, bytes: &[u8]) { + // Very small, very hot + self.state = self.state.wrapping_add(bytes.len() as u64); + } +} +``` + +## When #[inline(always)] Helps + +```rust +// ✅ Tiny functions in hot inner loops +#[inline(always)] +fn fast_hash(a: u64, b: u64) -> u64 { + a.wrapping_mul(b).wrapping_add(a) +} + +// ✅ Generic functions that benefit from monomorphization +#[inline(always)] +fn swap(a: &mut T, b: &mut T) { + std::mem::swap(a, b); +} + +// ✅ Iterator adapters and closures +#[inline(always)] +fn apply T>(f: F, x: T) -> T { + f(x) +} + +// ✅ SIMD/vectorization helpers +#[inline(always)] +fn add_simd(a: &[f32], b: &[f32], out: &mut [f32]) { + // ... +} +``` + +## Inline Variants + +```rust +// #[inline] - hint to inline, compiler may ignore +#[inline] +fn suggested_inline(x: i32) -> i32 { x + 1 } + +// #[inline(always)] - force inline (almost always) +#[inline(always)] +fn force_inline(x: i32) -> i32 { x + 1 } + +// #[inline(never)] - prevent inlining (for profiling, code size) +#[inline(never)] +fn no_inline(x: i32) -> i32 { x + 1 } + +// No annotation - compiler decides based on heuristics +fn compiler_decides(x: i32) -> i32 { x + 1 } +``` + +## Measuring Inline Impact + +```rust +// Use criterion to benchmark +use criterion::{criterion_group, criterion_main, Criterion}; + +fn bench_with_inline(c: &mut Criterion) { + c.bench_function("hot_path_inline", |b| { + b.iter(|| hot_loop()) + }); +} + +// Compare binary sizes +// cargo bloat --release --crates + +// Check if function was inlined +// cargo asm --rust my_crate::hot_function +``` + +## Generic Functions + +```rust +// Generic functions across crate boundaries often need #[inline] +// Because the generic code is compiled in the calling crate + +// In library crate: +#[inline] // Allow inlining in downstream crates +pub fn generic_function(x: T) { + println!("{}", x); +} + +// Without #[inline], the generic function can't be inlined +// across crate boundaries even if beneficial +``` + +## See Also + +- [opt-inline-small](./opt-inline-small.md) - Regular inline for small functions +- [opt-inline-never-cold](./opt-inline-never-cold.md) - Preventing inlining +- [perf-profile-first](./perf-profile-first.md) - Profile before optimizing diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-never-cold.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-never-cold.md new file mode 100644 index 00000000..64a564b2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-never-cold.md @@ -0,0 +1,181 @@ +# opt-inline-never-cold + +> Use `#[inline(never)]` and `#[cold]` for error paths and rarely-executed code + +## Why It Matters + +Inlining error handling code into hot paths wastes instruction cache space and can prevent other optimizations. `#[inline(never)]` keeps cold code out of the hot path. `#[cold]` tells the compiler this branch is unlikely, enabling better branch prediction hints and code layout. + +## Bad + +```rust +fn process_data(data: &[u8]) -> Result { + if data.is_empty() { + // Error path inlined into hot function + return Err(Error::Empty { + context: format!("Expected data, got empty slice"), + suggestions: vec!["Check input", "Validate before calling"], + }); + } + + // Hot path - now polluted with error construction code + do_processing(data) +} +``` + +## Good + +```rust +fn process_data(data: &[u8]) -> Result { + if data.is_empty() { + return Err(empty_data_error()); // Cold path stays small + } + + do_processing(data) +} + +#[cold] +#[inline(never)] +fn empty_data_error() -> Error { + Error::Empty { + context: format!("Expected data, got empty slice"), + suggestions: vec!["Check input", "Validate before calling"], + } +} +``` + +## #[cold] for Unlikely Branches + +```rust +fn parse_value(input: &str) -> Result { + match input.parse() { + Ok(n) => Ok(n), + Err(e) => cold_parse_error(input, e), + } +} + +#[cold] +fn cold_parse_error(input: &str, e: std::num::ParseIntError) -> Result { + Err(ParseError { + input: input.to_string(), + source: e, + }) +} +``` + +## Panic Paths + +```rust +fn get_index(&self, idx: usize) -> &T { + if idx >= self.len { + cold_out_of_bounds(idx, self.len); + } + unsafe { self.ptr.add(idx).as_ref().unwrap() } +} + +#[cold] +#[inline(never)] +fn cold_out_of_bounds(idx: usize, len: usize) -> ! { + panic!("index {} out of bounds for length {}", idx, len); +} +``` + +## Error Construction Functions + +```rust +// Keep error construction out of hot path +impl MyError { + #[cold] + pub fn io_error(source: std::io::Error, path: &Path) -> Self { + MyError::Io { + source, + path: path.to_path_buf(), + context: get_context(), + } + } + + #[cold] + pub fn validation_error(msg: &str, field: &str) -> Self { + MyError::Validation { + message: msg.to_string(), + field: field.to_string(), + } + } +} + +fn read_config(path: &Path) -> Result { + std::fs::read_to_string(path) + .map_err(|e| MyError::io_error(e, path))? + .parse() + .map_err(|e| MyError::parse_error(e)) +} +``` + +## likely/unlikely Hints + +```rust +// Nightly: intrinsics for branch hints +#![feature(core_intrinsics)] +use std::intrinsics::{likely, unlikely}; + +fn process(data: Option<&Data>) -> Result { + if unlikely(data.is_none()) { + return cold_none_error(); + } + + let data = data.unwrap(); + + if likely(data.is_valid()) { + fast_process(data) + } else { + slow_validate_and_process(data) + } +} + +// Stable alternative: structure code so hot path is "fall through" +fn process(data: Option<&Data>) -> Result { + let data = match data { + Some(d) => d, + None => return cold_none_error(), // Early return = unlikely hint + }; + + // Compiler assumes code after early returns is "hot" + fast_process(data) +} +``` + +## Pattern: Extract Cold Code + +```rust +// Before: cold code inline +fn hot_function(x: i32) -> i32 { + if x < 0 { + log::error!("Negative value: {}", x); + eprintln!("Debug info: {:?}", std::backtrace::Backtrace::capture()); + return 0; + } + x * 2 +} + +// After: cold code extracted +fn hot_function(x: i32) -> i32 { + if x < 0 { + return handle_negative(x); + } + x * 2 +} + +#[cold] +#[inline(never)] +fn handle_negative(x: i32) -> i32 { + log::error!("Negative value: {}", x); + eprintln!("Debug info: {:?}", std::backtrace::Backtrace::capture()); + 0 +} +``` + +## See Also + +- [opt-inline-small](./opt-inline-small.md) - Inlining for hot code +- [opt-inline-always-rare](./opt-inline-always-rare.md) - Forced inlining +- [err-result-over-panic](./err-result-over-panic.md) - Error handling patterns diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-small.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-small.md new file mode 100644 index 00000000..2a22acf4 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-small.md @@ -0,0 +1,160 @@ +# opt-inline-small + +> Use `#[inline]` for small hot functions + +## Why It Matters + +Function call overhead (stack frame setup, register saves, jumps) can dominate small functions. Inlining eliminates this overhead and enables further optimizations by the compiler. The compiler often inlines automatically, but hints help for cross-crate calls. + +## Bad + +```rust +// Small hot function without inline hint +// May not be inlined across crate boundaries +fn is_ascii_digit(b: u8) -> bool { + b >= b'0' && b <= b'9' +} + +// Called millions of times +for byte in data { + if is_ascii_digit(*byte) { // Function call overhead + count += 1; + } +} +``` + +## Good + +```rust +#[inline] +fn is_ascii_digit(b: u8) -> bool { + b >= b'0' && b <= b'9' +} + +// Now the compiler will inline this +for byte in data { + if is_ascii_digit(*byte) { // Inlined, no call overhead + count += 1; + } +} +``` + +## Inline Attributes + +```rust +// No attribute - compiler decides (usually good for same-crate) +fn auto_decide() { } + +// Suggest inlining - helps cross-crate +#[inline] +fn suggest_inline() { } + +// Strongly suggest inlining - almost always inlined +#[inline(always)] +fn force_inline() { } + +// Strongly suggest NOT inlining - for large/cold code +#[inline(never)] +fn prevent_inline() { } +``` + +## When to Use Each + +```rust +// #[inline] - Small functions, especially in libraries +#[inline] +pub fn len(&self) -> usize { + self.inner.len() +} + +// #[inline(always)] - Critical hot path, verified by profiling +#[inline(always)] +fn hot_inner_loop_helper(x: u32) -> u32 { + x.wrapping_mul(0x9E3779B9) +} + +// #[inline(never)] - Error handlers, cold paths +#[inline(never)] +fn handle_error(err: Error) -> ! { + eprintln!("Fatal: {}", err); + std::process::exit(1); +} + +// No attribute - large functions, infrequent calls +fn complex_processing(data: &mut Data) { + // Many lines of code... +} +``` + +## Evidence from ripgrep + +```rust +// https://github.com/BurntSushi/ripgrep/blob/master/crates/printer/src/standard.rs + +#[inline(always)] +fn write_prelude( + &self, + absolute_byte_offset: u64, + line_number: Option, + column: Option, +) -> io::Result<()> { + // Hot path in printing matches +} + +#[inline(always)] +fn write_line(&self, line: &[u8]) -> io::Result<()> { + // Called for every line +} +``` + +## Generic Functions + +```rust +// Generic functions are already candidates for per-monomorphization inlining +// But #[inline] helps ensure it across crates + +#[inline] +pub fn min(a: T, b: T) -> T { + if a < b { a } else { b } +} +``` + +## Cautions + +```rust +// DON'T inline large functions - hurts instruction cache +#[inline(always)] // BAD for large function +fn large_complex_function(data: &mut [u8]) { + // 100+ lines of code + // Inlining bloats every call site +} + +// DON'T assume inlining always helps - measure! +// Sometimes the compiler makes better decisions + +// Inlining is non-transitive +#[inline] +fn outer() { + inner(); // inner() also needs #[inline] to be inlined together +} + +fn inner() { } // Won't be inlined at outer's call sites +``` + +## Verifying Inlining + +```bash +# Check if function was inlined using Cachegrind +# Non-inlined functions show entry/exit counts + +# Or examine assembly +cargo rustc --release -- --emit=asm +# Look for call instructions vs inlined code +``` + +## See Also + +- [opt-inline-always-rare](opt-inline-always-rare.md) - Use #[inline(always)] sparingly +- [opt-inline-never-cold](opt-inline-never-cold.md) - Use #[inline(never)] for cold paths +- [opt-cold-unlikely](opt-cold-unlikely.md) - Use #[cold] for unlikely paths +- [opt-lto-release](opt-lto-release.md) - LTO enables cross-crate inlining diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-likely-hint.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-likely-hint.md new file mode 100644 index 00000000..7a97fff2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-likely-hint.md @@ -0,0 +1,171 @@ +# opt-likely-hint + +> Use code structure to hint at likely branches; use intrinsics on nightly + +## Why It Matters + +Modern CPUs predict branches to speculatively execute code. Mispredictions cause pipeline stalls (10-20 cycles). Helping the compiler understand which branches are likely allows it to generate optimal code layout and branch hints, improving performance in hot paths. + +## Stable Rust: Code Structure Hints + +```rust +// Pattern 1: Early returns for unlikely cases +fn process(data: Option<&Data>) -> i32 { + // Compiler assumes early return is "unlikely" + let data = match data { + None => return 0, // Unlikely + Some(d) => d, + }; + + // Hot path continues here + complex_processing(data) +} + +// Pattern 2: if-else ordering +fn calculate(x: i32) -> i32 { + if x >= 0 { + // Put likely case in "if" branch + x * 2 + } else { + // Unlikely case in "else" + handle_negative(x) + } +} + +// Pattern 3: Cold function extraction +fn hot_path(data: &[u8]) -> Result<(), Error> { + if data.is_empty() { + return cold_empty_error(); // Extracted = unlikely + } + + process_fast(data) +} + +#[cold] +fn cold_empty_error() -> Result<(), Error> { + Err(Error::EmptyInput) +} +``` + +## Nightly: Intrinsics + +```rust +#![feature(core_intrinsics)] +use std::intrinsics::{likely, unlikely}; + +fn process(data: &Data) -> i32 { + if unlikely(data.is_corrupted()) { + return handle_corruption(data); + } + + if likely(data.is_cached()) { + return fast_cached_path(data); + } + + slow_uncached_path(data) +} +``` + +## Boolean Likely Wrapper (Nightly) + +```rust +#![feature(core_intrinsics)] + +#[inline(always)] +fn likely(b: bool) -> bool { + std::intrinsics::likely(b) +} + +#[inline(always)] +fn unlikely(b: bool) -> bool { + std::intrinsics::unlikely(b) +} + +// Usage +if likely(x > 0) { + hot_path(x) +} else { + cold_path(x) +} +``` + +## Stable: likely-stable Crate + +```rust +use likely_stable::{likely, unlikely}; + +fn check(value: i32) -> bool { + if unlikely(value < 0) { + handle_negative() + } else if likely(value < 1000) { + handle_common() + } else { + handle_large() + } +} +``` + +## Loop Optimization + +```rust +fn search(data: &[i32], target: i32) -> Option { + for (i, &item) in data.iter().enumerate() { + // Assume most iterations DON'T find the target + if unlikely(item == target) { + return Some(i); + } + } + None +} + +// Alternative: structure for likely case +fn search_common(data: &[i32], target: i32) -> Option { + // If target is usually found + for (i, &item) in data.iter().enumerate() { + if likely(item == target) { + return Some(i); + } + } + None +} +``` + +## Match Arm Ordering + +```rust +// Put most common variants first +fn process_message(msg: Message) { + match msg { + // Most common - listed first + Message::Data(d) => handle_data(d), + Message::Heartbeat => (), // Second most common + + // Rare cases last + Message::Error(e) => handle_error(e), + Message::Shutdown => shutdown(), + } +} +``` + +## Benchmark-Driven Hints + +```rust +// Profile first to know which branches are actually likely! +fn speculative(x: i32) -> i32 { + // DON'T GUESS - measure with profiling + // perf record / perf report + // cargo flamegraph + + if x > threshold { // Is this actually common? + path_a(x) + } else { + path_b(x) + } +} +``` + +## See Also + +- [opt-cold-unlikely](./opt-cold-unlikely.md) - #[cold] for unlikely functions +- [opt-inline-never-cold](./opt-inline-never-cold.md) - Keeping cold code separate +- [perf-profile-first](./perf-profile-first.md) - Profile to know what's likely diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-lto-release.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-lto-release.md new file mode 100644 index 00000000..0fe11191 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-lto-release.md @@ -0,0 +1,130 @@ +# opt-lto-release + +> Enable LTO in release builds + +## Why It Matters + +Link-Time Optimization (LTO) enables optimizations across crate boundaries that aren't possible during normal compilation. This includes cross-crate inlining, dead code elimination, and devirtualization. Typically provides 5-20% performance improvement. + +## Bad + +```toml +# Cargo.toml - default release profile +[profile.release] +opt-level = 3 +# No LTO = missed optimization opportunities +``` + +## Good + +```toml +# Cargo.toml - optimized release profile +[profile.release] +opt-level = 3 +lto = "fat" # Maximum optimization +codegen-units = 1 # Better optimization (single codegen unit) +panic = "abort" # Smaller binary, no unwind tables +strip = true # Remove symbols for smaller binary +``` + +## LTO Options Explained + +```toml +# No LTO (default) +lto = false + +# Thin LTO - fast compilation, most benefits +lto = "thin" + +# Fat LTO - slowest compilation, maximum optimization +lto = "fat" +# Equivalent to: +lto = true + +# Thin-local - LTO within each crate only +lto = "off" +``` + +## Trade-offs + +| Setting | Compile Time | Binary Size | Performance | +|---------|--------------|-------------|-------------| +| `lto = false` | Fast | Larger | Baseline | +| `lto = "thin"` | Medium | Smaller | +5-15% | +| `lto = "fat"` | Slow | Smallest | +10-20% | + +## Evidence from Production + +```toml +# From Anchor (Solana framework) +# https://github.com/solana-foundation/anchor/blob/master/cli/src/rust_template.rs +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 + +# From sol-trade-sdk +# https://github.com/0xfnzero/sol-trade-sdk +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +``` + +## Complete Optimized Profile + +```toml +[profile.release] +opt-level = 3 # Maximum optimization +lto = "fat" # Link-time optimization +codegen-units = 1 # Single codegen unit for better optimization +panic = "abort" # Remove panic unwinding code +strip = true # Strip symbols +debug = false # No debug info + +# For benchmarking (need some debug info for profiling) +[profile.bench] +inherits = "release" +debug = true +strip = false + +# Fast dev builds with optimized dependencies +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies even in dev +``` + +## When to Use Each + +| Situation | LTO Setting | +|-----------|-------------| +| Development | `false` (fast compiles) | +| CI builds | `"thin"` (balance) | +| Release binaries | `"fat"` (max perf) | +| Libraries (crates.io) | `false` (users choose) | + +## Measuring Impact + +```bash +# Build without LTO +cargo build --release +hyperfine ./target/release/myapp + +# Build with LTO +# (after adding lto = "fat" to Cargo.toml) +cargo build --release +hyperfine ./target/release/myapp + +# Compare binary sizes +ls -la target/release/myapp +``` + +## See Also + +- [opt-codegen-units](opt-codegen-units.md) - Use codegen-units = 1 +- [opt-pgo-profile](opt-pgo-profile.md) - Profile-guided optimization +- [perf-release-profile](perf-release-profile.md) - Full release profile settings diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-pgo-profile.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-pgo-profile.md new file mode 100644 index 00000000..b8001ca1 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-pgo-profile.md @@ -0,0 +1,167 @@ +# opt-pgo-profile + +> Use Profile-Guided Optimization (PGO) for maximum performance + +## Why It Matters + +PGO uses real runtime behavior to guide compiler optimization decisions. By profiling actual workloads, the compiler learns which code paths are hot, optimizing them aggressively while deprioritizing cold paths. This can yield 10-30% performance improvements beyond standard optimizations. + +## The PGO Process + +1. **Instrument**: Build with profiling instrumentation +2. **Profile**: Run representative workloads +3. **Optimize**: Rebuild using collected profile data + +## Step-by-Step + +```bash +# Step 1: Build instrumented binary +RUSTFLAGS="-Cprofile-generate=/tmp/pgo-data" \ + cargo build --release + +# Step 2: Run representative workloads +./target/release/my_app < test_data_1.txt +./target/release/my_app < test_data_2.txt +./target/release/my_app < typical_workload.txt + +# Step 3: Merge profile data +llvm-profdata merge -o /tmp/pgo-data/merged.profdata /tmp/pgo-data + +# Step 4: Build optimized binary using profile +RUSTFLAGS="-Cprofile-use=/tmp/pgo-data/merged.profdata" \ + cargo build --release +``` + +## Cargo Configuration + +```toml +# Cargo.toml +[profile.release] +lto = "fat" +codegen-units = 1 +opt-level = 3 + +# PGO flags set via RUSTFLAGS environment variable +``` + +## Build Script + +```bash +#!/bin/bash +set -e + +PGO_DIR=/tmp/pgo-$(date +%s) + +# Clean +cargo clean + +# Instrumented build +echo "Building instrumented binary..." +RUSTFLAGS="-Cprofile-generate=$PGO_DIR" cargo build --release + +# Run workloads +echo "Collecting profile data..." +./target/release/my_app --benchmark-mode +./target/release/my_app < test_fixtures/typical.txt +./target/release/my_app < test_fixtures/stress.txt + +# Merge profiles +echo "Merging profile data..." +llvm-profdata merge -o $PGO_DIR/merged.profdata $PGO_DIR + +# Optimized build +echo "Building optimized binary..." +RUSTFLAGS="-Cprofile-use=$PGO_DIR/merged.profdata" cargo build --release + +echo "Done! Optimized binary at target/release/my_app" +``` + +## Representative Workloads + +```rust +// Create benchmarks that match real usage patterns + +// Good: actual data samples +fn profile_workload() { + for file in real_customer_data_samples() { + process_file(&file); + } +} + +// Good: synthetic but realistic +fn profile_synthetic() { + for _ in 0..10000 { + let data = generate_realistic_data(); + process(&data); + } +} + +// Bad: artificial microbenchmarks +fn profile_bad() { + for _ in 0..1000000 { + small_operation(); // Doesn't reflect real hot paths + } +} +``` + +## BOLT Post-Link Optimization + +For even more gains, combine PGO with BOLT: + +```bash +# After PGO build, apply BOLT +llvm-bolt target/release/my_app \ + -o target/release/my_app.bolt \ + -data=perf.data \ + -reorder-blocks=ext-tsp \ + -reorder-functions=hfsort + +# BOLT can add another 5-15% on top of PGO +``` + +## CI/CD Integration + +```yaml +# GitHub Actions example +jobs: + pgo-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install LLVM tools + run: sudo apt-get install llvm + + - name: Instrumented build + run: RUSTFLAGS="-Cprofile-generate=/tmp/pgo" cargo build --release + + - name: Run profiling workloads + run: ./scripts/run_profiling_workloads.sh + + - name: Merge profiles + run: llvm-profdata merge -o /tmp/pgo/merged.profdata /tmp/pgo + + - name: Optimized build + run: RUSTFLAGS="-Cprofile-use=/tmp/pgo/merged.profdata" cargo build --release + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: optimized-binary + path: target/release/my_app +``` + +## When to Use PGO + +| Use PGO | Skip PGO | +|---------|----------| +| Production deployments | Development builds | +| Performance-critical apps | Libraries (users can PGO) | +| Stable workload patterns | Highly variable workloads | +| Sufficient profiling data | Quick iteration cycles | + +## See Also + +- [opt-lto-release](./opt-lto-release.md) - LTO works well with PGO +- [opt-codegen-units](./opt-codegen-units.md) - Single codegen unit for PGO +- [perf-profile-first](./perf-profile-first.md) - Profiling basics diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-simd-portable.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-simd-portable.md new file mode 100644 index 00000000..a5920a58 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-simd-portable.md @@ -0,0 +1,144 @@ +# opt-simd-portable + +> Use portable SIMD for vectorized operations across architectures + +## Why It Matters + +SIMD (Single Instruction, Multiple Data) processes multiple values per instruction—4x, 8x, or more speedup for suitable algorithms. Rust's portable SIMD (nightly) and crates like `wide` provide cross-platform vectorization without architecture-specific intrinsics. For stable Rust, let LLVM auto-vectorize or use platform-specific crates. + +## Autovectorization (Stable) + +```rust +// LLVM often vectorizes simple patterns automatically +fn sum(data: &[f32]) -> f32 { + data.iter().sum() // May vectorize to SIMD +} + +fn add_arrays(a: &[f32], b: &[f32], out: &mut [f32]) { + for ((x, y), o) in a.iter().zip(b).zip(out.iter_mut()) { + *o = x + y; // Often vectorizes + } +} + +// Help autovectorization: +// 1. Use iterators over indexing +// 2. Avoid early exits in loops +// 3. Use chunks_exact for aligned access +``` + +## Portable SIMD (Nightly) + +```rust +#![feature(portable_simd)] +use std::simd::*; + +fn sum_simd(data: &[f32]) -> f32 { + let (prefix, middle, suffix) = data.as_simd::<8>(); + + // Handle unaligned prefix + let mut sum = prefix.iter().sum::(); + + // SIMD loop - 8 floats at a time + let mut simd_sum = f32x8::splat(0.0); + for chunk in middle { + simd_sum += *chunk; + } + sum += simd_sum.reduce_sum(); + + // Handle unaligned suffix + sum += suffix.iter().sum::(); + + sum +} + +fn dot_product(a: &[f32], b: &[f32]) -> f32 { + assert_eq!(a.len(), b.len()); + + let (a_pre, a_mid, a_suf) = a.as_simd::<8>(); + let (b_pre, b_mid, b_suf) = b.as_simd::<8>(); + + let scalar: f32 = a_pre.iter().zip(b_pre).map(|(x, y)| x * y).sum(); + + let mut simd_sum = f32x8::splat(0.0); + for (av, bv) in a_mid.iter().zip(b_mid) { + simd_sum += *av * *bv; + } + + let suffix: f32 = a_suf.iter().zip(b_suf).map(|(x, y)| x * y).sum(); + + scalar + simd_sum.reduce_sum() + suffix +} +``` + +## wide Crate (Stable) + +```rust +use wide::*; + +fn process_simd(data: &mut [f32]) { + // Process 8 floats at a time + for chunk in data.chunks_exact_mut(8) { + let v = f32x8::from(chunk); + let result = v * f32x8::splat(2.0) + f32x8::splat(1.0); + chunk.copy_from_slice(&result.to_array()); + } +} + +fn blend_images(a: &[u8], b: &[u8], alpha: f32, out: &mut [u8]) { + let alpha_v = f32x8::splat(alpha); + let one_minus = f32x8::splat(1.0 - alpha); + + for ((a_chunk, b_chunk), out_chunk) in + a.chunks_exact(8).zip(b.chunks_exact(8)).zip(out.chunks_exact_mut(8)) + { + let av = f32x8::from([ + a_chunk[0] as f32, a_chunk[1] as f32, /* ... */ + ]); + let bv = f32x8::from([ + b_chunk[0] as f32, b_chunk[1] as f32, /* ... */ + ]); + + let result = av * one_minus + bv * alpha_v; + // Convert back to u8... + } +} +``` + +## Platform-Specific (When Needed) + +```rust +#[cfg(target_arch = "x86_64")] +use std::arch::x86_64::*; + +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +unsafe fn sum_avx2(data: &[f32]) -> f32 { + let mut sum = _mm256_setzero_ps(); + + for chunk in data.chunks_exact(8) { + let v = _mm256_loadu_ps(chunk.as_ptr()); + sum = _mm256_add_ps(sum, v); + } + + // Horizontal sum + let high = _mm256_extractf128_ps(sum, 1); + let low = _mm256_castps256_ps128(sum); + let sum128 = _mm_add_ps(high, low); + // ... continue reduction +} +``` + +## Choosing an Approach + +| Approach | Stability | Portability | Control | +|----------|-----------|-------------|---------| +| Autovectorization | Stable | Excellent | Low | +| `wide` crate | Stable | Good | Medium | +| Portable SIMD | Nightly | Excellent | High | +| Intrinsics | Stable | None | Maximum | + +## See Also + +- [opt-target-cpu](./opt-target-cpu.md) - Enable SIMD features +- [opt-bounds-check](./opt-bounds-check.md) - Unchecked access for SIMD +- [perf-profile-first](./perf-profile-first.md) - Identify vectorization opportunities diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-target-cpu.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-target-cpu.md new file mode 100644 index 00000000..bfce8294 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-target-cpu.md @@ -0,0 +1,154 @@ +# opt-target-cpu + +> Use `target-cpu=native` for maximum performance on known deployment targets + +## Why It Matters + +By default, Rust compiles for a generic x86-64 baseline (roughly Sandy Bridge era). Modern CPUs have SIMD extensions (AVX2, AVX-512), improved instructions, and micro-architectural optimizations that go unused. `target-cpu=native` enables all features of your current CPU, potentially unlocking significant speedups. + +## Bad + +```toml +# Cargo.toml - compiles for generic x86-64 +[profile.release] +# No target-cpu specified +# Binary works everywhere but uses only SSE2 +``` + +## Good + +```toml +# .cargo/config.toml - for known deployment target +[build] +rustflags = ["-C", "target-cpu=native"] + +# Or specific CPU for cross-compilation +# rustflags = ["-C", "target-cpu=skylake"] +``` + +## Via Environment + +```bash +# Build with native optimizations +RUSTFLAGS="-C target-cpu=native" cargo build --release + +# Check what features are enabled +rustc --print cfg -C target-cpu=native | grep target_feature +``` + +## Common Target CPUs + +```bash +# x86-64 targets +target-cpu=native # Current machine +target-cpu=x86-64 # Baseline (SSE2) +target-cpu=x86-64-v2 # SSE4.2, POPCNT +target-cpu=x86-64-v3 # AVX2, BMI2 +target-cpu=x86-64-v4 # AVX-512 + +# Intel specific +target-cpu=skylake # 6th gen Core +target-cpu=alderlake # 12th gen Core + +# AMD specific +target-cpu=znver3 # Zen 3 +target-cpu=znver4 # Zen 4 + +# ARM +target-cpu=apple-m1 # Apple Silicon +target-cpu=neoverse-n1 # AWS Graviton2 +``` + +## Feature Detection at Runtime + +```rust +// For portable binaries that use native features when available +#[cfg(target_arch = "x86_64")] +fn process_fast(data: &[u8]) -> u64 { + if is_x86_feature_detected!("avx2") { + unsafe { process_avx2(data) } + } else if is_x86_feature_detected!("sse4.2") { + unsafe { process_sse42(data) } + } else { + process_generic(data) + } +} + +#[target_feature(enable = "avx2")] +unsafe fn process_avx2(data: &[u8]) -> u64 { + // AVX2 optimized implementation +} +``` + +## Multi-Architecture Builds + +```bash +# Build multiple binaries +RUSTFLAGS="-C target-cpu=x86-64" cargo build --release +mv target/release/app target/release/app-generic + +RUSTFLAGS="-C target-cpu=x86-64-v3" cargo build --release +mv target/release/app target/release/app-avx2 + +# Select at runtime +if supports_avx2; then + ./app-avx2 +else + ./app-generic +fi +``` + +## Cargo Configuration + +```toml +# .cargo/config.toml + +# Native builds for development +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=native"] + +# AWS deployment (Graviton2) +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=neoverse-n1"] + +# Intel server deployment +[target.x86_64-unknown-linux-gnu.deployment] +rustflags = ["-C", "target-cpu=skylake-avx512"] +``` + +## What Changes + +```rust +// With AVX2 enabled: +// - 256-bit SIMD operations +// - Better autovectorization +// - FMA (fused multiply-add) +// - BMI (bit manipulation) + +// Example: sum of squares +fn sum_squares(data: &[f64]) -> f64 { + data.iter().map(|x| x * x).sum() +} +// Generic: scalar loop +// AVX2: processes 4 f64s per iteration +``` + +## Checking Enabled Features + +```bash +# What's enabled for native? +rustc --print cfg -C target-cpu=native | grep feature + +# Compare generic vs native +rustc --print cfg -C target-cpu=x86-64 | grep feature +rustc --print cfg -C target-cpu=native | grep feature + +# View generated assembly +cargo asm --rust --release my_crate::hot_function +``` + +## See Also + +- [opt-lto-release](./opt-lto-release.md) - Combine with LTO +- [opt-simd-portable](./opt-simd-portable.md) - Portable SIMD +- [opt-codegen-units](./opt-codegen-units.md) - Single codegen unit diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-arc-shared.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-arc-shared.md new file mode 100644 index 00000000..77cf8e38 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-arc-shared.md @@ -0,0 +1,141 @@ +# own-arc-shared + +> Use `Arc` for thread-safe shared ownership + +## Why It Matters + +`Arc` (Atomic Reference Counted) provides shared ownership across threads. Unlike `Rc`, its reference count is updated atomically, making it safe for concurrent access. Use it when multiple threads need to read the same data. + +## Bad + +```rust +use std::rc::Rc; +use std::thread; + +let data = Rc::new(vec![1, 2, 3]); +let data_clone = Rc::clone(&data); + +// ERROR: Rc cannot be sent between threads safely +thread::spawn(move || { + println!("{:?}", data_clone); +}); +``` + +## Good + +```rust +use std::sync::Arc; +use std::thread; + +let data = Arc::new(vec![1, 2, 3]); +let data_clone = Arc::clone(&data); + +thread::spawn(move || { + println!("{:?}", data_clone); // Safe! +}); + +println!("{:?}", data); // Original still accessible +``` + +## Arc with Mutex for Mutable Shared State + +```rust +use std::sync::{Arc, Mutex}; +use std::thread; + +let counter = Arc::new(Mutex::new(0)); +let mut handles = vec![]; + +for _ in 0..10 { + let counter = Arc::clone(&counter); + let handle = thread::spawn(move || { + let mut num = counter.lock().unwrap(); + *num += 1; + }); + handles.push(handle); +} + +for handle in handles { + handle.join().unwrap(); +} + +println!("Result: {}", *counter.lock().unwrap()); +``` + +## Arc vs Rc Decision Tree + +``` +Need shared ownership? +├── No → Use owned value or references +└── Yes → Will it cross thread boundaries? + ├── No → Use Rc (cheaper, no atomic ops) + └── Yes → Use Arc + └── Need mutation? + ├── No → Arc is enough + └── Yes → Arc> or Arc> +``` + +## Common Patterns + +```rust +use std::sync::Arc; + +// Shared configuration (read-only) +struct AppConfig { + database_url: String, + max_connections: u32, +} + +fn setup_workers(config: Arc) { + for i in 0..4 { + let config = Arc::clone(&config); + std::thread::spawn(move || { + println!("Worker {} using db: {}", i, config.database_url); + }); + } +} + +// Shared cache with interior mutability +use std::sync::RwLock; +use std::collections::HashMap; + +type Cache = Arc>>; + +fn get_cached(cache: &Cache, key: &str) -> Option { + cache.read().unwrap().get(key).cloned() +} + +fn set_cached(cache: &Cache, key: String, value: String) { + cache.write().unwrap().insert(key, value); +} +``` + +## Performance Considerations + +```rust +// Arc::clone is cheap - just increments atomic counter +let a = Arc::new(large_data); +let b = Arc::clone(&a); // Fast! No data copied + +// But atomic operations have overhead vs Rc +// Use Rc in single-threaded contexts for better performance + +// Avoid cloning Arc in hot loops if possible +// Bad: +for item in items { + let arc = Arc::clone(&shared); // Atomic op each iteration + process(arc, item); +} + +// Better: Clone once outside loop if possible +let arc = Arc::clone(&shared); +for item in items { + process(&arc, item); // Pass reference +} +``` + +## See Also + +- [own-rc-single-thread](own-rc-single-thread.md) - Use Rc for single-threaded sharing +- [own-mutex-interior](own-mutex-interior.md) - Use Mutex for interior mutability +- [async-clone-before-await](async-clone-before-await.md) - Clone Arc before await points diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-borrow-over-clone.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-borrow-over-clone.md new file mode 100644 index 00000000..ebe7cb5c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-borrow-over-clone.md @@ -0,0 +1,95 @@ +# own-borrow-over-clone + +> Prefer `&T` borrowing over `.clone()` + +## Why It Matters + +Cloning allocates new memory and copies data, while borrowing is free. Unnecessary clones can significantly impact performance, especially in hot paths or with large data structures. + +## Bad + +```rust +fn process(data: &String) { + let local = data.clone(); // Unnecessary allocation! + println!("{}", local); +} + +fn count_words(text: &String) -> usize { + let owned = text.clone(); // Why clone just to read? + owned.split_whitespace().count() +} + +// Clone in a loop - multiplied cost +fn process_all(items: &[String]) { + for item in items { + let copy = item.clone(); // N allocations! + handle(©); + } +} +``` + +## Good + +```rust +fn process(data: &str) { // Accept &str, more flexible + println!("{}", data); // No allocation needed +} + +fn count_words(text: &str) -> usize { + text.split_whitespace().count() // Just borrow +} + +// Borrow in a loop - zero allocations +fn process_all(items: &[String]) { + for item in items { + handle(item); // Pass reference + } +} +``` + +## When Clone Is Acceptable + +```rust +// 1. Need owned data for storage +struct Cache { + data: HashMap, +} + +impl Cache { + fn insert(&mut self, key: &str, value: &str) { + // Clone needed - we're storing owned data + self.data.insert(key.to_string(), value.to_string()); + } +} + +// 2. Need to send across threads +fn spawn_worker(data: &Config) { + let owned = data.clone(); // Clone needed for 'static + std::thread::spawn(move || { + use_config(owned); + }); +} + +// 3. Copy types (no heap allocation) +let x: i32 = 42; +let y = x; // Copy, not clone - this is fine +``` + +## Evidence + +From ripgrep's codebase - uses `Cow` to avoid clones: +```rust +// https://github.com/BurntSushi/ripgrep/blob/master/crates/globset/src/pathutil.rs +pub(crate) fn file_name<'a>(path: &Cow<'a, [u8]>) -> Option> { + match *path { + Cow::Borrowed(path) => Cow::Borrowed(&path[last_slash..]), + Cow::Owned(ref path) => Cow::Owned(path.clone()), + } +} +``` + +## See Also + +- [own-slice-over-vec](own-slice-over-vec.md) - Accept slices instead of references to collections +- [own-cow-conditional](own-cow-conditional.md) - Use Cow for conditional ownership +- [mem-clone-from](mem-clone-from.md) - Reuse allocations when cloning diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-clone-explicit.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-clone-explicit.md new file mode 100644 index 00000000..a3477309 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-clone-explicit.md @@ -0,0 +1,135 @@ +# own-clone-explicit + +> Use explicit `Clone` for types where copying has meaningful cost + +## Why It Matters + +Unlike `Copy` which is implicit and "free," `Clone` requires an explicit `.clone()` call, signaling that duplication has a cost. This makes heap allocations and deep copies visible in code, helping developers reason about performance. Types with heap data (`String`, `Vec`, `Box`) should implement `Clone` but not `Copy`. + +## Bad + +```rust +// Hiding expensive operations +fn process_data(data: Vec) -> Vec { + let backup = data; // Moved, not copied - but unclear at call site + transform(backup) +} + +let my_data = vec![1, 2, 3, 4, 5]; +let result = process_data(my_data); +// my_data is moved - surprise if you expected it to still exist +``` + +## Good + +```rust +fn process_data(data: Vec) -> Vec { + let backup = data; + transform(backup) +} + +let my_data = vec![1, 2, 3, 4, 5]; +let result = process_data(my_data.clone()); // Explicit: "I know this allocates" +// my_data still available + +// Or better - take reference if you don't need ownership +fn process_data_ref(data: &[u32]) -> Vec { + transform(data) +} +let result = process_data_ref(&my_data); // No clone needed +``` + +## Custom Clone Implementation + +For types with mixed cheap/expensive fields, implement `Clone` manually: + +```rust +#[derive(Debug)] +struct Document { + id: u64, // Cheap to copy + content: String, // Expensive to clone + metadata: Metadata, // Moderate cost +} + +impl Clone for Document { + fn clone(&self) -> Self { + Self { + id: self.id, + content: self.content.clone(), + metadata: self.metadata.clone(), + } + } + + // Optimization: reuse existing allocations + fn clone_from(&mut self, source: &Self) { + self.id = source.id; + self.content.clone_from(&source.content); // Reuses capacity + self.metadata.clone_from(&source.metadata); + } +} +``` + +## clone_from Optimization + +`clone_from` can reuse existing allocations: + +```rust +let mut buffer = String::with_capacity(1000); + +// Bad: drops old allocation, creates new one +buffer = source.clone(); + +// Good: reuses existing capacity if sufficient +buffer.clone_from(&source); +``` + +## Derive vs Manual Clone + +```rust +// Derive when all fields need cloning +#[derive(Clone)] +struct Simple { + data: Vec, + name: String, +} + +// Manual when you need special behavior +struct CachedValue { + value: i32, + cache: RefCell>, +} + +impl Clone for CachedValue { + fn clone(&self) -> Self { + Self { + value: self.value, + cache: RefCell::new(None), // Don't clone cache, let it rebuild + } + } +} +``` + +## When to Avoid Clone + +```rust +// Instead of cloning, consider: + +// 1. References +fn process(data: &MyType) { } // Borrow instead of clone + +// 2. Cow for conditional cloning +fn process(data: Cow<'_, str>) { } // Clone only if mutation needed + +// 3. Arc for shared ownership +let shared = Arc::new(expensive_data); +let handle = shared.clone(); // Cheap: just increments counter + +// 4. Passing by value when caller is done with it +fn consume(data: MyType) { } // Caller moves, no clone +``` + +## See Also + +- [own-copy-small](./own-copy-small.md) - When implicit Copy is appropriate +- [own-cow-conditional](./own-cow-conditional.md) - Avoiding clones with Cow +- [mem-clone-from](./mem-clone-from.md) - Optimizing repeated clones diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-copy-small.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-copy-small.md new file mode 100644 index 00000000..91396302 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-copy-small.md @@ -0,0 +1,124 @@ +# own-copy-small + +> Implement `Copy` for small, simple types + +## Why It Matters + +Types that implement `Copy` are implicitly duplicated on assignment instead of moved. This eliminates the need for explicit `.clone()` calls and makes the code more ergonomic. For small types (generally ≤16 bytes), copying is as fast or faster than moving a pointer. + +## Bad + +```rust +// Small type without Copy - requires explicit clone +#[derive(Clone, Debug)] +struct Point { + x: f64, + y: f64, +} + +fn distance(p1: Point, p2: Point) -> f64 { + ((p2.x - p1.x).powi(2) + (p2.y - p1.y).powi(2)).sqrt() +} + +let origin = Point { x: 0.0, y: 0.0 }; +let target = Point { x: 3.0, y: 4.0 }; + +let d1 = distance(origin.clone(), target.clone()); // Tedious +let d2 = distance(origin.clone(), target.clone()); // Every use needs clone +// origin and target still usable but verbose +``` + +## Good + +```rust +// Small type with Copy - implicit duplication +#[derive(Clone, Copy, Debug)] +struct Point { + x: f64, + y: f64, +} + +fn distance(p1: Point, p2: Point) -> f64 { + ((p2.x - p1.x).powi(2) + (p2.y - p1.y).powi(2)).sqrt() +} + +let origin = Point { x: 0.0, y: 0.0 }; +let target = Point { x: 3.0, y: 4.0 }; + +let d1 = distance(origin, target); // Implicitly copied +let d2 = distance(origin, target); // Still works! +// origin and target remain valid +``` + +## Copy Requirements + +A type can implement `Copy` only if: +1. All fields implement `Copy` +2. No custom `Drop` implementation +3. No heap-allocated data (`String`, `Vec`, `Box`, etc.) + +```rust +// ✅ Can be Copy +#[derive(Clone, Copy)] +struct Color { + r: u8, + g: u8, + b: u8, + a: u8, +} + +// ❌ Cannot be Copy - contains String +#[derive(Clone)] +struct Person { + name: String, // String is not Copy + age: u32, +} + +// ❌ Cannot be Copy - has Drop +struct FileHandle { + fd: i32, +} +impl Drop for FileHandle { + fn drop(&mut self) { /* close file */ } +} +``` + +## Size Guidelines + +| Size | Recommendation | +|------|----------------| +| ≤ 16 bytes | Implement `Copy` | +| 17-64 bytes | Consider `Copy`, benchmark if critical | +| > 64 bytes | Probably don't, prefer references | + +```rust +use std::mem::size_of; + +#[derive(Clone, Copy)] +struct SmallId(u64); // 8 bytes ✅ + +#[derive(Clone, Copy)] +struct Rect { x: f32, y: f32, w: f32, h: f32 } // 16 bytes ✅ + +#[derive(Clone)] // No Copy - 72 bytes +struct Transform { + matrix: [[f64; 3]; 3], // 72 bytes, too large +} +``` + +## Common Copy Types + +Standard library types that are `Copy`: +- All primitives: `i32`, `f64`, `bool`, `char`, etc. +- References: `&T`, `&mut T` +- Raw pointers: `*const T`, `*mut T` +- Function pointers: `fn(T) -> U` +- Tuples of `Copy` types: `(i32, f64)` +- Arrays of `Copy` types: `[u8; 32]` +- `Option` where `T: Copy` +- `PhantomData` + +## See Also + +- [own-clone-explicit](./own-clone-explicit.md) - When Clone without Copy is appropriate +- [type-newtype-ids](./type-newtype-ids.md) - Newtype pattern often uses Copy diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-cow-conditional.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-cow-conditional.md new file mode 100644 index 00000000..0955e291 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-cow-conditional.md @@ -0,0 +1,135 @@ +# own-cow-conditional + +> Use `Cow<'a, T>` for conditional ownership + +## Why It Matters + +`Cow` (Clone-on-Write) lets you avoid allocations when you *might* need to own data but usually don't. It holds either a borrowed reference or an owned value, cloning only when mutation is needed. + +## Bad + +```rust +// Always allocates, even when input doesn't need modification +fn normalize_path(path: &str) -> String { + if path.contains("//") { + path.replace("//", "/") // Allocation needed + } else { + path.to_string() // Unnecessary allocation! + } +} + +// Always clones the error message +fn format_error(code: u32) -> String { + match code { + 404 => "Not Found".to_string(), // Unnecessary! + 500 => "Internal Error".to_string(), // Unnecessary! + _ => format!("Error {}", code), // This one needs allocation + } +} +``` + +## Good + +```rust +use std::borrow::Cow; + +// Only allocates when needed +fn normalize_path(path: &str) -> Cow<'_, str> { + if path.contains("//") { + Cow::Owned(path.replace("//", "/")) // Allocate + } else { + Cow::Borrowed(path) // Zero-cost borrow + } +} + +// Static strings stay borrowed +fn format_error(code: u32) -> Cow<'static, str> { + match code { + 404 => Cow::Borrowed("Not Found"), // No allocation + 500 => Cow::Borrowed("Internal Error"), // No allocation + _ => Cow::Owned(format!("Error {}", code)), // Allocate only for unknown + } +} +``` + +## Real-World Example from ripgrep + +```rust +// https://github.com/BurntSushi/ripgrep/blob/master/crates/globset/src/pathutil.rs +pub(crate) fn file_name<'a>(path: &Cow<'a, [u8]>) -> Option> { + let last_slash = path.rfind_byte(b'/').map(|i| i + 1).unwrap_or(0); + match *path { + Cow::Borrowed(path) => Some(Cow::Borrowed(&path[last_slash..])), + Cow::Owned(ref path) => { + let mut path = path.clone(); + path.drain_bytes(..last_slash); + Some(Cow::Owned(path)) + } + } +} +``` + +## Clone-on-Write Pattern + +```rust +use std::borrow::Cow; + +fn process_text(text: Cow<'_, str>) -> Cow<'_, str> { + if text.contains("bad_word") { + // to_mut() clones if borrowed, returns &mut if owned + let mut owned = text.into_owned(); + owned = owned.replace("bad_word", "***"); + Cow::Owned(owned) + } else { + text // Pass through unchanged + } +} + +// Usage +let borrowed: Cow = Cow::Borrowed("hello world"); +let result = process_text(borrowed); // No allocation! + +let with_bad: Cow = Cow::Borrowed("hello bad_word"); +let result = process_text(with_bad); // Allocates only here +``` + +## Cow with Collections + +```rust +use std::borrow::Cow; + +// Mixed borrowed/owned in a collection +fn collect_errors<'a>( + static_errors: &[&'static str], + dynamic_errors: Vec, +) -> Vec> { + let mut errors: Vec> = Vec::new(); + + // Static strings - no allocation + for &e in static_errors { + errors.push(Cow::Borrowed(e)); + } + + // Dynamic strings - take ownership + for e in dynamic_errors { + errors.push(Cow::Owned(e)); + } + + errors +} +``` + +## When to Use Cow + +| Situation | Use Cow? | +|-----------|----------| +| Usually borrow, sometimes own | Yes | +| Always need owned data | No, just use owned type | +| Always borrow | No, just use reference | +| Hot path, avoiding all allocations | Yes | +| Returning static strings or formatted | Yes | + +## See Also + +- [own-borrow-over-clone](own-borrow-over-clone.md) - Prefer borrowing over cloning +- [mem-avoid-format](mem-avoid-format.md) - Avoid format! when possible diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-lifetime-elision.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-lifetime-elision.md new file mode 100644 index 00000000..ec75d5e7 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-lifetime-elision.md @@ -0,0 +1,134 @@ +# own-lifetime-elision + +> Rely on lifetime elision rules; add explicit lifetimes only when required + +## Why It Matters + +Rust's lifetime elision rules handle most common borrowing patterns automatically. Adding explicit lifetimes where they're not needed clutters code without adding clarity. However, understanding when elision applies helps you know when explicit lifetimes are truly necessary. + +## Bad + +```rust +// Unnecessary explicit lifetimes - elision handles these +fn first_word<'a>(s: &'a str) -> &'a str { + s.split_whitespace().next().unwrap_or("") +} + +fn get_name<'a>(person: &'a Person) -> &'a str { + &person.name +} + +impl<'a> Display for Wrapper<'a> { + fn fmt<'b>(&'b self, f: &'b mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} +``` + +## Good + +```rust +// Let elision do its job +fn first_word(s: &str) -> &str { + s.split_whitespace().next().unwrap_or("") +} + +fn get_name(person: &Person) -> &str { + &person.name +} + +impl Display for Wrapper<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} +``` + +## The Three Elision Rules + +1. **Each input reference gets its own lifetime:** + ```rust + fn foo(x: &str, y: &str) + // becomes + fn foo<'a, 'b>(x: &'a str, y: &'b str) + ``` + +2. **One input reference → output gets same lifetime:** + ```rust + fn foo(x: &str) -> &str + // becomes + fn foo<'a>(x: &'a str) -> &'a str + ``` + +3. **Method with `&self`/`&mut self` → output gets self's lifetime:** + ```rust + fn foo(&self, x: &str) -> &str + // becomes + fn foo<'a, 'b>(&'a self, x: &'b str) -> &'a str + ``` + +## When Explicit Lifetimes ARE Required + +```rust +// Multiple input references, output could come from either +fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { + if x.len() > y.len() { x } else { y } +} + +// Struct holding references +struct Parser<'input> { + source: &'input str, + position: usize, +} + +// Multiple distinct lifetimes needed +struct Context<'s, 'c> { + source: &'s str, + cache: &'c mut Cache, +} + +// Static lifetime for constants +fn get_default() -> &'static str { + "default" +} +``` + +## Anonymous Lifetime `'_` + +Use `'_` to let the compiler infer while being explicit about the presence of a lifetime: + +```rust +// In struct definitions +impl Iterator for Parser<'_> { + type Item = Token; + fn next(&mut self) -> Option { ... } +} + +// In function signatures where it adds clarity +fn parse(input: &str) -> Result, Error> { ... } + +// Especially useful in trait bounds +fn process(data: &impl AsRef) -> Cow<'_, str> { ... } +``` + +## Common Patterns + +```rust +// ✅ Elision works +fn trim(s: &str) -> &str { s.trim() } +fn first(v: &[i32]) -> Option<&i32> { v.first() } +fn name(&self) -> &str { &self.name } + +// ❌ Elision fails - multiple inputs, ambiguous output +fn pick(a: &str, b: &str, first: bool) -> &str // Error! + +// ✅ Fixed with explicit lifetime +fn pick<'a>(a: &'a str, b: &'a str, first: bool) -> &'a str { + if first { a } else { b } +} +``` + +## See Also + +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Prefer borrowing to avoid ownership issues +- [api-impl-asref](./api-impl-asref.md) - Generic borrowing with AsRef diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-move-large.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-move-large.md new file mode 100644 index 00000000..6fc0b86a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-move-large.md @@ -0,0 +1,134 @@ +# own-move-large + +> Move large types instead of copying; use `Box` if moves are expensive + +## Why It Matters + +In Rust, "moving" a value means copying its bytes to a new location and invalidating the old one. For large types (hundreds of bytes), this memcpy can be expensive. Boxing large types reduces move cost to copying a single pointer (8 bytes), making moves cheap regardless of the actual data size. + +## Bad + +```rust +// Large struct moved repeatedly = expensive memcpy each time +struct GameState { + board: [[Cell; 100]; 100], // 10,000 cells + history: [Move; 1000], // 1,000 moves + players: [Player; 4], // Player data + // Total: potentially tens of KB +} + +fn process_state(state: GameState) -> GameState { + // Moving ~40KB+ of data + let mut new_state = state; // Memcpy here + new_state.apply_rules(); + new_state // Memcpy on return +} + +let state = GameState::new(); +let state = process_state(state); // Two large memcpys +``` + +## Good + +```rust +// Box reduces move cost to 8 bytes +struct GameState { + board: Box<[[Cell; 100]; 100]>, // Pointer to heap + history: Vec, // Already heap-allocated + players: [Player; 4], +} + +fn process_state(mut state: GameState) -> GameState { + // Moving just pointers + small inline data + state.apply_rules(); + state // Cheap move +} + +// Or use Box at call site for one-off cases +fn process_large(state: Box) -> Box { + // 8-byte move regardless of LargeStruct size + state +} +``` + +## When to Box + +| Type Size | Move Frequency | Recommendation | +|-----------|----------------|----------------| +| < 128 bytes | Any | Don't box | +| 128-512 bytes | Rare | Probably don't box | +| 128-512 bytes | Frequent | Consider boxing | +| > 512 bytes | Any | Box or use references | +| > 4KB | Any | Definitely box | + +## Stack vs Heap Tradeoffs + +```rust +// Stack: fast allocation, limited size, moves copy bytes +struct StackHeavy { + data: [u8; 4096], // 4KB on stack +} + +// Heap: allocation cost, unlimited size, moves copy pointer +struct HeapLight { + data: Box<[u8; 4096]>, // 8 bytes on stack, 4KB on heap +} + +// Measure with size_of +use std::mem::size_of; +assert_eq!(size_of::(), 4096); +assert_eq!(size_of::(), 8); +``` + +## Alternative: References + +When you don't need ownership transfer, use references: + +```rust +// Best: no move at all +fn analyze_state(state: &GameState) -> Analysis { + // Borrows state, no copying + compute_analysis(state) +} + +// Mutable borrow for in-place modification +fn update_state(state: &mut GameState) { + state.tick(); +} +``` + +## Pattern: Builder Returns Boxed + +```rust +impl LargeConfig { + pub fn builder() -> ConfigBuilder { + ConfigBuilder::default() + } +} + +impl ConfigBuilder { + // Return boxed to avoid large move + pub fn build(self) -> Box { + Box::new(LargeConfig { + // ... fields from builder + }) + } +} +``` + +## Profile First + +Don't prematurely optimize. Use tools to identify if moves are actually a bottleneck: + +```rust +// Check type sizes +println!("Size of GameState: {}", std::mem::size_of::()); + +// Profile with cargo flamegraph or perf to find hot memcpys +``` + +## See Also + +- [own-copy-small](./own-copy-small.md) - Cheap types should be Copy +- [mem-box-large-variant](./mem-box-large-variant.md) - Boxing enum variants +- [perf-profile-first](./perf-profile-first.md) - Measure before optimizing diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-mutex-interior.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-mutex-interior.md new file mode 100644 index 00000000..69e09106 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-mutex-interior.md @@ -0,0 +1,105 @@ +# own-mutex-interior + +> Use `Mutex` for interior mutability across threads + +## Why It Matters + +When you need shared mutable state across threads, `Mutex` provides safe interior mutability with synchronization. Unlike `RefCell`, `Mutex` is `Send + Sync` and uses OS-level locking to ensure only one thread can access the data at a time. + +## Bad + +```rust +use std::cell::RefCell; +use std::sync::Arc; + +// RefCell is !Sync - this won't compile +let shared = Arc::new(RefCell::new(vec![])); + +// ERROR: RefCell cannot be shared between threads safely +std::thread::spawn({ + let shared = shared.clone(); + move || shared.borrow_mut().push(1) +}); +``` + +## Good + +```rust +use std::sync::{Arc, Mutex}; + +let shared = Arc::new(Mutex::new(vec![])); + +let handles: Vec<_> = (0..10).map(|i| { + let shared = shared.clone(); + std::thread::spawn(move || { + let mut data = shared.lock().unwrap(); + data.push(i); + }) +}).collect(); + +for handle in handles { + handle.join().unwrap(); +} + +println!("{:?}", shared.lock().unwrap()); // All values present +``` + +## Mutex Poisoning + +If a thread panics while holding a lock, the mutex becomes "poisoned": + +```rust +use std::sync::{Arc, Mutex}; + +let mutex = Arc::new(Mutex::new(0)); + +// Handle poisoning gracefully +match mutex.lock() { + Ok(guard) => println!("Value: {}", *guard), + Err(poisoned) => { + // Recover the data anyway + let guard = poisoned.into_inner(); + println!("Recovered value: {}", *guard); + } +} + +// Or ignore poisoning (use with caution) +let guard = mutex.lock().unwrap_or_else(|e| e.into_inner()); +``` + +## Prefer parking_lot::Mutex + +For better performance, consider `parking_lot::Mutex`: + +```rust +use parking_lot::Mutex; +use std::sync::Arc; + +let shared = Arc::new(Mutex::new(vec![])); + +// No poisoning, no Result to unwrap +let mut data = shared.lock(); +data.push(42); +// Lock automatically released when guard drops +``` + +Benefits of `parking_lot`: +- No poisoning (returns guard directly) +- Smaller size (1 byte vs 40+ bytes) +- Better performance under contention +- Fair locking option available + +## When to Use What + +| Type | Threading | Overhead | Use Case | +|------|-----------|----------|----------| +| `RefCell` | Single | Minimal | Interior mutability, same thread | +| `Mutex` | Multi | Locking | Shared mutable state across threads | +| `RwLock` | Multi | Locking | Many readers, few writers | +| `parking_lot::Mutex` | Multi | Less | Drop-in std::Mutex replacement | + +## See Also + +- [own-rwlock-readers](./own-rwlock-readers.md) - When reads dominate writes +- [own-refcell-interior](./own-refcell-interior.md) - Single-threaded alternative +- [async-no-lock-await](./async-no-lock-await.md) - Avoiding locks across await points diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rc-single-thread.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rc-single-thread.md new file mode 100644 index 00000000..3da8aa39 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rc-single-thread.md @@ -0,0 +1,65 @@ +# own-rc-single-thread + +> Use `Rc` for shared ownership in single-threaded contexts + +## Why It Matters + +`Rc` (Reference Counted) provides shared ownership without the atomic overhead of `Arc`. In single-threaded code, `Rc` is faster because it uses non-atomic reference counting. Using `Arc` when you don't need thread-safety wastes CPU cycles on unnecessary synchronization. + +## Bad + +```rust +use std::sync::Arc; + +// Single-threaded application using Arc unnecessarily +fn build_tree() -> Arc { + let root = Arc::new(Node::new("root")); + let child1 = Arc::new(Node::new("child1")); + let child2 = Arc::new(Node::new("child2")); + + // All in same thread, but paying atomic overhead + root.add_child(child1.clone()); + root.add_child(child2.clone()); + root +} +``` + +Atomic operations have measurable overhead even without contention. + +## Good + +```rust +use std::rc::Rc; + +// Single-threaded: use Rc for zero atomic overhead +fn build_tree() -> Rc { + let root = Rc::new(Node::new("root")); + let child1 = Rc::new(Node::new("child1")); + let child2 = Rc::new(Node::new("child2")); + + root.add_child(child1.clone()); + root.add_child(child2.clone()); + root +} + +// Compiler enforces single-thread: Rc is !Send + !Sync +// Attempting to send across threads = compile error +``` + +## Decision Guide + +| Scenario | Use | +|----------|-----| +| Single-threaded, shared ownership | `Rc` | +| Multi-threaded, shared ownership | `Arc` | +| Single owner, might need multiple later | Start with `Rc`, upgrade if needed | +| Library code, unknown threading model | `Arc` (safer default) | + +## Evidence + +The Rust standard library itself uses `Rc` extensively in single-threaded contexts like the `std::rc` module documentation examples. + +## See Also + +- [own-arc-shared](./own-arc-shared.md) - When you need thread-safe sharing +- [own-refcell-interior](./own-refcell-interior.md) - Combining Rc with interior mutability diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-refcell-interior.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-refcell-interior.md new file mode 100644 index 00000000..d6e0abc6 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-refcell-interior.md @@ -0,0 +1,97 @@ +# own-refcell-interior + +> Use `RefCell` for interior mutability in single-threaded code + +## Why It Matters + +Rust's borrow checker enforces rules at compile time, but sometimes you need to mutate data through a shared reference. `RefCell` moves borrow checking to runtime, allowing mutation through `&self`. This is essential for patterns like caches, lazy initialization, and observer patterns where compile-time borrowing is too restrictive. + +## Bad + +```rust +struct Cache { + // Requires &mut self to update, breaking shared reference patterns + data: HashMap, +} + +impl Cache { + fn get_or_compute(&mut self, key: &str) -> &str { + // Caller needs &mut Cache, can't share cache reference + if !self.data.contains_key(key) { + self.data.insert(key.to_string(), expensive_compute(key)); + } + &self.data[key] + } +} +``` + +This forces exclusive access even for logically shared operations. + +## Good + +```rust +use std::cell::RefCell; +use std::collections::HashMap; + +struct Cache { + data: RefCell>, +} + +impl Cache { + fn get_or_compute(&self, key: &str) -> String { + // Can mutate through &self + let mut data = self.data.borrow_mut(); + if !data.contains_key(key) { + data.insert(key.to_string(), expensive_compute(key)); + } + data[key].clone() + } +} + +// Multiple references can coexist +let cache = Cache::new(); +let ref1 = &cache; +let ref2 = &cache; +ref1.get_or_compute("key1"); +ref2.get_or_compute("key2"); +``` + +## Common Pattern: Rc> + +```rust +use std::rc::Rc; +use std::cell::RefCell; + +// Shared mutable state in single-threaded code +type SharedState = Rc>; + +fn create_handlers(state: SharedState) -> Vec> { + vec![ + Box::new({ + let state = state.clone(); + move || state.borrow_mut().increment() + }), + Box::new({ + let state = state.clone(); + move || state.borrow_mut().decrement() + }), + ] +} +``` + +## Runtime Panics + +`RefCell` panics if you violate borrowing rules at runtime: + +```rust +let cell = RefCell::new(5); +let borrow1 = cell.borrow(); +let borrow2 = cell.borrow_mut(); // PANIC: already borrowed +``` + +Use `try_borrow()` and `try_borrow_mut()` for fallible borrowing. + +## See Also + +- [own-rc-single-thread](./own-rc-single-thread.md) - Combining with Rc for shared ownership +- [own-mutex-interior](./own-mutex-interior.md) - Thread-safe alternative diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rwlock-readers.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rwlock-readers.md new file mode 100644 index 00000000..f37ac78e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rwlock-readers.md @@ -0,0 +1,122 @@ +# own-rwlock-readers + +> Use `RwLock` when reads significantly outnumber writes + +## Why It Matters + +`Mutex` allows only one thread to access data at a time, even for reads. `RwLock` allows multiple concurrent readers OR one exclusive writer. For read-heavy workloads, this dramatically improves throughput by eliminating unnecessary serialization of read operations. + +## Bad + +```rust +use std::sync::{Arc, Mutex}; + +// Configuration rarely changes but is read constantly +let config = Arc::new(Mutex::new(Config::load())); + +// Every read blocks other reads unnecessarily +fn get_setting(config: &Mutex, key: &str) -> String { + let guard = config.lock().unwrap(); + guard.get(key).to_string() +} + +// 100 threads reading = serialized, one at a time +``` + +## Good + +```rust +use std::sync::{Arc, RwLock}; + +// Multiple readers can proceed concurrently +let config = Arc::new(RwLock::new(Config::load())); + +fn get_setting(config: &RwLock, key: &str) -> String { + let guard = config.read().unwrap(); // Multiple threads can hold read lock + guard.get(key).to_string() +} + +fn update_setting(config: &RwLock, key: &str, value: &str) { + let mut guard = config.write().unwrap(); // Exclusive access for writes + guard.set(key, value); +} + +// 100 threads reading = parallel execution +``` + +## parking_lot::RwLock + +Prefer `parking_lot::RwLock` for better performance: + +```rust +use parking_lot::RwLock; +use std::sync::Arc; + +let data = Arc::new(RwLock::new(HashMap::new())); + +// Read - no unwrap needed +let value = data.read().get("key").cloned(); + +// Write +data.write().insert("key".to_string(), "value".to_string()); + +// Upgradeable read lock (unique to parking_lot) +let upgradeable = data.upgradable_read(); +if upgradeable.get("key").is_none() { + let mut write = parking_lot::RwLockUpgradableReadGuard::upgrade(upgradeable); + write.insert("key".to_string(), "default".to_string()); +} +``` + +## When RwLock Hurts + +RwLock has overhead for tracking readers. It can be slower than Mutex when: + +| Scenario | Better Choice | +|----------|---------------| +| Writes are frequent (>20% of operations) | `Mutex` | +| Lock held very briefly | `Mutex` | +| Single-threaded | `RefCell` | +| Reads dominate, lock held longer | `RwLock` | + +## Write Starvation + +Standard `RwLock` may starve writers if readers are continuous. `parking_lot::RwLock` is fair by default. + +```rust +// parking_lot is writer-fair, preventing starvation +use parking_lot::RwLock; + +// Or use std with explicit fairness (nightly) +// #![feature(rwlock_downgrade)] +``` + +## Real-World Pattern: Cached Computation + +```rust +use parking_lot::RwLock; +use std::sync::Arc; + +struct CachedData { + cache: RwLock>, +} + +impl CachedData { + fn get(&self) -> ExpensiveResult { + // Fast path: read lock + if let Some(cached) = self.cache.read().as_ref() { + return cached.clone(); + } + + // Slow path: compute and cache + let result = compute_expensive(); + *self.cache.write() = Some(result.clone()); + result + } +} +``` + +## See Also + +- [own-mutex-interior](./own-mutex-interior.md) - When writes are frequent +- [async-no-lock-await](./async-no-lock-await.md) - RwLock in async contexts diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-slice-over-vec.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-slice-over-vec.md new file mode 100644 index 00000000..d34f828a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-slice-over-vec.md @@ -0,0 +1,119 @@ +# own-slice-over-vec + +> Accept `&[T]` not `&Vec`, `&str` not `&String` + +## Why It Matters + +Accepting `&[T]` instead of `&Vec` makes your function more flexible - it can accept slices from arrays, vectors, or other sources. Similarly, `&str` accepts string slices from `String`, `&'static str`, or substrings. + +## Bad + +```rust +// Overly restrictive - only accepts &Vec +fn sum(numbers: &Vec) -> i32 { + numbers.iter().sum() +} + +// Overly restrictive - only accepts &String +fn greet(name: &String) { + println!("Hello, {}", name); +} + +// Can't call with arrays or slices +let arr = [1, 2, 3]; +// sum(&arr); // ERROR: expected &Vec + +let literal = "world"; +// greet(&literal); // ERROR: expected &String +``` + +## Good + +```rust +// Flexible - accepts any slice-like thing +fn sum(numbers: &[i32]) -> i32 { + numbers.iter().sum() +} + +// Flexible - accepts any string-like thing +fn greet(name: &str) { + println!("Hello, {}", name); +} + +// Now all of these work: +let vec = vec![1, 2, 3]; +let arr = [4, 5, 6]; +let slice = &vec[0..2]; + +sum(&vec); // Vec coerces to slice +sum(&arr); // Array coerces to slice +sum(slice); // Slice works directly + +let string = String::from("Alice"); +let literal = "Bob"; + +greet(&string); // String coerces to &str +greet(literal); // &str works directly +``` + +## The Deref Coercion Chain + +```rust +// These coercions happen automatically: +// Vec -> &[T] (via Deref) +// String -> &str (via Deref) +// Box -> &T (via Deref) +// Arc -> &T (via Deref) + +fn process(data: &[u8]) { /* ... */ } + +let vec: Vec = vec![1, 2, 3]; +let boxed: Box<[u8]> = vec.into_boxed_slice(); +let arc: Arc<[u8]> = Arc::from(&[1, 2, 3][..]); + +process(&vec); // Works +process(&boxed); // Works +process(&arc); // Works +``` + +## Path Types Too + +```rust +// Bad +fn read_config(path: &PathBuf) -> Config { /* ... */ } + +// Good - accepts &Path, &PathBuf, &str, &String +fn read_config(path: &Path) -> Config { /* ... */ } + +// Even better - accept anything path-like +fn read_config(path: impl AsRef) -> Config { + let path = path.as_ref(); + // ... +} +``` + +## When to Accept Owned Types + +```rust +// Accept owned when you need to store it +struct Logger { + prefix: String, // Needs to own the string +} + +impl Logger { + // Take ownership - caller decides to clone or move + fn new(prefix: String) -> Self { + Self { prefix } + } + + // Or use Into for flexibility + fn with_prefix(prefix: impl Into) -> Self { + Self { prefix: prefix.into() } + } +} +``` + +## See Also + +- [api-impl-asref](api-impl-asref.md) - Accept `impl AsRef` for maximum flexibility +- [own-borrow-over-clone](own-borrow-over-clone.md) - Prefer borrowing over cloning diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-black-box-bench.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-black-box-bench.md new file mode 100644 index 00000000..f82cc1b7 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-black-box-bench.md @@ -0,0 +1,153 @@ +# perf-black-box-bench + +> Use black_box in benchmarks + +## Why It Matters + +The compiler aggressively optimizes code, potentially eliminating computations whose results aren't used. In benchmarks, this can lead to measuring nothing instead of the actual code. `std::hint::black_box()` prevents the compiler from optimizing away values, ensuring accurate measurements. + +## Bad + +```rust +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn benchmark_bad(c: &mut Criterion) { + c.bench_function("compute", |b| { + b.iter(|| { + let result = expensive_computation(42); + // Result unused - compiler may eliminate the call! + }); + }); +} + +fn benchmark_also_bad(c: &mut Criterion) { + let input = 42; // Constant - compiler may precompute + + c.bench_function("compute", |b| { + b.iter(|| { + expensive_computation(input) + // Return value may still be optimized away + }); + }); +} +``` + +## Good + +```rust +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn benchmark_good(c: &mut Criterion) { + c.bench_function("compute", |b| { + b.iter(|| { + // black_box on input prevents constant folding + let result = expensive_computation(black_box(42)); + // black_box on output prevents dead code elimination + black_box(result) + }); + }); +} + +// Or simpler with Criterion's built-in support +fn benchmark_simpler(c: &mut Criterion) { + c.bench_function("compute", |b| { + b.iter(|| expensive_computation(black_box(42))) + }); +} +``` + +## What black_box Does + +| Without black_box | With black_box | +|-------------------|----------------| +| Input may be constant-folded | Input treated as unknown | +| Result may be eliminated | Result must be computed | +| Loops may be optimized away | Each iteration runs | +| Functions may be inlined | Call semantics preserved | + +## Standard Library Usage + +```rust +use std::hint::black_box; + +fn main() { + // In std since Rust 1.66 + let result = black_box(compute_something(black_box(input))); +} +``` + +## Criterion's black_box + +Criterion re-exports `std::hint::black_box`: + +```rust +use criterion::black_box; + +// Equivalent to std::hint::black_box +``` + +## Pattern: Benchmark with Setup + +```rust +fn benchmark_with_setup(c: &mut Criterion) { + c.bench_function("process_data", |b| { + // Setup outside iter - not measured + let data = generate_test_data(1000); + + b.iter(|| { + // black_box the input reference + let result = process(black_box(&data)); + black_box(result) + }); + }); +} +``` + +## Pattern: Benchmark Multiple Inputs + +```rust +fn benchmark_sizes(c: &mut Criterion) { + let mut group = c.benchmark_group("scaling"); + + for size in [100, 1000, 10000] { + let data = generate_data(size); + + group.bench_with_input( + BenchmarkId::from_parameter(size), + &data, + |b, data| { + b.iter(|| process(black_box(data))) + }, + ); + } + group.finish(); +} +``` + +## Common Mistakes + +```rust +// WRONG: black_box inside loop does nothing useful +for _ in 0..1000 { + black_box(()); // Doesn't help + compute(); +} + +// RIGHT: black_box the computation result +for _ in 0..1000 { + black_box(compute()); +} + +// WRONG: Only blocking output, not input +let x = 42; // Constant, may be optimized +black_box(expensive(x)); + +// RIGHT: Block both +black_box(expensive(black_box(42))); +``` + +## See Also + +- [test-criterion-bench](./test-criterion-bench.md) - Using Criterion +- [perf-profile-first](./perf-profile-first.md) - Profile before optimize +- [perf-release-profile](./perf-release-profile.md) - Release settings diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-chain-avoid.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-chain-avoid.md new file mode 100644 index 00000000..2cb9cdca --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-chain-avoid.md @@ -0,0 +1,136 @@ +# perf-chain-avoid + +> Avoid chain in hot loops + +## Why It Matters + +`Iterator::chain()` adds overhead for checking which iterator is active on every `.next()` call. In hot loops, this branch prediction overhead can impact performance. For performance-critical code, prefer single iterators or pre-combined collections. + +## Bad + +```rust +// Chain in hot inner loop +fn process_hot_path(a: &[i32], b: &[i32]) -> i64 { + let mut sum = 0i64; + + // Called millions of times + for _ in 0..1_000_000 { + for x in a.iter().chain(b.iter()) { // Branch every iteration + sum += *x as i64; + } + } + sum +} + +// Chaining multiple small slices in tight loop +fn combine_results(parts: &[&[u8]]) -> Vec { + let mut result = Vec::new(); + for part in parts { + for byte in std::iter::once(&0u8).chain(part.iter()) { + result.push(*byte); + } + } + result +} +``` + +## Good + +```rust +// Separate loops - branch-free inner loops +fn process_hot_path(a: &[i32], b: &[i32]) -> i64 { + let mut sum = 0i64; + + for _ in 0..1_000_000 { + for x in a { + sum += *x as i64; + } + for x in b { + sum += *x as i64; + } + } + sum +} + +// Pre-combine outside hot loop +fn combine_results(parts: &[&[u8]]) -> Vec { + let mut result = Vec::new(); + for part in parts { + result.push(0u8); + result.extend_from_slice(part); + } + result +} +``` + +## When Chain Is Fine + +Chain is perfectly acceptable when: + +```rust +// One-time iteration, not in hot path +fn collect_all(a: Vec, b: Vec) -> Vec { + a.into_iter().chain(b).collect() +} + +// Lazy evaluation with short-circuit +fn find_in_either(a: &[Item], b: &[Item], target: i32) -> Option<&Item> { + a.iter().chain(b.iter()).find(|x| x.id == target) +} + +// Small number of elements +fn get_prefixes() -> impl Iterator { + ["Mr.", "Mrs.", "Dr."].iter().copied() + .chain(["Prof."].iter().copied()) +} +``` + +## Alternative Patterns + +### Pre-allocate and Extend + +```rust +fn merge_slices(slices: &[&[i32]]) -> Vec { + let total: usize = slices.iter().map(|s| s.len()).sum(); + let mut result = Vec::with_capacity(total); + for slice in slices { + result.extend_from_slice(slice); + } + result +} +``` + +### Use append for Vecs + +```rust +fn combine_vecs(mut a: Vec, mut b: Vec) -> Vec { + a.append(&mut b); // Moves elements, no reallocation if a has capacity + a +} +``` + +### Flatten Instead of Chain + +```rust +// Instead of: a.iter().chain(b.iter()).chain(c.iter()) +let all = [a, b, c]; +for item in all.iter().flat_map(|slice| slice.iter()) { + process(item); +} +``` + +## Performance Impact + +| Pattern | Per-Item Overhead | +|---------|-------------------| +| Single iterator | None | +| `chain(a, b)` | 1 branch per item | +| `chain(a, b, c)` | 2 branches per item | +| Nested chains | Compounds | +| Separate loops | None (but code duplication) | + +## See Also + +- [perf-iter-over-index](./perf-iter-over-index.md) - Prefer iterators +- [perf-extend-batch](./perf-extend-batch.md) - Batch insertions +- [opt-cache-friendly](./opt-cache-friendly.md) - Cache-friendly patterns diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-into.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-into.md new file mode 100644 index 00000000..44824e05 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-into.md @@ -0,0 +1,133 @@ +# perf-collect-into + +> Use collect_into for reusing containers + +## Why It Matters + +`collect_into()` (stabilized in Rust 1.83) allows collecting iterator results into an existing collection, reusing its allocation. This avoids the allocation that `collect()` would make for a new collection. + +## Bad + +```rust +// Allocates new Vec each time +fn process_batches(batches: Vec>) -> Vec> { + batches.into_iter() + .map(|batch| { + batch.into_iter() + .filter(|x| *x > 0) + .collect::>() // New allocation per batch + }) + .collect() +} + +// Can't reuse cleared buffer +fn filter_loop(data: &[Vec]) { + for batch in data { + let filtered: Vec<_> = batch.iter() + .filter(|&&x| x > 0) + .copied() + .collect(); // New allocation each iteration + process(&filtered); + } +} +``` + +## Good + +```rust +// Reuse buffer with collect_into +fn filter_loop(data: &[Vec]) { + let mut buffer = Vec::new(); + + for batch in data { + buffer.clear(); // Keep allocation + batch.iter() + .filter(|&&x| x > 0) + .copied() + .collect_into(&mut buffer); + process(&buffer); + } +} + +// Also works with extend pattern +fn filter_loop_extend(data: &[Vec]) { + let mut buffer = Vec::new(); + + for batch in data { + buffer.clear(); + buffer.extend( + batch.iter() + .filter(|&&x| x > 0) + .copied() + ); + process(&buffer); + } +} +``` + +## Pre-1.83 Alternative: extend + +Before `collect_into()` was stabilized, use `extend()`: + +```rust +fn reuse_buffer(data: &[Vec]) { + let mut buffer = Vec::new(); + + for batch in data { + buffer.clear(); + buffer.extend(batch.iter().filter(|&&x| x > 0).copied()); + process(&buffer); + } +} +``` + +## Pattern: Transform and Reuse + +```rust +fn transform_batches(batches: &[Vec]) -> Vec { + let mut temp = Vec::new(); + let mut all_results = Vec::new(); + + for batch in batches { + temp.clear(); + batch.iter() + .map(ProcessedData::from) + .collect_into(&mut temp); + + // Process temp, append to results + all_results.extend(temp.drain(..).filter(|p| p.is_valid())); + } + + all_results +} +``` + +## Supported Collections + +`collect_into()` works with any type implementing `Extend`: + +```rust +use std::collections::{HashSet, HashMap, VecDeque}; + +let mut vec = Vec::new(); +let mut set = HashSet::new(); +let mut deque = VecDeque::new(); + +(0..10).collect_into(&mut vec); +(0..10).collect_into(&mut set); +(0..10).collect_into(&mut deque); +``` + +## Comparison + +| Method | Allocation | Buffer Reuse | +|--------|------------|--------------| +| `.collect()` | New each time | No | +| `.collect_into(&mut buf)` | Reuses buffer | Yes | +| `buf.extend(iter)` | Reuses buffer | Yes | + +## See Also + +- [perf-drain-reuse](./perf-drain-reuse.md) - Drain for reuse +- [mem-reuse-collections](./mem-reuse-collections.md) - Collection reuse +- [perf-extend-batch](./perf-extend-batch.md) - Batch extensions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-once.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-once.md new file mode 100644 index 00000000..8829bd89 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-once.md @@ -0,0 +1,120 @@ +# perf-collect-once + +> Don't collect intermediate iterators + +## Why It Matters + +Each `.collect()` allocates a new collection. Chaining multiple operations with intermediate collections wastes memory and CPU cycles. Keep iterator chains lazy and collect only once at the end. + +## Bad + +```rust +// Three allocations, three passes +fn process_users(users: Vec) -> Vec { + let active: Vec<_> = users.into_iter() + .filter(|u| u.is_active) + .collect(); + + let verified: Vec<_> = active.into_iter() + .filter(|u| u.is_verified) + .collect(); + + verified.into_iter() + .map(|u| u.name) + .collect() +} + +// Collecting to count +fn count_valid(items: &[Item]) -> usize { + items.iter() + .filter(|i| i.is_valid()) + .collect::>() // Unnecessary! + .len() +} +``` + +## Good + +```rust +// One allocation, one pass +fn process_users(users: Vec) -> Vec { + users.into_iter() + .filter(|u| u.is_active) + .filter(|u| u.is_verified) + .map(|u| u.name) + .collect() +} + +// No allocation needed +fn count_valid(items: &[Item]) -> usize { + items.iter() + .filter(|i| i.is_valid()) + .count() +} +``` + +## Pattern: Deferred Collection + +```rust +// Create the iterator chain +fn prepare_data(raw: Vec) -> impl Iterator { + raw.into_iter() + .filter(|d| d.is_valid()) + .map(ProcessedData::from) +} + +// Collect only when needed +let data: Vec<_> = prepare_data(input).collect(); + +// Or consume without collecting +prepare_data(input).for_each(|d| process(d)); +``` + +## When Intermediate Collection Is Needed + +```rust +// Need to iterate multiple times +let items: Vec<_> = data.iter() + .filter(|x| x.is_valid()) + .collect(); + +let count = items.len(); +let first = items.first(); +for item in &items { + process(item); +} + +// Need to sort (requires concrete collection) +let mut sorted: Vec<_> = data.iter() + .filter(|x| x.is_active) + .collect(); +sorted.sort_by_key(|x| x.priority); +``` + +## Comparison + +| Approach | Allocations | Passes | Memory | +|----------|-------------|--------|--------| +| Multiple `.collect()` | N | N | O(N × data) | +| Single chain + `.collect()` | 1 | 1 | O(data) | +| No `.collect()` (streaming) | 0 | 1 | O(1) | + +## Pattern: Collect with Capacity + +When you must collect, pre-allocate: + +```rust +// With estimated capacity +let mut result = Vec::with_capacity(items.len()); +result.extend( + items.iter() + .filter(|x| x.is_valid()) + .map(|x| x.clone()) +); +``` + +## See Also + +- [perf-iter-lazy](./perf-iter-lazy.md) - Keep iterators lazy +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocate collections +- [anti-collect-intermediate](./anti-collect-intermediate.md) - Anti-pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-drain-reuse.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-drain-reuse.md new file mode 100644 index 00000000..2016076d --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-drain-reuse.md @@ -0,0 +1,137 @@ +# perf-drain-reuse + +> Use drain to reuse allocations + +## Why It Matters + +`drain()` removes elements from a collection while keeping its allocated capacity. This allows reusing the same allocation across iterations, avoiding repeated allocate/deallocate cycles in loops. + +## Bad + +```rust +// Allocates new Vec every iteration +fn process_batches(data: Vec) { + let mut remaining = data; + + while !remaining.is_empty() { + let batch: Vec<_> = remaining.drain(..100.min(remaining.len())).collect(); + process_batch(batch); + // remaining keeps its capacity - good + // but batch allocates new every time - bad + } +} + +// Clears and reallocates +fn reuse_buffer() { + for _ in 0..1000 { + let mut buffer = Vec::new(); // Allocates each iteration + fill_buffer(&mut buffer); + process(&buffer); + } +} +``` + +## Good + +```rust +// Reuses allocation with drain +fn process_batches(mut data: Vec) { + let mut batch = Vec::with_capacity(100); + + while !data.is_empty() { + batch.extend(data.drain(..100.min(data.len()))); + process_batch(&batch); + batch.clear(); // Keeps capacity + } +} + +// Reuses buffer across iterations +fn reuse_buffer() { + let mut buffer = Vec::new(); + + for _ in 0..1000 { + buffer.clear(); // Keeps capacity + fill_buffer(&mut buffer); + process(&buffer); + } +} +``` + +## Drain Methods + +| Collection | Method | Behavior | +|------------|--------|----------| +| `Vec` | `.drain(range)` | Remove range, shift remaining | +| `Vec` | `.drain(..)` | Remove all (like clear) | +| `VecDeque` | `.drain(range)` | Remove range | +| `String` | `.drain(range)` | Remove char range | +| `HashMap` | `.drain()` | Remove all entries | +| `HashSet` | `.drain()` | Remove all elements | + +## Pattern: Batch Processing + +```rust +fn process_in_chunks(mut items: Vec, chunk_size: usize) { + while !items.is_empty() { + let chunk: Vec<_> = items.drain(..chunk_size.min(items.len())).collect(); + process_chunk(chunk); + } +} +``` + +## Pattern: Transfer Between Collections + +```rust +// Move all elements without reallocation +fn transfer_all(src: &mut Vec, dst: &mut Vec) { + dst.extend(src.drain(..)); + // src is now empty but keeps capacity +} + +// Move matching elements +fn transfer_matching(src: &mut Vec, dst: &mut Vec, predicate: impl Fn(&Item) -> bool) { + let matching: Vec<_> = src.drain(..).filter(predicate).collect(); + dst.extend(matching); +} +``` + +## Pattern: HashMap Drain + +```rust +use std::collections::HashMap; + +fn process_and_clear(map: &mut HashMap) { + // Process all entries, clearing the map + for (key, value) in map.drain() { + process(key, value); + } + // map is now empty but keeps capacity +} +``` + +## drain vs clear vs take + +| Operation | Elements | Capacity | Returns | +|-----------|----------|----------|---------| +| `.clear()` | Removed | Kept | Nothing | +| `.drain(..)` | Removed | Kept | Iterator | +| `std::mem::take()` | Moved out | Reset to 0 | Owned collection | + +```rust +// clear: just empty +vec.clear(); + +// drain: empty and iterate +for item in vec.drain(..) { + process(item); +} + +// take: swap with empty, get ownership +let old_vec = std::mem::take(&mut vec); +``` + +## See Also + +- [mem-reuse-collections](./mem-reuse-collections.md) - Reusing collections +- [perf-extend-batch](./perf-extend-batch.md) - Batch insertions +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-entry-api.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-entry-api.md new file mode 100644 index 00000000..63c8df78 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-entry-api.md @@ -0,0 +1,134 @@ +# perf-entry-api + +> Use entry API for map insert-or-update + +## Why It Matters + +The entry API performs a single lookup for insert-or-update operations. Without it, you lookup twice: once to check existence, once to insert. For `HashMap` and `BTreeMap`, the entry API is both faster and more idiomatic. + +## Bad + +```rust +use std::collections::HashMap; + +// Double lookup: contains_key + insert +fn increment(map: &mut HashMap, key: String) { + if map.contains_key(&key) { + *map.get_mut(&key).unwrap() += 1; + } else { + map.insert(key, 1); + } +} + +// Double lookup with get + insert +fn get_or_insert(map: &mut HashMap>, key: String) -> &mut Vec { + if !map.contains_key(&key) { + map.insert(key.clone(), Vec::new()); + } + map.get_mut(&key).unwrap() +} + +// Triple lookup pattern +fn update_or_default(map: &mut HashMap, key: &str, value: i32) { + match map.get(key) { + Some(config) => { + let mut new_config = config.clone(); + new_config.value = value; + map.insert(key.to_string(), new_config); + } + None => { + map.insert(key.to_string(), Config::default()); + } + } +} +``` + +## Good + +```rust +use std::collections::HashMap; +use std::collections::hash_map::Entry; + +// Single lookup with entry +fn increment(map: &mut HashMap, key: String) { + *map.entry(key).or_insert(0) += 1; +} + +// Single lookup, returns mutable reference +fn get_or_insert(map: &mut HashMap>, key: String) -> &mut Vec { + map.entry(key).or_insert_with(Vec::new) +} + +// Single lookup with and_modify +fn update_or_default(map: &mut HashMap, key: String, value: i32) { + map.entry(key) + .and_modify(|config| config.value = value) + .or_insert_with(Config::default); +} +``` + +## Entry API Methods + +| Method | Behavior | +|--------|----------| +| `.or_insert(val)` | Insert `val` if empty | +| `.or_insert_with(f)` | Insert `f()` if empty (lazy) | +| `.or_default()` | Insert `Default::default()` if empty | +| `.and_modify(f)` | Apply `f` if occupied | +| `.or_insert_with_key(f)` | Insert `f(&key)` if empty | + +## Pattern: Count Occurrences + +```rust +fn word_count(text: &str) -> HashMap<&str, usize> { + let mut counts = HashMap::new(); + for word in text.split_whitespace() { + *counts.entry(word).or_insert(0) += 1; + } + counts +} +``` + +## Pattern: Group By + +```rust +fn group_by_category(items: Vec) -> HashMap> { + let mut groups: HashMap> = HashMap::new(); + for item in items { + groups.entry(item.category.clone()) + .or_default() + .push(item); + } + groups +} +``` + +## Pattern: Complex Entry Logic + +```rust +match map.entry(key) { + Entry::Occupied(mut entry) => { + let value = entry.get_mut(); + if should_update(value) { + *value = new_value; + } + } + Entry::Vacant(entry) => { + entry.insert(default_value); + } +} +``` + +## Performance + +| Pattern | Lookups | Hash Computations | +|---------|---------|-------------------| +| `contains_key` + `insert` | 2 | 2 | +| `get` + `insert` | 2 | 2 | +| `entry().or_insert()` | 1 | 1 | + +## See Also + +- [perf-extend-batch](./perf-extend-batch.md) - Batch insertions +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocate maps +- [perf-drain-reuse](./perf-drain-reuse.md) - Reuse map allocations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-extend-batch.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-extend-batch.md new file mode 100644 index 00000000..489f041e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-extend-batch.md @@ -0,0 +1,150 @@ +# perf-extend-batch + +> Use extend for batch insertions + +## Why It Matters + +`extend()` can pre-allocate capacity for the incoming elements and insert them in a single operation. Individual `push()` calls may trigger multiple reallocations as the collection grows. For adding multiple elements, `extend()` is both faster and clearer. + +## Bad + +```rust +// Multiple potential reallocations +fn collect_results(sources: Vec) -> Vec { + let mut results = Vec::new(); + + for source in sources { + for result in source.get_results() { + results.push(result); // May reallocate + } + } + results +} + +// Loop with push for known data +fn build_list() -> Vec { + let mut list = Vec::new(); + for i in 0..1000 { + list.push(i); // Many reallocations + } + list +} + +// Appending another collection +fn combine(mut a: Vec, b: Vec) -> Vec { + for item in b { + a.push(item); + } + a +} +``` + +## Good + +```rust +// Single extend with size hint +fn collect_results(sources: Vec) -> Vec { + let mut results = Vec::new(); + + for source in sources { + results.extend(source.get_results()); + } + results +} + +// Direct collection from iterator +fn build_list() -> Vec { + (0..1000).collect() +} + +// Extend for combining +fn combine(mut a: Vec, b: Vec) -> Vec { + a.extend(b); + a +} +``` + +## Extend with Capacity + +For best performance, combine with `reserve()`: + +```rust +fn merge_all(chunks: Vec>) -> Vec { + // Calculate total size + let total: usize = chunks.iter().map(|c| c.len()).sum(); + + let mut result = Vec::with_capacity(total); + for chunk in chunks { + result.extend(chunk); + } + result +} +``` + +## Extend Methods + +| Method | Description | +|--------|-------------| +| `.extend(iter)` | Add all elements from iterator | +| `.extend_from_slice(&[T])` | Add from slice (for `Copy` types) | +| `.append(&mut Vec)` | Move all from another Vec | + +## Pattern: Building Strings + +```rust +// Bad: multiple allocations +fn build_message(parts: &[&str]) -> String { + let mut result = String::new(); + for part in parts { + result.push_str(part); // May reallocate + } + result +} + +// Good: extend with known parts +fn build_message(parts: &[&str]) -> String { + let total_len: usize = parts.iter().map(|s| s.len()).sum(); + let mut result = String::with_capacity(total_len); + for part in parts { + result.push_str(part); + } + result +} + +// Better: collect/join +fn build_message(parts: &[&str]) -> String { + parts.concat() // or parts.join("") +} +``` + +## HashMap/HashSet Extend + +```rust +use std::collections::HashMap; + +// Extend from iterator of tuples +fn merge_maps(mut base: HashMap, other: HashMap) -> HashMap { + base.extend(other); // Moves entries from other + base +} + +// Extend from iterator +let mut set = HashSet::new(); +set.extend(items.iter().map(|i| i.id)); +``` + +## Performance + +| Operation | Allocations | Complexity | +|-----------|-------------|------------| +| N × `push()` | O(log N) | O(N) amortized | +| `extend(iter)` | O(1)* | O(N) | +| `with_capacity` + `extend` | 1 | O(N) | + +*When iterator provides accurate `size_hint()` + +## See Also + +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation +- [perf-drain-reuse](./perf-drain-reuse.md) - Reusing allocations +- [mem-reuse-collections](./mem-reuse-collections.md) - Collection reuse diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-lazy.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-lazy.md new file mode 100644 index 00000000..3b98302c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-lazy.md @@ -0,0 +1,123 @@ +# perf-iter-lazy + +> Keep iterators lazy, collect only when needed + +## Why It Matters + +Rust iterators are lazy—they compute values on demand. This enables single-pass processing, avoids intermediate allocations, and allows short-circuiting. Calling `.collect()` too early forces evaluation and allocates unnecessarily. + +## Bad + +```rust +// Collects intermediate results unnecessarily +fn process(data: Vec) -> Vec { + let filtered: Vec<_> = data.into_iter() + .filter(|x| *x > 0) + .collect(); // Unnecessary allocation + + let mapped: Vec<_> = filtered.into_iter() + .map(|x| x * 2) + .collect(); // Another unnecessary allocation + + mapped.into_iter() + .take(10) + .collect() +} + +// Collects before checking existence +fn has_positive(data: &[i32]) -> bool { + let positives: Vec<_> = data.iter() + .filter(|&&x| x > 0) + .collect(); // Allocates entire filtered result + + !positives.is_empty() +} +``` + +## Good + +```rust +// Single chain, single collect +fn process(data: Vec) -> Vec { + data.into_iter() + .filter(|x| *x > 0) + .map(|x| x * 2) + .take(10) + .collect() +} + +// Short-circuits on first match +fn has_positive(data: &[i32]) -> bool { + data.iter().any(|&x| x > 0) +} +``` + +## Lazy Iterator Methods + +These methods return iterators (lazy): + +| Method | Description | +|--------|-------------| +| `.filter()` | Keep matching elements | +| `.map()` | Transform elements | +| `.take(n)` | Limit to n elements | +| `.skip(n)` | Skip first n elements | +| `.zip()` | Pair with another iterator | +| `.chain()` | Concatenate iterators | +| `.flat_map()` | Map and flatten | +| `.enumerate()` | Add index | + +## Consuming Methods + +These methods consume the iterator (evaluate immediately): + +| Method | Description | +|--------|-------------| +| `.collect()` | Gather into collection | +| `.for_each()` | Execute side effect | +| `.count()` | Count elements | +| `.sum()` | Sum elements | +| `.fold()` | Accumulate value | +| `.any()` | Check if any match | +| `.all()` | Check if all match | +| `.find()` | Find first match | + +## Short-Circuit Benefits + +```rust +// Without lazy: processes ALL items +let found: Vec<_> = items.iter() + .filter(|x| expensive_check(x)) + .collect(); +let result = found.first(); + +// With lazy: stops at first match +let result = items.iter() + .find(|x| expensive_check(x)); +``` + +## Pattern: Process Without Collecting + +```rust +// Print all matches without allocating +data.iter() + .filter(|x| x.is_valid()) + .for_each(|x| println!("{}", x)); + +// Count without collecting +let count = data.iter() + .filter(|x| x.is_valid()) + .count(); + +// Sum without intermediate collection +let total: i64 = data.iter() + .filter(|x| x.is_valid()) + .map(|x| x.value as i64) + .sum(); +``` + +## See Also + +- [perf-collect-once](./perf-collect-once.md) - Single collect +- [perf-iter-over-index](./perf-iter-over-index.md) - Prefer iterators +- [anti-collect-intermediate](./anti-collect-intermediate.md) - Anti-pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-over-index.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-over-index.md new file mode 100644 index 00000000..d3e5cd34 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-over-index.md @@ -0,0 +1,113 @@ +# perf-iter-over-index + +> Prefer iterators over manual indexing + +## Why It Matters + +Iterators are the idiomatic way to traverse collections in Rust. They enable bounds check elimination, SIMD auto-vectorization, and cleaner code. Manual indexing (`for i in 0..len`) often prevents these optimizations and introduces off-by-one error risks. + +## Bad + +```rust +// Manual indexing - bounds checked every iteration +fn sum_squares(data: &[i32]) -> i64 { + let mut sum = 0i64; + for i in 0..data.len() { + sum += (data[i] as i64) * (data[i] as i64); + } + sum +} + +// Index-based iteration with multiple collections +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + let mut sum = 0.0; + for i in 0..a.len().min(b.len()) { + sum += a[i] * b[i]; + } + sum +} + +// Mutating with indices +fn double_values(data: &mut [i32]) { + for i in 0..data.len() { + data[i] *= 2; + } +} +``` + +## Good + +```rust +// Iterator - bounds checks eliminated, SIMD-friendly +fn sum_squares(data: &[i32]) -> i64 { + data.iter() + .map(|&x| (x as i64) * (x as i64)) + .sum() +} + +// Zip iterators - no manual length handling +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| x * y) + .sum() +} + +// Mutable iteration +fn double_values(data: &mut [i32]) { + for x in data.iter_mut() { + *x *= 2; + } +} +``` + +## When Indexing Is Needed + +Sometimes you genuinely need indices: + +```rust +// Need the index for output or processing +for (i, value) in data.iter().enumerate() { + println!("Index {}: {}", i, value); +} + +// Non-sequential access patterns +fn interleave(data: &mut [i32]) { + let mid = data.len() / 2; + for i in 0..mid { + data.swap(i * 2, mid + i); + } +} +``` + +## Performance Comparison + +| Pattern | Bounds Checks | SIMD Potential | Clarity | +|---------|---------------|----------------|---------| +| `for i in 0..len` | Every access | Limited | Medium | +| `for &x in slice` | None | High | High | +| `.iter().enumerate()` | None | Medium | High | +| `get_unchecked` | None (unsafe) | High | Low | + +## Iterator Advantages + +```rust +// Chaining operations - single pass +let result: Vec<_> = data.iter() + .filter(|x| **x > 0) + .map(|x| x * 2) + .collect(); + +// Early termination optimized +let found = data.iter().any(|&x| x == target); + +// Parallel iteration (with rayon) +use rayon::prelude::*; +let sum: i64 = data.par_iter().map(|&x| x as i64).sum(); +``` + +## See Also + +- [perf-iter-lazy](./perf-iter-lazy.md) - Keep iterators lazy +- [opt-bounds-check](./opt-bounds-check.md) - Bounds check elimination +- [anti-index-over-iter](./anti-index-over-iter.md) - Anti-pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-profile-first.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-profile-first.md new file mode 100644 index 00000000..c4410d61 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-profile-first.md @@ -0,0 +1,175 @@ +# perf-profile-first + +> Profile before optimizing + +## Why It Matters + +Intuition about performance is often wrong. The code you think is slow frequently isn't, while actual bottlenecks hide in unexpected places. Profiling shows you exactly where time is spent, preventing wasted effort on optimizations that don't matter. + +## Bad + +```rust +// Optimizing without measuring +fn process(data: &[Item]) -> Vec { + // "I bet this clone is slow..." + let cloned: Vec<_> = data.iter().cloned().collect(); + + // Actually, 99% of time is spent here: + cloned.iter().map(|x| expensive_computation(x)).collect() +} + +// Over-engineering rarely-called code +#[inline(always)] +fn rarely_called() { + // This runs once at startup... +} +``` + +## Good + +```rust +// 1. Profile first +// cargo flamegraph --bin myapp +// cargo instruments -t time --bin myapp (macOS) + +// 2. Find the actual bottleneck +// Flamegraph shows expensive_computation takes 95% of time + +// 3. Optimize the hot spot +fn process(data: &[Item]) -> Vec { + // Clone is fine - only 1% of time + let cloned: Vec<_> = data.iter().cloned().collect(); + + // Focus optimization HERE + cloned.par_iter() // Parallelize the expensive part + .map(|x| expensive_computation(x)) + .collect() +} +``` + +## Profiling Tools + +### Flamegraphs (Recommended Start) + +```bash +# Install +cargo install flamegraph + +# Profile +cargo flamegraph --bin myapp -- + +# Opens flamegraph.svg showing call stacks by time +``` + +### perf (Linux) + +```bash +# Record +perf record -g cargo run --release + +# Report +perf report + +# Or generate flamegraph +perf script | inferno-collapse-perf | inferno-flamegraph > flamegraph.svg +``` + +### Instruments (macOS) + +```bash +# Install cargo-instruments +cargo install cargo-instruments + +# Time profiler +cargo instruments -t time --release + +# Allocations profiler +cargo instruments -t alloc --release +``` + +### DHAT (Heap Profiling) + +```bash +# In your code +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +fn main() { + let _profiler = dhat::Profiler::new_heap(); + // ... your code +} + +# Run and get allocation report +cargo run --release +``` + +### criterion (Micro-benchmarks) + +```rust +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn bench_my_function(c: &mut Criterion) { + c.bench_function("my_function", |b| { + b.iter(|| my_function(black_box(input))) + }); +} + +criterion_group!(benches, bench_my_function); +criterion_main!(benches); +``` + +## What to Look For + +``` +Flamegraph Reading: +├── Width = time spent +├── Height = call stack depth +└── Look for: + ├── Wide bars (time hogs) + ├── malloc/free (allocation heavy) + ├── memcpy (copying data) + └── Unexpected functions taking time +``` + +## Common Findings + +```rust +// Finding: HashMap operations are slow +// Fix: Use FxHashMap or AHashMap for non-crypto hashing + +// Finding: String allocation in hot loop +// Fix: Pre-allocate with capacity, use &str + +// Finding: Clone in hot path +// Fix: Use references or Cow + +// Finding: Bounds checks visible in profile +// Fix: Use iterators instead of indexing + +// Finding: Lock contention +// Fix: Reduce critical section, use RwLock, or partition data +``` + +## Optimization Workflow + +``` +1. Write correct code first +2. Write benchmarks for hot paths +3. Profile under realistic load +4. Identify actual bottlenecks +5. Optimize ONE thing +6. Measure improvement +7. Repeat if needed +``` + +## Evidence: Rust Performance Book + +> "The biggest performance improvements often come from changes to algorithms or data structures, rather than low-level optimizations." + +> "It is worth understanding which Rust data structures and operations cause allocations, because avoiding them can greatly improve performance." + +## See Also + +- [opt-lto-release](opt-lto-release.md) - Enable LTO for release builds +- [test-criterion-bench](test-criterion-bench.md) - Use criterion for benchmarking +- [anti-premature-optimize](anti-premature-optimize.md) - Don't optimize without data diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-release-profile.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-release-profile.md new file mode 100644 index 00000000..d8e65fa7 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-release-profile.md @@ -0,0 +1,149 @@ +# perf-release-profile + +> Optimize release profile settings + +## Why It Matters + +The default release profile prioritizes compile speed over runtime performance. For production binaries, tuning the release profile can yield significant performance improvements (10-40% in some cases) at the cost of longer compile times. + +## Default Profile + +```toml +[profile.release] +opt-level = 3 +debug = false +lto = false +codegen-units = 16 +``` + +## Optimized Profile + +```toml +[profile.release] +opt-level = 3 # Maximum optimization +lto = "fat" # Full link-time optimization +codegen-units = 1 # Better optimization, slower compile +panic = "abort" # Smaller binary, no unwinding +strip = true # Remove symbols + +[profile.release.package."*"] +# Keep dependencies optimized even if main crate changes +opt-level = 3 +``` + +## Profile Options + +| Option | Values | Effect | +|--------|--------|--------| +| `opt-level` | 0-3, "s", "z" | Optimization level | +| `lto` | false, "thin", "fat" | Link-time optimization | +| `codegen-units` | 1-256 | Parallel compilation units | +| `panic` | "unwind", "abort" | Panic behavior | +| `strip` | true, false, "symbols", "debuginfo" | Binary stripping | +| `debug` | true, false, 0-2 | Debug info level | + +## Optimization Levels + +| Level | Description | Use Case | +|-------|-------------|----------| +| `0` | No optimization | Debug builds | +| `1` | Basic optimization | Fast compile | +| `2` | Most optimizations | Balanced | +| `3` | All optimizations | Maximum performance | +| `"s"` | Optimize for size | Embedded | +| `"z"` | Minimize size | Smallest binary | + +## LTO Options + +| Option | Compile Time | Performance | Binary Size | +|--------|--------------|-------------|-------------| +| `false` | Fast | Baseline | Larger | +| `"thin"` | Medium | Good | Smaller | +| `"fat"` | Slow | Best | Smallest | + +## Custom Profiles + +```toml +# Fast release builds for development +[profile.release-dev] +inherits = "release" +lto = false +codegen-units = 16 + +# Maximum performance for production +[profile.release-prod] +inherits = "release" +lto = "fat" +codegen-units = 1 +strip = true + +# Profiling with symbols +[profile.profiling] +inherits = "release" +debug = true +strip = false +``` + +Use with: `cargo build --profile release-prod` + +## Dev Dependencies Optimization + +Speed up tests and dev builds: + +```toml +[profile.dev] +opt-level = 0 + +# Optimize dependencies even in dev +[profile.dev.package."*"] +opt-level = 3 +``` + +## Benchmarking Profile + +```toml +[profile.bench] +inherits = "release" +debug = true # For profiling +strip = false # Keep symbols for flamegraphs +lto = "fat" # Consistent with release-prod +``` + +## Size vs Speed Trade-offs + +```toml +# Smallest binary +[profile.min-size] +inherits = "release" +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +# Balance size and speed +[profile.balanced] +inherits = "release" +opt-level = "s" +lto = "thin" +``` + +## Workspace Configuration + +```toml +# In workspace Cargo.toml +[profile.release] +lto = "fat" +codegen-units = 1 + +# Override for specific package +[profile.release.package.fast-compile-lib] +lto = false +codegen-units = 16 +``` + +## See Also + +- [opt-lto-release](./opt-lto-release.md) - LTO details +- [opt-codegen-units](./opt-codegen-units.md) - Codegen units +- [opt-pgo-profile](./opt-pgo-profile.md) - Profile-guided optimization diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-bin-dir.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-bin-dir.md new file mode 100644 index 00000000..d606fbd3 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-bin-dir.md @@ -0,0 +1,142 @@ +# proj-bin-dir + +> Put multiple binaries in src/bin/ + +## Why It Matters + +When a crate produces multiple binaries, placing them in `src/bin/` keeps the project organized. Each file becomes a separate binary target automatically, without manual `Cargo.toml` configuration. + +## Bad + +``` +my-project/ +├── Cargo.toml # Complex [[bin]] sections for each binary +├── src/ +│ ├── main.rs # Which binary is this? +│ ├── server.rs # Is this a module or binary? +│ ├── cli.rs # Unclear +│ └── lib.rs +``` + +```toml +# Cargo.toml - verbose and error-prone +[[bin]] +name = "server" +path = "src/server.rs" + +[[bin]] +name = "cli" +path = "src/cli.rs" +``` + +## Good + +``` +my-project/ +├── Cargo.toml # Clean, no [[bin]] needed +├── src/ +│ ├── lib.rs # Shared library code +│ └── bin/ +│ ├── server.rs # Binary: my-project-server (or just server) +│ └── cli.rs # Binary: my-project-cli (or just cli) +``` + +Each file in `src/bin/` automatically becomes a binary named after the file. + +## Running Binaries + +```bash +# Run specific binary +cargo run --bin server +cargo run --bin cli + +# Build specific binary +cargo build --bin server + +# Build all binaries +cargo build --bins +``` + +## Pattern: Binary with Multiple Files + +For complex binaries, use directories: + +``` +src/ +├── lib.rs +└── bin/ + ├── server/ + │ ├── main.rs # Entry point + │ ├── config.rs # Server-specific module + │ └── handlers.rs + └── cli/ + ├── main.rs + └── commands.rs +``` + +## Pattern: Shared Library Code + +```rust +// src/lib.rs - Shared code +pub mod config; +pub mod database; +pub mod models; + +// src/bin/server.rs - Server binary +use my_project::{config, database, models}; + +fn main() { + let config = config::load(); + let db = database::connect(&config); + // ... +} + +// src/bin/cli.rs - CLI binary +use my_project::{config, models}; + +fn main() { + let config = config::load(); + // CLI logic using shared code +} +``` + +## Binary Naming + +| File Path | Binary Name | +|-----------|-------------| +| `src/main.rs` | `my-project` (crate name) | +| `src/bin/server.rs` | `server` | +| `src/bin/my-cli.rs` | `my-cli` | +| `src/bin/server/main.rs` | `server` | + +## Explicit Configuration + +When you need custom settings: + +```toml +[[bin]] +name = "my-server" +path = "src/bin/server.rs" +required-features = ["server"] + +[[bin]] +name = "my-cli" +path = "src/bin/cli.rs" +``` + +## Pattern: Default Binary + +```toml +# src/main.rs is the default binary +# Additional binaries in src/bin/ + +[package] +name = "my-tool" +default-run = "my-tool" # Or specify another +``` + +## See Also + +- [proj-lib-main-split](./proj-lib-main-split.md) - Keep main.rs minimal +- [proj-workspace-large](./proj-workspace-large.md) - Workspace for larger projects +- [proj-flat-small](./proj-flat-small.md) - Simple project structure diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-flat-small.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-flat-small.md new file mode 100644 index 00000000..a5798944 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-flat-small.md @@ -0,0 +1,133 @@ +# proj-flat-small + +> Keep small projects flat + +## Why It Matters + +Over-organizing small projects adds navigation overhead without benefit. A project with 5-10 files doesn't need nested directories. Start flat, add structure only when complexity demands it. + +## Bad + +``` +src/ +├── core/ +│ └── mod.rs # Just re-exports +├── domain/ +│ ├── mod.rs +│ └── models/ +│ ├── mod.rs +│ └── user.rs # 50 lines +├── infrastructure/ +│ ├── mod.rs +│ └── database/ +│ ├── mod.rs +│ └── connection.rs # 30 lines +├── application/ +│ ├── mod.rs +│ └── services/ +│ └── mod.rs # Empty +└── main.rs +``` + +## Good + +``` +src/ +├── main.rs +├── lib.rs +├── config.rs +├── database.rs +├── user.rs +└── error.rs +``` + +## When to Add Structure + +| File Count | Structure | +|------------|-----------| +| < 10 files | Flat in `src/` | +| 10-20 files | Group by feature | +| 20+ files | Feature folders with submodules | + +## Progressive Structuring + +### Stage 1: Flat + +``` +src/ +├── main.rs +├── config.rs +├── user.rs +└── database.rs +``` + +### Stage 2: Logical Groups + +``` +src/ +├── main.rs +├── config.rs +├── user.rs +├── order.rs # Getting bigger +├── order_item.rs # Related to order +└── database.rs +``` + +### Stage 3: Feature Folders + +``` +src/ +├── main.rs +├── config.rs +├── user.rs +├── order/ # Now complex enough +│ ├── mod.rs +│ ├── model.rs +│ └── item.rs +└── database.rs +``` + +## Signs You Need More Structure + +- Files exceed 300-500 lines +- Related files are hard to identify +- You're adding `_` prefixes for grouping (`user_model.rs`, `user_service.rs`) +- New team members get lost +- Same concepts repeated in file names + +## Signs of Over-Structure + +- Folders with 1-2 files +- `mod.rs` files that only re-export +- Deep nesting for simple concepts +- More lines in module declarations than code + +## Example: CLI Tool + +``` +src/ +├── main.rs # Argument parsing, entry point +├── commands.rs # CLI subcommands +├── config.rs # Configuration loading +└── output.rs # Formatting, printing +``` + +Not: + +``` +src/ +├── cli/ +│ └── commands/ +│ └── mod.rs +├── config/ +│ └── mod.rs +└── presentation/ + └── output/ + └── mod.rs +``` + +## See Also + +- [proj-mod-by-feature](./proj-mod-by-feature.md) - Feature organization +- [proj-lib-main-split](./proj-lib-main-split.md) - Lib/main separation +- [proj-mod-rs-dir](./proj-mod-rs-dir.md) - Multi-file modules diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-lib-main-split.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-lib-main-split.md new file mode 100644 index 00000000..278799f3 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-lib-main-split.md @@ -0,0 +1,148 @@ +# proj-lib-main-split + +> Keep `main.rs` minimal, logic in `lib.rs` + +## Why It Matters + +Putting your logic in `lib.rs` makes it testable, reusable, and keeps `main.rs` as a thin entry point. Integration tests can only access your library crate, not binary code in `main.rs`. + +## Bad + +```rust +// src/main.rs - everything here +fn main() { + let args = parse_args(); + let config = load_config(&args.config_path).unwrap(); + let db = connect_database(&config.db_url).unwrap(); + + // Hundreds of lines of application logic... + // All untestable from integration tests! +} + +fn parse_args() -> Args { /* ... */ } +fn load_config(path: &str) -> Result { /* ... */ } +fn connect_database(url: &str) -> Result { /* ... */ } +// ... more functions that can't be tested +``` + +## Good + +```rust +// src/main.rs - thin entry point +use my_app::{run, Config}; + +fn main() -> anyhow::Result<()> { + let config = Config::from_env()?; + run(config) +} + +// src/lib.rs - all the logic +pub mod config; +pub mod database; +pub mod handlers; + +pub use config::Config; + +pub fn run(config: Config) -> anyhow::Result<()> { + let db = database::connect(&config.db_url)?; + let app = handlers::build_app(db); + app.run() +} +``` + +## With CLI Arguments + +```rust +// src/main.rs +use clap::Parser; +use my_app::{run, Args}; + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + run(args) +} + +// src/lib.rs +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(name = "myapp", version, about)] +pub struct Args { + #[arg(short, long)] + pub config: PathBuf, + + #[arg(short, long, default_value = "info")] + pub log_level: String, +} + +pub fn run(args: Args) -> anyhow::Result<()> { + // All application logic here - testable! +} +``` + +## Project Structure + +``` +my_app/ +├── Cargo.toml +├── src/ +│ ├── main.rs # Entry point only +│ ├── lib.rs # Library root, re-exports +│ ├── config.rs # Configuration +│ ├── database.rs # Database connection +│ └── handlers/ # Request handlers +│ ├── mod.rs +│ └── users.rs +└── tests/ + └── integration.rs # Can access lib.rs! +``` + +## Testing Benefits + +```rust +// tests/integration.rs - can test everything! +use my_app::{Config, run, database}; + +#[test] +fn test_database_connection() { + let config = Config::test_config(); + let db = database::connect(&config.db_url).unwrap(); + assert!(db.is_connected()); +} + +#[test] +fn test_full_workflow() { + let config = Config::test_config(); + // Test the actual run function + assert!(my_app::run(config).is_ok()); +} +``` + +## Multiple Binaries + +```rust +// src/lib.rs - shared code +pub mod core; +pub mod utils; + +// src/bin/server.rs +use my_app::core::Server; + +fn main() -> anyhow::Result<()> { + Server::new()?.run() +} + +// src/bin/cli.rs +use my_app::core::Client; + +fn main() -> anyhow::Result<()> { + let client = Client::new()?; + client.execute_command() +} +``` + +## See Also + +- [proj-bin-dir](proj-bin-dir.md) - Put multiple binaries in src/bin/ +- [proj-mod-by-feature](proj-mod-by-feature.md) - Organize modules by feature +- [test-integration-dir](test-integration-dir.md) - Integration tests in tests/ diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-by-feature.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-by-feature.md new file mode 100644 index 00000000..c203c578 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-by-feature.md @@ -0,0 +1,130 @@ +# proj-mod-by-feature + +> Organize modules by feature, not type + +## Why It Matters + +Feature-based organization keeps related code together, making navigation intuitive and changes localized. Type-based organization (all handlers in one folder, all models in another) scatters related code across the codebase, making features harder to understand and modify. + +## Bad + +``` +src/ +├── controllers/ +│ ├── user_controller.rs +│ ├── order_controller.rs +│ └── product_controller.rs +├── models/ +│ ├── user.rs +│ ├── order.rs +│ └── product.rs +├── services/ +│ ├── user_service.rs +│ ├── order_service.rs +│ └── product_service.rs +└── repositories/ + ├── user_repository.rs + ├── order_repository.rs + └── product_repository.rs +``` + +## Good + +``` +src/ +├── user/ +│ ├── mod.rs # Re-exports public items +│ ├── model.rs # User struct, types +│ ├── repository.rs # Database operations +│ ├── service.rs # Business logic +│ └── handler.rs # HTTP handlers +├── order/ +│ ├── mod.rs +│ ├── model.rs +│ ├── repository.rs +│ ├── service.rs +│ └── handler.rs +├── product/ +│ ├── mod.rs +│ ├── model.rs +│ ├── repository.rs +│ └── handler.rs +└── lib.rs +``` + +## Benefits + +| Aspect | Type-Based | Feature-Based | +|--------|------------|---------------| +| Finding code | Search across folders | One folder per feature | +| Adding feature | Touch 4+ folders | Create one folder | +| Understanding feature | Jump between folders | Everything in one place | +| Deleting feature | Hunt through codebase | Delete one folder | +| Code ownership | Unclear | Clear feature owners | + +## Module Structure + +```rust +// src/user/mod.rs +mod model; +mod repository; +mod service; +mod handler; + +// Re-export public API +pub use model::{User, UserId, CreateUserRequest}; +pub use handler::router; +pub(crate) use service::UserService; +``` + +## Shared Code + +``` +src/ +├── user/ +├── order/ +├── shared/ # Cross-cutting concerns +│ ├── mod.rs +│ ├── database.rs # Connection pool +│ ├── error.rs # Common error types +│ └── middleware.rs # Auth, logging +└── lib.rs +``` + +## When to Flatten + +Small modules don't need deep nesting: + +``` +src/ +├── user/ +│ ├── mod.rs # Contains User struct + simple functions +│ └── repository.rs # Only if complex enough +├── config.rs # Simple enough for single file +└── lib.rs +``` + +## Hybrid Approach + +For larger features, nest further by concern: + +``` +src/ +├── billing/ +│ ├── mod.rs +│ ├── invoice/ +│ │ ├── mod.rs +│ │ ├── model.rs +│ │ └── service.rs +│ ├── payment/ +│ │ ├── mod.rs +│ │ ├── model.rs +│ │ └── processor.rs +│ └── shared.rs +``` + +## See Also + +- [proj-flat-small](./proj-flat-small.md) - Keep small projects flat +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Clean public API +- [proj-lib-main-split](./proj-lib-main-split.md) - Lib/main separation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-rs-dir.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-rs-dir.md new file mode 100644 index 00000000..63a2bb35 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-rs-dir.md @@ -0,0 +1,120 @@ +# proj-mod-rs-dir + +> Use mod.rs for multi-file modules + +## Why It Matters + +Rust offers two styles for multi-file modules. The `mod.rs` style is clearer for larger modules and aligns with how most Rust projects are structured. Choose one style consistently. + +## Two Styles + +### Style 1: mod.rs (Recommended for larger modules) + +``` +src/ +├── user/ +│ ├── mod.rs # Module root +│ ├── model.rs +│ └── repository.rs +└── lib.rs +``` + +```rust +// src/lib.rs +mod user; // Looks for user/mod.rs or user.rs + +// src/user/mod.rs +mod model; +mod repository; +pub use model::User; +``` + +### Style 2: Adjacent file (Recommended for smaller modules) + +``` +src/ +├── user.rs # Module root +├── user/ +│ ├── model.rs +│ └── repository.rs +└── lib.rs +``` + +```rust +// src/lib.rs +mod user; // Looks for user.rs, then user/ for submodules + +// src/user.rs +mod model; +mod repository; +pub use model::User; +``` + +## When to Use Each + +| Scenario | Recommendation | +|----------|----------------| +| Simple module (1-3 submodules) | Adjacent file (`user.rs` + `user/`) | +| Complex module (4+ submodules) | `mod.rs` style (`user/mod.rs`) | +| Deep nesting | `mod.rs` at each level | +| Library with public modules | Consistent style throughout | + +## mod.rs Benefits + +- Clear that `user/` is a module directory +- All module code inside the folder +- Easier to move/rename entire modules +- Common in large codebases (tokio, serde) + +## Adjacent File Benefits + +- Module declaration outside directory +- Can see module's interface without entering folder +- Matches Rust 2018+ default lint preference +- Good for small modules with few submodules + +## Example: Complex Module + +``` +src/ +├── database/ +│ ├── mod.rs # Main module, re-exports +│ ├── connection.rs # Connection pool +│ ├── migrations.rs # Schema migrations +│ ├── queries/ # Sub-module for queries +│ │ ├── mod.rs +│ │ ├── user.rs +│ │ └── order.rs +│ └── error.rs +└── lib.rs +``` + +```rust +// src/database/mod.rs +mod connection; +mod migrations; +mod queries; +mod error; + +pub use connection::Pool; +pub use error::DatabaseError; +pub use queries::{UserQueries, OrderQueries}; +``` + +## Consistency Rule + +Pick one style for your project and stick with it: + +```rust +// Cargo.toml or clippy.toml +[lints.clippy] +mod_module_files = "warn" # Enforces mod.rs style +# OR +self_named_module_files = "warn" # Enforces adjacent style +``` + +## See Also + +- [proj-flat-small](./proj-flat-small.md) - Keep small projects flat +- [proj-mod-by-feature](./proj-mod-by-feature.md) - Feature organization +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Re-export patterns diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-prelude-module.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-prelude-module.md new file mode 100644 index 00000000..5b75d116 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-prelude-module.md @@ -0,0 +1,155 @@ +# proj-prelude-module + +> Create prelude module for common imports + +## Why It Matters + +A `prelude` module collects the most commonly used types and traits for glob import. Users write `use my_crate::prelude::*` instead of many individual imports. This follows the pattern established by `std::prelude`. + +## Bad + +```rust +// Users must import everything individually +use my_crate::Client; +use my_crate::Config; +use my_crate::Error; +use my_crate::Request; +use my_crate::Response; +use my_crate::traits::Handler; +use my_crate::traits::Middleware; +use my_crate::types::Method; +``` + +## Good + +```rust +// src/lib.rs +pub mod prelude { + pub use crate::{ + Client, + Config, + Error, + Request, + Response, + }; + pub use crate::traits::{Handler, Middleware}; + pub use crate::types::Method; +} + +// Users write: +use my_crate::prelude::*; +``` + +## What to Include + +| Include | Don't Include | +|---------|---------------| +| Core types users always need | Rarely-used types | +| Common traits | Implementation details | +| Error types | Internal helpers | +| Extension traits | Feature-gated items (usually) | +| Type aliases | Everything | + +## Example: Web Framework Prelude + +```rust +pub mod prelude { + // Core request/response + pub use crate::{Request, Response, Body}; + + // Error handling + pub use crate::Error; + + // Common traits + pub use crate::traits::{FromRequest, IntoResponse}; + + // Routing + pub use crate::Router; + + // HTTP types + pub use crate::http::{Method, StatusCode}; +} +``` + +## Example: Database Library Prelude + +```rust +pub mod prelude { + // Connection and pool + pub use crate::{Connection, Pool}; + + // Query building + pub use crate::query::{Query, Select, Insert, Update, Delete}; + + // Traits for custom types + pub use crate::traits::{FromRow, ToSql}; + + // Error type + pub use crate::Error; +} +``` + +## Pattern: Tiered Preludes + +```rust +// Minimal prelude +pub mod prelude { + pub use crate::{Client, Config, Error}; +} + +// Full prelude for power users +pub mod full_prelude { + pub use crate::prelude::*; + pub use crate::advanced::*; + pub use crate::extensions::*; +} +``` + +## Pattern: Feature-Gated Prelude Items + +```rust +pub mod prelude { + pub use crate::{Client, Error}; + + #[cfg(feature = "async")] + pub use crate::async_client::AsyncClient; + + #[cfg(feature = "serde")] + pub use crate::serde::{Serialize, Deserialize}; +} +``` + +## Guidelines + +1. **Be conservative** - Only include truly common items +2. **Avoid conflicts** - Don't include names that might clash (e.g., `Error`) +3. **Document it** - List what's included in module docs +4. **Stay stable** - Removing items is breaking change + +## Documenting the Prelude + +```rust +//! Common imports for convenient glob importing. +//! +//! # Usage +//! +//! ``` +//! use my_crate::prelude::*; +//! ``` +//! +//! # Contents +//! +//! This prelude re-exports: +//! - [`Client`] - The main API client +//! - [`Config`] - Client configuration +//! - [`Error`] - Error type +pub mod prelude { + // ... +} +``` + +## See Also + +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Re-export patterns +- [api-extension-trait](./api-extension-trait.md) - Extension traits +- [doc-module-inner](./doc-module-inner.md) - Module documentation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-crate-internal.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-crate-internal.md new file mode 100644 index 00000000..d83bc36c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-crate-internal.md @@ -0,0 +1,139 @@ +# proj-pub-crate-internal + +> Use pub(crate) for internal APIs + +## Why It Matters + +`pub(crate)` exposes items within the crate but hides them from external users. This creates clear boundaries between public API and internal implementation, preventing accidental breakage and reducing public API surface. + +## Bad + +```rust +// Everything public - users depend on internals +pub mod internal { + pub struct InternalState { + pub buffer: Vec, // Implementation detail exposed + pub dirty: bool, + } + + pub fn process_internal(state: &mut InternalState) { + // Users can call this, creating coupling + } +} + +pub struct Widget { + pub state: internal::InternalState, // Exposed! +} +``` + +## Good + +```rust +// Internal module with crate visibility +pub(crate) mod internal { + pub(crate) struct InternalState { + pub(crate) buffer: Vec, + pub(crate) dirty: bool, + } + + pub(crate) fn process_internal(state: &mut InternalState) { + // Only callable within crate + } +} + +pub struct Widget { + state: internal::InternalState, // Private field +} + +impl Widget { + pub fn new() -> Self { + Self { + state: internal::InternalState { + buffer: Vec::new(), + dirty: false, + } + } + } + + pub fn do_something(&mut self) { + internal::process_internal(&mut self.state); + } +} +``` + +## Visibility Levels + +| Visibility | Accessible From | +|------------|-----------------| +| `pub` | Everywhere | +| `pub(crate)` | Current crate only | +| `pub(super)` | Parent module only | +| `pub(in path)` | Specific module path | +| (private) | Current module only | + +## Pattern: Internal Module + +```rust +// src/lib.rs +mod internal; // Private module +pub mod api; // Public API + +// src/internal.rs +pub(crate) struct Helper; +pub(crate) fn helper_function() -> Helper { Helper } + +// src/api.rs +use crate::internal::{Helper, helper_function}; + +pub struct PublicType { + helper: Helper, // Uses internal type, but field is private +} +``` + +## Pattern: Test Visibility + +```rust +pub struct Parser { + // Private implementation + state: ParserState, +} + +// Expose for testing but not public API +#[cfg(test)] +pub(crate) fn debug_state(&self) -> &ParserState { + &self.state +} + +// Or use a dedicated test helper +#[doc(hidden)] +pub mod __test_helpers { + pub use super::ParserState; +} +``` + +## Pattern: Feature Module Internals + +```rust +// src/user/mod.rs +mod repository; // Private +mod service; // Private + +pub use service::UserService; // Only export the public API + +// repository and service are pub(crate) internally +// so other modules in crate can use them if needed +``` + +## Benefits + +| Approach | API Stability | Flexibility | +|----------|---------------|-------------| +| All `pub` | Any change breaks users | None | +| `pub(crate)` internals | Only `pub` items matter | Can refactor freely | +| Private | Maximum encapsulation | Limits crate flexibility | + +## See Also + +- [proj-pub-super-parent](./proj-pub-super-parent.md) - Parent-only visibility +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Clean re-exports +- [api-non-exhaustive](./api-non-exhaustive.md) - Future-proof structs diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-super-parent.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-super-parent.md new file mode 100644 index 00000000..dceaf668 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-super-parent.md @@ -0,0 +1,135 @@ +# proj-pub-super-parent + +> Use pub(super) for parent-only visibility + +## Why It Matters + +`pub(super)` exposes items only to the immediate parent module. This is useful for helper functions and types that submodules share but shouldn't be visible to the rest of the crate. + +## Bad + +```rust +// src/parser/mod.rs +pub mod lexer; +pub mod ast; + +// src/parser/lexer.rs +pub fn internal_helper() { // Visible to entire crate! + // Helper only needed by lexer and ast +} + +pub(crate) struct Token { // Visible to entire crate + // Only parser submodules need this +} +``` + +## Good + +```rust +// src/parser/mod.rs +pub mod lexer; +pub mod ast; + +// Shared types for parser submodules only +pub(super) struct Token { + pub(super) kind: TokenKind, + pub(super) span: Span, +} + +pub(super) fn shared_helper() -> Token { + // Only visible in parser/* +} + +// src/parser/lexer.rs +use super::{Token, shared_helper}; + +pub fn lex(input: &str) -> Vec { + shared_helper(); + // ... +} + +// src/parser/ast.rs +use super::Token; + +pub fn parse(tokens: Vec) -> Ast { + // ... +} +``` + +## Visibility Hierarchy + +``` +src/ +├── lib.rs # crate root +├── parser/ +│ ├── mod.rs # pub(super) items visible here +│ ├── lexer.rs # can use pub(super) from mod.rs +│ └── ast.rs # can use pub(super) from mod.rs +└── codegen.rs # CANNOT see pub(super) parser items +``` + +## Pattern: Layered Visibility + +```rust +// src/database/mod.rs +mod connection; +mod query; +mod pool; + +// Only this module's children can see +pub(super) struct RawConnection { /* ... */ } + +// Entire crate can see +pub(crate) struct Pool { /* ... */ } + +// Everyone can see +pub struct Database { /* ... */ } +``` + +## Pattern: Test Helpers + +```rust +// src/parser/mod.rs +mod lexer; +mod ast; + +#[cfg(test)] +mod tests { + use super::*; + + // Test helper visible only to parser module's tests + pub(super) fn make_test_token() -> Token { + Token { kind: TokenKind::Test, span: Span::dummy() } + } +} + +// src/parser/lexer.rs +#[cfg(test)] +mod tests { + use super::super::tests::make_test_token; + // ... +} +``` + +## Comparison + +| Visibility | Scope | Use Case | +|------------|-------|----------| +| `pub` | Everywhere | Public API | +| `pub(crate)` | Crate-wide | Internal shared utilities | +| `pub(super)` | Parent module | Submodule helpers | +| `pub(in path)` | Specific path | Precise control | +| (private) | Current module | Implementation details | + +## When to Use pub(super) + +- Helper functions shared between sibling modules +- Types used by submodules but not the rest of crate +- Implementation details of a module group +- Test utilities for a module tree + +## See Also + +- [proj-pub-crate-internal](./proj-pub-crate-internal.md) - Crate visibility +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Re-export patterns +- [proj-mod-by-feature](./proj-mod-by-feature.md) - Feature organization diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-use-reexport.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-use-reexport.md new file mode 100644 index 00000000..02c672ba --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-use-reexport.md @@ -0,0 +1,162 @@ +# proj-pub-use-reexport + +> Use pub use for clean public API + +## Why It Matters + +`pub use` re-exports items from submodules at the current module level. This creates a flat, ergonomic public API while keeping internal organization flexible. Users import from one place; you can reorganize internals without breaking their code. + +## Bad + +```rust +// lib.rs - Deep module paths exposed +pub mod error; +pub mod config; +pub mod client; +pub mod types; + +// Users must write: +use my_crate::error::MyError; +use my_crate::config::Config; +use my_crate::client::http::HttpClient; +use my_crate::types::request::Request; +``` + +## Good + +```rust +// lib.rs - Flat public API +mod error; +mod config; +mod client; +mod types; + +pub use error::MyError; +pub use config::Config; +pub use client::http::HttpClient; +pub use types::request::Request; + +// Users write: +use my_crate::{Config, HttpClient, MyError, Request}; +``` + +## Pattern: Selective Re-export + +```rust +// src/lib.rs +mod internal; + +// Only re-export what users need +pub use internal::{ + PublicStruct, + PublicTrait, + public_function, +}; + +// Keep implementation details hidden +// internal::helper_function is NOT exported +``` + +## Pattern: Rename on Re-export + +```rust +mod v1 { + pub struct Client { /* old implementation */ } +} + +mod v2 { + pub struct Client { /* new implementation */ } +} + +// Re-export with clear names +pub use v2::Client; +pub use v1::Client as LegacyClient; +``` + +## Pattern: Prelude Module + +```rust +// src/lib.rs +pub mod prelude { + pub use crate::{ + Config, + Client, + Error, + Request, + Response, + }; +} + +// Users can glob import common items +use my_crate::prelude::*; +``` + +## Pattern: Feature-Gated Re-exports + +```rust +// src/lib.rs +mod core; +mod serde_impl; +mod async_impl; + +pub use core::*; + +#[cfg(feature = "serde")] +pub use serde_impl::*; + +#[cfg(feature = "async")] +pub use async_impl::*; +``` + +## Comparison: Module Structure vs Public API + +```rust +// Internal structure (complex) +src/ +├── transport/ +│ ├── http/ +│ │ └── client.rs // HttpClient +│ └── grpc/ +│ └── client.rs // GrpcClient +├── auth/ +│ └── token.rs // Token +└── lib.rs + +// Public API (flat) +pub use transport::http::client::HttpClient; +pub use transport::grpc::client::GrpcClient; +pub use auth::token::Token; + +// Users see: +my_crate::HttpClient +my_crate::GrpcClient +my_crate::Token +``` + +## Re-export External Types + +```rust +// Re-export dependencies users will need +pub use bytes::Bytes; +pub use http::{Method, StatusCode}; + +// Now users don't need to depend on these crates directly +``` + +## Glob Re-exports + +Use sparingly: + +```rust +// OK for internal modules +pub use internal::*; + +// Careful with external crates - pollutes namespace +pub use serde::*; // Usually too broad +``` + +## See Also + +- [proj-prelude-module](./proj-prelude-module.md) - Prelude pattern +- [proj-pub-crate-internal](./proj-pub-crate-internal.md) - Internal visibility +- [api-non-exhaustive](./api-non-exhaustive.md) - API stability diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-deps.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-deps.md new file mode 100644 index 00000000..d9155015 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-deps.md @@ -0,0 +1,186 @@ +# proj-workspace-deps + +> Use workspace dependency inheritance for consistent versions across crates + +## Why It Matters + +Multi-crate workspaces often have dependency version drift—different crates using different versions of the same dependency. Workspace dependency inheritance (Rust 1.64+) lets you declare dependencies once in the workspace `Cargo.toml` and inherit them in member crates, ensuring consistency. + +## Bad + +```toml +# crate-a/Cargo.toml +[dependencies] +serde = "1.0.150" +tokio = "1.25" + +# crate-b/Cargo.toml +[dependencies] +serde = "1.0.188" # Different version! +tokio = "1.32" # Different version! + +# Version drift leads to: +# - Larger binaries (multiple versions) +# - Compilation time increase +# - Subtle behavior differences +``` + +## Good + +```toml +# Root Cargo.toml +[workspace] +members = ["crate-a", "crate-b", "crate-c"] + +[workspace.dependencies] +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.32", features = ["full"] } +thiserror = "1.0" +anyhow = "1.0" +tracing = "0.1" + +# crate-a/Cargo.toml +[dependencies] +serde.workspace = true +tokio.workspace = true + +# crate-b/Cargo.toml +[dependencies] +serde.workspace = true +tokio.workspace = true +thiserror.workspace = true +``` + +## Override Features + +```toml +# Root Cargo.toml +[workspace.dependencies] +tokio = { version = "1.32", features = ["rt-multi-thread"] } + +# crate-a/Cargo.toml - add extra features +[dependencies] +tokio = { workspace = true, features = ["net", "io-util"] } +# Gets both workspace features AND local features + +# crate-b/Cargo.toml - minimal features +[dependencies] +tokio = { workspace = true } # Just workspace features +``` + +## Dev and Build Dependencies + +```toml +# Root Cargo.toml +[workspace.dependencies] +criterion = "0.5" +proptest = "1.0" +trybuild = "1.0" +cc = "1.0" + +# crate-a/Cargo.toml +[dev-dependencies] +criterion.workspace = true +proptest.workspace = true + +[build-dependencies] +cc.workspace = true +``` + +## Internal Crate Dependencies + +```toml +# Root Cargo.toml +[workspace.dependencies] +# Internal crates +my-core = { path = "crates/core" } +my-utils = { path = "crates/utils" } +my-derive = { path = "crates/derive" } + +# External crates +serde = "1.0" + +# crate-a/Cargo.toml +[dependencies] +my-core.workspace = true +my-utils.workspace = true +serde.workspace = true +``` + +## Optional Dependencies + +```toml +# Root Cargo.toml +[workspace.dependencies] +serde = { version = "1.0", optional = true } # Won't work! + +# Optional must be set in member, not workspace +[workspace.dependencies] +serde = "1.0" + +# crate-a/Cargo.toml +[dependencies] +serde = { workspace = true, optional = true } + +[features] +serde = ["dep:serde"] +``` + +## Complete Workspace Example + +```toml +# Root Cargo.toml +[workspace] +members = ["crates/*"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/user/repo" + +[workspace.dependencies] +# Internal +my-core = { path = "crates/core", version = "0.1" } + +# Async +tokio = { version = "1.32", features = ["full"] } +futures = "0.3" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling +thiserror = "1.0" +anyhow = "1.0" + +# Logging +tracing = "0.1" +tracing-subscriber = "0.3" + +# Testing +proptest = "1.0" +criterion = { version = "0.5", features = ["html_reports"] } + +# crates/core/Cargo.toml +[package] +name = "my-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true +``` + +## See Also + +- [proj-lib-main-split](./proj-lib-main-split.md) - Workspace structure +- [api-serde-optional](./api-serde-optional.md) - Optional dependencies +- [lint-deny-correctness](./lint-deny-correctness.md) - Workspace lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-large.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-large.md new file mode 100644 index 00000000..de07e5c1 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-large.md @@ -0,0 +1,162 @@ +# proj-workspace-large + +> Use workspaces for large projects + +## Why It Matters + +Cargo workspaces manage multiple related crates under one repository. They share a single `Cargo.lock`, build cache, and can be versioned together. For large projects, workspaces improve build times, enforce modularity, and simplify dependency management. + +## Bad + +``` +# Separate repositories for each crate +my-app-core/ +my-app-cli/ +my-app-server/ +my-app-common/ + +# Each has its own Cargo.lock +# Dependencies may drift +# Cross-crate development is painful +``` + +## Good + +``` +my-app/ +├── Cargo.toml # Workspace root +├── Cargo.lock # Shared lock file +├── crates/ +│ ├── core/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ ├── cli/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ ├── server/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ └── common/ +│ ├── Cargo.toml +│ └── src/ +└── README.md +``` + +## Workspace Cargo.toml + +```toml +# Root Cargo.toml +[workspace] +resolver = "2" # Use the new resolver +members = [ + "crates/core", + "crates/cli", + "crates/server", + "crates/common", +] + +# Shared dependencies - all crates use same versions +[workspace.dependencies] +tokio = { version = "1.0", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +tracing = "0.1" +anyhow = "1.0" + +# Shared lints +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "warn" +``` + +## Member Crate Cargo.toml + +```toml +# crates/core/Cargo.toml +[package] +name = "my-app-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Inherit from workspace +tokio = { workspace = true } +serde = { workspace = true } + +# Crate-specific dependencies +uuid = "1.0" + +# Internal dependency +my-app-common = { path = "../common" } + +[lints] +workspace = true # Inherit workspace lints +``` + +## When to Use Workspaces + +| Scenario | Recommendation | +|----------|----------------| +| Single binary/library | No workspace needed | +| Library + CLI | Maybe, depends on size | +| Multiple related crates | Yes | +| Shared internal libraries | Yes | +| Microservices mono-repo | Yes | +| Plugin architecture | Yes | + +## Benefits + +| Aspect | Single Crate | Workspace | +|--------|--------------|-----------| +| Build cache | Crate only | Shared across all | +| Dependency versions | Per-crate | Synchronized | +| Compile times | Full rebuild | Incremental | +| Modularity | Files/modules | Crate boundaries | +| Publishing | Single crate | Independent | + +## Commands + +```bash +# Build all crates +cargo build --workspace + +# Build specific crate +cargo build -p my-app-core + +# Test all crates +cargo test --workspace + +# Run specific binary +cargo run -p my-app-cli + +# Check all +cargo check --workspace +``` + +## Pattern: Virtual Workspace + +Root Cargo.toml is workspace-only (no `[package]`): + +```toml +[workspace] +members = ["crates/*"] + +[workspace.dependencies] +# ... +``` + +## Pattern: Crate Interdependencies + +```toml +# crates/server/Cargo.toml +[dependencies] +my-app-core = { path = "../core" } +my-app-common = { path = "../common" } +``` + +## See Also + +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace dependencies +- [proj-bin-dir](./proj-bin-dir.md) - Multiple binaries +- [proj-lib-main-split](./proj-lib-main-split.md) - Lib/main separation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-arrange-act-assert.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-arrange-act-assert.md new file mode 100644 index 00000000..0ad7cd59 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-arrange-act-assert.md @@ -0,0 +1,160 @@ +# test-arrange-act-assert + +> Structure tests with clear Arrange, Act, Assert sections + +## Why It Matters + +The AAA pattern makes tests readable and maintainable. Each section has a clear purpose: set up test data, execute the code under test, verify the results. This structure helps identify what's being tested and makes tests easier to debug when they fail. + +## Bad + +```rust +#[test] +fn test_user() { + assert_eq!(User::new("alice", "alice@example.com").unwrap().name(), "alice"); + assert!(User::new("", "email@example.com").is_err()); + let u = User::new("bob", "bob@example.com").unwrap(); + assert!(u.validate()); + assert_eq!(u.email(), "bob@example.com"); +} +// Multiple concerns, hard to understand, hard to debug +``` + +## Good + +```rust +#[test] +fn new_user_has_correct_name() { + // Arrange + let name = "alice"; + let email = "alice@example.com"; + + // Act + let user = User::new(name, email).unwrap(); + + // Assert + assert_eq!(user.name(), "alice"); +} + +#[test] +fn user_creation_fails_with_empty_name() { + // Arrange + let name = ""; + let email = "email@example.com"; + + // Act + let result = User::new(name, email); + + // Assert + assert!(result.is_err()); + assert!(matches!(result, Err(UserError::EmptyName))); +} +``` + +## With Comments + +```rust +#[test] +fn order_total_includes_tax() { + // Arrange + let mut order = Order::new(); + order.add_item(Item::new("Widget", 100.00)); + order.add_item(Item::new("Gadget", 50.00)); + let tax_rate = 0.10; + + // Act + let total = order.calculate_total(tax_rate); + + // Assert + let expected = (100.00 + 50.00) * 1.10; + assert_eq!(total, expected); +} +``` + +## Complex Arrange + +```rust +#[test] +fn search_returns_matching_documents() { + // Arrange + let mut index = SearchIndex::new(); + index.add_document(Document::new(1, "rust programming")); + index.add_document(Document::new(2, "python programming")); + index.add_document(Document::new(3, "rust web development")); + + let query = Query::new("rust"); + + // Act + let results = index.search(&query); + + // Assert + assert_eq!(results.len(), 2); + assert!(results.iter().any(|d| d.id == 1)); + assert!(results.iter().any(|d| d.id == 3)); +} +``` + +## Async Tests + +```rust +#[tokio::test] +async fn fetch_user_returns_user_data() { + // Arrange + let client = TestClient::new(); + let user_id = 42; + + // Act + let result = client.fetch_user(user_id).await; + + // Assert + assert!(result.is_ok()); + let user = result.unwrap(); + assert_eq!(user.id, user_id); +} +``` + +## Helper Functions + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Arrange helpers + fn create_test_user() -> User { + User::new("test", "test@example.com").unwrap() + } + + fn create_order_with_items(items: &[(&str, f64)]) -> Order { + let mut order = Order::new(); + for (name, price) in items { + order.add_item(Item::new(name, *price)); + } + order + } + + // Assert helpers + fn assert_order_total(order: &Order, expected: f64) { + let total = order.calculate_total(0.0); + assert!((total - expected).abs() < 0.01); + } + + #[test] + fn order_total_sums_items() { + // Arrange + let order = create_order_with_items(&[ + ("A", 10.0), + ("B", 20.0), + ]); + + // Act & Assert + assert_order_total(&order, 30.0); + } +} +``` + +## See Also + +- [test-descriptive-names](./test-descriptive-names.md) - Test naming +- [test-fixture-raii](./test-fixture-raii.md) - Test setup/teardown +- [test-mock-traits](./test-mock-traits.md) - Mocking dependencies diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-cfg-test-module.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-cfg-test-module.md new file mode 100644 index 00000000..53f7eb70 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-cfg-test-module.md @@ -0,0 +1,151 @@ +# test-cfg-test-module + +> Put unit tests in `#[cfg(test)] mod tests { }` within each module + +## Why It Matters + +The `#[cfg(test)]` attribute ensures test code is only compiled during `cargo test`, not in release builds. Placing tests in a `tests` submodule within the same file keeps tests close to the code they test while maintaining separation. This is Rust's idiomatic unit test pattern. + +## Bad + +```rust +// Tests without cfg(test) - compiled into release binary +mod tests { + #[test] + fn test_something() { ... } // Included in release build! +} + +// Tests in separate file without access to private items +// src/my_module.rs +fn private_helper() { ... } + +// tests/my_module_test.rs +// Can't access private_helper! +``` + +## Good + +```rust +// src/my_module.rs + +fn public_api() -> i32 { + private_helper() * 2 +} + +fn private_helper() -> i32 { + 21 +} + +#[cfg(test)] +mod tests { + use super::*; // Access to private items + + #[test] + fn test_public_api() { + assert_eq!(public_api(), 42); + } + + #[test] + fn test_private_helper() { + assert_eq!(private_helper(), 21); // Can test private! + } +} +``` + +## Module Structure + +```rust +// src/lib.rs +mod parser; +mod lexer; +mod ast; + +// src/parser.rs +pub fn parse(input: &str) -> Result { + let tokens = tokenize(input)?; + build_ast(tokens) +} + +fn tokenize(input: &str) -> Result, Error> { ... } +fn build_ast(tokens: Vec) -> Result { ... } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple() { + let ast = parse("1 + 2").unwrap(); + assert_eq!(ast.evaluate(), 3); + } + + #[test] + fn test_tokenize() { + let tokens = tokenize("1 + 2").unwrap(); + assert_eq!(tokens.len(), 3); + } +} +``` + +## Test Helpers + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Test-only helpers + fn create_test_data() -> Data { + Data { + id: 1, + name: "test".into(), + values: vec![1, 2, 3], + } + } + + fn assert_valid(data: &Data) { + assert!(data.id > 0); + assert!(!data.name.is_empty()); + } + + #[test] + fn test_processing() { + let data = create_test_data(); + let result = process(&data); + assert_valid(&result); + } +} +``` + +## Multiple Test Modules + +```rust +// For larger test suites, use submodules +#[cfg(test)] +mod tests { + use super::*; + + mod parsing { + use super::*; + + #[test] + fn test_parse_number() { ... } + + #[test] + fn test_parse_string() { ... } + } + + mod validation { + use super::*; + + #[test] + fn test_validate_range() { ... } + } +} +``` + +## See Also + +- [test-use-super](./test-use-super.md) - Importing from parent module +- [test-integration-dir](./test-integration-dir.md) - Integration tests +- [test-descriptive-names](./test-descriptive-names.md) - Test naming diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-criterion-bench.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-criterion-bench.md new file mode 100644 index 00000000..cdc44238 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-criterion-bench.md @@ -0,0 +1,171 @@ +# test-criterion-bench + +> Use `criterion` for benchmarking + +## Why It Matters + +Criterion provides statistically rigorous benchmarking with warmup, multiple iterations, outlier detection, and comparison between runs. It's far more reliable than simple timing with `Instant::now()`. + +## Setup + +```toml +# Cargo.toml +[dev-dependencies] +criterion = "0.5" + +[[bench]] +name = "my_benchmark" +harness = false +``` + +## Basic Benchmark + +```rust +// benches/my_benchmark.rs +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn fibonacci(n: u64) -> u64 { + match n { + 0 => 0, + 1 => 1, + n => fibonacci(n - 1) + fibonacci(n - 2), + } +} + +fn bench_fibonacci(c: &mut Criterion) { + c.bench_function("fib 20", |b| { + b.iter(|| fibonacci(black_box(20))) + }); +} + +criterion_group!(benches, bench_fibonacci); +criterion_main!(benches); +``` + +## black_box is Critical + +```rust +// BAD: Compiler may optimize away the computation +b.iter(|| fibonacci(20)); // Result unused, might be eliminated + +// GOOD: black_box prevents optimization +b.iter(|| fibonacci(black_box(20))); + +// Also wrap the result if needed +b.iter(|| black_box(fibonacci(black_box(20)))); +``` + +## Comparing Implementations + +```rust +fn bench_comparison(c: &mut Criterion) { + let mut group = c.benchmark_group("String concat"); + + let data = "hello"; + + group.bench_function("format!", |b| { + b.iter(|| format!("{}{}", black_box(data), " world")) + }); + + group.bench_function("push_str", |b| { + b.iter(|| { + let mut s = String::from(black_box(data)); + s.push_str(" world"); + s + }) + }); + + group.bench_function("concat", |b| { + b.iter(|| [black_box(data), " world"].concat()) + }); + + group.finish(); +} +``` + +## Parameterized Benchmarks + +```rust +fn bench_vec_push(c: &mut Criterion) { + let mut group = c.benchmark_group("Vec::push"); + + for size in [100, 1000, 10000].iter() { + group.bench_with_input( + BenchmarkId::from_parameter(size), + size, + |b, &size| { + b.iter(|| { + let mut v = Vec::new(); + for i in 0..size { + v.push(black_box(i)); + } + v + }); + }, + ); + } + + group.finish(); +} +``` + +## Throughput Measurement + +```rust +use criterion::Throughput; + +fn bench_parse(c: &mut Criterion) { + let input = "a]ong string to parse..."; + + let mut group = c.benchmark_group("Parser"); + group.throughput(Throughput::Bytes(input.len() as u64)); + + group.bench_function("parse", |b| { + b.iter(|| parse(black_box(input))) + }); + + group.finish(); +} +``` + +## Running Benchmarks + +```bash +# Run all benchmarks +cargo bench + +# Run specific benchmark +cargo bench -- fib + +# Save baseline for comparison +cargo bench -- --save-baseline main + +# Compare against baseline +cargo bench -- --baseline main +``` + +## Evidence from tokio + +```rust +// https://github.com/tokio-rs/tokio/blob/master/benches/sync_mpsc.rs +use criterion::{criterion_group, criterion_main, Criterion}; + +fn send_data( + g: &mut BenchmarkGroup, + prefix: &str +) { + let rt = rt(); + g.bench_function(format!("{prefix}_{SIZE}"), |b| { + b.iter(|| { + let (tx, mut rx) = mpsc::channel::(SIZE); + rt.block_on(tx.send(T::default())).unwrap(); + rt.block_on(rx.recv()).unwrap(); + }) + }); +} +``` + +## See Also + +- [perf-profile-first](perf-profile-first.md) - Profile before optimizing +- [perf-black-box-bench](perf-black-box-bench.md) - Use black_box in benchmarks diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-descriptive-names.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-descriptive-names.md new file mode 100644 index 00000000..de77cc7c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-descriptive-names.md @@ -0,0 +1,142 @@ +# test-descriptive-names + +> Use descriptive test names that explain what is being tested + +## Why It Matters + +Test names appear in test output and serve as documentation. A good test name tells you what behavior is being verified without reading the test body. When a test fails, a descriptive name immediately tells you what broke. + +## Bad + +```rust +#[test] +fn test1() { ... } + +#[test] +fn test_parse() { ... } // Parse what? What behavior? + +#[test] +fn it_works() { ... } + +#[test] +fn test_function() { ... } + +// Failure output: "test test_parse ... FAILED" +// What failed? No idea. +``` + +## Good + +```rust +#[test] +fn parse_returns_error_for_empty_input() { ... } + +#[test] +fn parse_handles_unicode_characters() { ... } + +#[test] +fn user_creation_requires_valid_email() { ... } + +#[test] +fn expired_token_is_rejected() { ... } + +// Failure output: "test parse_returns_error_for_empty_input ... FAILED" +// Immediately know what broke! +``` + +## Naming Patterns + +```rust +// Pattern: function_condition_expected_result +#[test] +fn parse_valid_json_returns_document() { ... } + +#[test] +fn parse_invalid_json_returns_syntax_error() { ... } + +// Pattern: scenario_expectation +#[test] +fn empty_cart_has_zero_total() { ... } + +#[test] +fn adding_item_increases_cart_total() { ... } + +// Pattern: when_given_then (BDD-style) +#[test] +fn when_user_not_found_then_returns_404() { ... } +``` + +## Edge Cases + +```rust +#[test] +fn handles_empty_string() { ... } + +#[test] +fn handles_max_length_input() { ... } + +#[test] +fn handles_unicode_emoji() { ... } + +#[test] +fn handles_null_bytes() { ... } + +#[test] +fn handles_concurrent_access() { ... } +``` + +## Error Cases + +```rust +#[test] +fn rejects_negative_quantity() { ... } + +#[test] +fn returns_error_for_invalid_email_format() { ... } + +#[test] +fn panics_on_double_initialization() { ... } + +#[test] +fn timeout_returns_timeout_error() { ... } +``` + +## Module Organization + +```rust +#[cfg(test)] +mod tests { + use super::*; + + mod parsing { + use super::*; + + #[test] + fn accepts_valid_json() { ... } + + #[test] + fn rejects_trailing_comma() { ... } + } + + mod validation { + use super::*; + + #[test] + fn requires_name_field() { ... } + + #[test] + fn email_must_contain_at_symbol() { ... } + } +} + +// Test output: +// tests::parsing::accepts_valid_json +// tests::parsing::rejects_trailing_comma +// tests::validation::requires_name_field +``` + +## See Also + +- [test-arrange-act-assert](./test-arrange-act-assert.md) - Test structure +- [test-cfg-test-module](./test-cfg-test-module.md) - Test module organization +- [doc-examples-section](./doc-examples-section.md) - Documentation tests diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-doctest-examples.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-doctest-examples.md new file mode 100644 index 00000000..1b9428ec --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-doctest-examples.md @@ -0,0 +1,168 @@ +# test-doctest-examples + +> Keep documentation examples as executable doctests + +## Why It Matters + +Doctests are examples in documentation that are automatically tested. They serve dual purposes: demonstrating usage to readers and verifying the examples compile and work. When your API changes, failing doctests catch outdated documentation. + +## Bad + +```rust +/// Parses a number from a string. +/// +/// Example: +/// let n = parse("42"); // Not tested! +/// assert_eq!(n, 42); +pub fn parse(s: &str) -> i32 { + s.parse().unwrap() +} + +// Documentation can become outdated: +/// Adds two numbers. +/// +/// ``` +/// let sum = add(1, 2, 3); // Wrong number of args - not caught! +/// ``` +pub fn add(a: i32, b: i32) -> i32 { + a + b +} +``` + +## Good + +```rust +/// Parses a number from a string. +/// +/// # Examples +/// +/// ``` +/// use my_crate::parse; +/// +/// let n = parse("42"); +/// assert_eq!(n, 42); +/// ``` +pub fn parse(s: &str) -> i32 { + s.parse().unwrap() +} + +/// Adds two numbers. +/// +/// # Examples +/// +/// ``` +/// use my_crate::add; +/// +/// let sum = add(1, 2); +/// assert_eq!(sum, 3); +/// ``` +pub fn add(a: i32, b: i32) -> i32 { + a + b +} +``` + +## Hiding Setup Code + +```rust +/// Processes data from a file. +/// +/// # Examples +/// +/// ``` +/// # use std::io::Write; +/// # let mut file = tempfile::NamedTempFile::new().unwrap(); +/// # writeln!(file, "test data").unwrap(); +/// # let path = file.path(); +/// use my_crate::process_file; +/// +/// let result = process_file(path)?; +/// assert!(!result.is_empty()); +/// # Ok::<(), Box>(()) +/// ``` +pub fn process_file(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(Error::from) +} +``` + +## Showing Error Handling + +```rust +/// Parses and validates an email address. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Email; +/// +/// let email = Email::parse("user@example.com")?; +/// assert_eq!(email.domain(), "example.com"); +/// # Ok::<(), my_crate::EmailError>(()) +/// ``` +/// +/// # Errors +/// +/// Returns error for invalid format: +/// +/// ``` +/// use my_crate::Email; +/// +/// assert!(Email::parse("not-an-email").is_err()); +/// ``` +pub fn parse(s: &str) -> Result { + // ... +} +``` + +## no_run and ignore + +```rust +/// Starts the server. +/// +/// ```no_run +/// use my_crate::Server; +/// +/// // This compiles but doesn't run (would block forever) +/// Server::new().run(); +/// ``` +pub fn run(&self) { ... } + +/// Platform-specific example. +/// +/// ```ignore +/// // This might not compile on all platforms +/// use windows_specific::Feature; +/// ``` +``` + +## compile_fail + +```rust +/// This type is not Clone. +/// +/// ```compile_fail +/// use my_crate::UniqueHandle; +/// +/// let a = UniqueHandle::new(); +/// let b = a.clone(); // Error: Clone not implemented +/// ``` +pub struct UniqueHandle { ... } +``` + +## Running Doctests + +```bash +# Run all tests including doctests +cargo test + +# Run only doctests +cargo test --doc + +# Run doctests for specific item +cargo test --doc my_function +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Documentation structure +- [doc-hidden-setup](./doc-hidden-setup.md) - Hiding setup code +- [doc-question-mark](./doc-question-mark.md) - Error handling in examples diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-fixture-raii.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-fixture-raii.md new file mode 100644 index 00000000..cd66fd21 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-fixture-raii.md @@ -0,0 +1,151 @@ +# test-fixture-raii + +> Use RAII pattern (Drop trait) for automatic test cleanup + +## Why It Matters + +Tests often need setup and teardown—creating temp files, starting servers, setting environment variables. Using RAII (Resource Acquisition Is Initialization) with Drop ensures cleanup happens automatically, even if the test panics. This prevents test pollution and resource leaks. + +## Bad + +```rust +#[test] +fn test_with_temp_file() { + let path = "/tmp/test_file.txt"; + std::fs::write(path, "test data").unwrap(); + + let result = process_file(path); + + std::fs::remove_file(path).unwrap(); // Might not run if test panics! + assert!(result.is_ok()); +} + +#[test] +fn test_with_env_var() { + std::env::set_var("MY_VAR", "test_value"); + + let result = read_config(); + + std::env::remove_var("MY_VAR"); // Might not run if test panics! + assert!(result.is_ok()); +} +``` + +## Good + +```rust +use tempfile::NamedTempFile; + +#[test] +fn test_with_temp_file() { + // Arrange - file deleted automatically when `file` drops + let file = NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "test data").unwrap(); + + // Act + let result = process_file(file.path()); + + // Assert - file cleaned up even if assertion panics + assert!(result.is_ok()); +} + +// Custom RAII guard for environment variables +struct EnvGuard { + key: String, + original: Option, +} + +impl EnvGuard { + fn set(key: &str, value: &str) -> Self { + let original = std::env::var(key).ok(); + std::env::set_var(key, value); + EnvGuard { + key: key.to_string(), + original, + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.original { + Some(v) => std::env::set_var(&self.key, v), + None => std::env::remove_var(&self.key), + } + } +} + +#[test] +fn test_with_env_var() { + let _guard = EnvGuard::set("MY_VAR", "test_value"); + + let result = read_config(); + + assert!(result.is_ok()); +} // MY_VAR automatically restored +``` + +## Common RAII Patterns + +```rust +// Temporary directory +use tempfile::TempDir; + +#[test] +fn test_with_temp_dir() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.txt"); + std::fs::write(&file_path, "data").unwrap(); + + // dir and all contents deleted on drop +} + +// Server guard +struct TestServer { + handle: std::thread::JoinHandle<()>, + shutdown: std::sync::mpsc::Sender<()>, +} + +impl Drop for TestServer { + fn drop(&mut self) { + let _ = self.shutdown.send(()); + // Wait for server to stop + } +} + +// Database transaction rollback +struct TestTransaction<'a> { + conn: &'a mut Connection, +} + +impl Drop for TestTransaction<'_> { + fn drop(&mut self) { + self.conn.execute("ROLLBACK").unwrap(); + } +} +``` + +## scopeguard Crate + +```rust +use scopeguard::defer; + +#[test] +fn test_with_defer() { + let path = "/tmp/test_file.txt"; + std::fs::write(path, "data").unwrap(); + + defer! { + std::fs::remove_file(path).ok(); + } + + // Test logic here + // File removed when scope exits +} +``` + +## See Also + +- [test-arrange-act-assert](./test-arrange-act-assert.md) - Test structure +- [test-tokio-async](./test-tokio-async.md) - Async test cleanup +- [test-mock-traits](./test-mock-traits.md) - Mocking with RAII diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-integration-dir.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-integration-dir.md new file mode 100644 index 00000000..0767fd0c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-integration-dir.md @@ -0,0 +1,144 @@ +# test-integration-dir + +> Put integration tests in the `tests/` directory + +## Why It Matters + +Integration tests live in `tests/` at the crate root, separate from `src/`. Each file in `tests/` is compiled as a separate crate, testing your library's public API as external users would. This separation ensures you're testing the real public interface, not implementation details. + +## Structure + +``` +my_project/ +├── Cargo.toml +├── src/ +│ ├── lib.rs +│ └── internal.rs +└── tests/ + ├── integration_test.rs # Each file is a separate test binary + ├── api_tests.rs + └── common/ # Shared test utilities + └── mod.rs +``` + +## Bad + +```rust +// src/lib.rs +// Mixing integration test logic in library code +#[test] +fn integration_test_full_workflow() { + // This is a unit test location, not integration +} +``` + +## Good + +```rust +// tests/integration_test.rs +use my_crate::{Client, Config}; // Uses public API only + +#[test] +fn test_full_workflow() { + let config = Config::default(); + let client = Client::new(config); + + let result = client.process("input"); + assert!(result.is_ok()); +} + +#[test] +fn test_error_handling() { + let client = Client::new(Config::strict()); + + let result = client.process("invalid"); + assert!(matches!(result, Err(Error::InvalidInput { .. }))); +} +``` + +## Shared Test Utilities + +```rust +// tests/common/mod.rs +use my_crate::Config; + +pub fn test_config() -> Config { + Config { + timeout: Duration::from_secs(5), + retries: 3, + debug: true, + } +} + +pub fn setup_test_environment() { + // Set up test fixtures +} + +// tests/api_tests.rs +mod common; + +use my_crate::Client; + +#[test] +fn test_with_shared_config() { + common::setup_test_environment(); + let client = Client::new(common::test_config()); + // ... +} +``` + +## Organizing Many Tests + +```rust +// tests/api/mod.rs +mod auth; +mod users; +mod orders; + +// tests/api/auth.rs +use my_crate::auth::{login, logout}; + +#[test] +fn test_login_success() { ... } + +#[test] +fn test_login_invalid_credentials() { ... } + +// tests/api/users.rs +use my_crate::users::{create_user, get_user}; + +#[test] +fn test_create_user() { ... } +``` + +## Integration vs Unit Tests + +| Unit Tests | Integration Tests | +|------------|-------------------| +| In `src/` with `#[cfg(test)]` | In `tests/` directory | +| Access private items | Public API only | +| Test individual functions | Test module interactions | +| Fast, isolated | May be slower | +| `cargo test --lib` | `cargo test --test '*'` | + +## Running Specific Tests + +```bash +# Run all tests +cargo test + +# Run only integration tests +cargo test --test '*' + +# Run specific integration test file +cargo test --test integration_test + +# Run tests matching pattern +cargo test --test api_tests test_login +``` + +## See Also + +- [test-cfg-test-module](./test-cfg-test-module.md) - Unit test modules +- [test-descriptive-names](./test-descriptive-names.md) - Test naming +- [test-tokio-async](./test-tokio-async.md) - Async integration tests diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mock-traits.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mock-traits.md new file mode 100644 index 00000000..f3da4bb4 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mock-traits.md @@ -0,0 +1,189 @@ +# test-mock-traits + +> Use traits for dependencies to enable mocking in tests + +## Why It Matters + +Concrete dependencies make testing hard—you can't easily test error paths, timeouts, or edge cases without real external systems. Extracting dependencies behind traits lets you inject test doubles (mocks, fakes, stubs), enabling isolated unit tests that run fast and cover edge cases. + +## Bad + +```rust +struct UserService { + db: PostgresConnection, // Concrete type - hard to test +} + +impl UserService { + async fn get_user(&self, id: u64) -> Result { + // Directly calls Postgres - needs real database to test + self.db.query("SELECT * FROM users WHERE id = $1", &[&id]).await + } +} + +// Test requires real Postgres instance +#[tokio::test] +async fn test_get_user() { + let db = PostgresConnection::connect("postgres://...").await?; + let service = UserService { db }; + // Slow, flaky, can't test error paths +} +``` + +## Good + +```rust +// Define trait for dependency +#[async_trait] +trait UserRepository: Send + Sync { + async fn find_by_id(&self, id: u64) -> Result, DbError>; + async fn save(&self, user: &User) -> Result<(), DbError>; +} + +// Production implementation +struct PostgresUserRepo { + pool: PgPool, +} + +#[async_trait] +impl UserRepository for PostgresUserRepo { + async fn find_by_id(&self, id: u64) -> Result, DbError> { + sqlx::query_as("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await + } + // ... +} + +// Service depends on trait, not concrete type +struct UserService { + repo: R, +} + +impl UserService { + async fn get_user(&self, id: u64) -> Result { + self.repo.find_by_id(id).await? + .ok_or(Error::NotFound) + } +} + +// Test with mock +#[cfg(test)] +mod tests { + struct MockUserRepo { + users: HashMap, + } + + #[async_trait] + impl UserRepository for MockUserRepo { + async fn find_by_id(&self, id: u64) -> Result, DbError> { + Ok(self.users.get(&id).cloned()) + } + // ... + } + + #[tokio::test] + async fn test_get_user_found() { + let mut mock = MockUserRepo { users: HashMap::new() }; + mock.users.insert(1, User { id: 1, name: "Alice".into() }); + + let service = UserService { repo: mock }; + let user = service.get_user(1).await.unwrap(); + + assert_eq!(user.name, "Alice"); + } + + #[tokio::test] + async fn test_get_user_not_found() { + let mock = MockUserRepo { users: HashMap::new() }; + let service = UserService { repo: mock }; + + let result = service.get_user(999).await; + assert!(matches!(result, Err(Error::NotFound))); + } +} +``` + +## mockall Crate + +```rust +use mockall::*; +use mockall::predicate::*; + +#[automock] +#[async_trait] +trait Database: Send + Sync { + async fn query(&self, sql: &str) -> Result, Error>; +} + +#[tokio::test] +async fn test_with_mockall() { + let mut mock = MockDatabase::new(); + + mock.expect_query() + .with(eq("SELECT 1")) + .times(1) + .returning(|_| Ok(vec![Row::new()])); + + let result = mock.query("SELECT 1").await; + assert!(result.is_ok()); +} +``` + +## Testing Error Paths + +```rust +#[async_trait] +trait HttpClient: Send + Sync { + async fn get(&self, url: &str) -> Result; +} + +struct FailingClient; + +#[async_trait] +impl HttpClient for FailingClient { + async fn get(&self, _url: &str) -> Result { + Err(HttpError::Timeout) // Always fails + } +} + +#[tokio::test] +async fn test_handles_timeout() { + let client = FailingClient; + let service = ApiService { client }; + + let result = service.fetch_data().await; + assert!(matches!(result, Err(Error::NetworkError(_)))); +} +``` + +## Dynamic Dispatch Alternative + +```rust +// When you don't want generics everywhere +struct UserService { + repo: Box, +} + +impl UserService { + fn new(repo: impl UserRepository + 'static) -> Self { + Self { repo: Box::new(repo) } + } +} + +// Slight runtime cost but cleaner API +``` + +## Cargo.toml + +```toml +[dev-dependencies] +mockall = "0.11" +async-trait = "0.1" # For async trait mocking +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Trait design +- [test-proptest-properties](./test-proptest-properties.md) - Property-based testing +- [proj-lib-main-split](./proj-lib-main-split.md) - Testable architecture diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mockall-mocking.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mockall-mocking.md new file mode 100644 index 00000000..8499a64a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mockall-mocking.md @@ -0,0 +1,226 @@ +# test-mockall-mocking + +> Use mockall for trait mocking + +## Why It Matters + +Unit tests should isolate the code under test from external dependencies (databases, APIs, file systems). Mockall generates mock implementations of traits, allowing you to control and verify behavior without real dependencies. + +## Setup + +```toml +# Cargo.toml +[dev-dependencies] +mockall = "0.12" +``` + +## Basic Usage + +```rust +use mockall::automock; + +#[automock] +trait Database { + fn get_user(&self, id: u64) -> Option; + fn save_user(&self, user: &User) -> Result<(), Error>; +} + +#[cfg(test)] +mod tests { + use super::*; + use mockall::predicate::*; + + #[test] + fn test_get_user() { + let mut mock = MockDatabase::new(); + + mock.expect_get_user() + .with(eq(42)) + .returning(|_| Some(User { id: 42, name: "Alice".into() })); + + let service = UserService::new(mock); + let user = service.find_user(42); + + assert_eq!(user.unwrap().name, "Alice"); + } +} +``` + +## Expectations + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_save_calls() { + let mut mock = MockDatabase::new(); + + // Expect exactly one call + mock.expect_save_user() + .times(1) + .returning(|_| Ok(())); + + // Expect call with specific argument + mock.expect_get_user() + .with(eq(42)) + .returning(|_| Some(User::default())); + + // Expect multiple calls + mock.expect_get_user() + .times(3..) // At least 3 times + .returning(|_| None); + + // Expectations are verified on drop + } +} +``` + +## Predicates + +```rust +use mockall::predicate::*; + +mock.expect_process() + .with(eq(42)) // Exact match + .returning(|_| Ok(())); + +mock.expect_validate() + .with(function(|s: &str| s.len() > 5)) // Custom predicate + .returning(|_| true); + +mock.expect_search() + .withf(|query, limit| { // Multiple args + query.len() < 100 && *limit <= 1000 + }) + .returning(|_, _| vec![]); +``` + +## Sequences + +```rust +use mockall::Sequence; + +#[test] +fn test_ordered_calls() { + let mut seq = Sequence::new(); + let mut mock = MockDatabase::new(); + + mock.expect_connect() + .times(1) + .in_sequence(&mut seq) + .returning(|| Ok(())); + + mock.expect_query() + .times(1) + .in_sequence(&mut seq) + .returning(|_| Ok(vec![])); + + mock.expect_disconnect() + .times(1) + .in_sequence(&mut seq) + .returning(|| Ok(())); +} +``` + +## Return Values + +```rust +// Fixed value +mock.expect_count().returning(|| 42); + +// Based on input +mock.expect_double().returning(|x| x * 2); + +// Different values per call +mock.expect_next() + .times(3) + .returning(|| 1) + .returning(|| 2) + .returning(|| 3); + +// Return owned values +mock.expect_get_name() + .returning(|| "Alice".to_string()); +``` + +## Mocking External Traits + +```rust +// For traits you don't own +#[cfg_attr(test, mockall::automock)] +trait HttpClient { + fn get(&self, url: &str) -> Result; +} + +// In production +struct RealHttpClient; +impl HttpClient for RealHttpClient { + fn get(&self, url: &str) -> Result { /* ... */ } +} + +// In tests +#[cfg(test)] +fn mock_client() -> MockHttpClient { + let mut mock = MockHttpClient::new(); + mock.expect_get() + .returning(|_| Ok(Response::new(200, "OK"))); + mock +} +``` + +## Async Mocking + +```rust +#[automock] +#[async_trait] +trait AsyncDatabase { + async fn fetch(&self, id: u64) -> Option; +} + +#[tokio::test] +async fn test_async() { + let mut mock = MockAsyncDatabase::new(); + + mock.expect_fetch() + .returning(|_| Some(Data::default())); + + let result = mock.fetch(1).await; + assert!(result.is_some()); +} +``` + +## Design for Testability + +```rust +// Accept trait, not concrete type +struct Service { + db: D, +} + +impl Service { + fn new(db: D) -> Self { + Self { db } + } +} + +// Tests use mock +#[test] +fn test_service() { + let mock = MockDatabase::new(); + let service = Service::new(mock); +} + +// Production uses real implementation +fn main() { + let db = PostgresDatabase::connect(); + let service = Service::new(db); +} +``` + +## See Also + +- [test-mock-traits](./test-mock-traits.md) - Mock trait design +- [test-proptest-properties](./test-proptest-properties.md) - Property testing +- [test-arrange-act-assert](./test-arrange-act-assert.md) - Test structure diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-proptest-properties.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-proptest-properties.md new file mode 100644 index 00000000..dee45e89 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-proptest-properties.md @@ -0,0 +1,161 @@ +# test-proptest-properties + +> Use proptest for property-based testing + +## Why It Matters + +Property-based testing generates random inputs to verify that properties hold across all possible values, not just hand-picked examples. Proptest finds edge cases you wouldn't think to test manually—empty strings, integer overflows, unicode edge cases. + +## Setup + +```toml +# Cargo.toml +[dev-dependencies] +proptest = "1.0" +``` + +## Basic Usage + +```rust +use proptest::prelude::*; + +proptest! { + #[test] + fn test_reverse_reverse_is_identity(s in ".*") { + let reversed: String = s.chars().rev().collect(); + let double_reversed: String = reversed.chars().rev().collect(); + assert_eq!(s, double_reversed); + } + + #[test] + fn test_sort_is_idempotent(mut v in prop::collection::vec(any::(), 0..100)) { + v.sort(); + let sorted = v.clone(); + v.sort(); + assert_eq!(v, sorted); + } +} +``` + +## Common Strategies + +```rust +use proptest::prelude::*; + +proptest! { + // Any type implementing Arbitrary + #[test] + fn test_i32(x in any::()) { } + + // Regex-based string generation + #[test] + fn test_email(email in "[a-z]+@[a-z]+\\.[a-z]{2,3}") { } + + // Ranges + #[test] + fn test_range(x in 0..100i32) { } + + // Collections + #[test] + fn test_vec(v in prop::collection::vec(any::(), 0..10)) { } + + // Optionals + #[test] + fn test_option(opt in prop::option::of(any::())) { } +} +``` + +## Custom Strategies + +```rust +use proptest::prelude::*; + +#[derive(Debug, Clone)] +struct User { + name: String, + age: u8, +} + +fn user_strategy() -> impl Strategy { + ("[a-zA-Z]{1,20}", 0..120u8) + .prop_map(|(name, age)| User { name, age }) +} + +proptest! { + #[test] + fn test_user(user in user_strategy()) { + assert!(user.age < 150); + assert!(!user.name.is_empty()); + } +} + +// Or derive Arbitrary +use proptest_derive::Arbitrary; + +#[derive(Debug, Arbitrary)] +struct Point { + x: i32, + y: i32, +} +``` + +## Properties to Test + +| Property | Example | +|----------|---------| +| Roundtrip | `decode(encode(x)) == x` | +| Idempotence | `f(f(x)) == f(x)` | +| Commutativity | `f(a, b) == f(b, a)` | +| Associativity | `f(f(a, b), c) == f(a, f(b, c))` | +| Identity | `f(x, identity) == x` | +| Invariants | `len(push(v, x)) == len(v) + 1` | + +## Example: Parser Roundtrip + +```rust +proptest! { + #[test] + fn parse_roundtrip(config in valid_config_strategy()) { + let serialized = config.to_string(); + let parsed = Config::parse(&serialized).unwrap(); + assert_eq!(config, parsed); + } +} +``` + +## Shrinking + +Proptest automatically shrinks failing inputs to minimal cases: + +```rust +// If this fails with vec![100, 50, 75, 25, 0] +// Proptest will shrink to vec![1, 0] (minimal failing case) +proptest! { + #[test] + fn test_sorted(v in prop::collection::vec(0..1000i32, 1..100)) { + let sorted = is_sorted(&v); + // This will fail and shrink + } +} +``` + +## Configuration + +```rust +proptest! { + #![proptest_config(ProptestConfig { + cases: 1000, // More test cases + max_shrink_iters: 10000, // More shrinking + ..ProptestConfig::default() + })] + + #[test] + fn extensive_test(x in any::()) { } +} +``` + +## See Also + +- [test-criterion-bench](./test-criterion-bench.md) - Benchmarking +- [test-mockall-mocking](./test-mockall-mocking.md) - Mocking +- [test-arrange-act-assert](./test-arrange-act-assert.md) - Test structure diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-should-panic.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-should-panic.md new file mode 100644 index 00000000..1b4b98eb --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-should-panic.md @@ -0,0 +1,130 @@ +# test-should-panic + +> Use `#[should_panic]` to test that code panics as expected + +## Why It Matters + +Some code should panic on invalid inputs or invariant violations. `#[should_panic]` verifies the panic occurs, optionally checking the panic message. This ensures defensive panics work correctly and documents expected panic conditions. + +## Bad + +```rust +#[test] +fn test_panic() { + // Just calling panicking code makes test fail + divide(1, 0); // Test fails with panic +} + +// Using catch_unwind is verbose +#[test] +fn test_panic_manual() { + let result = std::panic::catch_unwind(|| divide(1, 0)); + assert!(result.is_err()); +} +``` + +## Good + +```rust +#[test] +#[should_panic] +fn divide_by_zero_panics() { + divide(1, 0); // Test passes when this panics +} + +// With expected message +#[test] +#[should_panic(expected = "division by zero")] +fn divide_by_zero_panics_with_message() { + divide(1, 0); // Panics with "division by zero" +} + +// Partial message match +#[test] +#[should_panic(expected = "index out of bounds")] +fn index_panic_contains_message() { + let v = vec![1, 2, 3]; + let _ = v[100]; // Message contains "index out of bounds" +} +``` + +## Testing Invariants + +```rust +struct NonEmpty(Vec); + +impl NonEmpty { + fn new(items: Vec) -> Self { + assert!(!items.is_empty(), "NonEmpty cannot be empty"); + NonEmpty(items) + } +} + +#[test] +#[should_panic(expected = "NonEmpty cannot be empty")] +fn non_empty_rejects_empty_vec() { + NonEmpty::new(Vec::::new()); +} + +#[test] +fn non_empty_accepts_non_empty_vec() { + let ne = NonEmpty::new(vec![1, 2, 3]); + assert_eq!(ne.0.len(), 3); +} +``` + +## With expect() Messages + +```rust +fn get_config_value(key: &str) -> String { + CONFIG.get(key) + .expect(&format!("missing required config: {}", key)) + .to_string() +} + +#[test] +#[should_panic(expected = "missing required config: DATABASE_URL")] +fn missing_config_panics_with_key() { + get_config_value("DATABASE_URL"); +} +``` + +## When NOT to Use should_panic + +```rust +// ❌ For recoverable errors - use Result +#[test] +#[should_panic] // Wrong: this should return Err, not panic +fn invalid_input_panics() { + parse_config("invalid"); // Should return Err, not panic +} + +// ✅ Return Result and test the error +#[test] +fn invalid_input_returns_error() { + let result = parse_config("invalid"); + assert!(result.is_err()); +} +``` + +## Combining with Result + +```rust +#[test] +#[should_panic] +fn test_panics() -> Result<(), Error> { + // Can combine with Result for setup + let data = setup_test_data()?; + + // This should panic + process_invalid(&data); + + Ok(()) // Never reached +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Panic vs Result +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to use expect +- [test-descriptive-names](./test-descriptive-names.md) - Test naming diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-tokio-async.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-tokio-async.md new file mode 100644 index 00000000..f0d970a0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-tokio-async.md @@ -0,0 +1,154 @@ +# test-tokio-async + +> Use `#[tokio::test]` for async tests + +## Why It Matters + +Async functions can't be called directly—they need a runtime to drive them. `#[tokio::test]` provides a Tokio runtime for your test, handling setup automatically. This is simpler than manually creating a runtime and essential for testing async code. + +## Bad + +```rust +// Won't compile - async fn can't be called without runtime +#[test] +async fn test_async_function() { // Error! + let result = fetch_data().await; + assert!(result.is_ok()); +} + +// Manual runtime - verbose and error-prone +#[test] +fn test_async_function() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let result = fetch_data().await; + assert!(result.is_ok()); + }); +} +``` + +## Good + +```rust +#[tokio::test] +async fn test_async_function() { + let result = fetch_data().await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_concurrent_operations() { + let (a, b) = tokio::join!( + fetch_user(1), + fetch_user(2), + ); + assert!(a.is_ok()); + assert!(b.is_ok()); +} +``` + +## Runtime Configuration + +```rust +// Multi-threaded runtime (default) +#[tokio::test] +async fn test_default_runtime() { + // Uses multi-thread runtime +} + +// Single-threaded (current_thread) +#[tokio::test(flavor = "current_thread")] +async fn test_single_threaded() { + // Simpler, deterministic +} + +// With specific thread count +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_with_workers() { + // Exactly 2 worker threads +} + +// With time control +#[tokio::test(start_paused = true)] +async fn test_with_time_control() { + // Time starts paused for deterministic testing + tokio::time::advance(Duration::from_secs(60)).await; +} +``` + +## Testing Timeouts + +```rust +use tokio::time::{timeout, Duration}; + +#[tokio::test] +async fn test_operation_completes_in_time() { + let result = timeout( + Duration::from_secs(5), + slow_operation() + ).await; + + assert!(result.is_ok(), "Operation timed out"); +} + +#[tokio::test] +async fn test_timeout_triggers() { + let result = timeout( + Duration::from_millis(100), + never_completes() + ).await; + + assert!(result.is_err(), "Expected timeout"); +} +``` + +## Testing Channels + +```rust +use tokio::sync::mpsc; + +#[tokio::test] +async fn test_channel_communication() { + let (tx, mut rx) = mpsc::channel(10); + + tokio::spawn(async move { + tx.send("hello").await.unwrap(); + tx.send("world").await.unwrap(); + }); + + assert_eq!(rx.recv().await, Some("hello")); + assert_eq!(rx.recv().await, Some("world")); + assert_eq!(rx.recv().await, None); +} +``` + +## Testing with Mocks + +```rust +use mockall::*; + +#[automock] +#[async_trait::async_trait] +trait Database { + async fn get_user(&self, id: u64) -> Option; +} + +#[tokio::test] +async fn test_with_mock_database() { + let mut mock = MockDatabase::new(); + mock.expect_get_user() + .with(eq(42)) + .returning(|_| Some(User { id: 42, name: "Alice".into() })); + + let service = UserService::new(mock); + let user = service.find_user(42).await; + + assert_eq!(user.unwrap().name, "Alice"); +} +``` + +## See Also + +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime configuration +- [test-mock-traits](./test-mock-traits.md) - Mocking async traits +- [test-fixture-raii](./test-fixture-raii.md) - Async test cleanup diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-use-super.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-use-super.md new file mode 100644 index 00000000..f2d14df6 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-use-super.md @@ -0,0 +1,127 @@ +# test-use-super + +> Use `use super::*;` in test modules to access parent module items + +## Why It Matters + +The test module is a child of the module being tested. `use super::*` imports all items from the parent module, including private ones. This gives tests access to both public API and internal implementation details for thorough testing. + +## Bad + +```rust +// Verbose imports +#[cfg(test)] +mod tests { + use crate::my_module::public_function; + use crate::my_module::MyStruct; + // Can't access private items this way! + + #[test] + fn test_function() { + let result = public_function(); + // ... + } +} +``` + +## Good + +```rust +// src/my_module.rs +pub struct PublicStruct { ... } +struct PrivateStruct { ... } // Private + +pub fn public_function() -> i32 { ... } +fn private_helper() -> i32 { ... } // Private + +#[cfg(test)] +mod tests { + use super::*; // Imports everything from parent + + #[test] + fn test_public_struct() { + let s = PublicStruct::new(); + // ... + } + + #[test] + fn test_private_struct() { + let s = PrivateStruct::new(); // Can access private! + // ... + } + + #[test] + fn test_private_helper() { + assert_eq!(private_helper(), 42); // Can test private! + } +} +``` + +## Selective Imports + +```rust +#[cfg(test)] +mod tests { + // When you want to be explicit + use super::{parse, ParseError, Token}; + + // Or import all plus test utilities + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_parse() { ... } +} +``` + +## Nested Modules + +```rust +mod outer { + pub fn outer_fn() -> i32 { 1 } + + mod inner { + pub fn inner_fn() -> i32 { 2 } + + #[cfg(test)] + mod tests { + use super::*; // Gets inner's items + use super::super::*; // Gets outer's items + + #[test] + fn test_inner() { + assert_eq!(inner_fn(), 2); + assert_eq!(outer_fn(), 1); + } + } + } +} +``` + +## With External Dependencies + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Test-only dependencies + use proptest::prelude::*; + use mockall::predicate::*; + + proptest! { + #[test] + fn test_property(s: String) { + let result = process(&s); + prop_assert!(result.is_ok()); + } + } +} +``` + +## See Also + +- [test-cfg-test-module](./test-cfg-test-module.md) - Test module structure +- [test-integration-dir](./test-integration-dir.md) - Integration tests +- [proj-pub-crate-internal](./proj-pub-crate-internal.md) - Visibility modifiers diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-enum-states.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-enum-states.md new file mode 100644 index 00000000..902fe41a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-enum-states.md @@ -0,0 +1,154 @@ +# type-enum-states + +> Use enums for mutually exclusive states + +## Why It Matters + +When a value can be in exactly one of several states, an enum makes invalid states unrepresentable. The compiler ensures all states are handled. Contrast with boolean flags or optional fields that can represent impossible combinations. + +## Bad + +```rust +struct Connection { + is_connected: bool, + is_authenticated: bool, + is_disconnected: bool, // Can all three be true? False? + socket: Option, + credentials: Option, +} + +// Possible invalid states: +// - is_connected && is_disconnected (contradiction) +// - is_authenticated && !is_connected (impossible) +// - socket is None but is_connected is true (inconsistent) +``` + +## Good + +```rust +enum ConnectionState { + Disconnected, + Connecting { address: SocketAddr }, + Connected { socket: TcpStream }, + Authenticated { socket: TcpStream, session: Session }, + Failed { error: ConnectionError }, +} + +struct Connection { + state: ConnectionState, +} + +// Impossible states are unrepresentable +// Each state has exactly the data it needs +``` + +## Pattern Matching Ensures Completeness + +```rust +fn handle_connection(conn: &Connection) { + match &conn.state { + ConnectionState::Disconnected => { + println!("Not connected"); + } + ConnectionState::Connecting { address } => { + println!("Connecting to {}", address); + } + ConnectionState::Connected { socket } => { + println!("Connected, not authenticated"); + } + ConnectionState::Authenticated { socket, session } => { + println!("Authenticated as {}", session.user); + } + ConnectionState::Failed { error } => { + println!("Failed: {}", error); + } + } + // Compiler error if any state is missing +} +``` + +## State Transitions + +```rust +impl Connection { + fn connect(&mut self, addr: SocketAddr) -> Result<(), Error> { + match &self.state { + ConnectionState::Disconnected => { + self.state = ConnectionState::Connecting { address: addr }; + Ok(()) + } + _ => Err(Error::AlreadyConnected), + } + } + + fn on_connected(&mut self, socket: TcpStream) { + if let ConnectionState::Connecting { .. } = &self.state { + self.state = ConnectionState::Connected { socket }; + } + } + + fn authenticate(&mut self, creds: Credentials) -> Result<(), Error> { + match std::mem::replace(&mut self.state, ConnectionState::Disconnected) { + ConnectionState::Connected { socket } => { + let session = perform_auth(&socket, creds)?; + self.state = ConnectionState::Authenticated { socket, session }; + Ok(()) + } + other => { + self.state = other; + Err(Error::NotConnected) + } + } + } +} +``` + +## Result and Option as State Enums + +```rust +// Option is an enum for "might not exist" +enum Option { + Some(T), + None, +} + +// Result is an enum for "might have failed" +enum Result { + Ok(T), + Err(E), +} + +// Use these instead of nullable/sentinel values +fn find_user(id: u64) -> Option { ... } +fn parse_config(s: &str) -> Result { ... } +``` + +## Avoid Boolean Flags + +```rust +// Bad: boolean flags +struct Task { + is_running: bool, + is_completed: bool, + is_failed: bool, + error: Option, +} + +// Good: enum state +enum TaskState { + Pending, + Running { started_at: Instant }, + Completed { result: Output }, + Failed { error: Error }, +} + +struct Task { + state: TaskState, +} +``` + +## See Also + +- [api-typestate](./api-typestate.md) - Type-level state machines +- [api-non-exhaustive](./api-non-exhaustive.md) - Forward-compatible enums +- [type-option-nullable](./type-option-nullable.md) - Option for optional values diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-generic-bounds.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-generic-bounds.md new file mode 100644 index 00000000..a306509e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-generic-bounds.md @@ -0,0 +1,142 @@ +# type-generic-bounds + +> Add trait bounds only where needed, prefer where clauses for readability + +## Why It Matters + +Trait bounds constrain what types can be used with generic code. Adding unnecessary bounds limits flexibility. Adding bounds in the right place (impl vs function vs where clause) affects usability and readability. Well-placed bounds keep APIs flexible while ensuring type safety. + +## Bad + +```rust +// Bounds on struct definition - limits all uses +struct Container { // Even storage requires Clone? + items: Vec, +} + +// Inline bounds make signature hard to read +fn process( + value: T +) -> Result { ... } + +// Redundant bounds +fn print_twice(value: T) +where + T: Clone, // Already specified above +{ ... } +``` + +## Good + +```rust +// No bounds on struct - store anything +struct Container { + items: Vec, +} + +// Bounds only on impls that need them +impl Container { + fn duplicate(&self) -> Self { + Container { items: self.items.clone() } + } +} + +impl Container { + fn debug_print(&self) { + println!("{:?}", self.items); + } +} + +// Where clause for readability +fn process(value: T) -> Result +where + T: Clone + Debug + Send + Sync + 'static, + E: Error + Send + Clone, +{ ... } +``` + +## Bound Placement + +```rust +// On struct: affects all uses of the type +struct MustBeClone { data: T } // Rarely needed + +// On impl: affects specific functionality +impl Container { ... } // Common pattern + +// On function: affects that function only +fn requires_send(value: T) { ... } + +// Recommendation: start with no bounds, add as needed +``` + +## Where Clause Benefits + +```rust +// Inline: hard to read +fn complex + Into>(t: T, u: U) { } + +// Where clause: clear and scannable +fn complex(t: T, u: U) +where + T: Clone + Debug + Send, + U: AsRef + Into, +{ } + +// Essential for complex bounds +fn foo(t: T, u: U) +where + T: Iterator, + U: Clone + Into, + Vec: Debug, // Bounds on expressions +{ } +``` + +## Implied Bounds + +```rust +// Supertrait bounds are implied +trait Foo: Clone + Debug {} + +fn process(value: T) { + // T: Clone and T: Debug are implied by T: Foo + let cloned = value.clone(); + println!("{:?}", cloned); +} + +// Associated type bounds +fn process(iter: I) +where + I: Iterator, + I::Item: Clone, // Bound on associated type +{ } +``` + +## Conditional Trait Implementation + +```rust +struct Wrapper(T); + +// Implement Clone only when T: Clone +impl Clone for Wrapper { + fn clone(&self) -> Self { + Wrapper(self.0.clone()) + } +} + +// Implement Debug only when T: Debug +impl Debug for Wrapper { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_tuple("Wrapper").field(&self.0).finish() + } +} + +// Wrapper is Clone + Debug +// Wrapper is neither +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - Using Into bounds +- [api-impl-asref](./api-impl-asref.md) - Using AsRef bounds +- [name-type-param-single](./name-type-param-single.md) - Type parameter naming diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-never-diverge.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-never-diverge.md new file mode 100644 index 00000000..0460d1c3 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-never-diverge.md @@ -0,0 +1,146 @@ +# type-never-diverge + +> Use `!` (never type) for functions that never return + +## Why It Matters + +The never type `!` indicates a function will never return normally—it either loops forever, panics, or exits the process. This helps the compiler understand control flow and enables `!` to coerce to any type, making it useful in match arms and expressions. + +## Bad + +```rust +// Return type doesn't indicate non-returning +fn infinite_loop() { + loop { + process_events(); + } + // Implicit () return type, but never returns +} + +// Using Option when it always panics +fn unreachable_code() -> Option<()> { + panic!("This should never be called"); +} +``` + +## Good + +```rust +// ! indicates function never returns +fn infinite_loop() -> ! { + loop { + process_events(); + } +} + +fn abort_with_error(msg: &str) -> ! { + eprintln!("Fatal error: {}", msg); + std::process::exit(1); +} + +fn panic_handler() -> ! { + panic!("Unexpected state"); +} +``` + +## Coercion to Any Type + +```rust +// ! coerces to any type +fn get_value(opt: Option) -> i32 { + match opt { + Some(v) => v, + None => panic!("No value"), // panic! returns !, coerces to i32 + } +} + +// Useful in Result handling +fn must_get_config() -> Config { + match load_config() { + Ok(c) => c, + Err(e) => { + log_error(&e); + std::process::exit(1) // Returns !, coerces to Config + } + } +} +``` + +## Standard Library Examples + +```rust +// std::process::exit +pub fn exit(code: i32) -> ! + +// panic! macro +// Expands to an expression of type ! + +// std::hint::unreachable_unchecked +pub unsafe fn unreachable_unchecked() -> ! + +// loop {} with no break +fn forever() -> ! { + loop {} +} +``` + +## In Match Expressions + +```rust +enum State { + Running, + Stopped, + Error, +} + +fn get_status(state: &State) -> &str { + match state { + State::Running => "running", + State::Stopped => "stopped", + State::Error => unreachable!(), // ! coerces to &str + } +} + +// With Result +fn process(r: Result) -> Data { + match r { + Ok(d) => d, + Err(e) => panic!("Unexpected error: {}", e), // ! coerces to Data + } +} +``` + +## Diverging Closures + +```rust +// Closures that never return +let handler: fn() -> ! = || { + panic!("Handler called"); +}; + +// In thread spawn +std::thread::spawn(|| -> ! { + loop { + process_work(); + } +}); +``` + +## Current Limitations (Nightly) + +```rust +// Full ! type is nightly +#![feature(never_type)] + +// Can use ! as type parameter +type NeverResult = Result<(), !>; // Can never be Err + +// On stable, use std::convert::Infallible +type StableNeverResult = Result<(), std::convert::Infallible>; +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - When to panic vs return Result +- [type-result-fallible](./type-result-fallible.md) - Result for errors +- [opt-cold-unlikely](./opt-cold-unlikely.md) - Marking unlikely paths diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-ids.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-ids.md new file mode 100644 index 00000000..7f81e34f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-ids.md @@ -0,0 +1,160 @@ +# type-newtype-ids + +> Wrap IDs in newtypes: `UserId(u64)` + +## Why It Matters + +Using raw integers for IDs is error-prone. It's easy to accidentally pass a `user_id` where a `post_id` is expected. Newtypes make these mix-ups compile-time errors instead of runtime bugs. + +## Bad + +```rust +fn get_user_posts(user_id: u64, post_id: u64) -> Vec { + // Which is which? Easy to swap by accident +} + +// Oops! Arguments swapped - compiles fine, wrong at runtime +let posts = get_user_posts(post_id, user_id); + +// Even worse with multiple IDs +fn transfer(from: u64, to: u64, amount: u64) { + // from/to can easily be swapped +} +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct UserId(pub u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PostId(pub u64); + +fn get_user_posts(user_id: UserId, post_id: PostId) -> Vec { + // Types are distinct +} + +// This won't compile - types don't match +// let posts = get_user_posts(post_id, user_id); // ERROR! + +// Correct usage +let posts = get_user_posts(UserId(1), PostId(42)); +``` + +## Derive Common Traits + +```rust +#[derive( + Debug, // For printing + Clone, // For copying + Copy, // For implicit copies (if small) + PartialEq, // For == comparison + Eq, // For HashMap keys + Hash, // For HashMap keys + PartialOrd, // For sorting (optional) + Ord, // For BTreeMap keys (optional) +)] +pub struct UserId(pub u64); +``` + +## Add Useful Methods + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct UserId(u64); + +impl UserId { + pub const fn new(id: u64) -> Self { + Self(id) + } + + pub const fn get(self) -> u64 { + self.0 + } + + // For database queries + pub fn as_i64(self) -> i64 { + self.0 as i64 + } +} + +impl From for UserId { + fn from(id: u64) -> Self { + Self(id) + } +} + +impl std::fmt::Display for UserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "user:{}", self.0) + } +} +``` + +## With Serde + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] // Serializes as just the inner value +pub struct UserId(pub u64); + +// JSON: {"user_id": 123} not {"user_id": {"0": 123}} +``` + +## String IDs (UUIDs, etc.) + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SessionId(String); + +impl SessionId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } + + pub fn parse(s: &str) -> Result { + // Validate format + uuid::Uuid::parse_str(s)?; + Ok(Self(s.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} +``` + +## Multiple Related IDs + +```rust +// Macro for consistent ID types +macro_rules! define_id { + ($name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct $name(pub u64); + + impl $name { + pub const fn new(id: u64) -> Self { Self(id) } + pub const fn get(self) -> u64 { self.0 } + } + + impl From for $name { + fn from(id: u64) -> Self { Self(id) } + } + }; +} + +define_id!(UserId); +define_id!(PostId); +define_id!(CommentId); +define_id!(TeamId); +``` + +## See Also + +- [api-newtype-safety](api-newtype-safety.md) - Newtypes for type safety +- [type-newtype-validated](type-newtype-validated.md) - Newtypes for validated data +- [api-parse-dont-validate](api-parse-dont-validate.md) - Parse into validated types diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-validated.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-validated.md new file mode 100644 index 00000000..6b52661c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-validated.md @@ -0,0 +1,159 @@ +# type-newtype-validated + +> Use newtypes to enforce validation at construction time + +## Why It Matters + +A validated newtype guarantees its inner value is always valid. Once you have an `Email`, you know it passed validation—no re-checking needed. This "parse, don't validate" pattern catches errors at boundaries and makes invalid states unrepresentable. + +## Bad + +```rust +// Validation scattered throughout code +fn send_email(to: &str, body: &str) -> Result<(), Error> { + if !is_valid_email(to) { // Must check every time + return Err(Error::InvalidEmail); + } + // ... +} + +fn add_recipient(list: &mut Vec, email: &str) -> Result<(), Error> { + if !is_valid_email(email) { // Check again + return Err(Error::InvalidEmail); + } + list.push(email.to_string()); + Ok(()) +} +``` + +## Good + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Email(String); + +impl Email { + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(EmailError::Invalid(s.to_string())) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// No validation needed - Email is always valid +fn send_email(to: &Email, body: &str) -> Result<(), Error> { + // to is guaranteed valid + send_to_address(to.as_str(), body) +} + +fn add_recipient(list: &mut Vec, email: Email) { + // email is guaranteed valid + list.push(email); +} +``` + +## Common Validated Types + +```rust +// URLs +pub struct Url(url::Url); + +impl Url { + pub fn parse(s: &str) -> Result { + url::Url::parse(s) + .map(Url) + .map_err(UrlError::from) + } +} + +// Non-empty strings +pub struct NonEmptyString(String); + +impl NonEmptyString { + pub fn new(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(NonEmptyString(s)) + } + } +} + +// Positive numbers +pub struct PositiveI32(i32); + +impl PositiveI32 { + pub fn new(n: i32) -> Option { + if n > 0 { + Some(PositiveI32(n)) + } else { + None + } + } + + pub fn get(&self) -> i32 { + self.0 + } +} + +// Bounded ranges +pub struct Percentage(f64); + +impl Percentage { + pub fn new(value: f64) -> Result { + if (0.0..=100.0).contains(&value) { + Ok(Percentage(value)) + } else { + Err(RangeError::OutOfBounds) + } + } +} +``` + +## With Serde + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +pub struct Email(String); + +impl<'de> Deserialize<'de> for Email { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Email::new(&s).map_err(serde::de::Error::custom) + } +} + +// JSON deserialization automatically validates +let email: Email = serde_json::from_str(r#""user@example.com""#)?; +``` + +## Compile-Time Validation + +```rust +// For values known at compile time +macro_rules! email { + ($s:literal) => {{ + const _: () = assert!(is_valid_email_const($s)); + Email::new_unchecked($s) + }}; +} + +let admin = email!("admin@example.com"); // Validated at compile time +``` + +## See Also + +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Parse at boundaries +- [api-newtype-safety](./api-newtype-safety.md) - Type-safe distinctions +- [type-newtype-ids](./type-newtype-ids.md) - ID newtypes diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-no-stringly.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-no-stringly.md new file mode 100644 index 00000000..a44cf2c7 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-no-stringly.md @@ -0,0 +1,144 @@ +# type-no-stringly + +> Avoid stringly-typed APIs; use enums, newtypes, or validated types + +## Why It Matters + +Strings accept any value—typos, wrong formats, invalid data all compile fine. Enums, newtypes, and validated types catch errors at compile time or construction time, not runtime. They also provide better IDE support, documentation, and make invalid states unrepresentable. + +## Bad + +```rust +// Status as string - easy to get wrong +fn set_status(status: &str) { + match status { + "pending" => { ... } + "active" => { ... } + "completed" => { ... } + _ => panic!("Unknown status"), // Runtime error + } +} + +// Easy to misuse +set_status("pending"); // OK +set_status("Pending"); // Runtime error - wrong case +set_status("aktive"); // Runtime error - typo +set_status("done"); // Runtime error - wrong word + +// Configuration as strings +fn configure(key: &str, value: &str) { + // No type safety, no validation +} +``` + +## Good + +```rust +// Status as enum - compile-time safety +enum Status { + Pending, + Active, + Completed, +} + +fn set_status(status: Status) { + match status { + Status::Pending => { ... } + Status::Active => { ... } + Status::Completed => { ... } + } // Exhaustive - compiler checks all cases +} + +// Can only pass valid values +set_status(Status::Pending); // OK +set_status(Status::Aktivev); // Compile error - typo caught! + +// Configuration with typed builder +struct Config { + timeout: Duration, + retries: u32, + mode: Mode, +} + +enum Mode { Fast, Safe, Balanced } +``` + +## Parsing at Boundaries + +```rust +use std::str::FromStr; + +#[derive(Debug, Clone, Copy)] +enum Priority { + Low, + Medium, + High, +} + +impl FromStr for Priority { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "low" => Ok(Priority::Low), + "medium" | "med" => Ok(Priority::Medium), + "high" => Ok(Priority::High), + _ => Err(ParseError::UnknownPriority(s.to_string())), + } + } +} + +// Parse once at boundary +fn handle_request(priority_str: &str) -> Result<(), Error> { + let priority: Priority = priority_str.parse()?; + // From here, priority is type-safe + process(priority); + Ok(()) +} +``` + +## Validated Newtypes + +```rust +// Instead of string for email +struct Email(String); + +impl Email { + fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(ValidationError::InvalidEmail) + } + } +} + +// Instead of string for UUID +struct UserId(uuid::Uuid); + +// Instead of string for paths +struct ConfigPath(PathBuf); +``` + +## With Serde + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum EventType { + UserCreated, + UserDeleted, + UserUpdated, +} + +// JSON: {"type": "user_created", ...} +// Automatically validated during deserialization +``` + +## See Also + +- [anti-stringly-typed](./anti-stringly-typed.md) - Anti-pattern details +- [type-newtype-validated](./type-newtype-validated.md) - Validated newtypes +- [type-enum-states](./type-enum-states.md) - Enums for states diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-option-nullable.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-option-nullable.md new file mode 100644 index 00000000..1d05274b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-option-nullable.md @@ -0,0 +1,137 @@ +# type-option-nullable + +> Use `Option` for values that might not exist + +## Why It Matters + +`Option` explicitly represents "value or nothing" in the type system. Unlike null pointers or sentinel values, you can't accidentally use a missing value—the compiler forces you to handle the `None` case. This eliminates null pointer exceptions at compile time. + +## Bad + +```rust +// Sentinel values - easy to forget to check +fn find_user(id: u64) -> User { + // Returns "empty" user if not found - caller might not check + users.get(&id).cloned().unwrap_or(User::empty()) +} + +// Nullable-style with raw pointers +fn find_user(id: u64) -> *const User { + // Null if not found - unsafe, no compiler help +} + +// Error-prone usage +let user = find_user(42); +println!("{}", user.name); // Might be empty user - silent bug +``` + +## Good + +```rust +// Option makes absence explicit +fn find_user(id: u64) -> Option { + users.get(&id).cloned() +} + +// Must handle the None case +let user = find_user(42); +match user { + Some(u) => println!("{}", u.name), + None => println!("User not found"), +} + +// Or use combinators +let name = find_user(42) + .map(|u| u.name) + .unwrap_or_else(|| "Unknown".to_string()); +``` + +## Common Option Patterns + +```rust +// if let for single case +if let Some(user) = find_user(id) { + process(user); +} + +// Chaining with map +let upper_name = find_user(id) + .map(|u| u.name) + .map(|n| n.to_uppercase()); + +// Providing defaults +let user = find_user(id).unwrap_or_default(); +let user = find_user(id).unwrap_or_else(|| User::guest()); + +// ? operator for propagation +fn get_user_email(id: u64) -> Option { + let user = find_user(id)?; + Some(user.email) +} + +// and_then for chained optionals +fn get_user_country(id: u64) -> Option { + find_user(id) + .and_then(|u| u.address) + .and_then(|a| a.country) +} +``` + +## Struct Fields + +```rust +struct User { + name: String, + email: String, + phone: Option, // Optional field + avatar_url: Option, // Optional field +} + +impl User { + fn display_phone(&self) -> &str { + self.phone.as_deref().unwrap_or("Not provided") + } +} +``` + +## Option vs Result + +```rust +// Option: value might not exist (no error context) +fn find(key: &str) -> Option { ... } + +// Result: operation might fail (with error context) +fn parse(input: &str) -> Result { ... } + +// Convert Option to Result +let value = find("key").ok_or(Error::NotFound)?; + +// Convert Result to Option +let value = parse("input").ok(); // Discards error +``` + +## Option References + +```rust +// Option<&T> for optional borrows +fn get(&self, key: &str) -> Option<&Value> { + self.map.get(key) +} + +// as_ref() to borrow Option contents +let opt: Option = Some("hello".to_string()); +let opt_ref: Option<&String> = opt.as_ref(); +let opt_str: Option<&str> = opt.as_deref(); + +// as_mut() for mutable borrow +let mut opt = Some(vec![1, 2, 3]); +if let Some(v) = opt.as_mut() { + v.push(4); +} +``` + +## See Also + +- [type-result-fallible](./type-result-fallible.md) - Result for errors +- [type-enum-states](./type-enum-states.md) - Enums for states +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Handling Option safely diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-phantom-marker.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-phantom-marker.md new file mode 100644 index 00000000..88bcb0ce --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-phantom-marker.md @@ -0,0 +1,188 @@ +# type-phantom-marker + +> Use `PhantomData` to express type relationships without runtime cost + +## Why It Matters + +Sometimes your type needs to be parameterized by a type that doesn't appear in any field—for variance, drop order, or semantic purposes. `PhantomData` tells the compiler your type is "associated with" `T` without storing any `T` data. It has zero runtime cost. + +## Bad + +```rust +// Type parameter unused - compiler error +struct Handle { + id: u64, + // Error: parameter `T` is never used +} + +// Workaround with unnecessary storage +struct Handle { + id: u64, + _type: Option, // Wastes memory, requires T: Default +} +``` + +## Good + +```rust +use std::marker::PhantomData; + +struct Handle { + id: u64, + _marker: PhantomData, // Zero-size, tells compiler about T +} + +impl Handle { + fn new(id: u64) -> Self { + Handle { + id, + _marker: PhantomData, + } + } +} + +// Different Handle types are incompatible +struct User; +struct Order; + +fn process_user(h: Handle) { ... } + +let user_handle = Handle::::new(1); +let order_handle = Handle::::new(2); + +process_user(user_handle); // OK +process_user(order_handle); // Error: expected Handle, found Handle +``` + +## Expressing Ownership + +```rust +use std::marker::PhantomData; + +// Owns T conceptually (like Box) +struct Container { + ptr: *mut T, + _marker: PhantomData, // Acts like we own a T +} + +// Drop will be called on T when Container drops +impl Drop for Container { + fn drop(&mut self) { + unsafe { + std::ptr::drop_in_place(self.ptr); + } + } +} +``` + +## Expressing Borrowing + +```rust +use std::marker::PhantomData; + +// Borrows T for lifetime 'a +struct Ref<'a, T> { + ptr: *const T, + _marker: PhantomData<&'a T>, // Acts like &'a T +} + +// Compiler tracks lifetime correctly +impl<'a, T> Ref<'a, T> { + fn get(&self) -> &'a T { + unsafe { &*self.ptr } + } +} +``` + +## Type-Level State Machine + +```rust +use std::marker::PhantomData; + +// States as zero-size types +struct Unlocked; +struct Locked; + +struct Door { + _state: PhantomData, +} + +impl Door { + fn lock(self) -> Door { + println!("Locking..."); + Door { _state: PhantomData } + } + + fn open(&self) { + println!("Opening..."); + } +} + +impl Door { + fn unlock(self) -> Door { + println!("Unlocking..."); + Door { _state: PhantomData } + } + + // Can't call open() on Locked door - method doesn't exist +} + +fn example() { + let door: Door = Door { _state: PhantomData }; + door.open(); // OK + let locked = door.lock(); + // locked.open(); // Error: no method `open` for Door + let unlocked = locked.unlock(); + unlocked.open(); // OK +} +``` + +## Variance Control + +```rust +use std::marker::PhantomData; + +// Covariant in T (PhantomData) +struct Producer { + _marker: PhantomData, // Covariant +} + +// Contravariant in T (PhantomData) +struct Consumer { + _marker: PhantomData, // Contravariant +} + +// Invariant in T (PhantomData T>) +struct Both { + _marker: PhantomData T>, // Invariant +} +``` + +## Common Uses + +```rust +// 1. FFI handles with type safety +struct FileHandle { + fd: i32, + _marker: PhantomData, +} + +// 2. Generic iterators +struct Iter<'a, T> { + ptr: *const T, + end: *const T, + _marker: PhantomData<&'a T>, +} + +// 3. Allocator-aware types +struct Vec { + buf: RawVec, + len: usize, +} +``` + +## See Also + +- [api-typestate](./api-typestate.md) - State machine pattern +- [api-newtype-safety](./api-newtype-safety.md) - Type-safe wrappers +- [type-newtype-ids](./type-newtype-ids.md) - ID types diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-repr-transparent.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-repr-transparent.md new file mode 100644 index 00000000..bd4701fe --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-repr-transparent.md @@ -0,0 +1,143 @@ +# type-repr-transparent + +> Use `#[repr(transparent)]` for newtypes in FFI contexts + +## Why It Matters + +`#[repr(transparent)]` guarantees a newtype has the same memory layout as its inner type. This is essential for FFI where you need type safety in Rust but must match C ABI layouts. Without it, the compiler may add padding or change layout. + +## Bad + +```rust +// No layout guarantee - might not match inner type in FFI +struct Handle(u64); + +// Passing to C code might fail +extern "C" { + fn process_handle(h: Handle); // May not work correctly +} + +// Wrapping C type without layout guarantee +struct SafePointer(*mut c_void); +``` + +## Good + +```rust +// Guaranteed same layout as inner type +#[repr(transparent)] +struct Handle(u64); + +// Safe for FFI +extern "C" { + fn process_handle(h: Handle); // Works - same layout as u64 +} + +// FFI pointer wrapper +#[repr(transparent)] +struct SafePointer(*mut c_void); + +impl SafePointer { + // Safe Rust API around raw pointer + pub fn new(ptr: *mut c_void) -> Option { + if ptr.is_null() { + None + } else { + Some(SafePointer(ptr)) + } + } +} +``` + +## What repr(transparent) Guarantees + +```rust +use std::mem::{size_of, align_of}; + +#[repr(transparent)] +struct Meters(f64); + +// Same size +assert_eq!(size_of::(), size_of::()); + +// Same alignment +assert_eq!(align_of::(), align_of::()); + +// Same ABI - can pass where f64 expected +extern "C" fn measure(distance: Meters) { ... } +``` + +## With PhantomData + +```rust +use std::marker::PhantomData; + +// PhantomData is zero-sized, doesn't affect layout +#[repr(transparent)] +struct TypedHandle { + raw: u64, + _marker: PhantomData, // Zero-sized, ignored for layout +} + +// Still same layout as u64 +assert_eq!(size_of::>(), size_of::()); +``` + +## NonZero Wrappers + +```rust +use std::num::NonZeroU64; + +#[repr(transparent)] +struct NonZeroHandle(NonZeroU64); + +// Inherits null-pointer optimization +assert_eq!(size_of::>(), size_of::()); +``` + +## FFI Pattern + +```rust +mod ffi { + use std::os::raw::c_int; + + #[repr(transparent)] + pub struct FileDescriptor(c_int); + + extern "C" { + pub fn open(path: *const i8, flags: c_int) -> FileDescriptor; + pub fn close(fd: FileDescriptor) -> c_int; + pub fn read(fd: FileDescriptor, buf: *mut u8, len: usize) -> isize; + } +} + +// Safe wrapper +pub struct File { + fd: ffi::FileDescriptor, +} + +impl File { + pub fn open(path: &str) -> std::io::Result { + let c_path = std::ffi::CString::new(path)?; + let fd = unsafe { ffi::open(c_path.as_ptr(), 0) }; + // ... error handling + Ok(File { fd }) + } +} +``` + +## When to Use + +| Scenario | Use `#[repr(transparent)]`? | +|----------|----------------------------| +| FFI newtype wrappers | Yes | +| Type-safe handles | Yes | +| NonZero optimization | Yes | +| Pure Rust newtypes | Optional (doesn't hurt) | +| Multi-field structs | N/A (only for single-field) | + +## See Also + +- [type-newtype-ids](./type-newtype-ids.md) - Newtype pattern +- [type-phantom-marker](./type-phantom-marker.md) - PhantomData usage +- [api-newtype-safety](./api-newtype-safety.md) - Type-safe newtypes diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-result-fallible.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-result-fallible.md new file mode 100644 index 00000000..fec570ff --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-result-fallible.md @@ -0,0 +1,131 @@ +# type-result-fallible + +> Use `Result` for operations that can fail + +## Why It Matters + +`Result` makes failure explicit in the type system. Callers must acknowledge and handle potential errors—they can't accidentally ignore failures. The `?` operator makes error propagation ergonomic while maintaining explicit error handling. + +## Bad + +```rust +// Returning Option loses error context +fn read_config(path: &str) -> Option { + let content = std::fs::read_to_string(path).ok()?; // Why did it fail? + toml::from_str(&content).ok() // Parse error lost +} + +// Panicking on errors +fn read_config(path: &str) -> Config { + let content = std::fs::read_to_string(path).unwrap(); // Crashes + toml::from_str(&content).unwrap() // Crashes +} + +// Sentinel values +fn divide(a: i32, b: i32) -> i32 { + if b == 0 { return -1; } // Magic value, easy to miss + a / b +} +``` + +## Good + +```rust +// Result with clear error type +fn read_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(ConfigError::IoError)?; + toml::from_str(&content) + .map_err(ConfigError::ParseError) +} + +fn divide(a: i32, b: i32) -> Result { + if b == 0 { + return Err(DivisionError::DivideByZero); + } + Ok(a / b) +} + +// Caller must handle +match divide(10, 0) { + Ok(result) => println!("Result: {}", result), + Err(e) => println!("Error: {}", e), +} +``` + +## The ? Operator + +```rust +fn process_file(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // Propagates Err + let parsed: RawData = serde_json::from_str(&content)?; + let validated = validate(parsed)?; + let processed = transform(validated)?; + Ok(processed) +} + +// Equivalent to: +fn process_file(path: &str) -> Result { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => return Err(e.into()), + }; + // ... etc +} +``` + +## Result Combinators + +```rust +let result: Result = Ok(42); + +// map: transform success value +let doubled = result.map(|n| n * 2); // Ok(84) + +// map_err: transform error +let with_context = result.map_err(|e| format!("Failed: {}", e)); + +// and_then: chain fallible operations +let processed = result.and_then(|n| { + if n > 0 { Ok(n * 2) } else { Err(Error::Negative) } +}); + +// unwrap_or: provide default on error +let value = result.unwrap_or(0); + +// ok(): convert to Option, discarding error +let maybe_value: Option = result.ok(); +``` + +## Defining Error Types + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("failed to read file: {0}")] + Io(#[from] std::io::Error), + + #[error("failed to parse config: {0}")] + Parse(#[from] toml::de::Error), + + #[error("missing required field: {0}")] + MissingField(String), +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // Io error + let config: Config = toml::from_str(&content)?; // Parse error + if config.name.is_empty() { + return Err(ConfigError::MissingField("name".into())); + } + Ok(config) +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Defining error types +- [err-question-mark](./err-question-mark.md) - Using ? operator +- [type-option-nullable](./type-option-nullable.md) - Option vs Result diff --git a/crates/graphql-orm-ai/.github/workflows/ci.yml b/crates/graphql-orm-ai/.github/workflows/ci.yml new file mode 100644 index 00000000..8d7a9ea5 --- /dev/null +++ b/crates/graphql-orm-ai/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: + push: + pull_request: + +jobs: + unit-and-compile: + runs-on: ubuntu-latest + defaults: + run: + working-directory: graphql-orm-ai + steps: + - uses: actions/checkout@v5 + with: + path: graphql-orm-ai + fetch-depth: 0 + - uses: actions/checkout@v5 + with: + repository: Dastari/graphql-orm + path: graphql-orm + - uses: actions/checkout@v5 + with: + repository: Dastari/agql-auth + path: agql-auth + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --check + - run: cargo test --features provider-openai + - run: cargo test --features graphql-case-pascal --test graphql_naming + - run: cargo check --no-default-features --features postgres + - run: cargo check --no-default-features --features mssql + - run: cargo clippy --all-targets --features provider-openai -- -D warnings + - run: RUSTDOCFLAGS="-D warnings" cargo doc --features provider-openai --no-deps + - run: RUSTDOCFLAGS="-D warnings" cargo doc --features graphql-case-pascal --no-deps + + release-policy: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + defaults: + run: + working-directory: graphql-orm-ai + steps: + - uses: actions/checkout@v5 + with: + path: graphql-orm-ai + fetch-depth: 0 + - run: scripts/check-release-policy.sh "${{ github.event.pull_request.base.sha }}" + + semver: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + path: graphql-orm-ai + fetch-depth: 0 + - uses: actions/checkout@v5 + with: + repository: Dastari/graphql-orm + path: graphql-orm + - uses: actions/checkout@v5 + with: + repository: Dastari/agql-auth + path: agql-auth + - name: Create baseline worktree + run: git -C graphql-orm-ai worktree add ../graphql-orm-ai-baseline "${{ github.event.pull_request.base.sha }}" + - uses: obi1kenobi/cargo-semver-checks-action@v2 + with: + manifest-path: graphql-orm-ai/Cargo.toml + baseline-root: graphql-orm-ai-baseline + feature-group: default-features diff --git a/crates/graphql-orm-ai/.gitignore b/crates/graphql-orm-ai/.gitignore new file mode 100644 index 00000000..6f849663 --- /dev/null +++ b/crates/graphql-orm-ai/.gitignore @@ -0,0 +1,8 @@ +/target/ +*.profraw +.env +.env.* +!.env.example +*.key +*-key.txt +credentials*.json diff --git a/crates/graphql-orm-ai/AGENTS.md b/crates/graphql-orm-ai/AGENTS.md new file mode 100644 index 00000000..7a7668c4 --- /dev/null +++ b/crates/graphql-orm-ai/AGENTS.md @@ -0,0 +1,73 @@ +# Repository Rules + +These rules apply to every human or automated change in this repository. + +## Project boundary + +- Keep `graphql-orm-ai` project-agnostic. No consumer crate, product entity, + route, tenant policy, deployment topology, or domain mutation belongs in + `src/`, public examples, fixtures, or generated GraphQL names. +- Consumers extend the crate through typed tools, proposal schemas, access and + egress policy, logical GraphQL targets, providers, storage, and auth traits. +- Application work executes through authenticated GraphQL resolvers. Do not + add raw SQL, direct application repository access, shell, or arbitrary + model-authored GraphQL execution. +- Database syntax and backend-specific migrations belong in `graphql-orm`. + Authentication, principal lifecycle, assurance, and reusable delegation + primitives belong in `agql-auth`. + +## Database and integration safety + +- Never connect tests, migrations, diagnostics, or development commands to a + live local, development, staging, or production PostgreSQL/MSSQL database. +- SQLite tests use temporary or in-memory stores. +- PostgreSQL/MSSQL tests must create and own a disposable Docker container, + generated credentials, a unique database, and cleanup. Never fall back to + `DATABASE_URL` or `TEST_DATABASE_URL`. +- Do not run integration tests against consumer applications from this + repository. Consumer agents own their integration and migration tests. + +## Security invariants + +- Tool discovery is not authorization; registration and enablement are + default-deny. +- Rehydrate current principals before provider egress, every application tool, + after approval, and at long-running checkpoints. Never persist bearer + credentials or stale scope/role snapshots. +- Provider disclosure requires an exact egress proof and atomic budget proof. +- Application tool results require a fingerprint-bound static disclosure + schema. Runtime classification can only tighten the static result. +- Approval never substitutes for resolver authorization. Consequential actions + use a server-generated canonical preview and exact one-shot binding. +- Every worker/provider result is fenced. Restore keeps the runtime closed + until reconciliation succeeds. + +## Change documentation and SemVer + +- Update `CHANGELOG.md` under `Unreleased` for every user-visible API, + behavior, feature, security, provider, GraphQL, or persistence change. +- Update `MIGRATION.md` in the same change for every public Rust API, GraphQL + SDL, feature/default, configuration, authorization, persistence, + backup/restore, or behavioral contract change. State explicitly when no data + migration is needed. +- Any entity, index, constraint, or persistent semantic change must bump + `AI_SCHEMA_MODULE_VERSION`; never reuse an applied module version. +- Follow SemVer, including pre-1.0 breaking changes. Bump `Cargo.toml` before a + release/compatibility branch and run `cargo-semver-checks` against the + reviewed base or tag. Rust API checks do not replace GraphQL SDL and schema + migration checks. +- Keep `Cargo.lock`, dependency source identity, sibling versions, README + examples, changelog, and migration guide consistent. + +## Documentation and verification + +- Document every public Rust item. Fallible public APIs include `# Errors`; + security-sensitive APIs describe what the type proves and does not prove. +- Keep the root README concise and route detailed guidance through + `docs/README.md`. +- Before handoff run formatting, tests, warnings-denied Clippy, warnings-denied + rustdoc, PascalCase GraphQL contract tests, and compile-only PostgreSQL/MSSQL + checks. Never use `--all-features` while backend features are mutually + exclusive. + +See `docs/development.md` and `docs/release-process.md` for exact commands. diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md new file mode 100644 index 00000000..069848f2 --- /dev/null +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +All notable user-visible changes are recorded here. The crate follows +Semantic Versioning and keeps migration instructions in [MIGRATION.md](MIGRATION.md). + +## [Unreleased] + +### Added + +- Project-agnostic AI schema module with 35 private persistence entities for + configuration, sessions, protected history, fenced runs, tools, approvals, + proposals, budgets, usage, egress, audit, and restore readiness. +- Owner-isolated ORM-backed session/configuration services and resumable + durable session-event subscriptions for SQLite/PostgreSQL. +- Provider-neutral streaming contracts, deterministic mock provider, and a + feature-gated OpenAI Responses adapter. +- Explicit egress manifests/proofs, secret-store/content-protection contracts, + default-deny tools, structured proposals, and restore/start gates. +- Logical local/remote GraphQL execution targets with schema/document/ + projection/disclosure bindings and no model-visible URL. +- Static recursive disclosure schemas that reject unknown, mismatched, + oversized, secret, and structurally non-exportable result nodes. +- Atomic budget reservation domain contracts and provider-call proofs bound to + run, attempt, fence, provider, model, output ceiling, pricing version, and + expiry. +- Full approval action-envelope types binding resources/versions, policies, + actor/delegation identity, operation contracts, and server-generated + canonical previews. +- Fresh principal/scope/descriptor/argument-aware tool authorization inside + the authenticated bridge, JSON Schema 2020-12 argument validation, and + disclosure-validated runtime result envelopes. +- Optional `graphql-case-pascal` feature for coherent PascalCase resolvers, + arguments, inputs, outputs, subscriptions, and forwarded ORM fields. +- Repository governance, documentation index, migration/release policy, CI + rustdoc checks, and SemVer enforcement scaffolding. +- Project-agnostic local execution design covering local HTTP model servers and + allowlisted native/ACP subprocess harnesses without arbitrary shell, + environment, filesystem, network, or tool authority. + +### Changed + +- AI schema module version is now `0.5.0` after adding budget counter and + reservation tables plus stronger approval columns. +- `ProviderRequestContext` now requires an exact `AuthorizedBudgetReservation` + in addition to egress proofs. +- `AuthenticatedToolBridge` now requires an immutable logical target registry; + request-context factories receive the validated target, and runtime builders + require an `AiToolAuthorizationPolicy`. +- `AiRuntime::execute_tool` now requires a registered tool ID and returns an + `AiToolExecutionResult` only after current policy, resolver, byte/list limit, + and static disclosure checks succeed. +- Non-internal tool catalog registration now requires an exact GraphQL + operation contract and static disclosure schema. +- The opt-in OpenAI smoke-test key file now rejects labels, wrapped values, and + internal whitespace instead of sending an ambiguous bearer credential. + +### Security + +- Tool registration rejects current AI control-plane and GraphQL introspection + roots, including casing variants, before policy enablement. +- Provider model/output swaps invalidate budget proofs before transport. +- Approval changes to resource, policy, schema, document, projection, actor, + preview, or authorization-state bindings invalidate the grant. +- OpenAI HTTP 401 responses map to the redacted `CredentialUnavailable` + category instead of a generic provider rejection. + +## 0.1.0 + +Initial release is not yet published. Everything above remains unreleased +until the production gates in `docs/plan.md` are satisfied. diff --git a/crates/graphql-orm-ai/Cargo.lock b/crates/graphql-orm-ai/Cargo.lock new file mode 100644 index 00000000..deaf52a5 --- /dev/null +++ b/crates/graphql-orm-ai/Cargo.lock @@ -0,0 +1,4459 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + +[[package]] +name = "agql-auth" +version = "0.8.1" +dependencies = [ + "argon2", + "async-graphql", + "async-trait", + "base64 0.22.1", + "data-encoding", + "hmac", + "jsonwebtoken", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "serde_json", + "sha1", + "sha2", + "subtle", + "thiserror 2.0.18", + "time", + "uuid", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "as-slice" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" +dependencies = [ + "generic-array 0.12.4", + "generic-array 0.13.3", + "generic-array 0.14.9", + "stable_deref_trait", +] + +[[package]] +name = "ascii_utils" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" + +[[package]] +name = "async-graphql" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1057a9f7ccf2404d94571dec3451ade1cb524790df6f1ada0d19c2a49f6b0f40" +dependencies = [ + "async-graphql-derive", + "async-graphql-parser", + "async-graphql-value", + "async-io", + "async-trait", + "asynk-strim", + "base64 0.22.1", + "bytes", + "fast_chemail", + "fnv", + "futures-channel", + "futures-util", + "handlebars", + "http", + "indexmap", + "lru", + "mime", + "multer", + "num-traits", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "static_assertions_next", + "tempfile", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "async-graphql-derive" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e6cbeadc8515e66450fba0985ce722192e28443697799988265d86304d7cc68" +dependencies = [ + "Inflector", + "async-graphql-parser", + "darling 0.23.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "strum", + "syn", + "thiserror 2.0.18", +] + +[[package]] +name = "async-graphql-parser" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64ef70f77a1c689111e52076da1cd18f91834bcb847de0a9171f83624b07fbf" +dependencies = [ + "async-graphql-value", + "pest", + "serde", + "serde_json", +] + +[[package]] +name = "async-graphql-value" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" +dependencies = [ + "bytes", + "indexmap", + "serde", + "serde_json", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[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 = "asynchronous-codec" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[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 = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.9", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "connection-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.9", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array 0.14.9", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array 0.14.9", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast_chemail" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495a39d30d624c2caabe6312bfead73e7717692b44e0b32df168c275a2e8e9e4" +dependencies = [ + "ascii_utils", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float_next_after" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37007738a80ea34f969af54a3390dd72cacdef654974cfd449c9f6f72dbaac10" + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +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-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +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", +] + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "geo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30eb1fdc57c1e5cfd11826fe0caec4b9dc7901f3758263bb506228d88c8d9e9a" +dependencies = [ + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "rand 0.10.2", + "rand_pcg", + "robust", + "rstar 0.12.2", + "sif-itree", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx", + "num-traits", + "rstar 0.10.0", + "rstar 0.11.0", + "rstar 0.12.2", + "rstar 0.8.4", + "rstar 0.9.3", + "serde", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "geojson" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510c094bfc76ea34d02eee00833254945b70491d79a9c0b050abed6eaa799ffb" +dependencies = [ + "geo-types", + "log", + "serde", + "serde_json", + "thiserror 2.0.18", + "tinyvec", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "graphql-orm" +version = "0.6.1" +dependencies = [ + "async-graphql", + "futures", + "geo", + "geo-types", + "geojson", + "graphql-orm-macros", + "serde", + "serde_json", + "sqlx", + "tiberius", + "tokio", + "tokio-stream", + "tokio-util", + "uuid", +] + +[[package]] +name = "graphql-orm-ai" +version = "0.1.0" +dependencies = [ + "agql-auth", + "async-graphql", + "async-stream", + "async-trait", + "futures", + "graphql-orm", + "hex", + "jsonschema", + "reqwest", + "secrecy", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "graphql-orm-macros" +version = "0.6.0" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "handlebars" +version = "6.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4633d16a2350341713c379d6d06a4b9e1845329386026a49ce4fd09c2f3b16f6" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "hash32" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heapless" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" +dependencies = [ + "as-slice", + "generic-array 0.14.9", + "hash32 0.1.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[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 = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.41", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "i_float" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "813145bb0ad5b60f55cbbf3c74cdceda1c0a9d253b35c4cc36ae0df7887cb78f" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73d122b937fca067feb0ad74f62388920272b27c356d4df2d0cfdd59e044cf0" + +[[package]] +name = "i_overlay" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd314b4668e2b3a12508f2e125558c82a6c0a8636fa5107a900f79ce414e450" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", +] + +[[package]] +name = "i_shape" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa9eac533d7509a8ab87672b60ac610c17240f9ea4851d26227689fdfe349c8" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4804bdc1dc124eb7e1aa9e144ecc04096bcf787a10a15fa44af682b51f0f6cce" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281c43ff06dcb331e9356d30e38853d559ce3d0a3f693e0b0e102667dec14fb1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee0b351864e7ffbc5db9273daf7fa1b4d5177b0946713d667ca571b83c0b4045" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.7", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-modular" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pdqselect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty-hex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.41", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.41", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[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 0.6.4", +] + +[[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]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348e860aeb0b7bd035778fd11dd9cd5290d32e4aed3b8f2274a00287a9fd362b" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.41", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rstar" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" +dependencies = [ + "heapless 0.6.1", + "num-traits", + "pdqselect", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.41", + "rustls-native-certs 0.8.4", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array 0.14.9", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sif-itree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls 0.23.41", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array 0.14.9", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.7", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions_next" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "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 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]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiberius" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +dependencies = [ + "async-trait", + "asynchronous-codec", + "byteorder", + "bytes", + "chrono", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-util", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "rustls-native-certs 0.6.3", + "rustls-pemfile", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.24.1", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "serde_core", + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.41", + "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", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[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.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml new file mode 100644 index 00000000..a852dc2b --- /dev/null +++ b/crates/graphql-orm-ai/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "graphql-orm-ai" +version = "0.1.0" +edition = "2024" +authors = ["Toby Martin "] +description = "Project-agnostic AI agent runtime for graphql-orm applications" +license = "MIT" +repository = "https://github.com/Dastari/graphql-orm-ai" +homepage = "https://github.com/Dastari/graphql-orm-ai" +documentation = "https://docs.rs/graphql-orm-ai" +readme = "README.md" +keywords = ["ai", "graphql", "agents", "orm", "async-graphql"] +categories = ["web-programming", "api-bindings"] +publish = false + +[features] +default = ["sqlite"] +sqlite = ["graphql-orm/sqlite"] +postgres = ["graphql-orm/postgres"] +mssql = ["graphql-orm/mssql"] +provider-openai = ["dep:reqwest"] +provider-anthropic = [] +provider-xai = [] +provider-ollama = [] +provider-openai-compatible = [] +graphql-case-pascal = [ + "graphql-orm/resolver-case-pascal", + "graphql-orm/argument-case-pascal", + "graphql-orm/field-case-pascal", +] + +[dependencies] +agql-auth = { path = "../agql-auth", version = "0.8.0" } +async-graphql = { version = "7", features = ["uuid"] } +async-stream = "0.3" +async-trait = "0.1" +futures = "0.3" +graphql-orm = { path = "../graphql-orm/crates/graphql-orm", version = "0.6.1", default-features = false } +hex = "0.4" +jsonschema = { version = "0.47", default-features = false } +reqwest = { version = "0.13", default-features = false, features = ["http2", "json", "rustls", "stream"], optional = true } +secrecy = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +time = { version = "0.3", features = ["serde"] } +tokio = { version = "1", features = ["macros", "sync", "time"] } +url = "2" +uuid = { version = "1", features = ["serde", "v4"] } + +[dev-dependencies] +tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md new file mode 100644 index 00000000..e33f3aa9 --- /dev/null +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -0,0 +1,84 @@ +# Migration Guide + +`graphql-orm-ai` is not yet published. This guide is still mandatory so early +Git consumers and disposable test deployments can track schema and API changes +without guessing. + +## Unreleased: schema module 0.4.0 to 0.5.0 + +Apply the dependency-owned `AiSchemaModule` through the normal +`graphql-orm` schema manager using a new managed migration version. Do not copy +SQL or create AI tables manually. + +The additive schema change creates: + +- `graphql_orm_ai_budget_counters` +- `graphql_orm_ai_budget_reservations` + +It also adds exact target/schema/document/projection/disclosure, principal/ +delegation, resource precondition, policy/auth-state, canonical preview, and +one-shot consumption columns to `graphql_orm_ai_approvals`. + +No existing conversational content needs rewriting. Existing pre-release +approval rows cannot safely manufacture the new bindings: expire/revoke them +during restore/startup reconciliation and require a fresh approval. Existing +unaccounted provider work must complete or be classified as uncertain before +enabling hard budgets. + +Back up a disposable environment before rehearsing the migration. Runtime +workers, subscriptions, webhooks, and schedules remain closed until managed +schema validation and restore reconciliation report module `0.5.0` ready. + +### Rust API changes + +- `ProviderRequestContext::new` requires an `AuthorizedBudgetReservation`. +- `AiRuntimeBuilder` requires `graphql_targets(...)`. +- `GraphqlRequestContextFactory::build` receives the validated + `GraphqlExecutionTarget`. +- `ToolGraphqlRequest` carries an exact `GraphqlOperationContract`. +- `GraphqlInvocationContext` carries explicit causation and optional safe + delegation references plus the exact application scope. +- Application GraphQL tools use + `AiToolCatalog::register_with_disclosure`; `register` is reserved for + internal proposal-staging tools. +- `AiRuntimeBuilder` requires `tool_authorization_policy(...)` so current + principal/scope/descriptor/arguments are authorized on every call. +- `AiRuntime::execute_tool` requires the registered `AiToolId` and returns an + `AiToolExecutionResult` after argument, output-limit, and disclosure checks. +- Tool argument schemas must explicitly declare JSON Schema 2020-12. + +These are deliberate pre-1.0 breaking changes. Update host construction and +mock fixtures together; do not create permissive placeholder targets, +disclosure schemas, or budget grants. + +### Provider error classification + +The OpenAI adapter now maps HTTP 401 to `ProviderError::CredentialUnavailable` +instead of `ProviderError::Rejected`. Hosts matching public error categories +should handle the credential category as a redacted configuration/rotation +failure. No data migration is required. + +### GraphQL naming + +The default SDL remains async-graphql camelCase. Hosts requiring PascalCase +enable: + +```toml +graphql-orm-ai = { + version = "0.1.0", + features = ["sqlite", "graphql-case-pascal"] +} +``` + +This changes resolver, argument, input, output, subscription, and generated ORM +field names as one compile-time schema contract. There are no lowercase aliases. +Regenerate client documents and compare SDL before rollout. No database +migration is caused solely by the naming feature. + +## Initial adoption + +New deployments compose `AiSchemaModule`, apply its managed schema, configure +content protection and immutable deployment boundaries, and keep the runtime +start gate closed until readiness succeeds. PostgreSQL/MSSQL rehearsal must use +a disposable Docker-owned database; never point migration commands at a live +machine database. diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md new file mode 100644 index 00000000..cab94913 --- /dev/null +++ b/crates/graphql-orm-ai/README.md @@ -0,0 +1,211 @@ +# graphql-orm-ai + +`graphql-orm-ai` is a project-agnostic, security-first AI agent runtime for +applications built with [`graphql-orm`](https://github.com/Dastari/graphql-orm) +and [`agql-auth`](https://github.com/Dastari/agql-auth). It turns explicitly +reviewed application GraphQL operations into authenticated agent tools while +keeping application authorization, disclosure policy, approvals, spend, and +durable history under server control. + +This crate is an active, unpublished pre-release. The concrete session, +configuration, subscription, provider, and security foundations compile and +are tested; the durable orchestration worker and several operational adapters +listed below are still being implemented. + +## What it provides + +- An ORM-owned `AiSchemaModule` with 35 private records for configuration, + protected chat history, runs, attempts, tool calls, proposals, approvals, + budgets, usage, egress, audit, skills, and restore readiness. +- Multiple owner-isolated, archivable chat sessions per principal with + protected message blocks, stable pagination, idempotent send, and resumable + session-event subscriptions designed for virtualized frontends. +- Local or remote authenticated GraphQL execution through deployment-owned + logical targets. A model never selects an endpoint, audience, credential, + schema, operation document, projection, or disclosure contract. +- Default-deny tool registration and enablement, maturity gates, exact + descriptor fingerprints, recursive AI-control-plane denial, and static + result disclosure schemas. +- Fresh `agql-auth` principal rehydration before application tools, with the + host's ordinary GraphQL context, resolver authorization, row policy, + assurance, rate limits, and audit remaining authoritative. +- Provider-neutral streaming events, deterministic network-free mocks, and a + feature-gated OpenAI Responses/SSE adapter. Anthropic, xAI, Ollama, and + explicitly profiled OpenAI-compatible adapters have reserved feature gates. +- Separate, exact proofs for provider egress and atomic budget reservation. + Provider built-ins such as web search, file search, code execution, image + analysis, and image generation require their own authorized transfer. +- Structured AI-owned proposals and exact one-shot approval envelopes bound to + resource versions, policy/auth state, actor/delegation, target/schema/ + document/projection, and a server-generated canonical action preview. +- Fenced run/attempt contracts, fail-closed startup, and restore reconciliation + that treats uncertain external effects as uncertain rather than replayable. +- Optional coherent PascalCase GraphQL naming for consumers whose schema + conventions require it; lowercase aliases are not emitted. + +The crate never accesses application tables directly and contains no consumer +domain entity, resolver, route, deployment product, or policy. Applications +register their own scopes, targets, tools, projections, disclosure schemas, +proposal types, provider policies, and authenticated executor. + +## Security model + +Tool discovery is not authorization. Reading data is not permission to send it +to a model. Approval is not resolver authorization. These boundaries are +enforced independently: + +1. A server-authored tool descriptor is registered with an exact GraphQL + operation, logical target, schema fingerprint, result projection, and + recursive static disclosure schema. +2. Deployment and scope policy explicitly enable that exact fingerprint. +3. The current principal is rehydrated and the normal application GraphQL + authorization path executes the operation. +4. The result must conform to its static disclosure schema. Unknown fields, + wrong shapes, limits, and `NeverExport` nodes fail closed; runtime + classification may only tighten it. +5. Each external transfer requires an exact egress decision and a concurrent, + atomic budget reservation bound to the run attempt and fencing generation. +6. Consequential work additionally requires a current, one-shot approval for + the exact canonical action, followed by fresh authorization. + +Bearer tokens, provider keys, raw delegation credentials, arbitrary URLs, +hidden model reasoning, and secret-classified result nodes are never stored in +chat or exposed to a model. See the [security guide](docs/security.md) for the +complete trust model. + +## Feature flags + +Exactly one persistence backend should be selected: + +| Feature | Default | Status | +| --- | --- | --- | +| `sqlite` | yes | ORM persistence and in-memory automated tests | +| `postgres` | no | ORM persistence, compile-checked without a database | +| `mssql` | no | Schema/compile support pending ORM write parity | +| `provider-openai` | no | Native OpenAI Responses/SSE adapter | +| `provider-anthropic` | no | Reserved; adapter not implemented yet | +| `provider-xai` | no | Reserved; adapter not implemented yet | +| `provider-ollama` | no | Reserved; adapter not implemented yet | +| `provider-openai-compatible` | no | Reserved; requires explicit endpoint profiles | +| `graphql-case-pascal` | no | PascalCase roots, arguments, inputs, outputs, and ORM fields | + +Do not build with `--all-features`: the database backends are mutually +exclusive. + +## Integration outline + +Add the crate from a reviewed revision using the same dependency universe as +the matching `graphql-orm` and `agql-auth` releases: + +```toml +[dependencies] +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm-ai", rev = "", features = ["sqlite"] } +``` + +A host then: + +1. Composes `AiSchemaModule` and applies its dependency-owned migration + through the `graphql-orm` schema manager. +2. Installs its ordinary `AuthPrincipal` request context and a + `CurrentPrincipalResolver` for durable work. +3. Supplies protected-content, secret-store, session access, fresh + principal-aware tool authorization, egress policy, provider, and + restore-readiness implementations. +4. Registers immutable logical GraphQL targets and reviewed application tools + with exact operation and disclosure contracts. +5. Composes `AiQueryRoot`, `AiMutationRoot`, and `AiSubscriptionRoot` into the + application or dedicated AI subgraph. +6. Opens the runtime start gate only after managed migration validation and + restore reconciliation succeed. + +Remote/federated consumers implement the same `AuthenticatedGraphqlExecutor` +contract with private destination enforcement and short-lived bounded +delegation. The crate deliberately has no dependency on a particular router, +federation implementation, or service topology. + +The [getting-started guide](docs/getting-started.md) tracks which runtime +services are concrete today and which host seams are still foundations. + +## Chat and streaming model + +Messages, content blocks, events, runs, tool calls, and artifacts are separate +bounded resources. Reads use stable keyset windows; subscriptions replay from +a cursor to a captured watermark and then switch to commit-only wakeups. A +frontend can therefore retain a small virtualized window even for extremely +large histories instead of receiving or rendering the entire session. + +Attachments use opaque AI-owned references and will pass through ownership, +size/type, quarantine, scanning, disclosure, and provider-egress checks. The +full attachment storage/scanning pipeline is not production-ready yet. + +## Current maturity + +Implemented and tested foundations include ORM-backed SQLite/PostgreSQL +session and configuration services, resumable session events, OpenAI and mock +provider contracts, content protection, egress proofs, logical GraphQL target +contracts, static disclosure validation, atomic budget proof types, exact +approval binding, proposal schemas, fenced state transitions, and restore +planning. + +Production blockers include the durable orchestration worker, transactional +budget counter service, approval/proposal/usage GraphQL lifecycles, attachment +pipeline, production mutable secret stores/keyrings, other provider adapters, +remote delegated credential implementation, generated resolver disclosure +metadata, Ollama/OpenAI-compatible and allowlisted installed local-harness +drivers, and Docker-owned PostgreSQL parity testing. Details live in +[implementation status](docs/implementation-status.md). + +Local execution remains in scope. Ollama and OpenAI-compatible loopback servers +will use ordinary provider adapters. Installed CLI/ACP agents will use a +separate deployment-registered subprocess driver with no shell, fixed command +and arguments, sanitized environment, sandbox/resource limits, and mediated +tool callbacks through this runtime; GraphQL may select an approved logical +profile but can never configure an arbitrary command. + +## Development safety and checks + +Automated tests never connect to an external database. SQLite tests use only +in-memory databases. PostgreSQL/MSSQL checks are compile-only unless a test +harness proves that it created and owns a disposable Docker container, unique +credentials, database, and cleanup. Generic `DATABASE_URL` fallbacks are +forbidden. Consumer-application integration tests belong to those consumers. + +```bash +cargo fmt --check +cargo test --features provider-openai +cargo clippy --all-targets --features provider-openai -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --features provider-openai --no-deps +cargo test --features graphql-case-pascal --test graphql_naming +cargo check --no-default-features --features postgres +cargo check --no-default-features --features mssql +``` + +The ignored live OpenAI smoke test sends only synthetic text and is never part +of CI. It must be explicitly selected and given a key-file path. The file must +contain exactly one raw `sk-…` credential with no labels, wrapping, or other +values: + +```bash +GRAPHQL_ORM_AI_OPENAI_KEY_FILE=/path/to/key \ + cargo test --features provider-openai \ + live_openai_synthetic_text_smoke_test -- --ignored +``` + +Mocked HTTP/SSE tests are the default and require no provider credential. + +## Documentation and releases + +The [documentation index](docs/README.md) links architecture, security, +development, release, implementation-status, and long-form planning guides. +Public APIs are documented in generated Rustdoc. + +Every user-visible change updates [CHANGELOG.md](CHANGELOG.md). Every public +Rust/GraphQL/configuration/security/persistence/restore contract change also +updates [MIGRATION.md](MIGRATION.md), even when no data migration is required. +CI enforces those files, crate/schema version movement, `cargo-semver-checks`, +warnings-denied Rustdoc, and the PascalCase SDL contract. Repository rules are +recorded in [AGENTS.md](AGENTS.md). + +## License + +MIT diff --git a/crates/graphql-orm-ai/docs/README.md b/crates/graphql-orm-ai/docs/README.md new file mode 100644 index 00000000..41fcb296 --- /dev/null +++ b/crates/graphql-orm-ai/docs/README.md @@ -0,0 +1,17 @@ +# Documentation + +Start with the root [README](../README.md), then use the focused guides below. + +- [Getting started](getting-started.md) +- [Architecture and crate boundaries](architecture.md) +- [Security model](security.md) +- [Development and verification](development.md) +- [Release, SemVer, changelog, and migration process](release-process.md) +- [Implementation status](implementation-status.md) +- [Architecture and implementation plan](plan.md) +- [Migration guide](../MIGRATION.md) +- [Changelog](../CHANGELOG.md) + +Provider-, attachment-, worker-, approval-, and tool-authoring guides will be +added as their production implementations land. Public Rust API details are +available through generated rustdoc. diff --git a/crates/graphql-orm-ai/docs/architecture.md b/crates/graphql-orm-ai/docs/architecture.md new file mode 100644 index 00000000..2454a1bf --- /dev/null +++ b/crates/graphql-orm-ai/docs/architecture.md @@ -0,0 +1,49 @@ +# Architecture and Crate Boundaries + +`graphql-orm-ai` owns reusable agent orchestration: sessions, protected +messages, runs, tools, approvals, proposals, usage, budgets, egress decisions, +provider adapters, and recovery. It owns the `graphql_orm_ai_*` schema module +but does not own application domain data. + +`graphql-orm` owns backend SQL, generated repositories, transactions, CAS, +keyset pagination, schema modules, migration planning, backup metadata, and +portable durable-stream/lease primitives. `graphql-orm-ai` never issues SQL. + +`agql-auth` owns safe principal references, current-principal rehydration, +session/token status, assurance, and reusable audience/resource-bound +delegation. The AI crate owns tool maturity, AI approvals, provider egress, and +budgets. + +Host applications own domain resolvers and policies, scope mapping, request +context construction, proposal schemas/UI, deployment network and secret +isolation, and the final mutations that apply reviewed proposals. + +## Execution topologies + +The runtime supports both embedded and separately deployed execution. It does +not understand federation products. A tool targets a logical deployment ID; +the host resolves that ID to a finished local schema or private authenticated +GraphQL transport. + +Remote targets bind audience, resource, schema fingerprint, operation document, +projection, and disclosure metadata. Delegated credentials are ephemeral. The +user's original bearer token, endpoint URL, and secret material are never +stored in sessions, tool calls, or model context. + +For every registered call the bridge rehydrates the principal, invokes a +required host `AiToolAuthorizationPolicy` over the scope/descriptor/validated +arguments, builds the ordinary request context, and executes the resolver. +The runtime returns an `AiToolExecutionResult` only after byte/list bounds and +the closed static disclosure schema succeed. External disclosure still needs a +separate egress decision. + +## Persistence and streaming + +Local ORM state is canonical. Messages, blocks, runs, tool calls, and durable +events are independently windowed. Subscriptions replay to a watermark and use +commit-only wakeups; clients never need a complete session snapshot. + +Schema migration, backup, restore, and runtime readiness use the dependency- +owned `AiSchemaModule`. A restored database is not runnable until leases, +approvals, provider continuations, uncertain side effects, and content +protection have been reconciled. diff --git a/crates/graphql-orm-ai/docs/development.md b/crates/graphql-orm-ai/docs/development.md new file mode 100644 index 00000000..04202e0b --- /dev/null +++ b/crates/graphql-orm-ai/docs/development.md @@ -0,0 +1,55 @@ +# Development and Verification + +## Default checks + +```bash +cargo fmt --check +cargo test --features provider-openai +cargo clippy --all-targets --features provider-openai -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --features provider-openai --no-deps +``` + +Check the optional naming contract independently: + +```bash +cargo test --features graphql-case-pascal --test graphql_naming +RUSTDOCFLAGS="-D warnings" \ + cargo doc --features graphql-case-pascal --no-deps +``` + +Compile other backends without connecting to them: + +```bash +cargo check --no-default-features --features postgres +cargo check --no-default-features --features mssql +``` + +Do not run Cargo `--all-features`: the persistence backends are intentionally +mutually exclusive. Provider feature matrices must select exactly one backend. + +## Database tests + +Default tests use in-memory SQLite. PostgreSQL and MSSQL integration tests are +permitted only through a harness that creates and owns a disposable Docker +container, generated credentials, unique database, and cleanup. A generic +database URL is never accepted. No current check needs a PostgreSQL server. + +## Rustdoc + +The crate denies rustdoc warnings in CI. Public APIs need useful documentation, +not placeholder comments. Fallible methods document `# Errors`; proof types +explain the exact binding they establish and any checks that still remain. + +Private ORM derive output is kept in the private persistence module and is the +only scoped exception to generated missing-doc warnings. + +## Release-policy check + +On a committed branch, compare against the reviewed base: + +```bash +scripts/check-release-policy.sh +``` + +CI additionally runs `cargo-semver-checks` against a sibling baseline worktree +so local path dependencies resolve consistently. diff --git a/crates/graphql-orm-ai/docs/getting-started.md b/crates/graphql-orm-ai/docs/getting-started.md new file mode 100644 index 00000000..6e65c156 --- /dev/null +++ b/crates/graphql-orm-ai/docs/getting-started.md @@ -0,0 +1,40 @@ +# Getting Started + +`graphql-orm-ai` is currently a Git-only pre-release crate. Use the same +reviewed dependency universe for `graphql-orm-ai`, `graphql-orm`, and +`agql-auth`; local sibling paths are for development only. + +## Features + +Exactly one persistence backend is currently required: + +- `sqlite` (default) +- `postgres` +- `mssql` (schema/compile support until ORM write parity lands) + +Provider adapters are opt-in. `provider-openai` enables the native OpenAI +Responses adapter. `graphql-case-pascal` changes the complete GraphQL naming +contract from default camelCase to PascalCase. + +## Host integration outline + +1. Compose `AiSchemaModule` and apply its managed schema through + `graphql-orm`. +2. Install `AuthPrincipal` in normal GraphQL request context and provide a + `CurrentPrincipalResolver` for durable work. +3. Provide session/configuration access, fresh principal-aware + `AiToolAuthorizationPolicy`, egress, secret-store, and content-protection + implementations. +4. Register immutable logical GraphQL targets. Remote target URLs and + credential issuance remain deployment-owned and never model-visible. +5. Register reviewed application tools with server-authored documents, exact + operation contracts, and static disclosure schemas. Registration does not + enable a tool. +6. Register proposal types and provider adapters. +7. Apply/validate migrations and restore reconciliation, then open the runtime + start gate. + +The current crate exposes concrete session/configuration/subscription services +and foundation contracts; the durable orchestration worker, attachment +pipeline, budget persistence service, and full approval GraphQL lifecycle are +still under implementation. See [implementation status](implementation-status.md). diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md new file mode 100644 index 00000000..37bc7c2b --- /dev/null +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -0,0 +1,183 @@ +# Implementation Status + +This file tracks delivery against `docs/plan.md`. It is intentionally explicit +about what is a compiled contract versus production-ready behavior. + +## Implemented foundation + +- Crate scaffold and SQLite/PostgreSQL/MSSQL compile-time backend selection. +- Project-boundary test rejecting direct SQLx/Tiberius, generic database URLs, + and known consumer/deployment references from crate source. +- `agql-auth` safe `PrincipalReference`, `ResolvedPrincipal`, + `CurrentPrincipalResolver`, purpose-bound grant reference, and linked + invocation audit metadata. +- `graphql-orm` dependency-owned schema-module catalog with module ID, version, + namespace, fingerprint, backup metadata, and restore-hook declarations. +- `graphql-orm` portable fencing/CAS state contracts and stale-worker tests. +- `graphql-orm` validated Relay-style bidirectional keyset input, portable + `before` predicates, and generated repository `first/after` plus + `last/before` connections. +- AI schema-module identity (currently version `0.5.0`) and 35 private records + spanning provider/model configuration, content/egress/tool/retention/budget + policy and atomic reservations, sessions, attachments, runs, approvals, + proposals/items, checkpoints, skills/versions, usage, webhook receipts, + audit, secret cleanup, egress decisions, and restore readiness. +- Private repository generation for SQLite/PostgreSQL AI records without + composing or exporting generic internal CRUD roots; MSSQL remains + schema-only until write parity exists. +- Provider-neutral capability/request/event/stream interfaces with validated + function schemas and separately authorized built-in tools. +- Deterministic mock provider and native feature-gated OpenAI Responses/SSE + adapter with `store: false` by default, redirects disabled, secret resolution + immediately before each request, structured output, custom functions, + built-in web/file/code/image request mapping, typed normalization, usage, + citations, forward-compatible unknown events, and no hidden reasoning + persistence. +- Exact provider request binding: an egress proof cannot be paired with a + changed provider/model/session/run/payload estimate, every built-in or + attachment capability requires its own matching authorized transfer, and an + opaque atomic budget proof must match run/attempt/fence/provider/model/output + ceiling/expiry before transport. +- Secret-store contract plus explicit, read-only, allowlist-mapped environment + bootstrap store. Runtime construction now requires a secret store. +- Per-scope content-protection policy/envelope/protector contracts with a + fail-closed database-managed implementation and authorized policy resolver. + Runtime construction now requires both the resolver and protector. +- Redacted authenticated GraphQL configuration roots for provider profiles, + credential set/rotate/remove, and content-protection policy. Credential and + protection mutations explicitly require service-level CAS, redacted audit, + administrative authorization, and recent MFA. +- Default-deny tool catalog, fingerprint-bound policies, rollout maturity + caps, JSON Schema 2020-12 argument validation, and current principal/scope/ + descriptor/argument-aware authorization inside the authenticated bridge. +- Exact local/private-routed/private-direct logical GraphQL target registry + with audience/resource and schema/document/projection/disclosure bindings; + the model never receives a target URL or credential. +- Static recursive result-disclosure schemas with closed objects, bounded + lists, `NeverExport` nodes, classification tightening, stable fingerprints, + and runtime enforcement before tool results leave the execution boundary. +- Full action-envelope approval domain contract binding tool/argument, + principal/delegation, logical target/schema/document/projection/disclosure, + resources/versions, policy/auth-state, canonical preview, expiry, and + one-shot consumption identity. +- Explicit egress manifests, deployment boundary, policy decision, and + allowed-manifest proof. +- JSON Schema 2020-12 structured proposal registry and provenance validation. +- Canonical host request-context/executor contracts and current-principal tool + bridge. Registered tool execution now returns a bounded + `AiToolExecutionResult` only after fresh tool policy, ordinary resolver + execution, and static disclosure validation. +- Fail-closed runtime builder and restore/start readiness gate. +- Pure fenced run-state and side-effect-safe restore reconciliation planning. +- Initial authenticated `AiQueryRoot`/`AiMutationRoot` session contract with + bounded session/message/block/event reads and lifecycle/message operations + over an owner/scope-aware service trait. +- Concrete SQLite/PostgreSQL ORM-backed session service using only generated + repository/transaction APIs: principal-kind-aware owner isolation, separate + message/event sequence heads, protected preview/content/event envelopes, + content-bound client idempotency, atomic message+block+queued-run+event + persistence, attachment ownership/quarantine checks, CAS archive/restore/ + delete, and bounded keyset/event/block reads. +- Concrete SQLite/PostgreSQL ORM-backed configuration service: host-owned admin + policy, recent-MFA enforcement, endpoint SSRF policy seam, provider-profile + and content-policy CAS, same-transaction redacted audit append, fresh-reference + credential rotation with compensation, and durable obsolete-secret cleanup. +- In-memory SQLite service tests cover owner isolation, idempotent atomic send, + windowed reads, lifecycle CAS, recent MFA, stale-version rejection, endpoint + policy, credential rotation/removal, and content-protection readiness. +- `AiSubscriptionRoot` and a concrete SQLite/PostgreSQL durable session-event + subscription service: receiver-before-replay race avoidance, bounded replay + to a captured watermark, commit-only wakeup hints, database re-reads after + wake/lag, explicit reset signaling, and periodic principal rehydration plus + session/scope reauthorization. +- Optional coherent `graphql-case-pascal` contract covering roots, arguments, + inputs, outputs, subscriptions, enums, and forwarded generated ORM fields + without lowercase aliases. +- Root README, documentation index/guides, changelog, migration guide, + repository rules, release-policy script, SemVer CI, and warnings-denied + Rustdoc/SDL checks. + +## Not yet production-ready + +- Applied host migrations and production PostgreSQL parity testing. PostgreSQL + remains compile-checked only; no local or production PostgreSQL was touched. +- Durable per-principal inbox sequencing/subscriptions and retention purge + execution. Session-event live wakeup/replay/reauthorization is implemented; + reset signaling is present, while actual retention pruning remains. +- Approval, proposal, attachment, usage, skill, and subscription roots beyond + the initial session/configuration surfaces. +- Application-encrypted field/keyring and production mutable secret-store + implementations. Database-managed protection and the safe service seams are + implemented. +- Attachment/quarantine/storage pipeline. +- Durable database worker claim/heartbeat/recovery operations. +- Transactional approval persistence, canonical preview provider, atomic + one-shot consumption, and recent-MFA flow. Exact domain bindings and schema + columns exist, but the lifecycle service/root does not. +- Provider HTTP adapters for Anthropic, xAI, Ollama, and explicitly profiled + OpenAI-compatible endpoints. +- OpenAI attachment/file upload resolution, background/webhooks, provider file + deletion, image/file input, and full built-in result normalization. The + current adapter intentionally rejects local opaque attachment IDs until that + pipeline exists. +- Provider webhooks/background processing. +- Concrete transactional budget counter/reservation service, usage, + retention/purge, and telemetry sinks. Atomic request/proof/reconciliation + contracts and persistence entities exist. +- Backup adapter execution and applied restore transactions. +- Resolver-operation disclosure metadata generation and complete schema-aware + control-plane recursion validation. The current catalog uses explicit + reviewed operation contracts, disclosure schemas, and a fail-closed + identifier scanner. +- Concrete delegated-credential issuer and remote HTTP GraphQL executor. The + target/audience/resource/context contracts are present and transport remains + host-owned. +- Ollama/OpenAI-compatible local provider adapters and the allowlisted installed + local-harness/ACP process driver. Local execution remains in scope; no model + may choose a command, arguments, working directory, environment, mount, or + network authority. +- Any consumer integration testing or migration. That work is explicitly left + to each consumer project/agent. + +## Next implementation slice + +1. Implement the ORM-backed transactional budget counter/reservation service, + conservative reconciliation, and concurrency tests using only in-memory + SQLite. +2. Implement mock-provider orchestration through the fenced durable worker, + registered tool execution, result disclosure, and provider egress loop. +3. Implement proposal and exact approval services/GraphQL lifecycles, including + canonical previews and atomic one-shot consumption. +4. Add the generic delegated-authority seam and remote authenticated GraphQL + executor fixtures without embedding a federation/router product. +5. Add the attachment quarantine/scanning/storage pipeline and connect its + authorized image/file resolution to provider adapters. +6. Add the per-principal inbox stream and retention/pruning worker, then the + remaining provider/configuration surfaces, including Ollama and the + deterministic fake-process foundation for an allowlisted local harness. +7. Add Docker-owned PostgreSQL parity tests only after the harness can prove it + created the exact disposable database handle. + +## Current verification + +- `cargo test --features provider-openai`: 34 integration tests and four + active unit tests passed; one explicit live-provider test remained ignored. +- `cargo clippy --all-targets --features provider-openai -- -D warnings`: + passed. +- Warnings-denied Rustdoc passed for `provider-openai` and + `graphql-case-pascal`. +- PascalCase SDL contract test passed with no camelCase aliases. +- `cargo check --no-default-features --features postgres`: passed, compile-only. +- `cargo check --no-default-features --features mssql`: passed, schema-only; + existing dependency warnings remain in `graphql-orm`. +- The mutually exclusive backend features intentionally cannot be checked with + Cargo `--all-features` in one build. + +## Provider test note + +- Mocked OpenAI HTTP/SSE and all other automated tests pass without credentials. +- The requested synthetic live OpenAI smoke test was attempted with the + configured key file. OpenAI returned HTTP 401 for both its sole + `sk-svcacct-…` token and the whitespace-compacted file; no credential or + response body was logged. The file currently contains internal whitespace + and is rejected by the strict one-token key-file loader. diff --git a/crates/graphql-orm-ai/docs/plan.md b/crates/graphql-orm-ai/docs/plan.md new file mode 100644 index 00000000..2f574e26 --- /dev/null +++ b/crates/graphql-orm-ai/docs/plan.md @@ -0,0 +1,2107 @@ +# `graphql-orm-ai` Architecture and Implementation Plan + +## Summary + +Implement `graphql-orm-ai` as a project-agnostic Rust agent runtime built around: + +- Durable per-user chat sessions and background runs. +- Efficient, cursor-windowed GraphQL history and resumable subscriptions. +- Provider-neutral model adapters for OpenAI, Anthropic, xAI/Grok, Ollama, and local/OpenAI-compatible endpoints. +- Secure tool execution through the application's existing authenticated GraphQL schema. +- An AI-owned structured-proposal workflow that lets early deployments stage suggested changes without mutating application records. +- Discovery of all generated resolvers plus explicit registration of handwritten application resolvers. +- Default-deny tool exposure, risk-based approvals, current-user reauthorization, and data-egress policy. +- Explicit authorization for each external data transfer; ordinary read permission never implies permission to disclose data to a model, built-in tool, or MCP server. +- GraphQL-managed runtime configuration, provider profiles, tool policies, skills, retention, budgets, and content-protection policy. +- Attachment storage through `graphql-orm-storage`. +- Backup integration through `graphql-orm-backup`. +- Authentication, principal rehydration, delegation, recent-MFA, and long-lived connection security through `agql-auth`. +- The same `graphql-orm` database selected by the host application, with no sidecar database and no raw SQL outside `graphql-orm`. + +This planning document records the requested Digitise investigation. No crate source, public API, schema name, example, fixture, or runtime behavior may depend on or reference Digitise. + +The package name and existing folder spelling will be `graphql-orm-ai`; prompt spellings such as `grapqhl-orm-ai` and `graphql-orm-stroage` are treated as typos. + +## Locked Decisions + +- Tool exposure is default-deny. +- Resolver discovery does not itself authorize model use. +- Enabled tools are always constrained by the current user's ordinary GraphQL authorization. +- Risk-based approval is mandatory: + - Read-only tools may run without per-call approval after policy enablement. + - Low-risk, idempotent writes may be policy-approved. + - Publish, delete, permission changes, credential changes, external sending, destructive operations, and other high-impact actions always require one-shot approval. +- The first production consumer pilot is read-only with respect to application data. Its only write-capable agent action is creating a validated proposal in AI-owned staging tables. +- A human applies accepted proposal fields through the application's ordinary mutation path. Direct application mutation tools remain in the full implementation scope, but are enabled only after a separate write-maturity security gate and use one-shot approval for high-impact actions. +- GraphQL read authorization and external data-egress authorization are independent checks. Both must allow a value before it can leave the application trust boundary. +- Provider credentials support centrally managed profiles plus optional per-user BYOK. +- Session deletion purges content while retaining only redacted, non-content security audit facts. +- Navigation support is limited to typed, validated UI intents; frontend routing and drawer implementation stay application-owned. +- Runtime state uses the host's configured `graphql-orm` backend. +- SQLite, PostgreSQL, and eventually MSSQL are supported without raw SQL in `graphql-orm-ai`. +- Full MSSQL writes, migrations, transactions, and security parity will be implemented in `graphql-orm`; no hidden SQLite/PostgreSQL sidecar is allowed for MSSQL applications. +- Content protection is configured per application, tenant, or project scope before AI is enabled: + - `DatabaseManaged`: rely on database/volume encryption for conversational content. + - `FieldEncrypted`: encrypt conversational content through first-class `graphql-orm` encrypted fields. + - Provider credentials are always field-encrypted or stored in an external secret store regardless of scope policy. +- Delivery is phased. SQLite/PostgreSQL production support and OpenAI land before every provider and advanced protocol are complete. +- Every leased run uses a monotonically increasing fencing token. A worker that loses its lease cannot append events, save tool/provider results, or finalize the run. +- Restore is a runtime lifecycle state, not just row import. Workers, subscriptions, and provider callbacks remain closed until post-restore reconciliation succeeds. +- Local database tests must never connect to a live PostgreSQL or MSSQL instance. Containers are mandatory. +- The runtime supports both embedded/local schemas and separately deployed GraphQL services. Federation products and router brands are host concerns; the reusable boundary is a target-bound authenticated GraphQL executor. +- A model never chooses a GraphQL URL, schema, subgraph, delegation audience, or execution target. Tools bind to deployment-registered logical targets and exact schema/document/projection fingerprints. +- Tool-result disclosure is derived from server-owned field/projection metadata. Runtime classification may only raise classification, redact, or remove data; it cannot make statically forbidden data exportable. +- Provider calls require an atomically reserved budget proof. Estimated capacity is reserved before egress and actual usage is reconciled exactly once afterward. +- Approvals bind to the complete server-generated action envelope, including target resources and versions, policy and schema versions, actor/delegation identity, and a canonical preview. +- GraphQL resolver, argument, and field naming is a compile-time integration choice. Camel case is the default; PascalCase and other supported conventions must not require aliases or consumer-specific roots. +- Local execution is a first-class deployment option. HTTP model servers + (Ollama/OpenAI-compatible) use provider adapters; installed agent/model + harnesses use a separate allowlisted process/ACP driver. Neither path grants + shell, filesystem, network, credential, or application-tool authority by + implication. +- Public API, GraphQL contract, feature, or schema changes follow SemVer and must update `CHANGELOG.md` and `MIGRATION.md` under the repository release rules. + +## Repository Investigation + +### `graphql-orm` + +The current runtime is version 0.6.1 and already provides much of the required foundation: + +- Managed SQLite and PostgreSQL writes and migrations. +- Entity, field, row, relation, repository, and GraphQL surface policies. +- `AuthSubject` and `DbAuthContext`. +- State-machine transactions. +- Versioned compare-and-swap. +- Append-only entities. +- Composite forward keyset pagination. +- Backup metadata and a transactional change journal. +- Generated queries, mutations, and subscriptions. +- Composition of generated and handwritten GraphQL roots. + +Relevant foundations are documented in `../../graphql-orm/docs/portable-persistence.md` and `../../graphql-orm/docs/strict-authorization.md`. + +Required gaps: + +1. No stable resolver-operation registry covering generated operations. +2. No bidirectional `before`/`last` keyset connection for chat history. +3. Generated subscriptions are in-memory broadcast streams rather than durable replayable streams. +4. Generated subscription filter arguments are currently unused, and events are not row-policy filtered per subscriber. +5. Long-lived subscriptions do not periodically rehydrate and reauthorize their principal. +6. MSSQL is intentionally read/query-only. +7. Existing field transforms are insufficient as a full encrypted-field contract: they lack keyring lifecycle, rotation, repository-path support, backup semantics, and search/filter restrictions. +8. No provider-neutral vector storage/search contract. +9. Schema modules cannot currently contribute migration-only entities without exposing generated CRUD roots. +10. Some backup/restore and durable queue primitives still force sibling crates toward raw SQL. + +### `graphql-orm-storage` + +The existing 0.5.0 design is the correct attachment boundary: + +- Provider-neutral `BlobStore`. +- Streaming reads and writes. +- Range reads. +- Conditional writes and copy. +- Local, S3, and SMB providers. +- No unsafe default GraphQL upload/download resolvers. + +Its explicit decision that authorization and GraphQL routes remain application-owned should be preserved. See `../../graphql-orm-storage/docs/architecture.md` and `../../graphql-orm-storage/docs/blob-store.md`. + +No major redesign is required for the AI core. The AI crate should wrap `BlobStore` with attachment ownership, quarantine, validation, scanning, and lifecycle metadata. + +### `graphql-orm-backup` + +The current 0.4.0 crate has: + +- Full logical backup and restore. +- Blob-backed repositories. +- Object checksum verification and deduplication. +- Incremental backup orchestration and manifest support. + +Gaps affecting AI: + +- The ORM adapter still reports incremental export/restore as unsupported even though `graphql-orm` now has a change journal. +- The ORM adapter contains direct SQL for table counts, clearing restore targets, truncation, and SQLite foreign-key handling. +- The object index assumes one metadata table, while AI attachments and application objects may occupy multiple tables. +- AI secret, encrypted-content, raw-provider-payload, and retention policies need explicit backup rules. + +### `agql-auth` + +Version 0.8.0 already provides: + +- `AuthPrincipal` for user sessions and API/service tokens. +- Scopes, roles, tenant metadata, resource binding, token IDs, session IDs, actor data, and correlation IDs. +- Recent-MFA/session-assurance primitives. +- Token status checking. +- Fail-closed `ReauthorizationPolicy`. +- Audience-bound resource-server validation. +- Structured, redacted authorization decisions. + +The long-lived connection documentation currently leaves the actual timer/status/close loop to each host. See `../../agql-auth/docs/websocket-reauthorization.md`. + +Required additions are principal references, current-principal rehydration, reusable transport reauthorization, and bounded delegation support. + +### Digitise reference-consumer audit + +The audited consumer currently contains: + +- `FileMetadata`, `FileAnalysisRun`, and `FileTextSegment`. +- `AgentSession`, `AgentMessage`, `AgentTask`, and `AgentUsageEntry`. +- A direct OpenAI Responses HTTP integration. +- Encrypted AI settings scoped by application, collection, or user. +- An unbounded in-process queue plus database task rows. +- Startup recovery of queued/running work. +- Structured image analysis and metadata extraction. +- Admin-only AI mutations. + +Important findings: + +- The `AgentSession`, `AgentMessage`, `AgentTask`, and `AgentUsageEntry` types are not included in the composed `schema_roots!` entity list, despite documentation implying they are exposed. +- The scheduler uses an unbounded channel and has no durable lease/heartbeat/dead-letter model. +- The model integration is OpenAI-specific. +- The agent path frequently uses repository access rather than the authenticated GraphQL resolver path. +- Existing repository authorization is broad enough to become a privilege bypass if reused for user-delegated tools. +- AI policies are too coarse for per-user sessions. +- There are no AI session subscriptions or bounded history windows. +- Generated entity subscriptions are currently unsuitable for security-sensitive agent watches. +- Provider settings and encryption are useful prototypes but should move behind generic provider-profile and secret-store contracts. +- Application-specific catalog/file-analysis entities should remain in the application. Orchestration, providers, sessions, tool calls, attachments, usage, approvals, and common structured-analysis execution should move to `graphql-orm-ai`. + +The current implementation is primarily in: + +- `../../digitse/src/ai/manager.rs` +- `../../digitse/src/ai/file_analysis.rs` +- `../../digitse/src/domain/entities/ai.rs` +- `../../digitse/docs/engineering/ai-delivery-plan.md` +- `../../digitse/docs/engineering/ai-agent-manager-plan.md` +- `../../digitse/docs/product/ai-assisted-cataloging-and-ingest-workspace.md` + +### Digitise agent review of this plan + +A subsequent review by the Digitise agent agreed with the overall scope but identified six contracts that must be stronger before implementation: + +1. AI schema/table ownership must be explicit rather than inferred from composition. +2. Internal tool execution must share authorization, request-context, rate-limit, and application-audit behavior with ordinary GraphQL execution. +3. Permission to read data must not automatically authorize external model/tool egress. +4. Worker leases need fencing, not expiry/heartbeat alone. +5. Restore must reconcile uncertain runtime/external state before any worker resumes. +6. Digitise should be used earlier, initially with read-only tools and structured suggestions that a human applies through normal mutations. + +This revision adopts all six. It retains the full long-term direct-mutation and multi-step-agent scope, but separates capability design from rollout authority: the early pilot is `ProposalOnly`, supervised mutations come after a distinct security gate, and high-impact actions remain one-shot approved. + +### Federated and independently deployed consumer review + +A later review from an independently deployed, federated consumer validated the existing default-deny, egress, reauthorization, fencing, restore, and proposal-only decisions. It also exposed reusable gaps that apply beyond any one router, service topology, or product domain. This plan adopts the following project-agnostic changes: + +1. Treat federation as remote authenticated GraphQL execution, not as a federation-specific runtime mode. +2. Support logical local and remote execution targets with immutable deployment registration, audience/resource binding, schema fingerprints, and short-lived delegation. +3. Prevent recursion structurally: application tools cannot invoke AI control-plane roots, introspection, configuration, approval, or tool-discovery operations. +4. Replace arbitrary post-hoc JSON classification with a server-owned disclosure schema bound to the tool projection. Unknown and non-exportable fields fail closed. +5. Reserve budget atomically before every provider call and reconcile actual usage afterward so concurrent runs cannot overspend the same remaining allowance. +6. Bind approvals to resource versions, policy state, delegated actor, target/schema/document fingerprints, and a server-generated canonical action preview. +7. Add compile-time GraphQL naming features so a host can consistently select camelCase, PascalCase, or another supported convention without aliases. +8. Add reusable conformance tests for local/remote authorization parity, destination enforcement, token non-persistence, recursion denial, disclosure denial, and concurrent budget reservations. + +The review's deployment recommendations remain host-owned. This crate does not mandate a standalone service, separate database, particular router, provider, tenant rollout, or domain proposal type. It supports both embedded and standalone operation while preserving the same security contracts. + +## External Architecture Research + +### T3 Code + +The current [T3 Code repository](https://github.com/pingdotgg/t3code) was inspected as an architectural reference. + +Useful patterns to adopt: + +- Append-oriented orchestration events with sequence, stream version, command, causation, correlation, and actor metadata. +- Provider-driver isolation and capability discovery. +- Queue-backed workers and runtime receipts. +- Normalized provider events. +- Explicit approval records. +- Projection tables for fast UI reads. +- Push events with sequence-based resume. +- Virtualized message rendering and scroll-anchor preservation. + +A limitation not to copy: + +- T3 currently returns complete thread message/activity snapshots in important paths. DOM virtualization helps rendering, but does not bound backend query cost, network transfer, or client memory. `graphql-orm-ai` must provide server-side message and content-block windows. + +### Provider APIs + +Provider tool semantics are not uniform: + +- OpenAI Responses exposes custom functions, streaming event types, web search, file search, image generation, code execution, MCP, and background processing. The design should follow the native [tools](https://developers.openai.com/api/docs/guides/tools), [streaming](https://developers.openai.com/api/docs/guides/streaming-responses), [conversation state](https://developers.openai.com/api/docs/guides/conversation-state), and [webhook](https://developers.openai.com/api/docs/guides/webhooks) contracts. +- Anthropic distinguishes application-executed tools from server tools and requires the application to drive the client-tool loop. Its current contract is documented in [How tool use works](https://platform.claude.com/docs/en/agents-and-tools/tool-use/how-tool-use-works). +- xAI supports server tools and client function calls through its Responses-style APIs, including parallel calls. See [xAI Tools](https://docs.x.ai/developers/tools/overview). +- Ollama supports native streaming tool calls and partial OpenAI compatibility. See [Ollama tool calling](https://docs.ollama.com/capabilities/tool-calling) and [OpenAI compatibility](https://docs.ollama.com/api/openai-compatibility). + +Therefore: + +- Implement an internal provider-neutral event and capability SPI. +- Use native adapters for the four required providers. +- Do not make a third-party framework part of the public API. +- Rig was evaluated because it supports many providers, tools, streaming, RAG, and MCP in Rust ([Rig](https://rig.rs/)). It will not be the core abstraction because provider built-ins, event reconciliation, background runs, security policy, and audit behavior must remain under this crate's control. An optional internal Rig adapter may be considered later. + +### Security and standards + +- Tool schemas use JSON Schema 2020-12. +- History connections follow the [Relay Cursor Connections specification](https://relay.dev/graphql/connections.htm). +- Agent threats are modeled against OWASP's prompt-injection, sensitive-disclosure, and excessive-agency risks. The 2025 excessive-agency guidance identifies excessive functionality, permissions, and autonomy as distinct root causes ([OWASP](https://owasp.org/www-project-top-10-for-large-language-model-applications/2_0_vulns/LLM06_ExcessiveAgency.html)). +- Telemetry follows OpenTelemetry GenAI naming, while content, tool arguments, and tool results remain redacted by default because the conventions identify them as sensitive ([OpenTelemetry](https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/)). +- MCP support targets specification `2025-11-25`. Streamable HTTP requires origin validation, authentication, and secure resumption ([MCP transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)); authorization uses OAuth resource/audience binding ([MCP authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)). +- ACP is suitable as an optional installed-agent boundary, not as the internal + application-agent protocol. Its established local topology is a JSON-RPC + subprocess over stdio; the standard also assumes a trusted coding agent that + may receive editor-mediated file and MCP access, which this server runtime + must not inherit automatically ([ACP introduction](https://agentclientprotocol.com/get-started/introduction), + [ACP architecture](https://agentclientprotocol.com/get-started/architecture)). + Remote ACP transports remain a separate evolving concern. Stable + `session/close` capability can be used to release per-session resources when + supported ([ACP session close](https://agentclientprotocol.com/announcements/session-close-stabilized)). + +## System Boundary + +`graphql-orm-ai` is not primarily an MCP server. It is an application agent runtime that may execute in-process or as a separately deployed GraphQL service, with optional MCP client/server adapters. + +```mermaid +flowchart LR + Client[Frontend / drawer] -->|GraphQL + graphql-transport-ws| AiRoots[AI query, mutation, subscription roots] + AiRoots --> Runtime[AiRuntime] + Runtime --> Store[AI entities and durable streams] + Store -->|generated ORM APIs only| ORM[graphql-orm Database] + Runtime --> Providers[Provider adapters] + Providers --> OpenAI + Providers --> Anthropic + Providers --> XAI[xAI / Grok] + Providers --> Ollama + Runtime --> Catalog[Default-deny tool catalog] + Catalog --> Approval[Policy + approval engine] + Runtime --> Egress[Explicit egress authorization] + Egress --> Providers + Approval --> Bridge[Authenticated GraphQL execution bridge] + Bridge --> Targets[Deployment-registered logical targets] + Targets --> AppSchema[Local schema or private GraphQL endpoint] + AppSchema --> Policies[Entity / row / field / app policy] + Runtime --> Proposals[AI-owned structured proposals] + Runtime --> Attachments[Attachment service] + Attachments --> BlobStore[graphql-orm-storage BlobStore] + Runtime --> Auth[agql-auth principal rehydration] + Runtime --> Audit[Redacted audit + telemetry] +``` + +Core rules: + +- The model never receives a general SQL, repository, shell, or arbitrary GraphQL execution tool. +- User-delegated application work executes through server-authored GraphQL documents against the already-built host schema. +- Each tool call uses a freshly rehydrated principal and the exact same request-context construction path the ordinary client resolver receives. +- The host resolver's normal authorization and audit run unchanged. AI orchestration adds an outer audit record linked by correlation, causation, run, and tool-call IDs; it does not replace the application audit or invent a second actor. +- Data crossing to a provider, provider built-in, remote MCP server, web destination, or other external processor requires a separate egress decision over the exact outbound manifest. +- `SystemAccess` and trusted repository surfaces are prohibited for model-requested application tools. +- Internal AI persistence may use generated repository methods because it is the runtime's own data, not an application capability granted to the model. +- Logical target IDs are server-owned and resolve through immutable deployment registration. GraphQL configuration may disable or narrow a target but cannot add arbitrary destinations or relax its audience/resource boundary. +- A remote executor mints or obtains short-lived delegated authority immediately before the request. It never stores or forwards the user's original bearer token. +- Direct-service execution is a separately capped target class and may not grant more authority than the ordinary routed path. Hosts prove parity through conformance tests. + +## Schema and Workflow Ownership + +Ownership must be unambiguous so applications cannot accidentally fork AI persistence or bypass lifecycle rules: + +| Owner | Responsibilities | +|---|---| +| `graphql-orm-ai` | AI entity definitions, reserved table namespace, schema-module ID/version/fingerprint, migrations, indexes, backup descriptors, restore hooks, proposal storage, retention rules, and runtime reconciliation. | +| `graphql-orm` | Portable schema-module composition, database-specific migration syntax, schema introspection, transactions, leases, durable streams, and restore primitives. | +| Host application | Domain entities and resolvers, application policies, request-context construction, scope mapping, proposal type registrations, proposal review UI, and the normal domain mutation used to apply an accepted proposal. | +| `agql-auth` | Principal identity, rehydration, session/token status, assurance, delegation, and long-lived authorization contracts. | + +Rules: + +- Reserve the `graphql_orm_ai_*` table namespace for this crate. The host must not reproduce, rename, or manually migrate these tables. +- Publish a stable schema-module ID plus semantic module version and descriptor fingerprint. Startup compares compiled metadata with the managed schema and fails closed on unknown ownership, incompatible versions, or drift. +- Applications extend behavior through typed registries (`AiToolDescriptor`, `AiProposalTypeDescriptor`, access/egress policy, and UI intents), not by modifying AI-owned entities. +- Application-specific staged data may remain application-owned, but the generic proposal envelope and lifecycle are AI-owned. The boundary is explicit in its descriptor. +- The application owns the final domain write. A generic AI mutation never applies an application proposal to a domain record. +- Backup and restore discover AI state through the schema module. The host must not maintain a second list of AI tables. + +## Crate Structure + +Begin as one published crate with feature-gated modules. Do not split provider crates until compile time or dependency pressure justifies it. + +```text +graphql-orm-ai/ +├── Cargo.toml +├── src/ +│ ├── lib.rs +│ ├── runtime/ +│ ├── provider/ +│ │ ├── openai.rs +│ │ ├── anthropic.rs +│ │ ├── xai.rs +│ │ ├── ollama.rs +│ │ └── openai_compatible.rs +│ ├── persistence/ +│ ├── graphql/ +│ ├── tools/ +│ ├── approvals/ +│ ├── security/ +│ ├── attachments/ +│ ├── skills/ +│ ├── context/ +│ ├── telemetry/ +│ ├── mcp/ +│ └── acp/ +├── tests/ +└── docs/ + └── plan.md +``` + +Feature flags: + +- Backends: `sqlite`, `postgres`, `mssql`; exactly one AI persistence backend per runtime instance. +- Providers: `provider-openai`, `provider-anthropic`, `provider-xai`, `provider-ollama`, `provider-openai-compatible`. +- Integrations: `auth-agql`, `storage`, `backup`, `mcp-client`, `mcp-server`, `acp`. +- Transport/security: `rustls`. +- Advanced: `embeddings`, `vector-search`, `image-generation`, `audio`. + +A host that enables multiple `graphql-orm` backends may still select exactly one primary AI persistence backend. AI entity definitions should be generated behind explicit backend modules so Cargo feature unification does not make the derives ambiguous. + +## Public Rust Interfaces + +### Runtime construction + +```rust +pub struct AiRuntime { /* private */ } + +pub struct AiRuntimeBuilder { /* private */ } + +impl AiRuntimeBuilder { + pub fn database(self, database: Database) -> Self; + pub fn principal_resolver( + self, + resolver: Arc, + ) -> Self; + pub fn graphql_executor( + self, + executor: Arc, + ) -> Self; + pub fn egress_policy(self, policy: Arc) -> Self; + pub fn access_policy(self, policy: Arc) -> Self; + pub fn secret_store(self, store: Arc) -> Self; + pub fn blob_store(self, store: Arc) -> Self; + pub fn audit_sink(self, sink: Arc) -> Self; + pub fn build(self) -> Result<(AiRuntime, GraphqlExecutorBinding), AiError>; +} +``` + +Schema construction is two-stage to avoid cyclic ownership: + +1. Build the runtime with an unbound one-time executor slot. +2. Add `AiRuntime` to schema data and compose AI roots. +3. Finish the application schema. +4. Bind a clone of the finished schema to `GraphqlExecutorBinding`. +5. Verify the AI schema-module identity/version/fingerprint and runtime start gate. +6. Start workers, subscriptions, and webhook processing only after binding and any restore reconciliation succeed. + +Startup fails closed if any enabled tool document cannot validate against the composed schema. + +### Provider SPI + +```rust +pub trait AiProvider: Send + Sync { + fn provider_kind(&self) -> ProviderKind; + fn capabilities(&self) -> ProviderCapabilities; + + fn stream( + &self, + request: ModelRequest, + context: ProviderRequestContext, + ) -> ProviderEventStream; + + async fn embed( + &self, + request: EmbeddingRequest, + ) -> Result; + + async fn generate_image( + &self, + request: ImageGenerationRequest, + ) -> Result; +} +``` + +`ProviderCapabilities` includes: + +- Text and multimodal input/output. +- Streaming. +- Custom tools and parallel tool calls. +- Structured output. +- Embeddings. +- Image generation. +- Audio/transcription. +- Provider web search, file search, code execution, and MCP. +- Prompt caching. +- Background mode/webhooks. +- Local execution. +- Maximum context/output/file constraints. + +`ProviderEvent` is exhaustive for known normalized semantics and includes an `Unknown` variant so new provider events do not crash streams: + +- `ResponseStarted` +- `TextDelta` +- `ReasoningSummaryDelta` +- `ToolCallStarted` +- `ToolArgumentsDelta` +- `ToolCallCompleted` +- `BuiltinToolStarted` +- `BuiltinToolCompleted` +- `Citation` +- `Usage` +- `ResponseCompleted` +- `Error` +- `Unknown` + +Hidden chain-of-thought is never persisted. Only provider-supported reasoning summaries may be retained. + +### Authenticated application execution and audit parity + +The bridge must use the host's canonical request-envelope factory. A host must not manually reconstruct a reduced approximation of its HTTP/WS context for AI calls. + +```rust +pub trait GraphqlRequestContextFactory: Send + Sync { + async fn build( + &self, + principal: &ResolvedPrincipal, + invocation: &GraphqlInvocationContext, + ) -> Result; +} +``` + +The factory is the same implementation used by ordinary GraphQL transports and supplies identity, authorization, rate-limit, loader, request, and audit context. `GraphqlInvocationContext` identifies the mechanism as an AI tool call but preserves the rehydrated user/delegated principal as the actor. + +```rust +pub trait AuthenticatedGraphqlExecutor: Send + Sync { + async fn execute( + &self, + principal: &ResolvedPrincipal, + request: ToolGraphqlRequest, + ) -> Result; + + fn execute_stream( + &self, + principal: ResolvedPrincipal, + request: ToolGraphqlRequest, + ) -> ToolGraphqlResponseStream; +} +``` + +The host implementation must inject its normal: + +- `AuthPrincipal` +- `AuthUser` or API-token principal +- `AuthSubject` +- `DbAuthContext` +- Request/correlation context +- Application state +- Loaders and policy data +- Rate-limit and application audit context + +`ToolGraphqlRequest` contains a server-authored operation document, operation name, validated variables, tool-call ID, idempotency key, and correlation metadata. It never accepts a model-authored GraphQL document. + +Execution parity requirements: + +- The same principal and variables presented through the normal client and tool bridge produce the same allow/deny decision and domain result. +- The ordinary resolver audit remains authoritative for the domain operation. +- The outer AI audit records the run/tool mechanism and points to the application audit using correlation and causation IDs. +- The actor is the user or bounded delegation, never a fictional unrestricted "AI user". The run/tool call is recorded as the mechanism. +- Request context creation, rate limits, transaction behavior, policy data, and loader visibility cannot differ merely because the caller is an AI tool. +- Tests compare ordinary transport and tool-bridge decisions, outputs, and audit facts for the same operation. + +The same contract supports local and remote execution. A remote implementation adds a deployment-registered `GraphqlExecutionTarget` and a target-bound, ephemeral authority envelope. The public target contains only a logical ID, transport/trust class, audience/resource binding, and schema fingerprint; provider-facing tools and model context never receive a URL or credential. + +Every non-internal descriptor binds: + +- Logical execution-target ID. +- Target schema fingerprint/version. +- Server-authored operation-document hash and operation name. +- Server-owned result-projection and disclosure-schema fingerprints. +- Operation ownership/domain (`Application`, proposal staging, or forbidden AI control plane). + +Registration parses and rejects introspection and recursively reachable AI control-plane operations. Execution rejects target, schema, document, operation, or projection drift before constructing delegated authority. Remote authority is non-serializable, redacted in `Debug`, audience/resource/purpose bound, short-lived, and minted immediately before transport execution. Correlation, causation, human actor, delegation reference, rate-limit identity, and application audit context cross the boundary. + +Router/subgraph topology remains opaque to this crate. A host may register a private federated router, a private service, or a local schema under the same interface. Direct-service targets are disabled by default and must pass the same or narrower authorization contract as the routed target. + +### Application access policy + +```rust +pub trait AiAccessPolicy: Send + Sync { + async fn can_access_session( + &self, + principal: &AuthPrincipal, + session: &AiSessionRef, + action: AiSessionAction, + ) -> AiDecision; + + async fn can_use_provider_profile( + &self, + principal: &AuthPrincipal, + scope: &AiScope, + profile: &AiProviderProfileRef, + ) -> AiDecision; + +} +``` + +Application access policy decides whether data may be read or acted upon. It does not assign a permissive classification to arbitrary output JSON. Tool-result disclosure is controlled by the static projection contract below; runtime policy may only narrow that contract. + +### External data-egress policy + +```rust +pub trait AiEgressPolicy: Send + Sync { + async fn authorize( + &self, + principal: &ResolvedPrincipal, + manifest: &AiEgressManifest, + ) -> AiEgressDecision; +} +``` + +`AiEgressManifest` describes the exact proposed transfer without embedding plaintext content: + +- Provider profile, provider kind, model, endpoint trust class, and destination/tool. +- Capability (`model_inference`, web search, image analysis/generation, provider file search, remote MCP, or other external processing). +- Session/scope, source message/block/attachment/tool-artifact references, and their data classifications. +- Approximate byte/token counts, attachment MIME/count, residency/retention characteristics, and purpose. +- Manifest hash, policy versions, current principal reference, and any required consent/approval reference. + +The decision is the intersection of deployment hard boundaries, scope policy, provider profile, current authorization, data-classification rules, and purpose-bound user consent where required. Deployment network policy cannot be relaxed through GraphQL. Secret and credential classifications are never eligible for egress. The decision and manifest hash are audited without content; a changed manifest invalidates a prior decision or approval. + +### Secret storage + +```rust +pub trait AiSecretStore: Send + Sync { + async fn put( + &self, + scope: &AiScope, + purpose: SecretPurpose, + value: SecretBytes, + ) -> Result; + + async fn resolve( + &self, + reference: &SecretRef, + ) -> Result; + + async fn rotate( + &self, + reference: &SecretRef, + value: SecretBytes, + ) -> Result; + + async fn delete( + &self, + reference: &SecretRef, + ) -> Result<(), SecretError>; +} +``` + +Provide: + +- `OrmEncryptedSecretStore` +- `EnvironmentSecretStore` for read-only bootstrap +- An adapter contract for external KMS/Vault systems + +No public response returns secret values. + +Mutable credential stores must support safe compensating rotation semantics. +Creating a credential without an existing reference allocates a fresh, +unguessable reference; it never silently overwrites another secret. The +configuration service writes that reference, its CAS update, a redacted audit +fact, and any cleanup command in one ORM transaction. If the transaction fails, +the fresh secret is deleted as compensation. After commit, the old reference is +deleted; a failed delete remains as a durable `AiSecretCleanup` command for a +bounded retry worker. External stores should also expire unreferenced fresh +values so loss of both database and compensating cleanup cannot create an +indefinite orphan. Secret plaintext and secret references never appear in the +GraphQL response, audit payload, model context, telemetry, or ordinary backup. + +## Persistent Data Model + +All tables are defined as `graphql-orm` entities and migrated through schema metadata. No migration or query in this crate may contain SQL. + +| Entity | Purpose and key fields | +|---|---| +| `AiProviderProfile` | Scope, provider kind, display name, base URL or logical deployment-owned local-harness reference, credential reference, enabled state, data policy, limits, version. GraphQL never stores a harness executable or arbitrary arguments. | +| `AiModelRoute` | Scope, task kind, priority, profile/model, fallbacks, model parameters, capability requirements, budget. | +| `AiScopePolicy` | AI enabled state, maximum tool maturity, allowed capabilities, proposal limits, runtime budgets, and version. It can only narrow deployment hard caps. | +| `AiContentProtectionPolicy` | Scope, selected protection mode, key policy/version, effective date, migration state. AI remains disabled for the scope until this exists. | +| `AiEgressPolicy` | Scope, destinations/providers/capabilities, classification ceiling, consent rule, residency/retention limits, enabled state, version. Deployment hard boundaries remain outside and always intersect this row. | +| `AiEgressConsent` | Purpose-bound principal/scope/destination consent, manifest constraints, grant/expiry/revocation, assurance, and version. Contains no transferred content. | +| `AiToolPolicy` | Scope, stable tool ID/fingerprint, enabled flag, constraints, risk override, approval rule, call/output limits. | +| `AiRetentionPolicy` | Scope, delta/raw-payload/audit/content retention and deletion behavior. | +| `AiBudgetCounter` | One policy/time-window counter with atomically reserved, committed, and released token/cost/run units plus CAS version. | +| `AiBudgetReservation` | Exact run/attempt/provider/model/pricing binding, reserved estimates, actual reconciliation, expiry, fencing generation, and reserved/committed/released/uncertain state. | +| `AiSession` | Owner, tenant/scope, title, state, active run, last activity, stream head/version, archive/delete timestamps. | +| `AiSessionParticipant` | Future-compatible owner/editor/viewer membership. Version one exposes owner-only sessions but stores ownership through this shape. | +| `AiSessionEvent` | Durable per-session sequence, type, event ID, run/causation/correlation IDs, protected payload, timestamp. | +| `AiInboxEvent` | Durable per-principal sequence for cross-session completions, approvals, errors, and drawer updates. | +| `AiMessage` | Session, stable order sequence, role, author, run, provider/model, block count, preview, completion status, timestamps. | +| `AiMessageBlock` | Message, block index, protected text/structured content, byte/line counts. Maximum uncompressed block size: 32 KiB. | +| `AiAttachment` | Owner/session/message, opaque blob reference, safe filename, declared/detected MIME, size, SHA-256, scan/processing state. | +| `AiAttachmentArtifact` | Thumbnail, OCR, extracted text, transcript, derivative, or provider-file reference. | +| `AiRun` | Session/input message, requested principal reference, provider route, durable state, attempt ID, lease owner, monotonically increasing lease generation/fencing token, lease expiry/heartbeat, retry, cancellation, error, usage, CAS version. | +| `AiRunAttempt` | Immutable attempt/generation history, claim/finish times, provider response references, recovery classification, and redacted outcome. Supports fencing audit and uncertain-side-effect recovery. | +| `AiRunStep` | Ordered provider/tool/approval/context step with timing and state. | +| `AiToolCall` | Tool ID/fingerprint, protected arguments/result, argument hash, risk, auth decision, approval, idempotency, status. | +| `AiApproval` | Tool call, complete action-binding hash, target/schema/document/projection fingerprints, protected canonical preview, resource/version preconditions, safe policy/auth-state versions, principal/delegation actor, decision, approver, expiry, MFA requirement, one-shot consumption, and CAS version. | +| `AiProposal` | AI-owned staging envelope: session/run/scope, proposal type/schema ID and version, protected structured payload, status, source references, creator, reviewer, expiry, applied outcome links, and CAS version. It never directly mutates an application record. | +| `AiProposalItem` | Optional bounded field/operation item for review, with stable path, suggested protected value, rationale/source references, review decision, and ordering. | +| `AiContextCheckpoint` | Summary through a session sequence, source hash, token estimate, provider/model, protected text. | +| `AiSkill` | Scope, name, description, enabled/current version. | +| `AiSkillVersion` | Immutable instructions, allowed tools, data policy, activation rule, schemas, budgets, provenance, checksum. | +| `AiUsageEntry` | Append-oriented provider/model usage, token/cache/tool/image units, calculated cost, session/run/scope. | +| `AiProviderWebhookReceipt` | Provider event ID, signature verification result, received/processed state for idempotency. | +| `AiEgressEvent` | Redacted manifest hash, provider/destination/capability, classifications, policy/consent versions, byte/token estimates, decision, reason, principal, run, and timestamp. No plaintext content. | +| `AiAuditEvent` | Redacted non-content action, actor, resource, allow/deny, reason code, correlation, timestamp. | +| `AiSecretCleanup` | Durable, redacted cleanup command for an obsolete or compensating secret reference, retry state, timing, and CAS version. It is never exposed through generic CRUD or model tools. | +| `AiRuntimeRecovery` | Restore/recovery epoch, module version/fingerprint, start-gate state, dry-run/applied status, redacted issue/action counts, operator, and timestamps. Detailed sensitive diagnostics remain protected. | + +Indexes must include: + +- Sessions by owner/scope/state and `(last_activity_at DESC, id DESC)`. +- Messages by `(session_id, sequence)`. +- Message blocks by `(message_id, block_index)`. +- Events by `(session_id, sequence)` and inbox events by `(principal_ref, sequence)`. +- Runs by `(status, next_attempt_at, priority, id)`. +- Lease expiry. +- Pending approvals by principal/session/status. +- Pending proposals by scope/session/status and `(created_at DESC, id DESC)`. +- Tool calls by run and idempotency key. +- Attachments by session/message/hash. +- Usage by scope/time/provider/model. +- Egress events by scope/principal/time and manifest hash. + +Conversational entities must be purgeable, so database-enforced append-only triggers are used only for redacted audit and usage records. Runtime code treats finalized messages and event rows as immutable but allows authorized retention purges. + +## GraphQL API + +The crate exports `AiQueryRoot`, `AiMutationRoot`, and `AiSubscriptionRoot` for composition through `schema_roots!`. + +### Queries + +- `aiSessions(filter, page)` + Returns session shells only. + +- `aiSession(id)` + Returns metadata and current run state, never the complete history. + +- `aiMessages(sessionId, page)` + Bidirectional keyset connection ordered by session sequence. + +- `aiMessageBlocks(messageId, page)` + Fetches bounded blocks only for visible/expanded messages. + +- `aiSessionEventPage(sessionId, afterSequence, first)` + Durable catch-up page with a watermark and `hasMore`. + +- `aiInboxEventPage(afterSequence, first)` + Cross-session catch-up for drawers and notifications. + +- `aiRun(id)` +- `aiPendingApprovals(sessionId)` +- `aiProposals(filter, page)` +- `aiProposal(id)` +- `aiAttachments(sessionId, page)` +- `aiSkills(scope, page)` +- `aiAvailableModels(scope)` +- `aiUsage(scope, interval, page)` +- `aiToolCatalog(scope, query, page)` + Returns only metadata the caller may know about. +- Administrative, redacted provider/configuration queries. +- Administrative, redacted egress-policy and egress-decision queries. + +### Mutations + +Session lifecycle: + +- `createAiSession` +- `renameAiSession` +- `archiveAiSession` +- `restoreAiSession` +- `deleteAiSession` +- `sendAiMessage` +- `retryAiRun` +- `resumeAiRun` +- `cancelAiRun` +- `submitAiFeedback` + +Approval lifecycle: + +- `approveAiToolCall` +- `denyAiToolCall` +- `revokeAiApproval` + +Proposal lifecycle: + +- `reviewAiProposal` records per-item accept/reject/edit intent but does not write domain data. +- `rejectAiProposal` +- `expireAiProposal` + +Proposal creation is an internal, schema-validated runtime service/tool, not an unrestricted public mutation. Applying a proposal is deliberately absent. The application exposes its ordinary domain mutation and, after a successful domain transaction, calls `AiProposalService::record_outcome` from trusted server code with the resulting resource and application-audit references. Clients cannot forge an applied outcome. + +Attachments: + +- `createAiAttachmentUpload` +- `finalizeAiAttachmentUpload` +- `removeAiAttachment` + +Configuration: + +- `upsertAiProviderProfile` +- `setAiProviderCredential` +- `rotateAiProviderCredential` +- `removeAiProviderCredential` +- `testAiProviderProfile` +- `upsertAiModelRoute` +- `upsertAiScopePolicy` +- `upsertAiToolPolicy` +- `upsertAiEgressPolicy` +- `grantAiEgressConsent` +- `revokeAiEgressConsent` +- `setAiContentProtectionPolicy` +- `setAiRetentionPolicy` +- `setAiBudget` +- `createAiSkill` +- `createAiSkillVersion` +- `publishAiSkillVersion` +- `disableAiSkill` + +Every configuration mutation uses compare-and-swap versions, emits a redacted audit event, and requires the relevant administrative capability. Credential, high-impact tool policy, content-protection, and break-glass changes require recent MFA by default. + +### Subscriptions + +- `aiSessionEvents(sessionId, afterSequence)` +- `aiInboxEvents(afterSequence)` + +Subscription behavior: + +1. Authenticate connection init. +2. Rehydrate and authorize the principal. +3. Read durable events after the supplied sequence to a captured watermark. +4. Attach to the live bounded wakeup stream. +5. Re-read the durable table whenever awakened; the broadcast payload is never the source of truth. +6. Deduplicate by event ID/sequence. +7. Detect retention gaps. +8. Emit `RESET_REQUIRED` when the requested sequence is no longer available. +9. Periodically reauthorize through `agql-auth`. +10. Close or pause on revocation, expiry, permission removal, session deletion, or scope loss. + +### Pagination defaults + +- Initial message page: `last: 50`. +- Maximum message page: 200. +- Older history: `last: 50, before: startCursor`. +- Event replay default: 100. +- Event replay maximum: 500. +- Content block maximum: 100 blocks. +- `totalCount` is opt-in and off by default. + +Message nodes contain: + +- A bounded preview, at most 4 KiB. +- Block count and attachment metadata. +- No unbounded nested content collection. + +This keeps database reads, network payloads, browser memory, and the DOM independently bounded. + +## Resolver Discovery and Tool Catalog + +### Generated resolvers + +`graphql-orm` will emit stable `ResolverOperationDescriptor` records for every generated query, mutation, and subscription. + +Each descriptor contains: + +- Stable operation ID. +- Schema coordinate and GraphQL field name. +- Operation kind. +- Entity and relation metadata. +- Description. +- Argument names and JSON Schema. +- GraphQL input/output type names. +- Server-generated operation document template. +- Default safe scalar projection. +- Auth mode and policy keys. +- Generated/read/write/bulk/destructive annotations. +- Maximum safe page/output defaults. +- Descriptor fingerprint. + +The runtime imports every descriptor but exposes none by default. + +### Handwritten application resolvers + +Applications register custom resolvers explicitly: + +```rust +AiToolCatalog::builder() + .graphql( + AiGraphqlTool::new( + "example.publish", + OperationKind::Mutation, + include_str!("graphql/publish.graphql"), + json_schema_for!(PublishVariables), + ) + .risk(ToolRisk::HighImpact) + .approval(ApprovalRule::Always) + .result_projection(ResultProjection::json_pointer("/publish")), + ); +``` + +Requirements: + +- Static server-owned GraphQL document. +- Explicit variable schema. +- Explicit result projection. +- Stable ID and risk. +- Output byte/record limits. +- Optional idempotency contract. +- Optional scope/input constraints. +- Startup validation against the composed schema. + +Schema or document fingerprint changes disable the persisted tool policy until an administrator reviews and re-enables it. + +Application mutation tools additionally declare a maturity class: + +- `ReadOnly`: no application state change. +- `ProposalOnly`: may write only a validated AI-owned proposal envelope. +- `SupervisedWrite`: may invoke an explicitly registered application mutation under approval policy. +- `AutonomousWrite`: reserved for a future narrowly proven workflow and disabled by default. + +The deployment and per-scope policy set a maximum maturity. The first reference-consumer pilot is hard-capped at `ProposalOnly`; configuration in GraphQL cannot raise it above the deployment cap. + +### Structured proposal registry + +Applications register project-specific suggestions without teaching the shared crate their domain: + +```rust +AiProposalCatalog::builder().register( + AiProposalTypeDescriptor::new( + "example.record-metadata.v1", + json_schema_for!(RecordMetadataSuggestion), + ) + .display_metadata(/* labels and field hints, no routes */) + .required_source_kinds([SourceKind::ResolverResult, SourceKind::Attachment]) + .max_items(100), +); +``` + +The internal `emit_proposal` tool validates the model output against the registered schema, enforces scope and size limits, protects the payload, records source provenance, and writes only the AI staging tables. The host UI lets a person review/edit fields. Accepted values are then submitted through the normal application mutation as that person. This gives the pilot useful write-like assistance without granting the model domain write authority. + +### Tool discovery + +Large schemas must not send hundreds of tool definitions in every provider request. + +Implement a provider-neutral `discover_tools` function that: + +- Searches only enabled tools visible in the session scope. +- Returns concise descriptors. +- Loads full schemas only for selected tools. +- Supports namespaces and skill allowlists. +- Never reveals secret/admin-only operations to ordinary users. + +Provider-native deferred tool search may optimize this, but the local catalog remains authoritative. + +### Safe projections + +Generated tools never grant the model control of arbitrary GraphQL selections. + +- Generated entity projections include readable, non-private scalar fields. +- Sensitive fields require explicit projection policy. +- Relations require separately registered tools or bounded projections. +- Query page sizes are capped. +- Results are limited to 64 KiB model-facing output by default. +- Larger results are summarized or stored as a protected tool artifact and returned by opaque reference. +- GraphQL errors are normalized to safe codes without raw database/provider details. + +### Static disclosure schemas + +Every application tool must register a server-owned disclosure schema for its exact result projection before it can be enabled. The schema describes the output shape recursively and assigns each object, list, and scalar field: + +- A minimum `DataClassification`. +- `Exportable` or `NeverExport` disposition. +- Bounded list/item limits where applicable. +- Stable schema version and fingerprint. + +Tool registration binds the operation document, target schema, result projection, and disclosure schema into the descriptor fingerprint. Result evaluation fails closed on unknown fields, unexpected shapes, oversized lists, fingerprint drift, or any selected `NeverExport` node. Secret and credential fields must be excluded from server-authored GraphQL projections; a runtime redactor is defense in depth, not the primary boundary. + +Application runtime policy may raise a classification, remove fields, or replace values with redacted markers. It cannot lower the static classification, admit unknown fields, or change `NeverExport` to exportable. Egress manifests use the effective maximum classification and preserve per-source provenance. + +`graphql-orm` should expose generic field/projection disclosure metadata so generated resolvers can produce these schemas. Until that metadata lands, hosts may register reviewed descriptors manually. Generated metadata remains discovery, never automatic AI exposure. + +### Atomic budget reservation + +Before any provider bytes leave the process, the runtime resolves every applicable deployment, scope, tenant, principal, session, skill, and route budget and reserves the estimated run/input/output/tool/image/cost units in one ORM transaction. + +- Reservations bind run, attempt, lease generation, provider kind, model, pricing-policy version, and an idempotency key. +- All applicable counters are checked and incremented atomically; partial reservation is rolled back. +- A provider call requires an opaque reservation proof matching the exact run/provider/model and requested output ceiling. +- Actual provider usage is appended and all counters are reconciled in the same transaction exactly once. Unused capacity is released. +- Missing usage settles conservatively at the reserved ceiling unless a provider-status reconciliation proves otherwise. +- Abandoned reservations expire only when no external call can still complete. Uncertain calls remain reserved for fenced recovery rather than being released optimistically. +- Fallback routes require a new reservation when model, provider, price, or output limits change. + +Concurrent runs therefore cannot independently pass a stale remaining-budget check. + +## Authorization and Approval Flow + +Every application tool call follows this sequence: + +1. Validate provider tool arguments against JSON Schema. +2. Resolve the stable tool descriptor and exact fingerprint. +3. Confirm the exact registered fingerprint has an enabled scope-policy + binding; catalog discovery alone never enables it. +4. Apply input constraints and data-classification rules. +5. Rehydrate the current principal from its non-secret reference. +6. Check token/session status and current assurance. +7. Invoke a fresh principal-, scope-, descriptor-, and validated-argument-aware host tool authorization policy and record its current policy version plus safe authorization-state digest. Catalog presence alone can never satisfy this step. +8. Enforce the deployment/scope tool-maturity cap. A `ProposalOnly` deployment rejects every application mutation descriptor even if an administrator accidentally enables it. +9. Evaluate approval policy. +10. If approval is needed, persist a request bound to: + - Tool-call ID. + - Canonical argument hash. + - Tool fingerprint. + - Logical execution target, target schema fingerprint, operation-document hash, operation name, result projection, and disclosure-schema fingerprint. + - Principal reference, delegated actor/grant reference, session, scope, and tenant/resource boundary. + - Every target resource reference plus expected row version, ETag, or precondition digest. + - Tool/scope/application authorization policy versions and a safe authorization-state digest that contains no role/scope snapshot. + - Server-generated canonical action preview and preview hash. + - Expiry. + - One-shot maximum use count. +11. After approval, rehydrate and reauthorize again, rebuild the canonical preview, and recheck all policy/resource/schema preconditions. Any mismatch expires the approval and requires a new one. +12. Build the GraphQL request through the host's canonical request-context factory. +13. Execute the server-owned GraphQL request. +14. Let the normal resolver, entity, row, field, application, rate-limit, and audit policies decide access exactly as for a client call. +15. Validate serialized size, record/list bounds, closed result shape, and every static disclosure rule before returning data to orchestration. Unknown or `NeverExport` fields fail closed. +16. Apply any runtime classification tightening/redaction and persist only the protected bounded result locally. +17. Build an outbound egress manifest for whatever portion would be returned to the provider, then independently authorize that transfer. +18. Audit application execution and AI orchestration as linked records, plus the allow/deny egress decision. +19. Return only the egress-authorized normalized result to the model. + +Approval defaults: + +| Risk | Default | +|---|---| +| Read-only internal | No per-call approval after explicit policy enablement. | +| AI-owned structured proposal | May be enabled in a `ProposalOnly` deployment; schema validation, provenance, limits, and human review remain mandatory. It grants no domain write permission. | +| Low-risk idempotent write | May be allowed by an explicit administrator policy. | +| Non-idempotent write | Per-call approval unless specifically proven safe. | +| Publish/external send | Always one-shot approval. | +| Delete/destructive | Always one-shot approval. | +| Permission/membership change | Always approval and recent MFA. | +| Credential/secret operation | Never model-callable by default; administrator UI only. | +| Arbitrary code/shell/computer | Disabled; future sandbox-only support. | +| External MCP tool | Approval determined by server trust, tool risk, and egress classification; default deny. | + +Approvals expire after five minutes by default and cannot be reused with changed arguments. + +The canonical action preview is created by a server-owned preview provider or dry-run resolver and contains typed targets, fields/diffs, impact class, and preconditions. Model-written prose is never the approved artifact. Approval authorizes only the exact preview; it does not grant resolver permission or preserve a stale role/scope decision. + +### Explicit egress authorization flow + +Read authorization answers whether the principal may use data inside the application. Egress authorization separately answers whether identified data may be disclosed to an identified external processor for an identified purpose. + +Before every provider request, provider built-in, remote web/file/image/code/MCP call, and tool result returned to a remote model: + +1. Assemble the exact candidate payload locally. +2. Classify every source and preserve provenance/trust markers. +3. Construct and hash the redacted `AiEgressManifest`. +4. Rehydrate the current principal. +5. Intersect deployment network/region boundaries, scope egress policy, provider profile policy, current access, classification ceiling, purpose, retention/residency constraints, and any required consent. +6. If policy requires one-shot consent or recent MFA, pause before any bytes leave the process and bind the grant to the manifest hash. +7. Recompute immediately before transmission. Any changed source, destination, model, capability, attachment, classification, or size invalidates the decision. +8. Persist a redacted allow/deny event and transmit only after allow. + +Provider-side web search is both model egress and a provider built-in capability; enabling general chat does not implicitly enable it. Remote MCP, provider file retention, image analysis, and code execution have independent capability switches. Local Ollama can have a different destination trust class, but still passes policy. Credentials, encryption keys, raw authentication artifacts, and values classified `Secret` are always denied. + +## Authentication and Background Delegation + +Never persist bearer tokens or stale role/scope snapshots. + +Persist only an `agql-auth` principal reference containing safe identifiers such as: + +- Principal kind. +- Subject. +- Session or API-token ID. +- Session family. +- Tenant/resource binding. +- Actor reference. +- Expiry metadata. +- Correlation reference. + +Interactive runs: + +- Require an active user session. +- Pause as `REAUTH_REQUIRED` when the session expires or is revoked. +- Resume only after the user reauthenticates. + +Background runs: + +- Continue across browser disconnects. +- Still stop when the underlying session/delegation is revoked or loses access. +- Long-running delegated work receives a bounded delegation grant containing a maximum expiry, allowed AI scope, tool set, and cost budget. +- The grant never adds scopes the user did not possess. +- Scheduled/system work uses an audience- and resource-bound service principal, not a borrowed user token. + +The principal is rehydrated: + +- Before provider egress. +- Before each tool call. +- After each approval. +- At periodic long-run checkpoints. +- On subscription reauthorization deadlines. + +## Provider Profiles and Model Routing + +Provider routing consumes an atomic budget reservation in addition to the existing egress proof. GraphQL-managed pricing and budget policy is versioned and can only narrow deployment ceilings. Deployment-owned provider destinations, credentials, and network capability remain immutable hard boundaries and cannot be introduced through a profile mutation. + +Configuration hierarchy: + +1. Application default. +2. Tenant/project scope. +3. User profile/BYOK, when enabled. + +Resolution always applies the most specific allowed profile without crossing tenant boundaries. + +Each model route declares: + +- Task kind. +- Required capabilities. +- Preferred provider/model. +- Ordered fallbacks. +- Maximum input/output. +- Tool/built-in policy. +- Data-classification ceiling. +- Cost/rate budget. +- Residency/storage requirements. + +Fallback is allowed only when: + +- Capability requirements still match. +- Data policy permits the destination provider. +- The user has access to that profile. +- BYOK policy permits fallback. +- The fallback does not widen enabled tools. + +Provider adapters: + +- OpenAI: native Responses API first. +- Anthropic: native Messages/tool-use adapter. +- xAI/Grok: native Responses-compatible adapter with xAI tool semantics. +- Ollama: native chat/tool stream, with OpenAI-compatible mode only as an optional fallback. +- OpenAI-compatible local/hosted endpoints: explicit adapter with a declared capability profile; do not assume full Responses compatibility. + +Endpoint security: + +- Remote endpoints require HTTPS and an administrator allowlist. +- Loopback HTTP is permitted for explicitly local Ollama/OpenAI-compatible profiles. +- Private-network, link-local, cloud metadata, and Unix-socket access are denied unless deployment policy explicitly permits them. +- DNS is revalidated across redirects. +- Deployment-level network policy and GraphQL configuration are intersected; GraphQL configuration cannot weaken the deployment boundary. + +## Canonical Conversation State and Context Management + +The local database is the source of truth. + +Provider response/conversation IDs are continuation hints only. A session must remain usable if provider-side state expires, is deleted, or is unavailable. + +Context assembly: + +1. Trusted runtime instructions. +2. Published, authorized skill versions. +3. Session scope and data policy. +4. Latest valid context checkpoint. +5. Recent messages and tool traces fitting the model budget. +6. Current user message and attachments. + +Never send the full session merely because it exists. + +Compaction: + +- Generate a protected summary checkpoint when token thresholds are reached. +- Record the exact covered session sequence and source hash. +- Preserve citations/provenance to source messages. +- Invalidate/rebuild summaries after affected content is deleted. +- Keep recent verbatim turns after the summary. + +Retention defaults, configurable through GraphQL: + +- Final messages/history: retained until user deletion or scope retention policy. +- Streaming delta batches: 24 hours after final reconciliation. +- Redacted provider raw envelopes: seven days, or disabled. +- Orphaned uploads: purge within 24 hours. +- Redacted audit facts: 365 days unless policy overrides. +- Deleted session content: purge job completes within 24 hours. +- Provider credentials: excluded from ordinary backups. + +## Durable Workers and Failure Handling + +The database is the durable queue. Tokio channels are bounded wakeup hints only. + +Run states: + +- `QUEUED` +- `LEASED` +- `RUNNING` +- `WAITING_APPROVAL` +- `WAITING_TOOL` +- `WAITING_REAUTH` +- `RETRY_SCHEDULED` +- `RECOVERY_REQUIRED` +- `COMPLETED` +- `FAILED` +- `CANCELLED` + +Worker behavior: + +- Query a bounded set of candidates through generated ORM APIs. +- Claim atomically with versioned compare-and-swap or the new generic ORM lease primitive. Every successful claim increments `lease_generation` and creates a fresh attempt ID. +- Lease TTL: 60 seconds. +- Heartbeat: every 20 seconds. +- Carry `(run_id, attempt_id, lease_generation, expected_version)` as the fencing proof for the entire attempt. +- Require a matching, unexpired fencing proof on every state transition, heartbeat, event append, provider delta/completion, tool result, usage record, approval transition initiated by a worker, and finalization. +- Recover expired leases by issuing a new generation; never revive an old attempt. +- Bounded provider concurrency and per-provider/user/scope limits. +- Database rescan fallback every two seconds when no wakeup arrives. +- Exponential backoff with full jitter. +- Maximum five retries for retryable provider/network failures. +- No automatic retry of non-idempotent application mutations. +- Tool calls retry only when the descriptor declares idempotency and the executor receives a stable idempotency key. +- Dead-letter state remains inspectable and manually retryable. +- Cancellation is durable and propagated into provider streams and subscription watches. +- Ignore/cancel late provider streams from stale attempts. Bind provider callbacks and webhook receipts to attempt ID plus provider response/event ID so delivery is idempotent and cannot complete the wrong generation. + +A worker that stalls after sending an external request may later resume after another worker has reclaimed the run. Its fencing token must make every subsequent database write fail, even if its process still believes the request succeeded. Fencing prevents dual finalization; idempotency and recovery policy separately address whether an uncertain external side effect may be repeated. + +Provider streaming uses bounded channels and coalesces text deltas into at most 50 ms or 4 KiB batches before durable persistence. + +## Backup Restore and Runtime Reconciliation + +Restoring rows is insufficient for an agent runtime because a snapshot may contain leases, pending approvals, provider continuation IDs, or uncertain external operations. `graphql-orm-ai` owns an `AiRestoreReconciler` and an `AiRuntimeStartGate` registered through its schema module. + +Restore lifecycle: + +1. Enter `RESTORING`; do not start workers, subscriptions, scheduled jobs, webhook processors, or provider callbacks. +2. Restore canonical messages, blocks, events, attachments, protected ciphertext, audit/usage rows, provider receipts, and original stream sequences through ORM/backup APIs. +3. Validate schema-module version/fingerprint and the availability of every required encryption key version before content can be served. +4. Produce a dry-run reconciliation report containing counts and redacted actions. An operator can inspect this before applying state repairs. +5. Apply reconciliation transactionally where possible: + - Clear lease owners, expiries, heartbeats, and process-local worker IDs; retain historical generations and increment on any future claim. + - Move `LEASED`, `RUNNING`, and `WAITING_TOOL` attempts to `RECOVERY_REQUIRED` unless the system can prove no external side effect was possible. + - Expire or revalidate pending approvals and egress consents; never assume the restored principal, policy, or MFA state remains current. + - Mark provider continuation/file references unverified until the provider confirms them; treat expired references as rebuildable hints, not canonical state. + - Preserve webhook receipts and provider response IDs for deduplication. + - Put uncertain non-idempotent application tool calls into manual review and never replay them automatically. + - Requeue only operations proven idempotent and policy-eligible, using a new attempt ID and fence. + - Verify attachment/object existence, ownership, checksums, quarantine state, and artifact references. + - Recompute each stream head from durable sequences and verify uniqueness/ordering; report gaps according to the stream retention contract rather than silently renumbering. +6. Rehydrate policy/configuration projections and emit a redacted restore audit event. +7. Open the runtime start gate only when all fatal checks pass. Failed key, schema, ownership, sequence, or policy validation leaves the runtime unavailable and recoverable by an operator. + +Restore never blindly resumes a provider background response or external mutation. A safe provider-only/idempotent run may be explicitly requeued; an operation with an uncertain side effect requires a human recovery decision. The same behavior applies after point-in-time restore, cloning, disaster recovery, or rollback to an older snapshot. + +## Attachments and Multimodal Data + +The AI crate stores metadata; `graphql-orm-storage::BlobStore` stores bytes. + +Upload flow: + +1. `createAiAttachmentUpload` authorizes the session and creates a pending upload. +2. The host receives an opaque one-time upload ticket. +3. Bytes stream through `AiAttachmentUploadService`; large bytes do not pass through ordinary GraphQL JSON. +4. Enforce byte limit while streaming. +5. Compute SHA-256. +6. Detect MIME by content rather than filename. +7. Store in a random, scope-bound quarantine key. +8. Run malware/content-validation hooks. +9. Promote to the final opaque key only after validation. +10. Persist metadata and emit a durable event. +11. `finalizeAiAttachmentUpload` links it to a message when needed. + +Defaults: + +- Maximum 10 attachments per message. +- Maximum 25 MiB per attachment. +- Scope quotas configurable. +- Archives and executables disabled by default. +- Original filename is metadata only and never becomes a storage path. +- Raw blob keys are never exposed to clients/models. +- Provider uploads receive time-limited provider file references. +- Provider file references are deleted when the provider supports deletion. +- OCR, thumbnails, transcripts, and extracted text are separate artifacts with their own protection and retention. + +Application tools may accept an AI attachment ID, but the application resolver decides whether and how it may be linked to an application entity. + +## Skills, Rules, and Typed UI Intents + +Skills are data, not executable plugins. + +A published skill version contains: + +- Name and description. +- Trusted instruction text. +- Scope and activation rule. +- Tool allowlist and descriptor fingerprints. +- Data-classification ceiling. +- Input/output JSON Schemas. +- Provider capability requirements. +- Step, duration, and cost limits. +- Optional UI intent types. +- Version, checksum, author, and audit metadata. +- Optional registered proposal types. Skills may select them but cannot invent or widen their schemas. + +Rules resolve hierarchically by application, tenant/project, and user. Lower scopes may narrow but not widen administrator policy. + +Unpublished or user-uploaded text never becomes a system instruction automatically. + +UI intents: + +```json +{ + "type": "navigate", + "target": "record", + "parameters": { + "recordId": "..." + } +} +``` + +- The host registers allowed intent types and JSON Schemas. +- The server validates emitted intents. +- Intents are suggestions delivered through session events. +- The backend never constructs TanStack Router URLs or forces navigation. +- Each frontend maps intent types to its own routes. + +## Common AI Task Coverage + +### Production core + +- Multi-session chat. +- Streaming text and structured events. +- Read-only custom resolver tools and structured proposal staging. +- Structured extraction with JSON Schema. +- Image/file inputs. +- Summarization and context compaction. +- Provider web search with citations. +- Usage/cost accounting. +- Approvals. +- Background tasks. +- Attachments. +- Skills/rules. +- Feedback capture. +- OpenAI provider. +- Authenticated execution/audit parity, explicit egress decisions, fenced workers, and restore reconciliation. + +### Post-pilot supervised writes + +- Explicitly registered application mutation tools. +- Dry-run/diff support where the application provides it. +- Argument-bound, expiring one-shot approvals. +- Recent-MFA and idempotency enforcement. +- Supervised multi-step catalog/application operations with a fresh approval at each externally consequential checkpoint. +- Direct publish, delete, permission, credential, and external-send actions remain disabled unless the application deliberately registers them and every deployment, scope, maturity, authorization, approval, and egress gate allows them. + +### Next provider phase + +- Anthropic. +- xAI/Grok. +- Ollama. +- OpenAI-compatible local endpoints. +- Provider file search. +- Provider code execution behind explicit sandbox policy. +- Image generation. +- Audio transcription/speech where supported. +- Provider background/webhook processing. + +### Advanced phase + +- Embeddings and RAG. +- Hybrid lexical/vector retrieval. +- Scheduled agents. +- Branch/fork conversation history. +- Pinned records and context. +- Shared read-only sessions. +- Multi-agent handoffs. +- Evaluation datasets and deterministic provider-stream replay. +- Dry-run mutation previews and human-readable diffs. +- Supervised multi-step application operations with one-shot approvals and explicit checkpoints. +- Undo/compensating-operation suggestions where application tools support them. +- MCP client/server. +- ACP/local coding harness. + +## MCP and Local Harness Decision + +### MCP client + +Add later as an optional tool source. + +Requirements: + +- Target MCP `2025-11-25`. +- Support stdio and Streamable HTTP. +- Treat all remote tool metadata, annotations, and content as untrusted. +- Validate origins and protocol versions. +- Use OAuth audience/resource binding. +- Never pass application bearer tokens through to downstream MCP servers. +- Prevent confused-deputy behavior. +- Apply SSRF and redirect controls. +- Import MCP tools into the same default-deny catalog and approval engine. +- Store no MCP session ID as an authentication credential. + +### MCP server + +Provide an optional facade, not the primary runtime: + +- Expose only explicitly allowlisted tools/resources. +- Authenticate the external caller. +- Execute as that caller through the same GraphQL bridge. +- Do not expose provider credentials, internal queue operations, or unrestricted resolver discovery. +- Map long-running AI runs to MCP task semantics when stable enough. + +### Local harnesses + +- Ollama and explicitly profiled OpenAI-compatible loopback endpoints are the + first local path. They implement `AiProvider`, use the same normalized + streaming/tool events, and still require disclosure, destination-trust, + capability, resource-budget, and audit decisions. “Local” does not mean + “unclassified” or “free.” +- Installed model/agent programs use a separate `LocalHarnessDriver`; they are + not represented as arbitrary provider URLs and are never exposed as a shell + tool. The driver may implement a narrow native protocol or ACP over stdio. +- Executable path, fixed argument vector, permitted version/digest, working + directory root, OS identity/container profile, filesystem mounts, network + mode, environment allowlist, concurrency, memory/CPU/time/output limits, and + shutdown behavior live in an immutable deployment-owned registration. + GraphQL configuration can enable, route, budget, or scope a logical harness + profile but cannot create/alter the executable, arguments, sandbox, mounts, + or network boundary. +- Spawn uses an executable directly without a shell, a clean/sanitized + environment, no inherited stdin/TTY, bounded framed stdin/stdout, capped + stderr diagnostics, explicit cancellation, graceful close when supported, + and forced termination after a bounded deadline. Process groups/containers + ensure descendants cannot survive cancellation or restore. +- User bearer tokens, provider keys, SSH agents, cloud credentials, home + directories, socket paths, and ambient environment are absent by default. + Any harness credential or config mount is an explicit secret/deployment + reference with its own scope, audit, rotation, and backup exclusion. +- A harness cannot directly call application GraphQL, databases, MCP servers, + or provider built-ins. Tool requests are normalized and routed back through + the registered tool catalog, fresh principal authorization, approval, + resolver, disclosure, egress, budget, and audit flow. Unsupported attempts + fail closed. +- Harness session IDs and resumable-state references are protected opaque + receipts, never authority. Fenced attempts own process/session generations; + late output from killed or superseded processes is discarded. Restore marks + non-provably resumable work uncertain rather than respawning it blindly. +- ACP capability negotiation is allowlisted. File read/write, terminal, + arbitrary MCP, editor mutation, and permission callbacks are disabled unless + a future separately sandboxed coding-workspace product deliberately enables + them. The application-agent runtime initially permits only conversational + streaming, bounded structured output, cancellation/close, and mediated tool + requests. +- Conformance tests use deterministic fake subprocesses and direct in-memory + protocol peers. They cover command/argument immutability, environment + stripping, output framing/limits, cancellation and descendant cleanup, + session isolation, fence rejection, forbidden capability requests, secret + non-persistence, and tool-policy parity. They require no installed third- + party harness. + +## Required `graphql-orm` Changes + +### 1. Resolver operation metadata + +Add: + +- `ResolverOperationDescriptor` +- `ResolverOperationKind` +- Argument/output descriptors +- Generated document/projection metadata +- Per-field/projection minimum disclosure classification and a structural non-exportable marker. +- Stable owning schema/service namespace and schema fingerprint for aggregating multiple local or remote catalogs without collisions. +- Stable fingerprints +- `graphql_orm_operation_metadata()` + +Keep naming generic; do not add AI-specific attributes to ordinary entities. + +### 2. Schema modules + +Add an `OrmSchemaModule` contract so a dependency can contribute: + +- Stable owner/module ID, semantic module version, descriptor fingerprint, and reserved table namespace. +- Migration entities. +- Backup descriptors. +- Restore reconciliation hooks and runtime-start prerequisites. +- Operation descriptors. +- Managed internal tables. + +`schema_roots!` gains `schema_modules: [...]`. AI internal entities participate in migrations/backups without exposing generated CRUD roots. + +The ORM records module ownership/version in managed schema metadata, detects namespace collisions and drift, orders compatible upgrades, and fails before runtime startup on an unknown downgrade or incompatible module. The dependency remains the single source of truth; host applications compose modules but do not copy their entities or migration lists. + +### 3. Bidirectional keyset connections + +Add a Relay-compatible input: + +```rust +pub struct KeysetConnectionInput { + pub after: Option, + pub before: Option, + pub first: Option, + pub last: Option, + pub include_total_count: bool, +} +``` + +Requirements: + +- Strict validation of incompatible combinations. +- Composite ordering with unique final tiebreaker. +- Forward and backward predicates. +- Reverse database order for `last/before`, then restore canonical edge order. +- `hasNextPage`, `hasPreviousPage`, start/end cursors. +- Existing forward-only APIs remain for compatibility. + +### 4. Sequenced durable streams + +Add generic ORM-owned primitives for: + +- Transactional per-stream sequence allocation. +- Expected-version appends. +- Bounded forward/backward reads. +- Replay-to-watermark. +- Commit-time wakeups. +- Retention purge. +- Backup descriptors. + +The ORM owns database syntax; `graphql-orm-ai` owns AI event types and payload semantics. + +### 5. Generated subscription security + +Before generated subscriptions can become tools: + +- Apply row and field policy to every delivered event. +- Implement the declared filter input. +- Rehydrate current entity state under the subscriber's auth context. +- Avoid leaking deleted-row bodies. +- Periodically reauthorize long-lived subscribers. +- Add optional durable replay based on the ORM change stream. +- Detect broadcast lag and refill from durable storage. +- Add negative cross-tenant tests. + +### 6. First-class encrypted fields + +Add: + +- `FieldCipher`/keyring contract. +- Versioned encrypted envelope with key ID and authenticated encryption. +- `#[graphql_orm(encrypted)]`. +- Async encryption/decryption across GraphQL, repository, transaction, relation, and loader paths. +- Associated-data binding to entity/field/row identity. +- Rotation and re-protection jobs. +- Fail-closed missing-key behavior. +- Automatic `sensitive` metadata. +- Default rejection of filter/order/search for encrypted fields. +- Explicit backup include/redact/exclude behavior. +- Protection-mode metadata for scopes that choose database-only storage. + +Credentials remain encrypted regardless of conversational content policy. + +### 7. MSSQL write parity + +Implement in `graphql-orm`, not the AI crate: + +- Transaction-capable Tiberius pool leases. +- `WriteBackend` for MSSQL. +- Insert/update/delete output decoding using SQL Server `OUTPUT`. +- Compare-and-swap. +- State-machine transaction isolation. +- Generated mutations and repository writes. +- Safe upsert without relying on unsafe general `MERGE` behavior. +- Managed schema creation and migration. +- Introspection and drift validation. +- Foreign keys, unique/index/check/default constraints. +- Append-only enforcement. +- Change journal and sequenced streams. +- Auth context and, where supported, database RLS/security-policy integration. +- Backup export/import and restore. +- Docker-only integration tests. + +### 8. Vector search + +Later, add an opt-in ORM vector contract so the AI crate never emits provider-specific SQL: + +- PostgreSQL `pgvector`, administrator-enabled rather than silently installed. +- SQLite's vector extension behind a pinned, statically controlled feature. +- SQL Server 2025 native vector support, version-gated. SQL Server 2025 has a native vector type intended for similarity search ([Microsoft](https://learn.microsoft.com/en-us/sql/t-sql/data-types/vector-data-type?view=sql-server-ver17)). +- Exact/cosine/L2 abstractions. +- HNSW/ANN capability reporting. +- Scope-aware filters. +- Migration/introspection support. + +PostgreSQL HNSW/IVFFlat support and their recall/performance tradeoffs are documented by [pgvector](https://github.com/pgvector/pgvector). SQLite support must be treated cautiously because available vector extensions are still evolving. + +### 9. Backup/restore helper APIs + +Move target-empty checks, managed-table clearing, constraint suspension, and incremental restore primitives into the ORM so sibling crates do not issue SQL. + +Add module-aware restore lifecycle hooks: preflight, dry-run report, restore, reconciliation, validation, and readiness. Hooks must be deterministic and transaction-aware, must not perform external side effects, and can keep a module's runtime start gate closed. + +### 10. Fenced lease and durable-attempt primitives + +Add reusable ORM operations for: + +- Atomic claim with CAS, new attempt ID, lease expiry, and monotonically increasing generation/fencing token. +- Heartbeat/release/transition conditioned on owner, attempt, generation, expiry, and expected row version. +- Fenced durable-stream append and child-result persistence in the same transaction where supported. +- Bounded expired-lease scans and explicit recovery transitions. +- Backend-independent affected-row/conflict semantics. + +No caller may emulate a claim with an unfenced read followed by update. The primitive must have concurrency parity on SQLite, PostgreSQL, and eventually MSSQL. + +### 11. Canonical GraphQL request-context integration + +Provide or document a generic async-graphql request-envelope factory seam that ordinary HTTP/WS transports and internal execution can share. It must preserve auth subjects, DB auth context, loaders, request IDs, rate limits, extensions, and application audit context without depending on AI types. `graphql-orm-ai` supplies invocation metadata through that seam rather than recreating context itself. Its authenticated bridge must also invoke a required `AiToolAuthorizationPolicy` after current-principal rehydration on every call; a registered descriptor or stale policy object is never treated as authorization. Resolver data is not released from the runtime until the exact registered disclosure schema and output limits succeed. + +### 12. GraphQL naming feature parity + +Expose consistent resolver-, argument-, and field-case features for generated and handwritten roots. `graphql-orm-ai` forwards these features and applies them to every AI query, mutation, subscription, input, output, and enum. Feature combinations are mutually exclusive per category and schema snapshot tests cover the selected contract. No compatibility alias is generated automatically. + +## Required `agql-auth` Changes + +Add generic, non-AI-specific contracts: + +1. `PrincipalReference` + - Serializable. + - Contains safe IDs and expiry/resource metadata. + - Never contains bearer/cookie/API-token secrets. + - Produced by `AuthPrincipal::reference()`. + +2. `CurrentPrincipalResolver` + - Rehydrates current roles, scopes, tenant membership, assurance, and token/session status from a reference. + - Host storage remains pluggable. + +3. `DelegationGrant` + - Bounded expiry. + - Audience/resource binding. + - Requested scopes must be a subset of the current principal. + - Revocable. + - Actor and correlation preserved. + - AI-specific tool/budget limits remain in `graphql-orm-ai`. + - A generic delegated-credential issuer/authority seam for remote resource-server calls. It accepts the freshly resolved principal plus exact audience, resource, purpose, scope subset, actor, correlation, and maximum expiry, and returns only ephemeral redacted authority. + - Credential minting and validation remain host implementations; no bearer credential is serializable into an AI record. + +4. Reusable long-lived authorization state + - Connection start/deadline tracking. + - Periodic `TokenStatusChecker` execution. + - Fail-closed close/pause decisions. + - Recent-MFA aging. + - Safe transport error codes. + - Integration helpers for `graphql-transport-ws`. + +5. Authorization audit enrichment + - Optional delegation/reference ID. + - Resource and correlation metadata. + - Invocation mechanism and causation ID so an application operation can record a user as actor and an AI run/tool as mechanism without creating a second privileged identity. + - No tool arguments or conversation content. + +6. Purpose-bound grant/consent reference + - Generic audience, resource, action/purpose, subject, grant/expiry/revocation, and assurance metadata. + - No AI payload, provider details, content, or data-classification policy in `agql-auth`. + - Allows `graphql-orm-ai` to bind an `AiEgressConsent` to current identity and revocation while keeping the egress manifest/policy in the AI crate. + - A grant cannot add read access, application scopes, or delegation authority. + +7. MCP resource-server helpers in the later MCP phase + - Protected resource metadata. + - Audience-bound validation. + - Scope challenges. + - No token passthrough. + +Egress classification and provider/destination policy do not belong in `agql-auth`; they remain in `graphql-orm-ai`. Auth owns who granted purpose-bound consent and whether that identity/assurance is still current. + +## Required `graphql-orm-backup` Changes + +- Remove all direct `sqlx::query` calls from the crate. +- Consume new ORM restore-target and constraint-management APIs. +- Wire `OrmBackupAdapter::export_incremental` to the ORM change journal. +- Implement incremental create/update/delete restore and tombstones. +- Support sequenced durable-stream tables. +- Support multiple object metadata descriptors. +- Persist schema-module owner/version/fingerprint metadata and invoke module restore preflight/reconciliation/start-gate hooks. +- Preserve encrypted content as ciphertext. +- Exclude provider credentials and key material by default. +- Make raw provider payload inclusion configurable and default-redacted/excluded. +- Require the field-encryption keyring to be restored separately before encrypted content is readable. +- Verify attachment object references and checksums. +- Preserve stream sequences, run attempts/fencing history, webhook receipts, proposal provenance/review state, and redacted egress decisions. +- Never restore a lease as active or automatically replay an uncertain external side effect. +- Support a redacted dry-run reconciliation report before opening a restored runtime. +- Add AI session/run/attachment/proposal/uncertain-operation backup-and-restore tests. + +## Required `graphql-orm-storage` Changes + +No mandatory core redesign. + +Possible additive helpers: + +- Bounded streaming/hash wrapper if existing `StorageService` cannot expose the required upload pipeline cleanly. +- Quarantine-to-final promotion helper built over conditional put/copy/delete. +- Multi-object backup index support coordinated with `graphql-orm-backup`. +- Azure completion as a separate storage roadmap item. + +Do not add unaudited default GraphQL upload/download/delete resolvers. + +## Early Digitise Reference Pilot + +Use Digitise early to validate the generic contracts, but do not move its existing data or make the shared crate consumer-specific during this pilot. + +Pilot capabilities: + +- Limited opt-in users/scopes behind a deployment feature flag capped at `ProposalOnly`. +- Per-user sessions, bounded history, durable streaming, archive/restore, attachments, image/file analysis, and usage/budget reporting. +- A small allowlist of read-only generated and handwritten resolver tools, executed with full request/audit parity. +- Explicit egress manifests/consent for model, attachment, image, web-search, and tool-result transfers. +- Project-specific `AiProposalTypeDescriptor` registrations for catalog metadata, notes, and other useful suggestions. +- AI writes structured suggestions only to AI-owned proposal tables. It cannot publish, edit, delete, change permissions, or send external application data through a domain mutation. +- The Digitise UI lets a person inspect sources, edit/select fields, and apply accepted values through existing Digitise mutations as that person. The normal mutation policy and audit remain authoritative; trusted server code links the successful result to the proposal outcome. +- Shadow/comparison metrics against the existing manager where practical: structured-output validity, acceptance/edit/rejection rates, resolver parity, latency, spend, egress denials, and recovery failures. + +Pilot exit criteria: + +- Cross-user/tenant isolation, read-tool parity, egress authorization, fencing, reconnect, deletion, backup/restore reconciliation, and proposal provenance tests pass. +- No AI code path can invoke an application mutation under the pilot deployment cap. +- Operators can disable AI, provider egress, a tool, a proposal type, or a scope independently. +- User feedback demonstrates that proposal schemas and review UX are stable enough to inform the general supervised-write API. + +This pilot occurs before broad direct-write support so real application requirements can shape the generic contracts. It does not remove direct mutations from the full roadmap. + +## Reference Consumer Migration + +The full data/backend migration occurs only after the generic runtime passes its production gate. It is distinct from the earlier proposal-only pilot. + +1. Add the AI schema module and AI GraphQL roots. +2. Build the host authenticated GraphQL execution bridge. +3. Register selected generated resolver descriptors. +4. Register handwritten workflow resolvers with static documents and risk metadata. +5. Retain the pilot's proposal-only deployment cap until the supervised-write gate is independently approved; enabling migrated sessions does not enable domain mutations. +6. Configure collection/project scope mapping through `AiAccessPolicy` and explicit `AiEgressPolicy`. +7. Migrate current provider settings into scoped provider profiles and encrypted secret references. +8. Migrate old agent session/message/task/usage rows into the generic entities: + - Preserve IDs where safe. + - Convert JSON message bodies into typed message blocks. + - Map task states to run/step states. + - Preserve provider/model/usage/timestamps. + - Record migration provenance. +9. Keep application-specific file-analysis entities in the application. +10. Replace the direct OpenAI manager with generic structured-analysis runs, read tools, and proposal types first. +11. Retain existing application AI mutation names as deprecated wrappers for one compatibility release. +12. Replace repository-bypass behavior with authenticated GraphQL tool calls. +13. Replace broad admin-only policies with per-user session ownership and collection/project capabilities. +14. Verify counts, hashes, attachments, usage, stream heads, and proposal outcomes. +15. Back up before cutover and run the restore reconciler in dry-run against a disposable environment. +16. Disable old writes. +17. Run a read-only/proposal-only comparison period. +18. Enable selected supervised application mutations only through a separate reviewed rollout with one-shot approvals; publishing and other high-impact operations remain individually gated. +19. Remove the old manager/entities only after rollback and restore tests pass. + +No migration SQL may live in the consumer; all schema/data migration mechanics use `graphql-orm`. + +## Documentation, Migration, and Release Governance + +The repository is maintained as a reusable library rather than an application implementation detail: + +- The root `README.md` is the concise supported-capability and integration entry point. Long-form guides live under `docs/` and are indexed by `docs/README.md`. +- Every public Rust item has rustdoc. Fallible public APIs document `# Errors`; security-sensitive types document their trust boundary and non-guarantees. CI builds rustdoc with warnings denied for every supported feature family. +- `CHANGELOG.md` follows Keep a Changelog-style `Unreleased` entries and records every user-visible API, behavior, feature, security, provider, GraphQL, or persistence change. +- `MIGRATION.md` is updated in the same change for every public API, GraphQL schema, feature/default, configuration, authorization, persistence/schema-module, backup/restore, or behavior change. It explicitly says when no data migration is required rather than remaining silent. +- Persistent entity/index/constraint changes bump the AI schema-module version, update its fingerprint tests, document rollout/rollback/restore consequences, and never reuse an applied module version. +- Crate versions follow SemVer, including pre-1.0 breaking-change rules. Public API checks run against the reviewed base/tag with `cargo-semver-checks`; GraphQL SDL and schema-module compatibility receive separate snapshot/contract checks because Rust API tooling cannot see them completely. +- Release CI requires formatting, tests, Clippy with warnings denied, rustdoc with warnings denied, backend compile matrices, changelog/migration policy checks, SemVer checks, and a clean generated schema contract. +- Git consumers pin a reviewed full commit SHA or annotated release tag. Sibling dependency versions and sources converge before a release; consumer-local substitute types are not accepted. +- Root `AGENTS.md` records these rules so future human and automated changes preserve them. + +No release check connects to an external database. Database compatibility tests use in-memory SQLite or a container handle created by the current test process. + +## Delivery Phases + +### Phase 0: Planning artifact and safety guardrails + +- Commit this plan to `docs/plan.md`. +- Add architecture decision records for: + - GraphQL execution boundary. + - Schema-module and table ownership. + - Default-deny tools. + - Read permission versus egress permission. + - Proposal-only first consumer rollout. + - Lease fencing and uncertain-side-effect recovery. + - Restore runtime start gate. + - Local canonical history. + - Per-scope content protection. + - MCP as an optional adapter. + - No raw SQL outside `graphql-orm`. +- Add CI guards that reject direct SQLx/Tiberius database queries outside `graphql-orm`. +- Add test harness guards that reject non-container PostgreSQL/MSSQL URLs. +- Add `CHANGELOG.md`, `MIGRATION.md`, documentation index/development/release guides, repository agent rules, SemVer policy checks, and warnings-denied rustdoc CI. + +### Phase 1: Shared SQLite/PostgreSQL prerequisites + +Implement in `graphql-orm`: + +- Resolver operation metadata. +- Schema modules. +- Canonical request-context factory integration. +- Bidirectional keysets. +- Sequenced streams. +- Fenced lease/attempt primitives. +- Generated subscription authorization fixes. +- Encrypted-field contract. +- Module-aware restore/reconciliation/start-gate APIs. + +Implement in `agql-auth`: + +- Principal references. +- Principal rehydration. +- Long-lived reauthorization. +- Delegation primitives. + +Gate: all existing ORM/auth tests remain compatible and new negative security tests pass. + +### Phase 2: AI foundation + +- Scaffold crate/features/modules. +- Define AI entities and migrations. +- Establish and verify the AI schema-module identity, version, fingerprint, and reserved namespace. +- Implement content-protection selection. +- Implement egress policies, consent, manifests, and redacted decisions. +- Implement GraphQL session/configuration roots. +- Implement proposal registry/storage/review lifecycle. +- Implement fenced durable worker, event, inbox, history, archive, delete, and purge. +- Implement restore reconciler and runtime start gate. +- Implement mock provider and deterministic provider event fixtures. +- Implement telemetry and redacted audits. +- Add compile-time GraphQL naming features and schema contract tests. +- Implement atomic budget counters/reservations and require a reservation proof for provider calls. + +Gate: complete multi-user chat/proposal lifecycle using the mock provider on SQLite and containerized PostgreSQL, including stale-worker fencing and post-restore recovery tests. + +### Phase 3: OpenAI production core + +- Native Responses adapter. +- Typed streaming reconciliation. +- Structured output. +- Image/file inputs. +- Web search/citations. +- Usage and cost accounting. +- Provider profile configuration and secrets. +- Webhook/background support where enabled. +- Attachment pipeline. +- Context compaction. +- Explicit egress checks for every provider/built-in/file/image/web transfer. + +Gate: reconnect, cancellation, provider retry, attachment, deletion, and budget tests pass. + +### Phase 4: Read-only resolver agent and early Digitise pilot + +- Import generated resolver descriptors. +- Implement tool search/deferred loading. +- Implement static read-only application resolver registration. +- Bind local schemas or deployment-registered remote GraphQL targets without exposing endpoint selection to the model. +- Implement current-principal execution through the canonical host request-context factory. +- Prove ordinary-client/tool-bridge authorization, result, rate-limit, and audit parity. +- Implement static disclosure schemas, fail-closed result evaluation, output limits, and separate egress authorization for tool results. +- Add recursion/introspection/control-plane registration denial and local/remote authorization parity conformance tests. +- Register generic proposal schemas and the internal `emit_proposal` tool. +- Deploy the limited `ProposalOnly` reference-consumer pilot described above. +- Complete security red-team suite. + +Gate: no model-requested tool can exceed the initiating user's current permissions or configured data-egress boundary, and no pilot code path can invoke an application mutation. Proposal sources and human-applied outcomes remain auditable. + +This is the first limited SQLite/PostgreSQL production pilot milestone. + +### Phase 5: Supervised mutation tools and approvals + +- Implement the complete risk engine, full action-envelope approvals, server-generated canonical previews, resource/policy/schema preconditions, recent MFA, idempotency, dry-run/diff hooks, output limits, and watches. +- Enable explicitly registered `SupervisedWrite` application mutation descriptors only where deployment and scope maturity caps allow them. +- Require one-shot approvals for publish, delete, external send, permission/membership, and other high-impact operations. +- Support supervised multi-step workflows with a new authorization/approval/egress checkpoint before each consequential step. +- Keep `AutonomousWrite` disabled by default and outside the initial production claim. + +Gate: no model-requested tool can exceed current user permission, maturity cap, approval, current assurance, or egress boundary; stale workers and retries cannot duplicate an application side effect. + +This is the first general SQLite/PostgreSQL production-ready milestone. + +### Phase 6: Provider parity and skills + +- Anthropic. +- xAI/Grok. +- Ollama. +- OpenAI-compatible local endpoints. +- Allowlisted installed local-harness driver with a deterministic fake-process + conformance suite; no general coding/filesystem/terminal authority. +- Provider capability conformance suite. +- Skills/rules/versioning. +- Typed UI intents. +- BYOK. +- Image generation/audio where supported. + +### Phase 7: MSSQL parity + +Implement the full `graphql-orm` MSSQL write plan, then enable the AI MSSQL feature. + +No MSSQL production claim is allowed before transaction, migration, policy, queue, stream, encryption, backup, and concurrency parity tests pass. + +### Phase 8: RAG and protocols + +- ORM vector contract. +- Embeddings and hybrid retrieval. +- MCP client. +- Optional MCP server. +- Optional ACP adapter and separately sandboxed coding-workspace harness + capabilities. The safe inference/application-agent local harness lands in + phase 6. +- Scheduled tasks and advanced memory. +- Multi-agent handoffs. + +### Phase 9: Reference-consumer migration + +Perform the compatibility and data migration plan without introducing consumer-specific behavior into the shared crate. + +## Testing Plan + +### Absolute database safety rule + +No test may connect to any live PostgreSQL or MSSQL server on the machine. + +- SQLite uses temporary/in-memory databases. +- PostgreSQL uses a disposable Docker container with: + - Pinned image. + - Random host port. + - Generated credentials. + - Unique test database name. + - Container labels. + - Disposable volume/tmpfs. + - Guaranteed cleanup. +- MSSQL follows the same pattern with an official container. +- Test harnesses must not read a generic `DATABASE_URL` as a fallback. +- A connection string is rejected before connection unless it came from the current test container handle and targets the generated test database. +- Destructive migration tests are container-only. +- Provider tests use mock HTTP servers by default; live provider tests are explicit, ignored, and never send production data. + +### Unit and property tests + +- Provider event normalization, including fragmented and unknown events. +- Tool JSON Schema validation. +- Canonical argument hashing. +- Egress manifest canonicalization/hashing and changed-manifest invalidation. +- Proposal-schema validation, item limits, protected payloads, and source provenance. +- Approval binding and expiry. +- Cursor encode/decode and bidirectional pagination. +- Sequence allocation. +- Context token budgeting and summary boundaries. +- Data classification and redaction. +- Static disclosure shape validation, unknown-field denial, non-exportable-field denial, and runtime-only classification tightening. +- Logical target/document/schema/projection fingerprint binding and recursion/introspection denial. +- Approval invalidation when target resource, policy, schema, actor, preview, or authorization-state digest changes. +- Budget reservation proof binding and usage reconciliation arithmetic. +- Content-protection envelope/version handling. +- Retry classification. +- URL/SSRF validation. +- MIME and filename handling. +- Stable public error codes. + +### Authorization tests + +- User A cannot list/read/subscribe to User B's sessions, messages, events, attachments, approvals, or usage. +- Tenant/project isolation. +- Disabled tools never reach providers. +- Enabled tools still fail when the ordinary resolver denies access. +- For identical principal/variables, ordinary transport and AI bridge produce authorization/result/rate-limit/application-audit parity. +- The domain audit actor remains the rehydrated user/delegation and links the AI run/tool as mechanism. +- Row and field policy apply to every tool result. +- Readable data cannot leave for a provider, built-in, web search, image/file processor, or MCP server without an independent allowed egress decision. +- Egress destination/model/source/classification/size changes invalidate consent or approval. +- Secret-classified values are denied from egress under every GraphQL configuration. +- Permissions removed between planning, approval, and execution cause denial. +- Session/token revocation stops runs and subscriptions. +- Stale tool fingerprints fail closed. +- Approval argument tampering fails. +- Recent-MFA expiry blocks protected actions. +- API/service token audience/resource mismatch fails. +- Break-glass content access requires reason, MFA, audit, and dedicated scope. +- Prompt injection in file, web, MCP, and resolver results cannot enable tools or modify system policy. +- A `ProposalOnly` deployment rejects every application mutation descriptor, including administrator misconfiguration attempts. +- Proposal review cannot forge an applied outcome or bypass the ordinary domain mutation. +- The model cannot select a remote destination, direct service, audience, resource, or delegated credential. +- Remote execution never stores or forwards the user's bearer token and preserves the human actor plus correlation/causation. +- AI control-plane roots, introspection, configuration, approval, and tool discovery cannot be registered recursively as application tools. +- Direct-service execution never has broader authorization than the ordinary routed target. + +### Persistence and concurrency tests + +- Multiple workers claim each run once. +- Worker crash and lease recovery. +- Worker A stalls, worker B reclaims with a newer generation, and every later event/result/finalization write from worker A fails its fence. +- Late provider streams/webhooks from an old attempt cannot mutate or complete the reclaimed run. +- Retry/dead-letter behavior. +- Idempotent webhook delivery. +- Idempotent send-message client IDs. +- Concurrent messages allocate unique ordered sequences. +- Reconnect during replay/live handoff has no missing or duplicate events. +- Retention gaps emit reset. +- Delete purges content and attachments while preserving redacted audit facts. +- Archive is reversible and does not alter history. +- Backup/restore preserves encrypted history, original sequences, provider receipts, proposal provenance, egress decisions, and attachment checksums. +- Restore clears leases, gates runtime startup, sends uncertain non-idempotent calls to manual recovery, revalidates approvals/consents/provider references, and never blindly replays an external side effect. +- Restore dry-run reports fatal key/schema/stream/object problems without starting workers or mutating recovery state. +- Key rotation reads old and new envelopes correctly. +- Policy changes schedule content re-protection safely. +- Concurrent provider starts atomically reserve every applicable budget; at most the available capacity succeeds. +- Reconciliation is idempotent, releases only proven unused capacity, and leaves uncertain calls conservatively reserved. + +### Pagination and scale tests + +Seed at least one million event/message metadata rows in a dedicated benchmark container. + +Verify: + +- Initial tail query reads at most `limit + 1` message rows. +- Older-page queries remain index/keyset bounded. +- `totalCount` is not executed unless requested. +- Event replay is capped. +- Message content is block-windowed. +- Server memory does not grow with total session length. +- Client contract never requires a full-session snapshot. +- Inserts before or after an active cursor do not duplicate already-viewed rows. +- Deletion/retention gaps produce deterministic reset behavior. + +### Provider conformance tests + +For every provider adapter: + +- Text streaming. +- Tool calls and parallel calls. +- Invalid/partial arguments. +- Structured output. +- Usage accounting. +- Built-in tool traces. +- Cancellation. +- 429/5xx retry behavior. +- Unknown event tolerance. +- Attachment limits. +- Provider state continuation/fallback. +- Egress denial occurs before the mock provider receives any bytes. +- Redaction of raw errors and credentials. + +### Attachment tests + +- Oversized stream abort. +- MIME mismatch. +- Path traversal filename. +- Duplicate hash. +- Malware scanner rejection. +- Archive/zip-bomb rejection. +- Interrupted upload cleanup. +- Quarantine promotion. +- Cross-session attachment access. +- Provider file cleanup. +- Backup/restore. + +### MSSQL tests + +Container-only parity tests for: + +- Managed migrations. +- Transactions and rollback. +- CAS. +- Generated CRUD. +- Stream sequences. +- Subscription replay. +- Encryption. +- Backup/restore. +- Concurrency and deadlock retry. +- SQL Server 2025 vector capability when an appropriate test image is available. + +## Production Acceptance Criteria + +The project is production-ready for a backend only when: + +- No database SQL appears outside `graphql-orm`. +- Every AI entity is owned, migrated, backed up, and restored through the versioned AI schema module; ownership/fingerprint drift fails closed. +- Every session query and subscription is owner/scope isolated. +- All tool exposure is default-deny. +- Application tools execute through the authenticated GraphQL schema. +- Tool execution uses the same request-context factory and produces authorization/result/rate-limit/application-audit parity with an ordinary client request. +- Permissions are rehydrated before every tool execution. +- Read authorization never substitutes for explicit egress authorization, and no denied payload reaches a provider or external tool. +- High-risk actions require bound one-shot approval. +- Revocation and MFA aging are enforced for long-lived work. +- Every run attempt is fenced; stale workers and provider callbacks cannot persist results or finalize a reclaimed run. +- Restore reconciliation completes and the runtime start gate opens before workers, subscriptions, schedules, or webhooks start. +- Streaming reconnects without full-history transfer. +- History and content blocks remain bounded at database, network, client-memory, and DOM levels. +- Provider credentials never appear in GraphQL output, logs, telemetry, backups, or model context. +- Scope content-protection policy is explicitly selected before AI activation. +- Provider and tool budgets are enforced. +- Every provider call carries an exact, unexpired, unreconciled atomic budget-reservation proof. +- Every model-visible resolver result conforms to a fingerprint-bound static disclosure schema; unknown, secret, and non-exportable fields fail closed. +- Local and remote GraphQL targets use server-owned logical IDs, exact schema/document/projection bindings, ephemeral resource-bound authority, and recursion prevention. +- GraphQL naming features produce a single coherent host-selected schema with no automatic aliases. +- Public Rust APIs, GraphQL SDL, schema-module migrations, changelog, migration guide, SemVer, and rustdoc checks pass the release gate. +- Session deletion completes content/blob purge within the configured SLA. +- Temporary/in-memory SQLite and containerized PostgreSQL tests pass for the first production milestone. +- The early reference pilot is capped at proposal-only: it cannot directly modify, publish, delete, or permission application records, and humans apply accepted fields through ordinary mutations. +- Direct application mutations are enabled only after the separate supervised-write gate and remain within user permission, maturity, approval, assurance, idempotency, and egress constraints. +- MSSQL is advertised only after its separate parity gate passes. +- The shared crate contains no consumer-specific runtime dependency or behavior. + +## Principal Technical Challenges + +- Safely executing composed handwritten GraphQL resolvers while preserving host-specific request data. +- Maintaining exact execution and audit parity between transport-originated GraphQL and internal tool calls. +- Keeping schema ownership/versioning and restore lifecycle coherent across independently versioned crates. +- Generating useful resolver tool schemas without permitting arbitrary GraphQL. +- Aggregating independently versioned local/remote resolver catalogs without target collision, schema drift, recursive AI invocation, or credential persistence. +- Deriving static disclosure schemas from server-owned projection metadata while failing closed on computed and unknown fields. +- Applying row/field policy to durable subscription replay, particularly delete events. +- Maintaining current authorization during long-running and disconnected work. +- Field encryption without breaking repository reads, backup, rotation, and migration. +- Efficient bidirectional keysets under concurrent inserts. +- Provider stream differences and partial tool arguments. +- Avoiding double execution after provider, worker, or webhook retries. +- Reserving budgets atomically across concurrent runs and reconciling uncertain external usage without either overspend or unsafe early release. +- Producing canonical action previews and binding approvals to multi-resource version/policy preconditions without treating approval as authorization. +- Fencing stale workers while reconciling uncertain external side effects that cannot be rolled back. +- Distinguishing in-application access from purpose/destination-specific external disclosure. +- Handling provider built-ins that execute outside the application. +- Keeping huge histories bounded even when a single message is very large. +- Full MSSQL write/migration parity with the existing SQLite/PostgreSQL abstractions. +- Portable vector search across three substantially different backends. +- Purging user content without weakening retained security audits. +- Preventing indirect prompt injection from resolver output, web pages, attachments, and MCP servers. + +## Explicit Assumptions and Defaults + +- `graphql-orm-ai` is a backend crate; no reusable TypeScript frontend package is included initially. +- The initial UI contract is GraphQL plus `graphql-transport-ws`. +- Sessions are private to their owner in version one; the participant table preserves a later sharing path. +- Runtime configuration is GraphQL-managed. +- GraphQL naming is selected at compile time because it changes the schema contract; runtime resolvers cannot rename fields. +- Database connection strings, TLS roots, encryption root keys/KMS credentials, and hard network sandbox policy remain deployment configuration. +- Remote GraphQL destinations and delegation audiences/resources are deployment-registered logical targets. GraphQL configuration may only disable or narrow them. +- Scope content protection must be chosen explicitly before enabling AI. +- Provider secrets are always encrypted or externally vaulted. +- Local history is canonical; provider conversations are optional optimizations. +- Models never receive arbitrary SQL, repository, shell, filesystem, or GraphQL tools. +- Generated resolver discovery is comprehensive but model exposure is deny-by-default. +- Handwritten resolvers require explicit static registration. +- Tool and skill schemas use JSON Schema 2020-12. +- Provider output and tool/web/file/MCP content are untrusted input. +- The first consumer pilot may create only validated AI-owned proposals; application records change only through human-initiated normal mutations. +- Direct mutation tools remain part of the full scope but require a separately raised deployment/scope maturity cap. +- Restore defaults to recovery review rather than replay whenever an external side effect is uncertain. +- Stable error codes are public; provider/database internals are private. +- The existing dirty worktrees in `graphql-orm-storage` and `graphql-orm-backup` are user-owned and must be preserved during implementation. diff --git a/crates/graphql-orm-ai/docs/release-process.md b/crates/graphql-orm-ai/docs/release-process.md new file mode 100644 index 00000000..3fbe90d4 --- /dev/null +++ b/crates/graphql-orm-ai/docs/release-process.md @@ -0,0 +1,44 @@ +# Release Process + +## Change classification + +For every change, classify all affected contracts: + +- Public Rust API and Cargo features/defaults. +- GraphQL SDL and naming. +- Persistent entities, indexes, constraints, data semantics, and schema-module + version. +- Configuration, authorization, egress, approval, budget, provider, backup, + restore, and operational behavior. + +Update `CHANGELOG.md` for user-visible changes. Update `MIGRATION.md` for every +contract category above, including an explicit “no data migration required” +statement where applicable. + +## SemVer + +Use Cargo SemVer rules, including the stronger compatibility implications of +pre-1.0 minor versions. `cargo-semver-checks` is mandatory but does not cover +all Rust type changes, GraphQL SDL, persistence schemas, generated macro output, +or runtime behavior; review those separately. + +Public source changes require a crate version change relative to the reviewed +release/base branch. Persistent schema changes also require a new +`AI_SCHEMA_MODULE_VERSION`. Never rewrite an applied schema-module version. + +## Release gate + +1. Confirm one exact reviewed dependency universe and full Git revisions for + unpublished sibling crates. +2. Run `scripts/check-release-policy.sh `. +3. Run formatting, tests, warnings-denied Clippy and rustdoc, PascalCase SDL, + and compile-only backend checks from `docs/development.md`. +4. Run `cargo-semver-checks` against the reviewed baseline. +5. Review GraphQL SDL, schema-module metadata/fingerprint, migration and restore + behavior, backup inclusion, and public error changes. +6. Confirm no test used a live database or real consumer integration. +7. Move `Unreleased` notes to the release version/date, update `Cargo.toml` and + `Cargo.lock`, commit, and create an annotated tag. + +Git consumers pin the reviewed full tag commit. Do not depend on a moving +default branch. diff --git a/crates/graphql-orm-ai/docs/security.md b/crates/graphql-orm-ai/docs/security.md new file mode 100644 index 00000000..586cd9cf --- /dev/null +++ b/crates/graphql-orm-ai/docs/security.md @@ -0,0 +1,49 @@ +# Security Model + +The runtime treats model output, resolver output, files, web results, provider +built-ins, and remote MCP data as untrusted input. + +## Authority + +- Resolver discovery is descriptive; tool registration and policy enablement + are separate default-deny steps. +- After principal rehydration, a required host tool policy evaluates the exact + scope, descriptor fingerprint, and schema-validated arguments on every call. + A catalog entry or stale decision object is not execution authority. +- Every application operation uses a freshly rehydrated user or bounded + delegation through the host's ordinary GraphQL authorization path. +- AI execution preserves the human actor and records the run/tool as mechanism. +- Approval is intent confirmation, not authorization. Resolver, row, field, + rate-limit, assurance, and resource-version checks run again after approval. + +## Disclosure + +Read permission does not imply permission to disclose data externally. Every +provider, built-in, attachment, web, image, code, MCP, and remote model transfer +requires an exact egress manifest and decision. + +Application tool output must conform to a server-owned static disclosure +schema. Unknown fields and `NeverExport` nodes fail closed. Runtime +classification may only raise classification or remove/redact fields. Secret +material is never model-facing, even when a deployment classification ceiling +is configured broadly. Serialized byte and registered list/record limits are +checked before a resolver result is returned to orchestration. + +## External execution and spend + +Provider calls require both an exact egress proof and an atomic budget +reservation proof. Reservations bind the run, attempt, fencing generation, +provider, model, output ceiling, pricing version, and expiry. Uncertain external +calls retain capacity until reconciliation. + +Logical remote GraphQL targets are deployment-registered. Models cannot choose +URLs, audiences, resources, direct-service routes, or credentials. Recursive +AI control-plane and introspection tools are rejected. + +## Operational safety + +Runs use monotonically increasing fencing generations. Stale workers and late +provider callbacks cannot persist results. Restore closes the runtime until +uncertain work and security state are reconciled. All content and credentials +use the configured protection/secret boundaries; logs and auth audits remain +redacted. diff --git a/crates/graphql-orm-ai/scripts/check-release-policy.sh b/crates/graphql-orm-ai/scripts/check-release-policy.sh new file mode 100755 index 00000000..0d750d2c --- /dev/null +++ b/crates/graphql-orm-ai/scripts/check-release-policy.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: scripts/check-release-policy.sh " >&2 + exit 2 +fi + +base_ref=$1 +git cat-file -e "${base_ref}^{commit}" 2>/dev/null || { + echo "release-policy: base revision does not exist: ${base_ref}" >&2 + exit 2 +} +base=$(git merge-base "${base_ref}" HEAD) +changed=$(git diff --name-only "${base}"...HEAD) + +if [[ -z "${changed}" ]]; then + echo "release-policy: no committed changes" + exit 0 +fi + +has_change() { + grep -Fxq "$1" <<<"${changed}" +} + +public_changed=false +if grep -Eq '^(src/.*\.rs|Cargo\.toml)$' <<<"${changed}"; then + public_changed=true +fi + +if [[ "${public_changed}" == true ]]; then + has_change CHANGELOG.md || { + echo "release-policy: public/runtime changes require CHANGELOG.md" >&2 + exit 1 + } + has_change MIGRATION.md || { + echo "release-policy: public/runtime changes require MIGRATION.md" >&2 + exit 1 + } + + current_version=$(awk -F ' *= *' '/^version = / {gsub(/"/, "", $2); print $2; exit}' Cargo.toml) + baseline_version=$(git show "${base}:Cargo.toml" | awk -F ' *= *' '/^version = / {gsub(/"/, "", $2); print $2; exit}') + if [[ -z "${current_version}" || -z "${baseline_version}" ]]; then + echo "release-policy: could not read current/baseline package version" >&2 + exit 1 + fi + if [[ "${current_version}" == "${baseline_version}" ]]; then + echo "release-policy: public/runtime changes require a SemVer version change" >&2 + exit 1 + fi + highest=$(printf '%s\n%s\n' "${baseline_version}" "${current_version}" | sort -V | tail -n 1) + if [[ "${highest}" != "${current_version}" ]]; then + echo "release-policy: package version must not move backwards" >&2 + exit 1 + fi +fi + +if grep -Eq '^src/persistence\.rs$' <<<"${changed}"; then + has_change MIGRATION.md || { + echo "release-policy: persistence changes require MIGRATION.md" >&2 + exit 1 + } + current_schema=$(sed -n 's/.*AI_SCHEMA_MODULE_VERSION: &str = "\([^"]*\)".*/\1/p' src/persistence.rs) + baseline_schema=$(git show "${base}:src/persistence.rs" | sed -n 's/.*AI_SCHEMA_MODULE_VERSION: &str = "\([^"]*\)".*/\1/p') + if [[ -n "${baseline_schema}" && "${current_schema}" == "${baseline_schema}" ]]; then + echo "release-policy: persistence changes require a new schema-module version" >&2 + exit 1 + fi +fi + +echo "release-policy: changelog, migration, crate-version, and schema-version checks passed" diff --git a/crates/graphql-orm-ai/src/access.rs b/crates/graphql-orm-ai/src/access.rs new file mode 100644 index 00000000..2abec696 --- /dev/null +++ b/crates/graphql-orm-ai/src/access.rs @@ -0,0 +1,119 @@ +//! Application-owned session/scope access policy. + +use agql_auth::AuthPrincipal; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::{AiScope, AiSessionId}; + +/// Session/scope action evaluated by the host application. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiSessionAction { + /// List session shells. + List, + /// Read metadata/history/events. + Read, + /// Create a session in a scope. + Create, + /// Send a message or update session metadata. + Write, + /// Archive/restore. + Archive, + /// Delete and purge. + Delete, + /// Subscribe to durable events. + Subscribe, +} + +/// Stable access outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiAccessOutcome { + /// Access is allowed subject to repository owner/tenant filters. + Allow, + /// Access is denied. + Deny, +} + +/// Redacted host access decision. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiAccessDecision { + /// Outcome. + pub outcome: AiAccessOutcome, + /// Stable host reason code. + pub reason_code: String, + /// Safe policy version. + pub policy_version: String, +} + +impl AiAccessDecision { + /// Creates an allowed decision. + pub fn allow(reason_code: impl Into, policy_version: impl Into) -> Self { + Self { + outcome: AiAccessOutcome::Allow, + reason_code: reason_code.into(), + policy_version: policy_version.into(), + } + } + + /// Creates a denied decision. + pub fn deny(reason_code: impl Into, policy_version: impl Into) -> Self { + Self { + outcome: AiAccessOutcome::Deny, + reason_code: reason_code.into(), + policy_version: policy_version.into(), + } + } + + /// Returns whether access is allowed. + pub fn is_allowed(&self) -> bool { + self.outcome == AiAccessOutcome::Allow + } +} + +/// Host application access policy. Repository owner/tenant predicates remain +/// mandatory even after this policy allows an action. +#[async_trait] +pub trait AiAccessPolicy: Send + Sync { + /// Evaluates whether the principal may perform an action in a scope. + async fn can_access_scope( + &self, + principal: &AuthPrincipal, + scope: &AiScope, + action: AiSessionAction, + ) -> AiAccessDecision; + + /// Evaluates whether the principal may perform an action on a session. + async fn can_access_session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + action: AiSessionAction, + ) -> AiAccessDecision; +} + +/// Fail-closed default application policy. +#[derive(Clone, Copy, Debug, Default)] +pub struct DenyAllAiAccessPolicy; + +#[async_trait] +impl AiAccessPolicy for DenyAllAiAccessPolicy { + async fn can_access_scope( + &self, + _principal: &AuthPrincipal, + _scope: &AiScope, + _action: AiSessionAction, + ) -> AiAccessDecision { + AiAccessDecision::deny("default_deny", "deny-all") + } + + async fn can_access_session( + &self, + _principal: &AuthPrincipal, + _session_id: AiSessionId, + _action: AiSessionAction, + ) -> AiAccessDecision { + AiAccessDecision::deny("default_deny", "deny-all") + } +} diff --git a/crates/graphql-orm-ai/src/approvals.rs b/crates/graphql-orm-ai/src/approvals.rs new file mode 100644 index 00000000..1ff415b2 --- /dev/null +++ b/crates/graphql-orm-ai/src/approvals.rs @@ -0,0 +1,216 @@ +//! Exact, expiring, one-shot approval bindings for consequential tool calls. + +use agql_auth::PrincipalReference; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; + +use crate::{AiApprovalId, AiError, AiScope, AiSessionId, AiToolCallId, GraphqlOperationContract}; + +/// Opaque application resource and optimistic-concurrency precondition. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct AiApprovalResourceBinding { + /// Host-defined resource type. + pub resource_type: String, + /// Opaque resource identifier. + pub resource_id: String, + /// Expected row version, ETag, or host-generated precondition digest. + pub expected_version: String, +} + +/// Server-generated canonical action preview shown to an approver. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AiCanonicalActionPreview { + /// Stable host-defined action kind. + pub action_kind: String, + /// Server-authored concise title. + pub title: String, + /// Typed target/precondition bindings included in the action. + pub targets: Vec, + /// Server-generated bounded structured diff/impact facts. + pub details: serde_json::Value, +} + +impl AiCanonicalActionPreview { + /// Returns a stable hash suitable for approval binding. + pub fn stable_hash(&self) -> String { + let mut canonical = self.clone(); + canonical.targets.sort(); + let encoded = serde_json::to_vec(&canonical) + .expect("AiCanonicalActionPreview consists only of serializable values"); + hex::encode(Sha256::digest(encoded)) + } +} + +/// Complete action envelope to which one approval is bound. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AiApprovalBinding { + /// Tool call awaiting approval. + pub tool_call_id: AiToolCallId, + /// Session owning the action. + pub session_id: AiSessionId, + /// Scope and optional tenant boundary. + pub scope: AiScope, + /// Exact reviewed tool descriptor fingerprint. + pub tool_fingerprint: String, + /// Canonical validated variables/arguments hash. + pub argument_hash: String, + /// Exact local/remote GraphQL target and operation contract. + pub operation: GraphqlOperationContract, + /// Fingerprint of the safe durable principal reference. + pub principal_reference_fingerprint: String, + /// Original/delegated actor subject when applicable. + pub delegated_actor_subject: Option, + /// Safe delegation/grant reference, never a token. + pub delegation_reference: Option, + /// Current tool/scope/application policy version. + pub policy_version: String, + /// Host-generated safe authorization-state/precondition digest. + pub authorization_state_digest: String, + /// Exact target resources and optimistic-concurrency preconditions. + pub resources: Vec, + /// Hash of the server-generated canonical action preview. + pub preview_hash: String, +} + +impl AiApprovalBinding { + /// Computes a stable hash over the complete approval envelope. + pub fn stable_hash(&self) -> String { + let mut canonical = self.clone(); + canonical.resources.sort(); + let encoded = serde_json::to_vec(&canonical) + .expect("AiApprovalBinding consists only of serializable values"); + hex::encode(Sha256::digest(encoded)) + } + + /// Validates that required policy and operation bindings are present. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidConfiguration`] when a required binding is + /// empty, target resources are duplicated, or the preview hash is stale. + pub fn validate(&self, preview: &AiCanonicalActionPreview) -> Result<(), AiError> { + if self.tool_fingerprint.trim().is_empty() + || self.argument_hash.trim().is_empty() + || self.policy_version.trim().is_empty() + || self.authorization_state_digest.trim().is_empty() + || self.preview_hash != preview.stable_hash() + { + return Err(AiError::InvalidConfiguration( + "approval binding is incomplete or stale".to_owned(), + )); + } + let mut resources = self.resources.clone(); + resources.sort(); + if resources.iter().any(|resource| { + resource.resource_type.trim().is_empty() + || resource.resource_id.trim().is_empty() + || resource.expected_version.trim().is_empty() + }) || resources.windows(2).any(|window| window[0] == window[1]) + { + return Err(AiError::InvalidConfiguration( + "approval resource binding is invalid".to_owned(), + )); + } + let mut preview_targets = preview.targets.clone(); + preview_targets.sort(); + if resources != preview_targets { + return Err(AiError::InvalidConfiguration( + "approval preview targets do not match action resources".to_owned(), + )); + } + Ok(()) + } + + /// Fingerprints a safe principal reference without preserving roles, + /// scopes, or any credential material. + pub fn principal_fingerprint(reference: &PrincipalReference) -> String { + let encoded = serde_json::to_vec(reference) + .expect("PrincipalReference consists only of serializable values"); + hex::encode(Sha256::digest(encoded)) + } +} + +/// Persisted lifecycle state for a one-shot approval. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiApprovalState { + /// Awaiting an authorized human decision. + Pending, + /// Approved for one exact future consumption. + Approved, + /// Explicitly denied. + Denied, + /// Binding or time window is no longer current. + Expired, + /// Previously approved authority was revoked. + Revoked, + /// The exact approved action was consumed once. + Consumed, +} + +/// Exact approved decision before transactional one-shot consumption. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiApprovalGrant { + /// Approval identifier. + pub id: AiApprovalId, + /// Complete action-envelope hash. + pub binding_hash: String, + /// Human approver subject. + pub approver_subject: String, + /// Current approval state. + pub state: AiApprovalState, + /// Decision timestamp. + pub approved_at: OffsetDateTime, + /// Exclusive expiry timestamp. + pub expires_at: OffsetDateTime, +} + +impl AiApprovalGrant { + /// Validates this grant against a freshly rebuilt action envelope. + /// + /// This check does not consume the approval and does not replace fresh + /// resolver authorization. Persistence must atomically transition the + /// matching approved row to `Consumed` before executing a side effect. + /// + /// # Errors + /// + /// Returns [`AiError::Forbidden`] for a stale, expired, mismatched, + /// or non-approved grant. + pub fn authorize( + &self, + current_binding: &AiApprovalBinding, + now: OffsetDateTime, + ) -> Result { + if self.state != AiApprovalState::Approved + || now < self.approved_at + || now >= self.expires_at + || self.binding_hash != current_binding.stable_hash() + { + return Err(AiError::Forbidden); + } + Ok(AuthorizedAiApproval { + approval_id: self.id, + binding_hash: self.binding_hash.clone(), + }) + } +} + +/// Opaque proof that an unexpired grant matched a freshly rebuilt action envelope. +#[derive(Clone, Debug)] +pub struct AuthorizedAiApproval { + approval_id: AiApprovalId, + binding_hash: String, +} + +impl AuthorizedAiApproval { + /// Returns the approval identifier for atomic consumption and audit linkage. + pub const fn approval_id(&self) -> AiApprovalId { + self.approval_id + } + + /// Returns the exact action-envelope hash. + pub fn binding_hash(&self) -> &str { + &self.binding_hash + } +} diff --git a/crates/graphql-orm-ai/src/budget.rs b/crates/graphql-orm-ai/src/budget.rs new file mode 100644 index 00000000..d09fcfbd --- /dev/null +++ b/crates/graphql-orm-ai/src/budget.rs @@ -0,0 +1,280 @@ +//! Atomic budget reservation contracts for provider execution. + +use agql_auth::ResolvedPrincipal; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + AiBudgetReservationId, AiError, AiRunId, AiScope, AiSessionId, ProviderError, ProviderKind, +}; + +/// Token, cost, and unit capacity reserved or consumed by one provider call. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiBudgetAmounts { + /// Estimated or actual non-cached input tokens. + pub input_tokens: u64, + /// Maximum or actual output tokens. + pub output_tokens: u64, + /// Provider/tool-specific billable units. + pub tool_units: u64, + /// Provider/image-specific billable units. + pub image_units: u64, + /// Cost in deployment-defined integer microunits. + pub cost_microunits: u64, + /// Run/call count consumed by the reservation. + pub runs: u64, +} + +impl AiBudgetAmounts { + /// Returns whether this amount fits completely within the supplied ceiling. + pub const fn fits_within(self, ceiling: Self) -> bool { + self.input_tokens <= ceiling.input_tokens + && self.output_tokens <= ceiling.output_tokens + && self.tool_units <= ceiling.tool_units + && self.image_units <= ceiling.image_units + && self.cost_microunits <= ceiling.cost_microunits + && self.runs <= ceiling.runs + } +} + +/// Durable state of an atomic provider budget reservation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiBudgetReservationState { + /// Capacity is reserved and may authorize its exact provider call once. + Reserved, + /// Actual usage was committed and unused capacity released. + Committed, + /// No provider call occurred and all reserved capacity was released. + Released, + /// External execution is uncertain; capacity remains held for reconciliation. + Uncertain, + /// A provably unused reservation expired and was released. + Expired, +} + +/// Request passed to the transactional budget service before provider egress. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiBudgetReservationRequest { + /// Session/application scope used to resolve applicable budget policies. + pub scope: AiScope, + /// Session owning the provider call. + pub session_id: AiSessionId, + /// Run owning the provider call. + pub run_id: AiRunId, + /// Current durable attempt identifier. + pub attempt_id: Uuid, + /// Current monotonically increasing run fencing generation. + pub lease_generation: i64, + /// Exact provider family. + pub provider_kind: ProviderKind, + /// Exact provider model. + pub model: String, + /// Immutable pricing-policy version used for the estimate. + pub pricing_policy_version: String, + /// Capacity to reserve before external execution. + pub estimate: AiBudgetAmounts, + /// Content-bound idempotency identifier for this provider start. + pub idempotency_key: String, + /// Latest time at which an unstarted reservation may be released. + pub expires_at: OffsetDateTime, +} + +/// Persistable exact reservation returned by an atomic budget service. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiBudgetReservation { + id: AiBudgetReservationId, + run_id: AiRunId, + attempt_id: Uuid, + lease_generation: i64, + provider_kind: ProviderKind, + model: String, + pricing_policy_version: String, + reserved: AiBudgetAmounts, + state: AiBudgetReservationState, + expires_at: OffsetDateTime, +} + +impl AiBudgetReservation { + /// Creates a reserved result after an implementation has atomically updated + /// every applicable counter. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidConfiguration`] for invalid identifiers, + /// versions, models, generations, or a zero run reservation. Expiry is + /// evaluated against the caller-provided trusted clock when authorizing a + /// provider call. + #[allow(clippy::too_many_arguments)] + pub fn new_reserved( + id: AiBudgetReservationId, + run_id: AiRunId, + attempt_id: Uuid, + lease_generation: i64, + provider_kind: ProviderKind, + model: impl Into, + pricing_policy_version: impl Into, + reserved: AiBudgetAmounts, + expires_at: OffsetDateTime, + ) -> Result { + let model = model.into(); + let pricing_policy_version = pricing_policy_version.into(); + if attempt_id.is_nil() + || lease_generation < 0 + || model.trim().is_empty() + || pricing_policy_version.trim().is_empty() + || reserved.runs == 0 + { + return Err(AiError::InvalidConfiguration( + "invalid budget reservation binding".to_owned(), + )); + } + Ok(Self { + id, + run_id, + attempt_id, + lease_generation, + provider_kind, + model, + pricing_policy_version, + reserved, + state: AiBudgetReservationState::Reserved, + expires_at, + }) + } + + /// Returns the durable reservation identifier. + pub const fn id(&self) -> AiBudgetReservationId { + self.id + } + + /// Returns the exact reserved capacity. + pub const fn reserved(&self) -> AiBudgetAmounts { + self.reserved + } + + /// Returns the immutable pricing-policy version. + pub fn pricing_policy_version(&self) -> &str { + &self.pricing_policy_version + } + + /// Converts a current exact reservation into the proof required by a + /// provider call. + /// + /// # Errors + /// + /// Returns [`ProviderError::BudgetDenied`] when the reservation is not + /// active, has expired, or does not match the run, provider, model, output + /// ceiling, attempt, or fencing generation. + #[allow(clippy::too_many_arguments)] + pub fn authorize_provider_call( + &self, + run_id: AiRunId, + attempt_id: Uuid, + lease_generation: i64, + provider_kind: &ProviderKind, + model: &str, + requested_maximum_output_tokens: u64, + now: OffsetDateTime, + ) -> Result { + if self.state != AiBudgetReservationState::Reserved + || now >= self.expires_at + || self.run_id != run_id + || self.attempt_id != attempt_id + || self.lease_generation != lease_generation + || &self.provider_kind != provider_kind + || self.model != model + || requested_maximum_output_tokens > self.reserved.output_tokens + { + return Err(ProviderError::BudgetDenied); + } + Ok(AuthorizedBudgetReservation { + reservation_id: self.id, + run_id: self.run_id, + provider_kind: self.provider_kind.clone(), + model: self.model.clone(), + maximum_output_tokens: self.reserved.output_tokens, + expires_at: self.expires_at, + }) + } +} + +/// Opaque proof that capacity was atomically reserved for one exact provider call. +#[derive(Clone, Debug)] +pub struct AuthorizedBudgetReservation { + reservation_id: AiBudgetReservationId, + run_id: AiRunId, + provider_kind: ProviderKind, + model: String, + maximum_output_tokens: u64, + expires_at: OffsetDateTime, +} + +impl AuthorizedBudgetReservation { + /// Returns the reservation identifier for usage/audit linkage. + pub const fn reservation_id(&self) -> AiBudgetReservationId { + self.reservation_id + } + + pub(crate) fn matches( + &self, + run_id: AiRunId, + provider_kind: &ProviderKind, + model: &str, + requested_maximum_output_tokens: u64, + now: OffsetDateTime, + ) -> bool { + self.run_id == run_id + && &self.provider_kind == provider_kind + && self.model == model + && requested_maximum_output_tokens <= self.maximum_output_tokens + && now < self.expires_at + } +} + +/// Final provider-call classification used for transactional reconciliation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiBudgetReconciliationOutcome { + /// Actual usage is authoritative and unused capacity may be released. + Commit, + /// The provider was provably not called and the full reservation may be released. + ReleaseUnused, + /// External execution may have occurred; reserved capacity must remain held. + MarkUncertain, +} + +/// Exact once-only budget reconciliation request. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiBudgetReconciliation { + /// Reservation being reconciled. + pub reservation_id: AiBudgetReservationId, + /// Current attempt identifier. + pub attempt_id: Uuid, + /// Current fencing generation. + pub lease_generation: i64, + /// Authoritative actual usage when known. + pub actual: Option, + /// Safe final classification. + pub outcome: AiBudgetReconciliationOutcome, +} + +/// Transactional budget boundary implemented with `graphql-orm` operations. +#[async_trait] +pub trait AiBudgetService: Send + Sync { + /// Atomically checks and reserves every applicable budget counter. + async fn reserve( + &self, + principal: &ResolvedPrincipal, + request: AiBudgetReservationRequest, + ) -> Result; + + /// Reconciles actual usage or retains capacity for uncertain recovery. + async fn reconcile( + &self, + principal: &ResolvedPrincipal, + reconciliation: AiBudgetReconciliation, + ) -> Result<(), AiError>; +} diff --git a/crates/graphql-orm-ai/src/configuration.rs b/crates/graphql-orm-ai/src/configuration.rs new file mode 100644 index 00000000..4a59b620 --- /dev/null +++ b/crates/graphql-orm-ai/src/configuration.rs @@ -0,0 +1,388 @@ +//! Redacted GraphQL-managed AI configuration contracts. + +use std::sync::Arc; + +use agql_auth::AuthPrincipal; +use async_graphql::{Context, Enum, ErrorExtensions, InputObject, Object, SimpleObject}; +use async_trait::async_trait; +use secrecy::SecretString; +use uuid::Uuid; + +use crate::{AiContentProtectionMode, AiError, AiScope, AiScopeInput}; + +/// Administrative configuration action evaluated by the host. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AiConfigurationAction { + /// Read redacted provider configuration. + ReadProviderProfiles, + /// Create or alter provider routing/endpoint metadata. + ManageProviderProfiles, + /// Store, rotate, or remove provider credentials. + ManageProviderCredentials, + /// Read content-protection readiness. + ReadContentProtection, + /// Change content-protection mode or key policy. + ManageContentProtection, +} + +/// Host-owned administrative authorization for GraphQL-managed AI settings. +/// Scope naming and wildcard semantics remain entirely in the host policy. +#[async_trait] +pub trait AiConfigurationAccessPolicy: Send + Sync { + /// Returns whether the current principal may perform the exact action in + /// the exact scope. Implementations must fail closed on dependency errors. + async fn can_configure( + &self, + principal: &AuthPrincipal, + scope: &AiScope, + action: AiConfigurationAction, + ) -> bool; +} + +/// Deployment-owned validation for configurable provider endpoints. +/// +/// The library performs basic URL safety validation first. This policy then +/// enforces network zones, allowed hosts/ports, local-provider rules, and any +/// SSRF protections specific to the deployment. +pub trait AiProviderEndpointPolicy: Send + Sync { + /// Authorizes a normalized endpoint for a provider kind. + fn authorize_endpoint(&self, provider_kind: AiProviderKindInput, normalized_url: &str) -> bool; +} + +/// Provider family accepted by GraphQL configuration. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Enum)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_items = "PascalCase"))] +pub enum AiProviderKindInput { + /// OpenAI native Responses API. + OpenAi, + /// Anthropic native API. + Anthropic, + /// xAI native API. + Xai, + /// Local/native Ollama API. + Ollama, + /// Explicit capability-profiled compatible endpoint. + OpenAiCompatible, +} + +impl AiProviderKindInput { + /// Stable persistence/configuration value. + pub const fn as_str(self) -> &'static str { + match self { + Self::OpenAi => "openai", + Self::Anthropic => "anthropic", + Self::Xai => "xai", + Self::Ollama => "ollama", + Self::OpenAiCompatible => "openai_compatible", + } + } +} + +/// Redacted provider profile. Credential references and values are omitted. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiProviderProfileView { + /// Profile ID. + pub id: Uuid, + /// Scope kind. + pub scope_kind: String, + /// Scope ID. + pub scope_id: String, + /// Optional tenant boundary. + pub tenant_id: Option, + /// Stable provider kind. + pub provider_kind: String, + /// Administrative display name. + pub display_name: String, + /// Redacted endpoint; native providers may omit it. + pub base_url: Option, + /// Whether a credential reference is configured. + pub credential_configured: bool, + /// Whether routing may select this profile. + pub enabled: bool, + /// CAS version. + pub row_version: i64, + /// Update time in Unix seconds. + pub updated_at: i64, +} + +/// Redacted scope content-protection state. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiContentProtectionPolicyView { + /// Scope kind. + pub scope_kind: String, + /// Scope ID. + pub scope_id: String, + /// Optional tenant boundary. + pub tenant_id: Option, + /// Stable selected mode. + pub protection_mode: String, + /// Whether migration/re-protection is ready. + pub ready: bool, + /// CAS version. + pub row_version: i64, + /// Effective time in Unix seconds. + pub effective_at: i64, +} + +/// Provider profile CAS upsert. +#[derive(InputObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct UpsertAiProviderProfileInput { + /// Existing profile ID, or absent to create. + pub id: Option, + /// Owning scope. + pub scope: AiScopeInput, + /// Provider family. + pub provider_kind: AiProviderKindInput, + /// Administrative display name. + pub display_name: String, + /// Endpoint for explicitly configurable providers. + pub base_url: Option, + /// Enable routing after all other policy gates pass. + pub enabled: bool, + /// Expected CAS version for an update. + pub expected_version: Option, +} + +/// Credential rotation input. This type deliberately does not derive `Debug`, +/// `Clone`, serialization, or equality. +#[derive(InputObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct SetAiProviderCredentialInput { + /// Provider profile. + pub profile_id: Uuid, + /// Provider credential plaintext. It is converted to [`SecretString`] + /// immediately in the resolver and must never be persisted in this form. + #[graphql(secret)] + pub credential: String, + /// Expected provider-profile CAS version. + pub expected_version: i64, +} + +/// Credential removal input. +#[derive(Clone, Debug, InputObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct RemoveAiProviderCredentialInput { + /// Provider profile. + pub profile_id: Uuid, + /// Expected provider-profile CAS version. + pub expected_version: i64, +} + +/// Content-protection policy CAS input. +#[derive(Clone, Debug, InputObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct SetAiContentProtectionPolicyInput { + /// Owning scope. + pub scope: AiScopeInput, + /// Database-managed or application-level envelope encryption. + pub mode: AiContentProtectionModeInput, + /// Non-secret key-policy reference for application encryption. + pub key_policy_reference: Option, + /// Expected CAS version, or absent to create. + pub expected_version: Option, +} + +/// GraphQL content-protection mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Enum)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_items = "PascalCase"))] +pub enum AiContentProtectionModeInput { + /// Deployment database/storage encryption at rest. + DatabaseManaged, + /// Application-level authenticated encryption before ORM persistence. + ApplicationEncrypted, +} + +impl From for AiContentProtectionMode { + fn from(value: AiContentProtectionModeInput) -> Self { + match value { + AiContentProtectionModeInput::DatabaseManaged => Self::DatabaseManaged, + AiContentProtectionModeInput::ApplicationEncrypted => Self::ApplicationEncrypted, + } + } +} + +/// Authenticated configuration backend. +/// +/// Implementations must enforce administrative authorization and scope/tenant +/// isolation for every method. Mutations must use CAS, append a redacted audit +/// event, and require current recent MFA for credential and content-protection +/// changes. A failed audit append fails the mutation. +#[async_trait] +pub trait AiConfigurationService: Send + Sync { + /// Lists at most 100 visible redacted profiles for a scope. + async fn provider_profiles( + &self, + principal: &AuthPrincipal, + scope: AiScope, + ) -> Result, AiError>; + + /// Loads the redacted content-protection state for a visible scope. + async fn content_protection_policy( + &self, + principal: &AuthPrincipal, + scope: AiScope, + ) -> Result, AiError>; + + /// Creates or CAS-updates a provider profile and audits the change. + async fn upsert_provider_profile( + &self, + principal: &AuthPrincipal, + input: UpsertAiProviderProfileInput, + ) -> Result; + + /// Stores/rotates a credential through [`crate::AiSecretStore`], updates + /// only its reference transactionally, and audits without the reference. + async fn set_provider_credential( + &self, + principal: &AuthPrincipal, + profile_id: Uuid, + credential: SecretString, + expected_version: i64, + ) -> Result; + + /// Removes/revokes a credential reference and audits the change. + async fn remove_provider_credential( + &self, + principal: &AuthPrincipal, + input: RemoveAiProviderCredentialInput, + ) -> Result; + + /// Creates or CAS-updates required scope content protection. + async fn set_content_protection_policy( + &self, + principal: &AuthPrincipal, + input: SetAiContentProtectionPolicyInput, + ) -> Result; +} + +/// Composable redacted configuration query root. +#[derive(Clone, Copy, Debug, Default)] +pub struct AiConfigurationQueryRoot; + +#[cfg_attr( + feature = "graphql-case-pascal", + Object(rename_fields = "PascalCase", rename_args = "PascalCase") +)] +#[cfg_attr(not(feature = "graphql-case-pascal"), Object)] +impl AiConfigurationQueryRoot { + /// Lists bounded redacted provider profiles. + async fn ai_provider_profiles( + &self, + context: &Context<'_>, + scope: AiScopeInput, + ) -> async_graphql::Result> { + let principal = agql_auth::principal_from_ctx(context)?; + let profiles = configuration_service(context)? + .provider_profiles(&principal, scope.into()) + .await + .map_err(extend)?; + if profiles.len() > 100 { + return Err(AiError::InvalidConfiguration( + "configuration service returned an unbounded profile list".to_owned(), + ) + .extend()); + } + Ok(profiles) + } + + /// Loads redacted scope content-protection readiness. + async fn ai_content_protection_policy( + &self, + context: &Context<'_>, + scope: AiScopeInput, + ) -> async_graphql::Result> { + let principal = agql_auth::principal_from_ctx(context)?; + configuration_service(context)? + .content_protection_policy(&principal, scope.into()) + .await + .map_err(extend) + } +} + +/// Composable configuration mutation root. +#[derive(Clone, Copy, Debug, Default)] +pub struct AiConfigurationMutationRoot; + +#[cfg_attr( + feature = "graphql-case-pascal", + Object(rename_fields = "PascalCase", rename_args = "PascalCase") +)] +#[cfg_attr(not(feature = "graphql-case-pascal"), Object)] +impl AiConfigurationMutationRoot { + /// Creates or CAS-updates a provider profile. + async fn upsert_ai_provider_profile( + &self, + context: &Context<'_>, + input: UpsertAiProviderProfileInput, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + configuration_service(context)? + .upsert_provider_profile(&principal, input) + .await + .map_err(extend) + } + + /// Stores or rotates a provider credential; no secret value/reference is + /// returned. + async fn set_ai_provider_credential( + &self, + context: &Context<'_>, + input: SetAiProviderCredentialInput, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + let credential = SecretString::from(input.credential); + configuration_service(context)? + .set_provider_credential( + &principal, + input.profile_id, + credential, + input.expected_version, + ) + .await + .map_err(extend) + } + + /// Removes/revokes a provider credential. + async fn remove_ai_provider_credential( + &self, + context: &Context<'_>, + input: RemoveAiProviderCredentialInput, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + configuration_service(context)? + .remove_provider_credential(&principal, input) + .await + .map_err(extend) + } + + /// Sets required per-scope content protection. + async fn set_ai_content_protection_policy( + &self, + context: &Context<'_>, + input: SetAiContentProtectionPolicyInput, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + configuration_service(context)? + .set_content_protection_policy(&principal, input) + .await + .map_err(extend) + } +} + +fn configuration_service( + context: &Context<'_>, +) -> async_graphql::Result> { + context + .data_opt::>() + .cloned() + .ok_or_else(|| { + AiError::InvalidConfiguration("AI configuration service is missing".to_owned()).extend() + }) +} + +fn extend(error: AiError) -> async_graphql::Error { + error.extend() +} diff --git a/crates/graphql-orm-ai/src/content_protection.rs b/crates/graphql-orm-ai/src/content_protection.rs new file mode 100644 index 00000000..56b1dd8e --- /dev/null +++ b/crates/graphql-orm-ai/src/content_protection.rs @@ -0,0 +1,167 @@ +//! Per-scope conversational content-protection contracts. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use agql_auth::AuthPrincipal; + +use crate::{AiError, AiScope}; + +/// Storage protection selected for a scope. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiContentProtectionMode { + /// The deployment database/storage layer provides encryption at rest. + DatabaseManaged, + /// The application protects content before it reaches ORM persistence. + ApplicationEncrypted, +} + +/// Scope policy resolved before content can be persisted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiContentProtectionPolicy { + /// Scope governed by this policy. + pub scope: AiScope, + /// Selected mode. + pub mode: AiContentProtectionMode, + /// Non-secret key policy or key-version reference. + pub key_policy_reference: Option, + /// CAS/configuration version. + pub version: u64, + /// Whether any required migration/re-protection has completed. + pub ready: bool, +} + +/// Resolves the current, authorized protection policy for one scope. +/// +/// Implementations normally read the GraphQL-managed configuration store. +/// They must apply scope/tenant isolation and fail closed when a policy is +/// absent, stale, migrating, or otherwise not ready. +#[async_trait] +pub trait AiContentProtectionPolicyResolver: Send + Sync { + /// Loads the policy effective for this principal and scope. + async fn resolve( + &self, + principal: &AuthPrincipal, + scope: &AiScope, + ) -> Result; +} + +/// Associated identity bound into application-level protection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContentProtectionContext { + /// Stable entity/table identity. + pub entity: String, + /// Stable row identity. + pub row_id: String, + /// Stable field identity. + pub field: String, + /// Owning scope. + pub scope: AiScope, +} + +/// Serializable content envelope. No public GraphQL output should expose this +/// type directly. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "protection", rename_all = "snake_case")] +pub enum ProtectedContentEnvelope { + /// Content relies on deployment-managed database/storage encryption. + DatabaseManaged { + /// Canonical JSON value stored by the ORM. + value: serde_json::Value, + }, + /// Content was protected before persistence. + ApplicationEncrypted { + /// Envelope version. + version: u16, + /// Non-secret key identifier. + key_id: String, + /// Authenticated-encryption algorithm identifier. + algorithm: String, + /// Encoded nonce/initialization material. + nonce: String, + /// Encoded authenticated ciphertext. + ciphertext: String, + }, +} + +/// Content-protection failure without key or plaintext details. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum ContentProtectionError { + /// No ready policy exists for the scope. + #[error("content protection policy is not ready")] + PolicyNotReady, + /// Required key material is unavailable. + #[error("content protection key is unavailable")] + KeyUnavailable, + /// Envelope identity/authentication validation failed. + #[error("protected content validation failed")] + ValidationFailed, + /// Configured protection mode is unsupported by this implementation. + #[error("content protection mode is unsupported")] + Unsupported, +} + +/// Application-level content protection seam. Implementations should bind +/// authenticated encryption to every field in [`ContentProtectionContext`]. +#[async_trait] +pub trait AiContentProtector: Send + Sync { + /// Protects a canonical JSON value for persistence. + async fn protect( + &self, + policy: &AiContentProtectionPolicy, + context: &ContentProtectionContext, + value: serde_json::Value, + ) -> Result; + + /// Opens a value after verifying its policy and associated identity. + async fn open( + &self, + policy: &AiContentProtectionPolicy, + context: &ContentProtectionContext, + envelope: &ProtectedContentEnvelope, + ) -> Result; +} + +/// Explicit database-managed implementation. It refuses application-encrypted +/// envelopes rather than silently treating ciphertext as plaintext. +#[derive(Clone, Copy, Debug, Default)] +pub struct DatabaseManagedContentProtector; + +#[async_trait] +impl AiContentProtector for DatabaseManagedContentProtector { + async fn protect( + &self, + policy: &AiContentProtectionPolicy, + _context: &ContentProtectionContext, + value: serde_json::Value, + ) -> Result { + if !policy.ready { + return Err(ContentProtectionError::PolicyNotReady); + } + if policy.mode != AiContentProtectionMode::DatabaseManaged { + return Err(ContentProtectionError::Unsupported); + } + Ok(ProtectedContentEnvelope::DatabaseManaged { value }) + } + + async fn open( + &self, + policy: &AiContentProtectionPolicy, + _context: &ContentProtectionContext, + envelope: &ProtectedContentEnvelope, + ) -> Result { + if !policy.ready { + return Err(ContentProtectionError::PolicyNotReady); + } + match (policy.mode, envelope) { + ( + AiContentProtectionMode::DatabaseManaged, + ProtectedContentEnvelope::DatabaseManaged { value }, + ) => Ok(value.clone()), + _ => Err(ContentProtectionError::ValidationFailed), + } + } +} diff --git a/crates/graphql-orm-ai/src/data.rs b/crates/graphql-orm-ai/src/data.rs new file mode 100644 index 00000000..e0399533 --- /dev/null +++ b/crates/graphql-orm-ai/src/data.rs @@ -0,0 +1,46 @@ +//! Data classification and provenance. + +use serde::{Deserialize, Serialize}; + +/// Ordered confidentiality classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DataClassification { + /// Safe for public disclosure. + Public, + /// Internal application data. + Internal, + /// Confidential user/tenant data. + Confidential, + /// Highly restricted regulated or sensitive data. + Restricted, + /// Credentials, keys, or other material that must never be model-facing. + Secret, +} + +/// Provenance trust applied to model-facing input. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiSourceTrust { + /// Runtime-authored instructions or metadata. + TrustedRuntime, + /// Authenticated user-provided content. + UserProvided, + /// Application resolver output. + ResolverResult, + /// Web, MCP, provider, or other untrusted external content. + ExternalUntrusted, +} + +/// Redacted source reference used for provenance and egress manifests. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct AiDataSourceRef { + /// Source kind such as `message_block`, `attachment`, or `tool_artifact`. + pub kind: String, + /// Opaque stable identifier; never source plaintext. + pub reference: String, + /// Confidentiality classification. + pub classification: DataClassification, + /// Trust/provenance classification. + pub trust: AiSourceTrust, +} diff --git a/crates/graphql-orm-ai/src/disclosure.rs b/crates/graphql-orm-ai/src/disclosure.rs new file mode 100644 index 00000000..42bf145a --- /dev/null +++ b/crates/graphql-orm-ai/src/disclosure.rs @@ -0,0 +1,321 @@ +//! Static, server-owned disclosure schemas for model-visible tool results. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::{AiError, DataClassification}; + +const MAXIMUM_DISCLOSURE_SCHEMA_DEPTH: usize = 64; + +/// Whether a selected result node may ever leave the application boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiDisclosureDisposition { + /// The node may be disclosed subject to its classification and egress policy. + Exportable, + /// The node is structurally forbidden from model/provider disclosure. + NeverExport, +} + +/// Static disclosure policy shared by a result node and its descendants. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiDisclosureRule { + /// Minimum confidentiality classification assigned by server-owned metadata. + pub classification: DataClassification, + /// Structural export eligibility. + pub disposition: AiDisclosureDisposition, +} + +impl AiDisclosureRule { + /// Creates an exportable rule with the supplied minimum classification. + pub const fn exportable(classification: DataClassification) -> Self { + Self { + classification, + disposition: AiDisclosureDisposition::Exportable, + } + } + + /// Creates a node that must never be included in model-facing output. + pub const fn never_export(classification: DataClassification) -> Self { + Self { + classification, + disposition: AiDisclosureDisposition::NeverExport, + } + } +} + +/// Exact recursive shape of a server-owned result projection. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "shape", rename_all = "snake_case")] +pub enum AiDisclosureShape { + /// A JSON string, number, boolean, or null value. + Scalar { + /// Static rule for this value. + rule: AiDisclosureRule, + }, + /// An object with a closed set of allowed fields. + Object { + /// Static rule for the object container. + rule: AiDisclosureRule, + /// Server-owned field shapes. Unknown result fields are rejected. + fields: BTreeMap, + }, + /// A bounded list whose items all use one static shape. + List { + /// Static rule for the list container. + rule: AiDisclosureRule, + /// Maximum number of model-visible list entries. + maximum_items: u32, + /// Static item shape. + item: Box, + }, +} + +impl AiDisclosureShape { + /// Creates a scalar shape. + pub const fn scalar(rule: AiDisclosureRule) -> Self { + Self::Scalar { rule } + } + + /// Creates a closed object shape. + pub fn object( + rule: AiDisclosureRule, + fields: impl IntoIterator, + ) -> Self { + Self::Object { + rule, + fields: fields.into_iter().collect(), + } + } + + /// Creates a bounded list shape. + pub fn list(rule: AiDisclosureRule, maximum_items: u32, item: AiDisclosureShape) -> Self { + Self::List { + rule, + maximum_items, + item: Box::new(item), + } + } + + fn validate(&self, depth: usize) -> Result<(), AiError> { + if depth > MAXIMUM_DISCLOSURE_SCHEMA_DEPTH { + return Err(AiError::InvalidConfiguration( + "disclosure schema exceeds maximum nesting depth".to_owned(), + )); + } + match self { + Self::Scalar { .. } => Ok(()), + Self::Object { fields, .. } => { + if fields + .keys() + .any(|field| field.is_empty() || field.starts_with("__")) + { + return Err(AiError::InvalidConfiguration( + "disclosure schema contains an invalid field".to_owned(), + )); + } + for shape in fields.values() { + shape.validate(depth + 1)?; + } + Ok(()) + } + Self::List { + maximum_items, + item, + .. + } => { + if *maximum_items == 0 { + return Err(AiError::InvalidConfiguration( + "disclosure list limit must be positive".to_owned(), + )); + } + item.validate(depth + 1) + } + } + } +} + +/// Fingerprint-bound disclosure contract for one exact tool projection. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiDisclosureSchema { + /// Host-controlled immutable schema version. + pub version: String, + /// Exact recursive projection shape. + pub root: AiDisclosureShape, + /// Stable fingerprint over the complete versioned schema. + pub fingerprint: String, +} + +impl AiDisclosureSchema { + /// Creates and fingerprints a validated static disclosure schema. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidConfiguration`] for an empty version, invalid + /// field name, zero list bound, or excessive schema nesting. + pub fn new(version: impl Into, root: AiDisclosureShape) -> Result { + let version = version.into(); + if version.trim().is_empty() { + return Err(AiError::InvalidConfiguration( + "disclosure schema version must not be empty".to_owned(), + )); + } + root.validate(0)?; + let mut schema = Self { + version, + root, + fingerprint: String::new(), + }; + schema.refresh_fingerprint(); + Ok(schema) + } + + /// Evaluates an exact JSON result against the static shape. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown fields, shape mismatches, + /// oversized lists, or any selected `NeverExport` node. + pub fn evaluate( + &self, + value: &serde_json::Value, + ) -> Result { + evaluate_node(value, &self.root, 0) + } + + pub(crate) fn maximum_list_bound(&self) -> u32 { + maximum_list_bound(&self.root) + } + + fn refresh_fingerprint(&mut self) { + self.fingerprint.clear(); + let encoded = serde_json::to_vec(self) + .expect("AiDisclosureSchema consists only of serializable values"); + self.fingerprint = hex::encode(Sha256::digest(encoded)); + } +} + +fn maximum_list_bound(shape: &AiDisclosureShape) -> u32 { + match shape { + AiDisclosureShape::Scalar { .. } => 0, + AiDisclosureShape::Object { fields, .. } => { + fields.values().map(maximum_list_bound).max().unwrap_or(0) + } + AiDisclosureShape::List { + maximum_items, + item, + .. + } => (*maximum_items).max(maximum_list_bound(item)), + } +} + +/// Safe summary produced after a result conforms to its static disclosure schema. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AiDisclosureEvaluation { + /// Highest effective static classification in the selected result. + pub maximum_classification: DataClassification, + /// Number of selected JSON nodes checked against server-owned metadata. + pub selected_node_count: u64, +} + +impl AiDisclosureEvaluation { + /// Applies a runtime classification that may only tighten the static result. + pub fn tighten(self, runtime_minimum: DataClassification) -> Self { + Self { + maximum_classification: self.maximum_classification.max(runtime_minimum), + selected_node_count: self.selected_node_count, + } + } +} + +/// Fail-closed disclosure validation error with no result content. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum AiDisclosureError { + /// A selected node is structurally forbidden from model/provider disclosure. + #[error("tool result contains a non-exportable field")] + NeverExport, + /// A result object contains a field absent from server-owned metadata. + #[error("tool result contains an unknown field")] + UnknownField, + /// A result node does not match the registered projection shape. + #[error("tool result does not match its disclosure schema")] + ShapeMismatch, + /// A result list exceeds its registered model-visible item bound. + #[error("tool result exceeds its disclosure list bound")] + ListLimitExceeded, +} + +fn evaluate_node( + value: &serde_json::Value, + shape: &AiDisclosureShape, + depth: usize, +) -> Result { + if depth > MAXIMUM_DISCLOSURE_SCHEMA_DEPTH { + return Err(AiDisclosureError::ShapeMismatch); + } + + let rule = match shape { + AiDisclosureShape::Scalar { rule } + | AiDisclosureShape::Object { rule, .. } + | AiDisclosureShape::List { rule, .. } => *rule, + }; + if rule.disposition == AiDisclosureDisposition::NeverExport { + return Err(AiDisclosureError::NeverExport); + } + + let mut evaluation = AiDisclosureEvaluation { + maximum_classification: rule.classification, + selected_node_count: 1, + }; + if value.is_null() { + return Ok(evaluation); + } + + match shape { + AiDisclosureShape::Scalar { .. } => { + if value.is_string() || value.is_number() || value.is_boolean() { + Ok(evaluation) + } else { + Err(AiDisclosureError::ShapeMismatch) + } + } + AiDisclosureShape::Object { fields, .. } => { + let object = value.as_object().ok_or(AiDisclosureError::ShapeMismatch)?; + for (field, field_value) in object { + let field_shape = fields.get(field).ok_or(AiDisclosureError::UnknownField)?; + merge_evaluation( + &mut evaluation, + evaluate_node(field_value, field_shape, depth + 1)?, + ); + } + Ok(evaluation) + } + AiDisclosureShape::List { + maximum_items, + item, + .. + } => { + let list = value.as_array().ok_or(AiDisclosureError::ShapeMismatch)?; + if list.len() > *maximum_items as usize { + return Err(AiDisclosureError::ListLimitExceeded); + } + for item_value in list { + merge_evaluation(&mut evaluation, evaluate_node(item_value, item, depth + 1)?); + } + Ok(evaluation) + } + } +} + +fn merge_evaluation(target: &mut AiDisclosureEvaluation, source: AiDisclosureEvaluation) { + target.maximum_classification = target + .maximum_classification + .max(source.maximum_classification); + target.selected_node_count = target + .selected_node_count + .saturating_add(source.selected_node_count); +} diff --git a/crates/graphql-orm-ai/src/domain.rs b/crates/graphql-orm-ai/src/domain.rs new file mode 100644 index 00000000..b681518e --- /dev/null +++ b/crates/graphql-orm-ai/src/domain.rs @@ -0,0 +1,70 @@ +//! Project-agnostic scope and identifier types. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Application-defined scope boundary for sessions, policy, and egress. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct AiScope { + /// Host-defined scope kind, such as `application`, `tenant`, or `project`. + pub kind: String, + /// Host-defined stable scope identifier. + pub id: String, + /// Optional tenant boundary. + pub tenant_id: Option, +} + +impl AiScope { + /// Creates a scope. + pub fn new(kind: impl Into, id: impl Into) -> Self { + Self { + kind: kind.into(), + id: id.into(), + tenant_id: None, + } + } + + /// Adds a tenant boundary. + pub fn with_tenant_id(mut self, tenant_id: impl Into) -> Self { + self.tenant_id = Some(tenant_id.into()); + self + } +} + +macro_rules! uuid_id { + ($name:ident, $doc:literal) => { + #[doc = $doc] + #[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, + )] + #[serde(transparent)] + pub struct $name(pub Uuid); + + impl $name { + /// Generates a new random identifier. + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl From for $name { + fn from(value: Uuid) -> Self { + Self(value) + } + } + }; +} + +uuid_id!(AiSessionId, "AI session identifier."); +uuid_id!(AiRunId, "AI run identifier."); +uuid_id!(AiToolCallId, "AI tool-call identifier."); +uuid_id!(AiApprovalId, "AI approval identifier."); +uuid_id!(AiBudgetReservationId, "AI budget-reservation identifier."); +uuid_id!(AiProposalId, "AI proposal identifier."); +uuid_id!(AiEgressDecisionId, "AI egress-decision identifier."); diff --git a/crates/graphql-orm-ai/src/egress.rs b/crates/graphql-orm-ai/src/egress.rs new file mode 100644 index 00000000..c7ea957c --- /dev/null +++ b/crates/graphql-orm-ai/src/egress.rs @@ -0,0 +1,316 @@ +//! Explicit external data-egress authorization. + +use std::collections::BTreeSet; + +use agql_auth::ResolvedPrincipal; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{ + AiDataSourceRef, AiEgressDecisionId, AiError, AiRunId, AiScope, AiSessionId, DataClassification, +}; + +/// External processing capability receiving application data. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiEgressCapability { + /// General model inference. + ModelInference, + /// Provider-hosted web search. + WebSearch, + /// Image understanding. + ImageAnalysis, + /// Image generation using supplied context. + ImageGeneration, + /// Provider-hosted file search or file retention. + ProviderFile, + /// Provider-hosted code execution. + CodeExecution, + /// Remote MCP server/tool. + RemoteMcp, + /// Tool result returned to a remote model. + ToolResult, +} + +/// Deployment trust class for a destination. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiDestinationTrust { + /// Loopback/local model explicitly allowed by deployment policy. + Local, + /// Contracted external model provider. + ManagedProvider, + /// Other allowlisted external processor. + ExternalProcessor, +} + +/// Redacted exact manifest for a proposed transfer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiEgressManifest { + /// Provider-profile reference. + pub provider_profile_id: String, + /// Provider kind. + pub provider_kind: String, + /// Model or processing route. + pub model: String, + /// Redacted destination identifier or endpoint trust name. + pub destination: String, + /// Destination trust class. + pub destination_trust: AiDestinationTrust, + /// Processing capability. + pub capability: AiEgressCapability, + /// Session scope. + pub scope: AiScope, + /// Session reference. + pub session_id: Option, + /// Run reference. + pub run_id: Option, + /// Exact source references and classifications; never plaintext. + pub sources: Vec, + /// Approximate outbound bytes. + pub estimated_bytes: u64, + /// Approximate outbound model tokens. + pub estimated_tokens: u64, + /// Attachment count. + pub attachment_count: u32, + /// Purpose limitation. + pub purpose: String, + /// Provider retention class. + pub retention: String, + /// Processing residency/region class. + pub residency: Option, + /// Egress-policy version. + pub policy_version: String, + /// Optional purpose-bound consent/grant reference. + pub consent_reference: Option, +} + +impl AiEgressManifest { + /// Returns the highest classification in the manifest. + pub fn maximum_classification(&self) -> DataClassification { + self.sources + .iter() + .map(|source| source.classification) + .max() + .unwrap_or(DataClassification::Public) + } + + /// Computes a stable hash over the redacted manifest. + /// + /// Source ordering does not affect the hash. + pub fn stable_hash(&self) -> String { + let mut canonical = self.clone(); + canonical.sources.sort(); + let encoded = serde_json::to_vec(&canonical) + .expect("AiEgressManifest consists only of serializable values"); + hex::encode(Sha256::digest(encoded)) + } +} + +/// Stable allow/deny outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiEgressOutcome { + /// Transfer may proceed exactly as manifested. + Allow, + /// Transfer must not occur. + Deny, +} + +/// Stable redacted reason code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiEgressReason { + /// All boundaries allowed the exact transfer. + Allowed, + /// Deployment boundary denied the destination or capability. + DeploymentDenied, + /// Scope/provider policy denied the transfer. + PolicyDenied, + /// Current principal was not authorized. + PrincipalDenied, + /// Data classification exceeds the destination ceiling. + ClassificationDenied, + /// Secret data can never leave the trust boundary. + SecretDataDenied, + /// Required consent is missing, expired, revoked, or mismatched. + ConsentRequired, + /// Budget or size boundary denied the transfer. + LimitExceeded, +} + +/// Auditable decision over one exact manifest hash. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiEgressDecision { + /// Decision identifier. + pub id: AiEgressDecisionId, + /// Exact manifest hash. + pub manifest_hash: String, + /// Outcome. + pub outcome: AiEgressOutcome, + /// Stable reason. + pub reason: AiEgressReason, + /// Applied policy version. + pub policy_version: String, + /// Safe principal subject reference. + pub principal_subject: String, +} + +impl AiEgressDecision { + /// Creates an allowed decision. + pub fn allow( + manifest: &AiEgressManifest, + policy_version: impl Into, + principal_subject: impl Into, + ) -> Self { + Self { + id: AiEgressDecisionId::new(), + manifest_hash: manifest.stable_hash(), + outcome: AiEgressOutcome::Allow, + reason: AiEgressReason::Allowed, + policy_version: policy_version.into(), + principal_subject: principal_subject.into(), + } + } + + /// Creates a denied decision. + pub fn deny( + manifest: &AiEgressManifest, + reason: AiEgressReason, + policy_version: impl Into, + principal_subject: impl Into, + ) -> Self { + Self { + id: AiEgressDecisionId::new(), + manifest_hash: manifest.stable_hash(), + outcome: AiEgressOutcome::Deny, + reason, + policy_version: policy_version.into(), + principal_subject: principal_subject.into(), + } + } + + /// Converts an allowed, unchanged decision into the token required by a + /// provider call. + /// + /// # Errors + /// + /// Returns [`AiError::EgressDenied`] for a denial or changed manifest. + pub fn authorize(&self, manifest: &AiEgressManifest) -> Result { + if self.outcome != AiEgressOutcome::Allow || self.manifest_hash != manifest.stable_hash() { + return Err(AiError::EgressDenied); + } + Ok(AuthorizedEgress { + decision_id: self.id, + manifest_hash: self.manifest_hash.clone(), + }) + } +} + +/// Proof that an exact manifest passed egress policy. +/// +/// Fields are private so providers cannot be called with an arbitrary string +/// in place of an allow decision. +#[derive(Clone, Debug)] +pub struct AuthorizedEgress { + decision_id: AiEgressDecisionId, + manifest_hash: String, +} + +impl AuthorizedEgress { + /// Returns the decision identifier for auditing. + pub fn decision_id(&self) -> AiEgressDecisionId { + self.decision_id + } + + /// Returns the exact allowed manifest hash. + pub fn manifest_hash(&self) -> &str { + &self.manifest_hash + } +} + +/// Application/scope egress policy. Deployment hard boundaries must be +/// intersected by the implementation and cannot be relaxed through GraphQL. +#[async_trait] +pub trait AiEgressPolicy: Send + Sync { + /// Authorizes an exact redacted manifest for the current principal. + async fn authorize( + &self, + principal: &ResolvedPrincipal, + manifest: &AiEgressManifest, + ) -> AiEgressDecision; +} + +/// Fail-closed default policy. +#[derive(Clone, Copy, Debug, Default)] +pub struct DenyAllEgressPolicy; + +#[async_trait] +impl AiEgressPolicy for DenyAllEgressPolicy { + async fn authorize( + &self, + principal: &ResolvedPrincipal, + manifest: &AiEgressManifest, + ) -> AiEgressDecision { + AiEgressDecision::deny( + manifest, + AiEgressReason::PolicyDenied, + "deny-all", + principal.principal().subject(), + ) + } +} + +/// Immutable deployment hard boundary used by policy implementations. +#[derive(Clone, Debug)] +pub struct AiDeploymentEgressBoundary { + /// Allowed destination trust classes. + pub allowed_destination_trust: BTreeSet, + /// Allowed processing capabilities. + pub allowed_capabilities: BTreeSet, + /// Maximum outbound classification. `Secret` is denied regardless. + pub maximum_classification: DataClassification, + /// Maximum bytes per transfer. + pub maximum_bytes: u64, + /// Maximum attachments per transfer. + pub maximum_attachments: u32, +} + +impl Default for AiDeploymentEgressBoundary { + fn default() -> Self { + Self { + allowed_destination_trust: BTreeSet::new(), + allowed_capabilities: BTreeSet::new(), + maximum_classification: DataClassification::Public, + maximum_bytes: 0, + maximum_attachments: 0, + } + } +} + +impl AiDeploymentEgressBoundary { + /// Applies hard deployment limits to a manifest. + pub fn evaluate(&self, manifest: &AiEgressManifest) -> Result<(), AiEgressReason> { + let classification = manifest.maximum_classification(); + if classification == DataClassification::Secret { + return Err(AiEgressReason::SecretDataDenied); + } + if !self + .allowed_destination_trust + .contains(&manifest.destination_trust) + || !self.allowed_capabilities.contains(&manifest.capability) + { + return Err(AiEgressReason::DeploymentDenied); + } + if classification > self.maximum_classification { + return Err(AiEgressReason::ClassificationDenied); + } + if manifest.estimated_bytes > self.maximum_bytes + || manifest.attachment_count > self.maximum_attachments + { + return Err(AiEgressReason::LimitExceeded); + } + Ok(()) + } +} diff --git a/crates/graphql-orm-ai/src/error.rs b/crates/graphql-orm-ai/src/error.rs new file mode 100644 index 00000000..ba18f527 --- /dev/null +++ b/crates/graphql-orm-ai/src/error.rs @@ -0,0 +1,78 @@ +//! Public error contract. + +use async_graphql::ErrorExtensions; +use thiserror::Error; + +/// Stable library error. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AiError { + /// Configuration is invalid. + #[error("invalid AI configuration: {0}")] + InvalidConfiguration(String), + /// Requested item already exists. + #[error("AI resource already exists: {0}")] + AlreadyExists(String), + /// Requested item was not found or is not visible. + #[error("AI resource not found")] + NotFound, + /// Current state changed or an idempotency/CAS precondition failed. + #[error("AI operation conflicts with current state")] + Conflict, + /// Current principal is not authorized. + #[error("AI operation forbidden")] + Forbidden, + /// A configuration operation requires current host-accepted MFA. + #[error("additional authentication is required")] + RecentMfaRequired, + /// External disclosure was denied. + #[error("AI data egress denied")] + EgressDenied, + /// Input failed a public schema contract. + #[error("invalid AI input: {0}")] + InvalidInput(String), + /// Authentication dependency failed closed. + #[error("AI principal reauthorization failed")] + ReauthorizationFailed, + /// Host GraphQL execution failed safely. + #[error("AI tool execution failed")] + ToolExecutionFailed, + /// Provider operation failed safely. + #[error("AI provider operation failed")] + ProviderFailed, + /// Runtime has not passed startup/restore readiness checks. + #[error("AI runtime is not ready")] + RuntimeNotReady, + /// Durable persistence is temporarily unavailable or failed safely. + #[error("AI persistence operation failed")] + PersistenceFailed, +} + +impl AiError { + /// Stable public error code. + pub const fn public_code(&self) -> &'static str { + match self { + Self::InvalidConfiguration(_) => "AI_INVALID_CONFIGURATION", + Self::AlreadyExists(_) => "AI_ALREADY_EXISTS", + Self::NotFound => "AI_NOT_FOUND", + Self::Conflict => "AI_CONFLICT", + Self::Forbidden => "AI_FORBIDDEN", + Self::RecentMfaRequired => "AI_RECENT_MFA_REQUIRED", + Self::EgressDenied => "AI_EGRESS_DENIED", + Self::InvalidInput(_) => "AI_INVALID_INPUT", + Self::ReauthorizationFailed => "AI_REAUTHORIZATION_FAILED", + Self::ToolExecutionFailed => "AI_TOOL_EXECUTION_FAILED", + Self::ProviderFailed => "AI_PROVIDER_FAILED", + Self::RuntimeNotReady => "AI_RUNTIME_NOT_READY", + Self::PersistenceFailed => "AI_PERSISTENCE_FAILED", + } + } +} + +impl ErrorExtensions for AiError { + fn extend(&self) -> async_graphql::Error { + async_graphql::Error::new(self.to_string()).extend_with(|_, extensions| { + extensions.set("code", self.public_code()); + }) + } +} diff --git a/crates/graphql-orm-ai/src/execution.rs b/crates/graphql-orm-ai/src/execution.rs new file mode 100644 index 00000000..925ccc34 --- /dev/null +++ b/crates/graphql-orm-ai/src/execution.rs @@ -0,0 +1,395 @@ +//! Authenticated application GraphQL execution contracts. + +use std::any::Any; +use std::collections::BTreeMap; +use std::sync::Arc; + +use agql_auth::{CurrentPrincipalResolver, PrincipalReference, ResolvedPrincipal}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + AiRunId, AiScope, AiToolAuthorizationDecision, AiToolAuthorizationPolicy, AiToolCallId, + AiToolDescriptor, +}; + +/// Stable deployment-owned logical GraphQL execution target identifier. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GraphqlExecutionTargetId(String); + +impl GraphqlExecutionTargetId { + /// Parses a non-secret logical target ID. + /// + /// # Errors + /// + /// Returns [`ToolExecutionError::InvalidTarget`] for an empty, overly + /// long, or non-ASCII identifier. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > 128 + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b'-') + }) + { + return Err(ToolExecutionError::InvalidTarget); + } + Ok(Self(value)) + } + + /// Returns the logical identifier without resolving a destination URL. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Deployment trust/routing class for authenticated GraphQL execution. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GraphqlExecutionTargetClass { + /// Finished schema executing in the current process. + Local, + /// Private routed/composed GraphQL endpoint. + PrivateRouted, + /// Private direct service endpoint, disabled unless explicitly registered. + PrivateDirect, +} + +/// Non-secret deployment registration for one logical GraphQL destination. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GraphqlExecutionTarget { + /// Stable logical ID used by server-owned tool descriptors. + pub id: GraphqlExecutionTargetId, + /// Local/routed/direct trust class. + pub class: GraphqlExecutionTargetClass, + /// Credential audience required for a remote target. + pub audience: Option, + /// Resource type required for a remote target. + pub resource_type: Option, + /// Resource identifier required for a remote target. + pub resource_id: Option, + /// Exact compiled or registry schema fingerprint. + pub schema_fingerprint: String, +} + +impl GraphqlExecutionTarget { + /// Validates a logical target without accepting or exposing a URL. + /// + /// # Errors + /// + /// Returns [`ToolExecutionError::InvalidTarget`] when a schema + /// fingerprint is absent or a remote target lacks audience/resource + /// binding. + pub fn validate(&self) -> Result<(), ToolExecutionError> { + if self.schema_fingerprint.trim().is_empty() { + return Err(ToolExecutionError::InvalidTarget); + } + if self.class != GraphqlExecutionTargetClass::Local + && (self.audience.as_deref().is_none_or(str::is_empty) + || self.resource_type.as_deref().is_none_or(str::is_empty) + || self.resource_id.as_deref().is_none_or(str::is_empty)) + { + return Err(ToolExecutionError::InvalidTarget); + } + Ok(()) + } +} + +/// Immutable deployment registry for logical GraphQL execution targets. +#[derive(Clone, Debug, Default)] +pub struct GraphqlExecutionTargetRegistry { + targets: BTreeMap, +} + +impl GraphqlExecutionTargetRegistry { + /// Creates an empty registry. No target is implicitly trusted. + pub fn new() -> Self { + Self::default() + } + + /// Registers one validated logical target. + /// + /// # Errors + /// + /// Returns a safe error for invalid or duplicate target IDs. + pub fn register(&mut self, target: GraphqlExecutionTarget) -> Result<(), ToolExecutionError> { + target.validate()?; + if self.targets.contains_key(&target.id) { + return Err(ToolExecutionError::InvalidTarget); + } + self.targets.insert(target.id.clone(), target); + Ok(()) + } + + /// Resolves a logical target without making its transport destination model-visible. + pub fn target(&self, id: &GraphqlExecutionTargetId) -> Option<&GraphqlExecutionTarget> { + self.targets.get(id) + } + + fn validate_contract( + &self, + contract: &GraphqlOperationContract, + document: &str, + ) -> Result<&GraphqlExecutionTarget, ToolExecutionError> { + let target = self + .targets + .get(&contract.target_id) + .ok_or(ToolExecutionError::InvalidTarget)?; + if target.schema_fingerprint != contract.schema_fingerprint + || contract.document_hash != stable_document_hash(document) + || contract.operation_name.trim().is_empty() + || contract.result_projection_fingerprint.trim().is_empty() + || contract.disclosure_schema_fingerprint.trim().is_empty() + { + return Err(ToolExecutionError::StaleContract); + } + Ok(target) + } +} + +/// Exact static operation binding carried to local or remote executors. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GraphqlOperationContract { + /// Deployment-registered logical target. + pub target_id: GraphqlExecutionTargetId, + /// Exact target schema fingerprint reviewed with this operation. + pub schema_fingerprint: String, + /// Operation name inside the server-authored document. + pub operation_name: String, + /// Stable hash of the server-authored document. + pub document_hash: String, + /// Stable result-projection fingerprint. + pub result_projection_fingerprint: String, + /// Static disclosure-schema fingerprint. + pub disclosure_schema_fingerprint: String, +} + +impl GraphqlOperationContract { + /// Binds a server-authored operation to its target/schema/projection contracts. + /// + /// # Errors + /// + /// Returns [`ToolExecutionError::StaleContract`] for missing contract data. + pub fn new( + target_id: GraphqlExecutionTargetId, + schema_fingerprint: impl Into, + operation_name: impl Into, + document: &str, + result_projection_fingerprint: impl Into, + disclosure_schema_fingerprint: impl Into, + ) -> Result { + let contract = Self { + target_id, + schema_fingerprint: schema_fingerprint.into(), + operation_name: operation_name.into(), + document_hash: stable_document_hash(document), + result_projection_fingerprint: result_projection_fingerprint.into(), + disclosure_schema_fingerprint: disclosure_schema_fingerprint.into(), + }; + if contract.schema_fingerprint.trim().is_empty() + || contract.operation_name.trim().is_empty() + || document.trim().is_empty() + || contract.result_projection_fingerprint.trim().is_empty() + || contract.disclosure_schema_fingerprint.trim().is_empty() + { + return Err(ToolExecutionError::StaleContract); + } + Ok(contract) + } +} + +fn stable_document_hash(document: &str) -> String { + use sha2::{Digest, Sha256}; + + hex::encode(Sha256::digest(document.as_bytes())) +} + +/// Invocation metadata linked into the host's normal audit context. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GraphqlInvocationContext { + /// Run causing the operation. + pub run_id: AiRunId, + /// Tool call causing the operation. + pub tool_call_id: AiToolCallId, + /// Application scope in which tool policy and resolver authorization run. + pub scope: AiScope, + /// Correlation identifier shared with the outer AI audit. + pub correlation_id: String, + /// Causal command/event identifier propagated into application audit. + pub causation_id: String, + /// Safe delegation/grant reference; never a bearer credential. + pub delegation_reference: Option, + /// Optional idempotency key for a descriptor proven idempotent. + pub idempotency_key: Option, +} + +/// Opaque host request context produced through the same factory used by +/// ordinary GraphQL transports. +#[derive(Clone)] +pub struct GraphqlRequestContext { + inner: Arc, +} + +impl GraphqlRequestContext { + /// Wraps a host-specific request context. + pub fn new(context: T) -> Self + where + T: Any + Send + Sync, + { + Self { + inner: Arc::new(context), + } + } + + /// Downcasts to the host-specific context type. + pub fn downcast_ref(&self) -> Option<&T> { + self.inner.downcast_ref() + } +} + +/// Server-authored GraphQL operation request. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ToolGraphqlRequest { + /// Static server-authored operation document. + pub document: String, + /// Operation name. + pub operation_name: String, + /// Exact target/schema/document/projection/disclosure binding. + pub contract: GraphqlOperationContract, + /// Schema-validated variables. + pub variables: serde_json::Value, + /// Invocation/audit metadata. + pub invocation: GraphqlInvocationContext, +} + +/// Bounded normalized GraphQL result. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ToolGraphqlResponse { + /// Projected JSON result. + pub data: serde_json::Value, + /// Safe stable public error codes. + pub error_codes: Vec, + /// Host application audit reference, when emitted. + pub application_audit_ref: Option, +} + +/// Safe bridge error. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ToolExecutionError { + /// Principal could not be rehydrated/currently authorized. + #[error("tool principal reauthorization failed")] + Reauthorization, + /// Current host tool policy denied the exact registered request. + #[error("tool authorization denied")] + Authorization, + /// Host request context could not be built. + #[error("tool request context unavailable")] + RequestContext, + /// Static operation no longer validates against the host schema. + #[error("tool operation contract is stale")] + StaleContract, + /// Logical target is absent, malformed, or not permitted by deployment registration. + #[error("tool GraphQL execution target is invalid")] + InvalidTarget, + /// Host execution failed safely. + #[error("tool GraphQL execution failed")] + Execution, +} + +/// Canonical host request-context factory shared with normal HTTP/WS paths. +#[async_trait] +pub trait GraphqlRequestContextFactory: Send + Sync { + /// Builds the complete auth, DB-auth, loader, rate-limit, request, and audit + /// envelope for an invocation. + async fn build( + &self, + principal: &ResolvedPrincipal, + target: &GraphqlExecutionTarget, + invocation: &GraphqlInvocationContext, + ) -> Result; +} + +/// Executes a server-authored operation against the composed host schema. +#[async_trait] +pub trait AuthenticatedGraphqlExecutor: Send + Sync { + /// Executes with the canonical host request context. + async fn execute( + &self, + context: GraphqlRequestContext, + request: ToolGraphqlRequest, + ) -> Result; +} + +/// Security-preserving bridge that always rehydrates before constructing and +/// executing a tool request. +#[derive(Clone)] +pub struct AuthenticatedToolBridge { + principal_resolver: Arc, + authorization_policy: Arc, + context_factory: Arc, + executor: Arc, + targets: GraphqlExecutionTargetRegistry, +} + +impl AuthenticatedToolBridge { + /// Creates a bridge from host implementations. + pub fn new( + principal_resolver: Arc, + authorization_policy: Arc, + context_factory: Arc, + executor: Arc, + targets: GraphqlExecutionTargetRegistry, + ) -> Self { + Self { + principal_resolver, + authorization_policy, + context_factory, + executor, + targets, + } + } + + /// Rehydrates the principal, builds the canonical request envelope, and + /// executes the static request. + pub async fn execute( + &self, + principal_reference: &PrincipalReference, + descriptor: &AiToolDescriptor, + request: ToolGraphqlRequest, + ) -> Result<(ToolGraphqlResponse, AiToolAuthorizationDecision), ToolExecutionError> { + if request.operation_name != request.contract.operation_name { + return Err(ToolExecutionError::StaleContract); + } + let target = self + .targets + .validate_contract(&request.contract, &request.document)?; + let principal = self + .principal_resolver + .resolve(principal_reference) + .await + .map_err(|_| ToolExecutionError::Reauthorization)?; + let authorization = self + .authorization_policy + .authorize( + &principal, + &request.invocation.scope, + descriptor, + &request.variables, + ) + .await; + if !authorization.is_complete_allow() { + return Err(ToolExecutionError::Authorization); + } + let context = self + .context_factory + .build(&principal, target, &request.invocation) + .await?; + let response = self.executor.execute(context, request).await?; + Ok((response, authorization)) + } +} diff --git a/crates/graphql-orm-ai/src/lib.rs b/crates/graphql-orm-ai/src/lib.rs new file mode 100644 index 00000000..7fa2ef6b --- /dev/null +++ b/crates/graphql-orm-ai/src/lib.rs @@ -0,0 +1,91 @@ +//! Project-agnostic AI agent runtime for `graphql-orm` applications. +//! +//! The crate is intentionally built around default-deny capabilities: +//! resolver metadata is discovery rather than authorization, application work +//! executes through the host's authenticated GraphQL context, and external +//! data egress requires a separate explicit decision. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +#[cfg(not(any(feature = "sqlite", feature = "postgres", feature = "mssql")))] +compile_error!("enable one graphql-orm-ai persistence backend"); + +#[cfg(any( + all(feature = "sqlite", feature = "postgres"), + all(feature = "sqlite", feature = "mssql"), + all(feature = "postgres", feature = "mssql") +))] +compile_error!( + "the initial graphql-orm-ai schema module requires exactly one backend; explicit multi-backend schema modules are planned" +); + +mod access; +mod approvals; +mod budget; +mod configuration; +mod content_protection; +mod data; +mod disclosure; +mod domain; +mod egress; +mod error; +mod execution; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +mod orm_configuration; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +mod orm_sessions; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +mod orm_subscriptions; +mod persistence; +mod proposals; +mod provider; +mod providers; +mod restore; +mod run_state; +mod runtime; +mod secrets; +mod sessions; +mod subscriptions; +mod tools; + +pub use access::*; +pub use approvals::*; +pub use budget::*; +pub use configuration::*; +pub use content_protection::*; +pub use data::*; +pub use disclosure::*; +pub use domain::*; +pub use egress::*; +pub use error::*; +pub use execution::*; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +pub use orm_configuration::*; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +pub use orm_sessions::*; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +pub use orm_subscriptions::*; +pub use persistence::*; +pub use proposals::*; +pub use provider::*; +pub use providers::*; +pub use restore::*; +pub use run_state::*; +pub use runtime::*; +pub use secrets::*; +pub use sessions::*; +pub use subscriptions::*; +pub use tools::*; + +/// Common imports for host integrations. +pub mod prelude { + pub use crate::{ + AiAccessPolicy, AiApprovalBinding, AiBudgetReservation, AiContentProtectionPolicy, + AiDataSourceRef, AiDisclosureSchema, AiEgressDecision, AiEgressManifest, AiEgressPolicy, + AiError, AiProposalCatalog, AiProposalTypeDescriptor, AiProvider, AiRuntime, + AiRuntimeBuilder, AiScope, AiSecretStore, AiToolAuthorizationPolicy, AiToolCatalog, + AiToolDescriptor, DataClassification, SecretRef, ToolMaturity, + }; + pub use agql_auth::{CurrentPrincipalResolver, PrincipalReference, ResolvedPrincipal}; +} diff --git a/crates/graphql-orm-ai/src/orm_configuration.rs b/crates/graphql-orm-ai/src/orm_configuration.rs new file mode 100644 index 00000000..306cc39c --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_configuration.rs @@ -0,0 +1,869 @@ +//! ORM-backed GraphQL-managed AI configuration service. + +#![cfg(any(feature = "sqlite", feature = "postgres"))] + +use std::sync::Arc; + +use agql_auth::{AuthPrincipal, Clock, RecentMfaPolicy}; +use async_trait::async_trait; +use graphql_orm::db::Database; +use graphql_orm::graphql::errors::{OrmErrorCode, OrmPublicError}; +use graphql_orm::graphql::filters::StringFilter; +use graphql_orm::graphql::orm::{ + ConditionalUpdateOutcome, DefaultWriteBackend, TransactionError, TransactionMode, +}; +use secrecy::SecretString; +use serde_json::json; +use sha2::{Digest, Sha256}; +use url::Url; +use uuid::Uuid; + +use crate::persistence::*; +use crate::{ + AiConfigurationAccessPolicy, AiConfigurationAction, AiConfigurationService, + AiContentProtectionMode, AiContentProtectionPolicy, AiContentProtectionPolicyResolver, + AiContentProtectionPolicyView, AiError, AiProviderEndpointPolicy, AiProviderKindInput, + AiProviderProfileView, AiScope, AiSecretStore, RemoveAiProviderCredentialInput, SecretRef, + SetAiContentProtectionPolicyInput, UpsertAiProviderProfileInput, +}; + +/// Durable configuration backend using generated ORM APIs and a compensating +/// secret-reference saga. Secret plaintext never enters an ORM input. +#[derive(Clone)] +pub struct OrmAiConfigurationService { + database: Database, + access_policy: Arc, + endpoint_policy: Arc, + recent_mfa_policy: RecentMfaPolicy, + clock: Arc, + secret_store: Arc, +} + +impl OrmAiConfigurationService { + /// Creates a fail-closed configuration service. + pub fn new( + database: Database, + access_policy: Arc, + endpoint_policy: Arc, + recent_mfa_policy: RecentMfaPolicy, + clock: Arc, + secret_store: Arc, + ) -> Self { + Self { + database, + access_policy, + endpoint_policy, + recent_mfa_policy, + clock, + secret_store, + } + } + + /// Returns the underlying ORM database handle for host schema wiring. + pub fn database(&self) -> &Database { + &self.database + } + + async fn require_access( + &self, + principal: &AuthPrincipal, + scope: &AiScope, + action: AiConfigurationAction, + ) -> Result<(), AiError> { + validate_scope(scope)?; + if self + .access_policy + .can_configure(principal, scope, action) + .await + { + Ok(()) + } else { + Err(AiError::Forbidden) + } + } + + fn require_recent_mfa(&self, principal: &AuthPrincipal) -> Result<(), AiError> { + let user = principal.as_user().ok_or(AiError::RecentMfaRequired)?; + self.recent_mfa_policy + .evaluate(user, self.clock.as_ref()) + .map_err(|_| AiError::RecentMfaRequired) + } + + async fn profile(&self, id: Uuid) -> Result { + AiProviderProfileRecord::find_by_id(&self.database, &id) + .await + .map_err(|error| map_orm(OrmPublicError::from(error)))? + .ok_or(AiError::NotFound) + } + + async fn complete_cleanup(&self, cleanup_id: Option, reference: &SecretRef) { + let Some(cleanup_id) = cleanup_id else { + return; + }; + if self.secret_store.delete(reference).await.is_ok() { + let _ = AiSecretCleanupRecord::update_by_id( + &self.database, + &cleanup_id, + UpdateAiSecretCleanupRecordInput { + state: Some("complete".to_owned()), + completed_at: Some(Some(unix_seconds())), + ..Default::default() + }, + ) + .await; + } + } +} + +#[async_trait] +impl AiConfigurationService for OrmAiConfigurationService { + async fn provider_profiles( + &self, + principal: &AuthPrincipal, + scope: AiScope, + ) -> Result, AiError> { + self.require_access( + principal, + &scope, + AiConfigurationAction::ReadProviderProfiles, + ) + .await?; + let scope_key = scope_key(&scope); + let rows = self + .database + .transaction(TransactionMode::Default, move |tx| { + Box::pin(async move { + tx.query::() + .filter(AiProviderProfileRecordWhereInput { + scope_key: Some(StringFilter { + eq: Some(scope_key), + ..Default::default() + }), + ..Default::default() + }) + .default_order() + .limit(101) + .fetch_all() + .await + .map_err(OrmPublicError::from) + }) + }) + .await + .map_err(map_transaction)?; + if rows.len() > 100 { + return Err(AiError::InvalidConfiguration( + "provider profile scope exceeds the bounded limit".to_owned(), + )); + } + Ok(rows.iter().map(provider_view).collect()) + } + + async fn content_protection_policy( + &self, + principal: &AuthPrincipal, + scope: AiScope, + ) -> Result, AiError> { + self.require_access( + principal, + &scope, + AiConfigurationAction::ReadContentProtection, + ) + .await?; + Ok(load_content_policy(&self.database, &scope) + .await? + .as_ref() + .map(content_policy_view)) + } + + async fn upsert_provider_profile( + &self, + principal: &AuthPrincipal, + input: UpsertAiProviderProfileInput, + ) -> Result { + self.require_recent_mfa(principal)?; + let scope: AiScope = input.scope.into(); + self.require_access( + principal, + &scope, + AiConfigurationAction::ManageProviderProfiles, + ) + .await?; + let display_name = input.display_name.trim().to_owned(); + if display_name.is_empty() || display_name.len() > 200 { + return Err(AiError::InvalidInput( + "invalid provider display name".to_owned(), + )); + } + let base_url = normalize_endpoint( + input.provider_kind, + input.base_url, + self.endpoint_policy.as_ref(), + )?; + let actor_kind = principal_kind(principal); + let actor_subject = principal.subject().to_owned(); + let scope_hash = scope_key(&scope); + let provider_kind = input.provider_kind.as_str().to_owned(); + let expected_version = input.expected_version; + let profile_id = input.id; + let profile = self + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let profile = match (profile_id, expected_version) { + (None, None) => tx + .insert::(CreateAiProviderProfileRecordInput { + scope_key: scope_hash, + scope_kind: scope.kind, + scope_id: scope.id, + tenant_id: scope.tenant_id, + provider_kind, + display_name, + base_url, + credential_reference: None, + enabled: input.enabled, + data_policy: json!({}), + limits: json!({}), + }) + .await + .map_err(OrmPublicError::from)?, + (Some(id), Some(expected_version)) => { + let current = tx + .find_by_id::(&id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + if current.scope_key != scope_hash { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + match tx + .compare_and_swap::( + &id, + expected_version, + AiProviderProfileRecordWhereInput::default(), + UpdateAiProviderProfileRecordInput { + provider_kind: Some(provider_kind), + display_name: Some(display_name), + base_url: Some(base_url), + enabled: Some(input.enabled), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(profile) => profile, + ConditionalUpdateOutcome::NotFound => { + return Err(OrmPublicError::not_found()); + } + ConditionalUpdateOutcome::Conflict => { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + } + } + _ => return Err(OrmPublicError::new(OrmErrorCode::InvalidInput)), + }; + insert_audit( + tx, + AuditFact { + actor_principal_kind: &actor_kind, + actor_subject: &actor_subject, + action: "ai.provider_profile.upsert", + resource_kind: "provider_profile", + resource_reference: &profile.id.to_string(), + outcome: "allowed", + reason_code: "configuration_updated", + policy_version: None, + }, + ) + .await?; + Ok(profile) + }) + }) + .await + .map_err(map_transaction)?; + Ok(provider_view(&profile)) + } + + async fn set_provider_credential( + &self, + principal: &AuthPrincipal, + profile_id: Uuid, + credential: SecretString, + expected_version: i64, + ) -> Result { + self.require_recent_mfa(principal)?; + let existing = self.profile(profile_id).await?; + let scope = profile_scope(&existing); + self.require_access( + principal, + &scope, + AiConfigurationAction::ManageProviderCredentials, + ) + .await?; + if existing.row_version != expected_version { + return Err(AiError::Conflict); + } + let new_reference = self + .secret_store + .put(None, credential) + .await + .map_err(|_| AiError::PersistenceFailed)?; + let previous_reference = existing + .credential_reference + .as_deref() + .map(|reference| SecretRef::parse(reference.to_owned())) + .transpose() + .map_err(|_| AiError::InvalidConfiguration("invalid secret reference".to_owned()))?; + let cleanup_id = previous_reference.as_ref().map(|_| Uuid::new_v4()); + let actor_kind = principal_kind(principal); + let actor_subject = principal.subject().to_owned(); + let new_reference_value = new_reference.as_str().to_owned(); + let previous_for_tx = previous_reference.clone(); + let result = self + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let current = tx + .find_by_id::(&profile_id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + if current.row_version != expected_version { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + let profile = match tx + .compare_and_swap::( + &profile_id, + expected_version, + AiProviderProfileRecordWhereInput::default(), + UpdateAiProviderProfileRecordInput { + credential_reference: Some(Some(new_reference_value)), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(profile) => profile, + ConditionalUpdateOutcome::NotFound => { + return Err(OrmPublicError::not_found()); + } + ConditionalUpdateOutcome::Conflict => { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + }; + if let (Some(cleanup_id), Some(previous)) = + (cleanup_id, previous_for_tx.as_ref()) + { + tx.insert::(CreateAiSecretCleanupRecordInput { + id: cleanup_id, + secret_reference: previous.as_str().to_owned(), + reason_code: "credential_rotated".to_owned(), + state: "pending".to_owned(), + retry_count: 0, + next_attempt_at: Some(unix_seconds()), + completed_at: None, + }) + .await + .map_err(OrmPublicError::from)?; + } + insert_audit( + tx, + AuditFact { + actor_principal_kind: &actor_kind, + actor_subject: &actor_subject, + action: "ai.provider_credential.set", + resource_kind: "provider_profile", + resource_reference: &profile_id.to_string(), + outcome: "allowed", + reason_code: "credential_rotated", + policy_version: None, + }, + ) + .await?; + Ok(profile) + }) + }) + .await; + let profile = match result { + Ok(profile) => profile, + Err(error) => { + let _ = self.secret_store.delete(&new_reference).await; + return Err(map_transaction(error)); + } + }; + if let Some(previous) = previous_reference.as_ref() { + self.complete_cleanup(cleanup_id, previous).await; + } + Ok(provider_view(&profile)) + } + + async fn remove_provider_credential( + &self, + principal: &AuthPrincipal, + input: RemoveAiProviderCredentialInput, + ) -> Result { + self.require_recent_mfa(principal)?; + let existing = self.profile(input.profile_id).await?; + let scope = profile_scope(&existing); + self.require_access( + principal, + &scope, + AiConfigurationAction::ManageProviderCredentials, + ) + .await?; + if existing.row_version != input.expected_version { + return Err(AiError::Conflict); + } + let previous_reference = existing + .credential_reference + .as_deref() + .map(|reference| SecretRef::parse(reference.to_owned())) + .transpose() + .map_err(|_| AiError::InvalidConfiguration("invalid secret reference".to_owned()))?; + let cleanup_id = previous_reference.as_ref().map(|_| Uuid::new_v4()); + let previous_for_tx = previous_reference.clone(); + let actor_kind = principal_kind(principal); + let actor_subject = principal.subject().to_owned(); + let profile_id = input.profile_id; + let expected_version = input.expected_version; + let profile = self + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let profile = match tx + .compare_and_swap::( + &profile_id, + expected_version, + AiProviderProfileRecordWhereInput::default(), + UpdateAiProviderProfileRecordInput { + credential_reference: Some(None), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(profile) => profile, + ConditionalUpdateOutcome::NotFound => { + return Err(OrmPublicError::not_found()); + } + ConditionalUpdateOutcome::Conflict => { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + }; + if let (Some(cleanup_id), Some(previous)) = + (cleanup_id, previous_for_tx.as_ref()) + { + tx.insert::(CreateAiSecretCleanupRecordInput { + id: cleanup_id, + secret_reference: previous.as_str().to_owned(), + reason_code: "credential_removed".to_owned(), + state: "pending".to_owned(), + retry_count: 0, + next_attempt_at: Some(unix_seconds()), + completed_at: None, + }) + .await + .map_err(OrmPublicError::from)?; + } + insert_audit( + tx, + AuditFact { + actor_principal_kind: &actor_kind, + actor_subject: &actor_subject, + action: "ai.provider_credential.remove", + resource_kind: "provider_profile", + resource_reference: &profile_id.to_string(), + outcome: "allowed", + reason_code: "credential_removed", + policy_version: None, + }, + ) + .await?; + Ok(profile) + }) + }) + .await + .map_err(map_transaction)?; + if let Some(previous) = previous_reference.as_ref() { + self.complete_cleanup(cleanup_id, previous).await; + } + Ok(provider_view(&profile)) + } + + async fn set_content_protection_policy( + &self, + principal: &AuthPrincipal, + input: SetAiContentProtectionPolicyInput, + ) -> Result { + self.require_recent_mfa(principal)?; + let scope: AiScope = input.scope.into(); + self.require_access( + principal, + &scope, + AiConfigurationAction::ManageContentProtection, + ) + .await?; + let mode: AiContentProtectionMode = input.mode.into(); + match (mode, input.key_policy_reference.as_deref()) { + (AiContentProtectionMode::DatabaseManaged, Some(_)) + | (AiContentProtectionMode::ApplicationEncrypted, None) => { + return Err(AiError::InvalidInput( + "content-protection key policy does not match mode".to_owned(), + )); + } + _ => {} + } + let scope_hash = scope_key(&scope); + let actor_kind = principal_kind(principal); + let actor_subject = principal.subject().to_owned(); + let expected_version = input.expected_version; + let protection_mode = protection_mode_value(mode).to_owned(); + let ready = mode == AiContentProtectionMode::DatabaseManaged; + let migration_state = if ready { "ready" } else { "pending" }.to_owned(); + let key_policy_reference = input.key_policy_reference; + let now = unix_seconds(); + let record = self + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let existing = tx + .query::() + .filter(AiContentProtectionPolicyRecordWhereInput { + scope_key: Some(StringFilter { + eq: Some(scope_hash.clone()), + ..Default::default() + }), + ..Default::default() + }) + .limit(2) + .fetch_all() + .await + .map_err(OrmPublicError::from)?; + if existing.len() > 1 { + return Err(OrmPublicError::new( + OrmErrorCode::AuthorizationMisconfigured, + )); + } + let record = match (existing.into_iter().next(), expected_version) { + (None, None) => tx + .insert::( + CreateAiContentProtectionPolicyRecordInput { + scope_key: scope_hash, + scope_kind: scope.kind, + scope_id: scope.id, + tenant_id: scope.tenant_id, + protection_mode, + key_policy_reference, + key_version: None, + migration_state, + ready, + effective_at: now, + }, + ) + .await + .map_err(OrmPublicError::from)?, + (Some(current), Some(expected_version)) => match tx + .compare_and_swap::( + ¤t.id, + expected_version, + AiContentProtectionPolicyRecordWhereInput::default(), + UpdateAiContentProtectionPolicyRecordInput { + protection_mode: Some(protection_mode), + key_policy_reference: Some(key_policy_reference), + key_version: Some(None), + migration_state: Some(migration_state), + ready: Some(ready), + effective_at: Some(now), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(record) => record, + ConditionalUpdateOutcome::NotFound => { + return Err(OrmPublicError::not_found()); + } + ConditionalUpdateOutcome::Conflict => { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + }, + _ => return Err(OrmPublicError::new(OrmErrorCode::Conflict)), + }; + insert_audit( + tx, + AuditFact { + actor_principal_kind: &actor_kind, + actor_subject: &actor_subject, + action: "ai.content_protection.set", + resource_kind: "content_protection_policy", + resource_reference: &record.id.to_string(), + outcome: "allowed", + reason_code: "content_protection_updated", + policy_version: None, + }, + ) + .await?; + Ok(record) + }) + }) + .await + .map_err(map_transaction)?; + Ok(content_policy_view(&record)) + } +} + +#[async_trait] +impl AiContentProtectionPolicyResolver for OrmAiConfigurationService { + async fn resolve( + &self, + principal: &AuthPrincipal, + scope: &AiScope, + ) -> Result { + self.require_access( + principal, + scope, + AiConfigurationAction::ReadContentProtection, + ) + .await?; + let record = load_content_policy(&self.database, scope) + .await? + .ok_or(AiError::RuntimeNotReady)?; + let mode = parse_protection_mode(&record.protection_mode)?; + Ok(AiContentProtectionPolicy { + scope: scope.clone(), + mode, + key_policy_reference: record.key_policy_reference, + version: u64::try_from(record.row_version).map_err(|_| AiError::PersistenceFailed)?, + ready: record.ready && record.migration_state == "ready", + }) + } +} + +async fn load_content_policy( + database: &Database, + scope: &AiScope, +) -> Result, AiError> { + let scope_hash = scope_key(scope); + let rows = database + .transaction(TransactionMode::Default, move |tx| { + Box::pin(async move { + tx.query::() + .filter(AiContentProtectionPolicyRecordWhereInput { + scope_key: Some(StringFilter { + eq: Some(scope_hash), + ..Default::default() + }), + ..Default::default() + }) + .limit(2) + .fetch_all() + .await + .map_err(OrmPublicError::from) + }) + }) + .await + .map_err(map_transaction)?; + if rows.len() > 1 { + return Err(AiError::InvalidConfiguration( + "multiple content-protection policies exist for one scope".to_owned(), + )); + } + Ok(rows.into_iter().next()) +} + +struct AuditFact<'a> { + actor_principal_kind: &'a str, + actor_subject: &'a str, + action: &'a str, + resource_kind: &'a str, + resource_reference: &'a str, + outcome: &'a str, + reason_code: &'a str, + policy_version: Option, +} + +async fn insert_audit( + tx: &mut graphql_orm::graphql::orm::MutationContext<'_, DefaultWriteBackend>, + fact: AuditFact<'_>, +) -> Result<(), OrmPublicError> { + tx.insert::(CreateAiAuditEventRecordInput { + actor_principal_kind: fact.actor_principal_kind.to_owned(), + actor_subject: fact.actor_subject.to_owned(), + action: fact.action.to_owned(), + resource_kind: fact.resource_kind.to_owned(), + resource_reference: fact.resource_reference.to_owned(), + outcome: fact.outcome.to_owned(), + reason_code: fact.reason_code.to_owned(), + correlation_id: Uuid::new_v4().to_string(), + causation_id: None, + policy_version: fact.policy_version, + }) + .await + .map_err(OrmPublicError::from)?; + Ok(()) +} + +fn provider_view(record: &AiProviderProfileRecord) -> AiProviderProfileView { + AiProviderProfileView { + id: record.id, + scope_kind: record.scope_kind.clone(), + scope_id: record.scope_id.clone(), + tenant_id: record.tenant_id.clone(), + provider_kind: record.provider_kind.clone(), + display_name: record.display_name.clone(), + base_url: record.base_url.clone(), + credential_configured: record.credential_reference.is_some(), + enabled: record.enabled, + row_version: record.row_version, + updated_at: record.updated_at, + } +} + +fn content_policy_view(record: &AiContentProtectionPolicyRecord) -> AiContentProtectionPolicyView { + AiContentProtectionPolicyView { + scope_kind: record.scope_kind.clone(), + scope_id: record.scope_id.clone(), + tenant_id: record.tenant_id.clone(), + protection_mode: record.protection_mode.clone(), + ready: record.ready && record.migration_state == "ready", + row_version: record.row_version, + effective_at: record.effective_at, + } +} + +fn profile_scope(record: &AiProviderProfileRecord) -> AiScope { + AiScope { + kind: record.scope_kind.clone(), + id: record.scope_id.clone(), + tenant_id: record.tenant_id.clone(), + } +} + +fn scope_key(scope: &AiScope) -> String { + let mut hash = Sha256::new(); + hash.update(b"graphql-orm-ai/scope/v1\0"); + for value in [ + Some(scope.kind.as_str()), + Some(scope.id.as_str()), + scope.tenant_id.as_deref(), + ] { + match value { + Some(value) => { + hash.update([1]); + hash.update((value.len() as u64).to_be_bytes()); + hash.update(value.as_bytes()); + } + None => hash.update([0]), + } + } + hex::encode(hash.finalize()) +} + +fn normalize_endpoint( + kind: AiProviderKindInput, + base_url: Option, + endpoint_policy: &dyn AiProviderEndpointPolicy, +) -> Result, AiError> { + let configurable = matches!( + kind, + AiProviderKindInput::Ollama | AiProviderKindInput::OpenAiCompatible + ); + if !configurable { + return if base_url.is_none() { + Ok(None) + } else { + Err(AiError::InvalidInput( + "native provider endpoints are deployment-fixed".to_owned(), + )) + }; + } + let raw = base_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| AiError::InvalidInput("provider endpoint is required".to_owned()))?; + let mut url = Url::parse(raw) + .map_err(|_| AiError::InvalidInput("invalid provider endpoint".to_owned()))?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(AiError::InvalidInput("unsafe provider endpoint".to_owned())); + } + url.set_query(None); + url.set_fragment(None); + let normalized = url.to_string(); + if !endpoint_policy.authorize_endpoint(kind, &normalized) { + return Err(AiError::Forbidden); + } + Ok(Some(normalized)) +} + +fn validate_scope(scope: &AiScope) -> Result<(), AiError> { + if scope.kind.trim().is_empty() + || scope.id.trim().is_empty() + || scope.kind.len() > 128 + || scope.id.len() > 512 + || scope + .tenant_id + .as_ref() + .is_some_and(|tenant| tenant.trim().is_empty() || tenant.len() > 512) + { + return Err(AiError::InvalidInput("invalid AI scope".to_owned())); + } + Ok(()) +} + +fn principal_kind(principal: &AuthPrincipal) -> String { + match principal { + AuthPrincipal::User(_) => "user".to_owned(), + AuthPrincipal::ApiToken(token) => { + format!("api_token:{}", token.principal_kind.as_str()) + } + } +} + +fn protection_mode_value(mode: AiContentProtectionMode) -> &'static str { + match mode { + AiContentProtectionMode::DatabaseManaged => "database_managed", + AiContentProtectionMode::ApplicationEncrypted => "application_encrypted", + } +} + +fn parse_protection_mode(value: &str) -> Result { + match value { + "database_managed" => Ok(AiContentProtectionMode::DatabaseManaged), + "application_encrypted" => Ok(AiContentProtectionMode::ApplicationEncrypted), + _ => Err(AiError::InvalidConfiguration( + "unknown content-protection mode".to_owned(), + )), + } +} + +fn unix_seconds() -> i64 { + time::OffsetDateTime::now_utc().unix_timestamp() +} + +fn map_transaction(error: TransactionError) -> AiError { + map_orm(error.public_error().clone()) +} + +fn map_orm(error: OrmPublicError) -> AiError { + match error.code { + OrmErrorCode::InvalidInput + | OrmErrorCode::CursorInvalid + | OrmErrorCode::PageLimitExceeded => AiError::InvalidInput(error.message), + OrmErrorCode::Unauthenticated | OrmErrorCode::Forbidden => AiError::Forbidden, + OrmErrorCode::NotFound => AiError::NotFound, + OrmErrorCode::Conflict | OrmErrorCode::ConstraintViolation => AiError::Conflict, + OrmErrorCode::ServiceUnavailable + | OrmErrorCode::InternalError + | OrmErrorCode::AuthorizationMisconfigured => AiError::PersistenceFailed, + } +} diff --git a/crates/graphql-orm-ai/src/orm_sessions.rs b/crates/graphql-orm-ai/src/orm_sessions.rs new file mode 100644 index 00000000..9a6c73f2 --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_sessions.rs @@ -0,0 +1,1072 @@ +//! Durable ORM-backed conversational session service. + +#![cfg(any(feature = "sqlite", feature = "postgres"))] + +use std::sync::Arc; + +use agql_auth::AuthPrincipal; +use async_trait::async_trait; +use graphql_orm::db::Database; +use graphql_orm::graphql::errors::{OrmErrorCode, OrmPublicError}; +use graphql_orm::graphql::filters::{IntFilter, StringFilter, UuidFilter}; +use graphql_orm::graphql::orm::{ + ConditionalUpdateOutcome, DefaultWriteBackend, TransactionError, TransactionMode, +}; +use graphql_orm::graphql::pagination::{ + KeysetConnectionInput, KeysetWindowDirection, ValidatedKeysetConnection, +}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::persistence::*; +use crate::{ + AiAccessPolicy, AiContentProtectionPolicy, AiContentProtectionPolicyResolver, + AiContentProtector, AiError, AiMessageBlockView, AiMessageConnection, AiMessageEdge, + AiMessageView, AiScope, AiSessionAction, AiSessionConnection, AiSessionEdge, + AiSessionEventPage, AiSessionEventView, AiSessionId, AiSessionService, AiSessionView, + AiSessionWakeup, ContentProtectionContext, CreateAiSessionInput, ProtectedContentEnvelope, + SendAiMessageInput, SendAiMessagePayload, +}; + +/// Service-side limits that are enforced even when callers bypass GraphQL. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AiSessionServiceLimits { + /// Maximum UTF-8 bytes accepted for a title. + pub maximum_title_bytes: usize, + /// Maximum UTF-8 bytes accepted for one user message. + pub maximum_message_bytes: usize, + /// Maximum attachments accepted on one message. + pub maximum_attachments: usize, + /// Maximum protected preview size. + pub maximum_preview_bytes: usize, +} + +impl Default for AiSessionServiceLimits { + fn default() -> Self { + Self { + maximum_title_bytes: 256, + maximum_message_bytes: 256 * 1024, + maximum_attachments: 10, + maximum_preview_bytes: 4 * 1024, + } + } +} + +/// Concrete owner-isolated session service using generated ORM repository and +/// transaction APIs only. It never executes backend-specific SQL. +pub struct OrmAiSessionService { + database: Database, + access_policy: Arc, + protection_policy: Arc, + content_protector: Arc, + limits: AiSessionServiceLimits, +} + +impl OrmAiSessionService { + /// Creates a durable session service. + pub fn new( + database: Database, + access_policy: Arc, + protection_policy: Arc, + content_protector: Arc, + ) -> Self { + Self { + database, + access_policy, + protection_policy, + content_protector, + limits: AiSessionServiceLimits::default(), + } + } + + /// Overrides bounded service limits. + #[must_use] + pub fn with_limits(mut self, limits: AiSessionServiceLimits) -> Self { + self.limits = limits; + self + } + + /// Returns the underlying ORM database handle for schema composition and + /// host wiring, without exposing a driver pool. + pub fn database(&self) -> &Database { + &self.database + } + + async fn require_scope( + &self, + principal: &AuthPrincipal, + scope: &AiScope, + action: AiSessionAction, + ) -> Result<(), AiError> { + if self + .access_policy + .can_access_scope(principal, scope, action) + .await + .is_allowed() + { + Ok(()) + } else { + Err(AiError::Forbidden) + } + } + + async fn require_session_policy( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + action: AiSessionAction, + ) -> Result<(), AiError> { + if self + .access_policy + .can_access_session(principal, session_id, action) + .await + .is_allowed() + { + Ok(()) + } else { + Err(AiError::Forbidden) + } + } + + async fn visible_session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + action: AiSessionAction, + ) -> Result, AiError> { + self.require_session_policy(principal, session_id, action) + .await?; + let record = AiSessionRecord::find_by_id(&self.database, &session_id.0) + .await + .map_err(|error| map_orm(OrmPublicError::from(error)))?; + let Some(record) = record else { + return Ok(None); + }; + if !is_owner(principal, &record) || record.state == "deleting" { + return Ok(None); + } + self.require_scope(principal, &record_scope(&record), action) + .await?; + Ok(Some(record)) + } + + async fn protection_policy( + &self, + principal: &AuthPrincipal, + scope: &AiScope, + ) -> Result { + let policy = self.protection_policy.resolve(principal, scope).await?; + if !policy.ready || policy.scope != *scope { + return Err(AiError::RuntimeNotReady); + } + Ok(policy) + } + + async fn open_value( + &self, + policy: &AiContentProtectionPolicy, + context: ContentProtectionContext, + value: &serde_json::Value, + ) -> Result { + let envelope: ProtectedContentEnvelope = + serde_json::from_value(value.clone()).map_err(|_| AiError::PersistenceFailed)?; + self.content_protector + .open(policy, &context, &envelope) + .await + .map_err(map_protection) + } + + async fn protect_value( + &self, + policy: &AiContentProtectionPolicy, + context: ContentProtectionContext, + value: serde_json::Value, + ) -> Result { + let envelope = self + .content_protector + .protect(policy, &context, value) + .await + .map_err(map_protection)?; + serde_json::to_value(envelope).map_err(|_| AiError::PersistenceFailed) + } +} + +#[async_trait] +impl AiSessionService for OrmAiSessionService { + async fn sessions( + &self, + principal: &AuthPrincipal, + page: ValidatedKeysetConnection, + ) -> Result { + let (kind, subject) = principal_identity(principal); + let connection = AiSessionRecord::keyset_connection_page( + &self.database, + AiSessionRecordWhereInput { + owner_principal_kind: Some(StringFilter { + eq: Some(kind), + ..Default::default() + }), + owner_subject: Some(StringFilter { + eq: Some(subject.to_owned()), + ..Default::default() + }), + ..Default::default() + }, + page_input(&page, false), + ) + .await + .map_err(map_orm)?; + + let mut edges = Vec::with_capacity(connection.edges.len()); + for edge in connection.edges { + if edge.node.state == "deleting" + || !self + .access_policy + .can_access_scope(principal, &record_scope(&edge.node), AiSessionAction::List) + .await + .is_allowed() + { + continue; + } + edges.push(AiSessionEdge { + node: session_view(&edge.node), + cursor: edge.cursor, + }); + } + let mut page_info = connection.page_info; + page_info.total_count = None; + Ok(AiSessionConnection { edges, page_info }) + } + + async fn session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + ) -> Result, AiError> { + Ok(self + .visible_session(principal, session_id, AiSessionAction::Read) + .await? + .as_ref() + .map(session_view)) + } + + async fn messages( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + page: ValidatedKeysetConnection, + ) -> Result { + let session = self + .visible_session(principal, session_id, AiSessionAction::Read) + .await? + .ok_or(AiError::NotFound)?; + let scope = record_scope(&session); + let policy = self.protection_policy(principal, &scope).await?; + let connection = AiMessageRecord::keyset_connection_page( + &self.database, + AiMessageRecordWhereInput { + session_id: Some(UuidFilter { + eq: Some(session_id.0), + ..Default::default() + }), + ..Default::default() + }, + page_input(&page, false), + ) + .await + .map_err(map_orm)?; + + let mut edges = Vec::with_capacity(connection.edges.len()); + for edge in connection.edges { + let preview = self + .open_value( + &policy, + content_context( + "graphql_orm_ai_messages", + edge.node.id, + "protected_preview", + &scope, + ), + &edge.node.protected_preview, + ) + .await?; + let preview = preview + .as_str() + .ok_or(AiError::PersistenceFailed)? + .to_owned(); + edges.push(AiMessageEdge { + node: message_view(&edge.node, preview), + cursor: edge.cursor, + }); + } + Ok(AiMessageConnection { + edges, + page_info: connection.page_info, + }) + } + + async fn message_blocks( + &self, + principal: &AuthPrincipal, + message_id: Uuid, + after_block_index: Option, + first: i64, + ) -> Result, AiError> { + if !(1..=100).contains(&first) + || after_block_index.is_some_and(|value| value < 0 || value > i64::from(i32::MAX)) + { + return Err(AiError::InvalidInput( + "invalid message-block window".to_owned(), + )); + } + let message = AiMessageRecord::find_by_id(&self.database, &message_id) + .await + .map_err(|error| map_orm(OrmPublicError::from(error)))? + .ok_or(AiError::NotFound)?; + let session = self + .visible_session( + principal, + AiSessionId(message.session_id), + AiSessionAction::Read, + ) + .await? + .ok_or(AiError::NotFound)?; + let scope = record_scope(&session); + let policy = self.protection_policy(principal, &scope).await?; + let block_after = after_block_index.map(|value| IntFilter { + gt: Some(value as i32), + ..Default::default() + }); + let rows = self + .database + .transaction(TransactionMode::Default, move |tx| { + Box::pin(async move { + tx.query::() + .filter(AiMessageBlockRecordWhereInput { + message_id: Some(UuidFilter { + eq: Some(message_id), + ..Default::default() + }), + block_index: block_after, + ..Default::default() + }) + .default_order() + .limit(first) + .fetch_all() + .await + .map_err(OrmPublicError::from) + }) + }) + .await + .map_err(map_transaction)?; + + let mut views = Vec::with_capacity(rows.len()); + for row in rows { + let content = self + .open_value( + &policy, + content_context( + "graphql_orm_ai_message_blocks", + row.id, + "protected_content", + &scope, + ), + &row.protected_content, + ) + .await?; + views.push(AiMessageBlockView { + id: row.id, + message_id: row.message_id, + block_index: row.block_index, + kind: row.block_kind, + content: async_graphql::Json(content), + byte_count: row.byte_count, + line_count: row.line_count, + }); + } + Ok(views) + } + + async fn session_event_page( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + after_sequence: i64, + first: i64, + ) -> Result { + if after_sequence < 0 || after_sequence > i64::from(i32::MAX) || !(1..=500).contains(&first) + { + return Err(AiError::InvalidInput("invalid event window".to_owned())); + } + let session = self + .visible_session(principal, session_id, AiSessionAction::Read) + .await? + .ok_or(AiError::NotFound)?; + let scope = record_scope(&session); + let policy = self.protection_policy(principal, &scope).await?; + let watermark = session.stream_head; + let rows = self + .database + .transaction(TransactionMode::Default, move |tx| { + Box::pin(async move { + tx.query::() + .filter(AiSessionEventRecordWhereInput { + session_id: Some(UuidFilter { + eq: Some(session_id.0), + ..Default::default() + }), + sequence: Some(IntFilter { + gt: Some(after_sequence as i32), + lte: Some(i32::try_from(watermark).map_err(|_| { + OrmPublicError::new(OrmErrorCode::InvalidInput) + })?), + ..Default::default() + }), + ..Default::default() + }) + .default_order() + .limit(first.saturating_add(1)) + .fetch_all() + .await + .map_err(OrmPublicError::from) + }) + }) + .await + .map_err(map_transaction)?; + let has_more = rows.len() > first as usize; + let mut rows = rows; + rows.truncate(first as usize); + let mut events = Vec::with_capacity(rows.len()); + for row in rows { + let payload = self + .open_value( + &policy, + content_context( + "graphql_orm_ai_session_events", + row.id, + "protected_payload", + &scope, + ), + &row.protected_payload, + ) + .await?; + events.push(AiSessionEventView { + id: row.id, + sequence: row.sequence, + event_type: row.event_type, + run_id: row.run_id, + correlation_id: row.correlation_id, + payload: async_graphql::Json(payload), + created_at: row.created_at, + }); + } + Ok(AiSessionEventPage { + events, + watermark, + has_more, + reset_required: false, + }) + } + + async fn create_session( + &self, + principal: &AuthPrincipal, + input: CreateAiSessionInput, + ) -> Result { + let scope: AiScope = input.scope.into(); + validate_scope(&scope)?; + self.require_scope(principal, &scope, AiSessionAction::Create) + .await?; + let title = input.title.unwrap_or_else(|| "New chat".to_owned()); + if title.trim().is_empty() || title.len() > self.limits.maximum_title_bytes { + return Err(AiError::InvalidInput("invalid session title".to_owned())); + } + let session_id = Uuid::new_v4(); + let participant_id = Uuid::new_v4(); + let (owner_principal_kind, owner_subject) = principal_identity(principal); + let owner_subject = owner_subject.to_owned(); + let now = unix_seconds(); + let scope_for_insert = scope.clone(); + let session = self + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let session = tx + .insert::(CreateAiSessionRecordInput { + id: session_id, + owner_principal_kind: owner_principal_kind.clone(), + owner_subject: owner_subject.clone(), + tenant_id: scope_for_insert.tenant_id, + scope_kind: scope_for_insert.kind, + scope_id: scope_for_insert.id, + title, + state: "active".to_owned(), + stream_head: 0, + message_head: 0, + last_activity_at: now, + archived_at: None, + deleted_at: None, + }) + .await + .map_err(OrmPublicError::from)?; + tx.insert::( + CreateAiSessionParticipantRecordInput { + id: participant_id, + session_id, + principal_kind: owner_principal_kind, + principal_subject: owner_subject, + participant_role: "owner".to_owned(), + }, + ) + .await + .map_err(OrmPublicError::from)?; + Ok(session) + }) + }) + .await + .map_err(map_transaction)?; + Ok(session_view(&session)) + } + + async fn archive_session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + ) -> Result { + self.transition_session(principal, session_id, "active", "archived", true) + .await + } + + async fn restore_session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + ) -> Result { + self.transition_session(principal, session_id, "archived", "active", false) + .await + } + + async fn delete_session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + ) -> Result { + self.require_session_policy(principal, session_id, AiSessionAction::Delete) + .await?; + let expected_kind = principal_identity(principal).0; + let expected_subject = principal.subject().to_owned(); + let now = unix_seconds(); + self.database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let session = tx + .find_by_id::(&session_id.0) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + if session.owner_principal_kind != expected_kind + || session.owner_subject != expected_subject + { + return Err(OrmPublicError::not_found()); + } + if session.state == "deleting" { + return Ok(true); + } + let outcome = tx + .compare_and_swap::( + &session.id, + session.row_version, + AiSessionRecordWhereInput::default(), + UpdateAiSessionRecordInput { + state: Some("deleting".to_owned()), + deleted_at: Some(Some(now)), + last_activity_at: Some(now), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)?; + match outcome { + ConditionalUpdateOutcome::Updated(_) => Ok(true), + ConditionalUpdateOutcome::NotFound => Err(OrmPublicError::not_found()), + ConditionalUpdateOutcome::Conflict => { + Err(OrmPublicError::new(OrmErrorCode::Conflict)) + } + } + }) + }) + .await + .map_err(map_transaction) + } + + async fn send_message( + &self, + principal: &AuthPrincipal, + input: SendAiMessageInput, + ) -> Result { + if input.text.trim().is_empty() + || input.text.len() > self.limits.maximum_message_bytes + || input.attachment_ids.len() > self.limits.maximum_attachments + { + return Err(AiError::InvalidInput( + "message exceeds configured limits".to_owned(), + )); + } + let mut deduplicated_attachments = input.attachment_ids.clone(); + deduplicated_attachments.sort_unstable(); + deduplicated_attachments.dedup(); + if deduplicated_attachments.len() != input.attachment_ids.len() { + return Err(AiError::InvalidInput("duplicate attachment ID".to_owned())); + } + self.require_session_policy( + principal, + AiSessionId(input.session_id), + AiSessionAction::Write, + ) + .await?; + let session = self + .visible_session( + principal, + AiSessionId(input.session_id), + AiSessionAction::Write, + ) + .await? + .ok_or(AiError::NotFound)?; + if session.state != "active" { + return Err(AiError::Conflict); + } + let scope = record_scope(&session); + let policy = self.protection_policy(principal, &scope).await?; + let message_id = Uuid::new_v4(); + let block_id = Uuid::new_v4(); + let run_id = Uuid::new_v4(); + let event_id = Uuid::new_v4(); + let content_hash = message_content_hash(&input.text, &deduplicated_attachments); + let preview = bounded_prefix(&input.text, self.limits.maximum_preview_bytes); + let protected_preview = self + .protect_value( + &policy, + content_context( + "graphql_orm_ai_messages", + message_id, + "protected_preview", + &scope, + ), + json!(preview), + ) + .await?; + let protected_content = self + .protect_value( + &policy, + content_context( + "graphql_orm_ai_message_blocks", + block_id, + "protected_content", + &scope, + ), + json!({"text": input.text}), + ) + .await?; + let protected_event = self + .protect_value( + &policy, + content_context( + "graphql_orm_ai_session_events", + event_id, + "protected_payload", + &scope, + ), + json!({"messageId": message_id, "runId": run_id}), + ) + .await?; + let principal_reference = + serde_json::to_value(principal.reference()).map_err(|_| AiError::PersistenceFailed)?; + let (principal_kind, principal_subject) = principal_identity(principal); + let principal_subject = principal_subject.to_owned(); + let line_count = input.text.lines().count().max(1) as i64; + let byte_count = input.text.len() as i64; + let session_id = input.session_id; + let client_message_id = input.client_message_id; + let attachments = deduplicated_attachments; + let now = unix_seconds(); + + self.database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let existing = tx + .query::() + .filter(AiMessageRecordWhereInput { + session_id: Some(UuidFilter { + eq: Some(session_id), + ..Default::default() + }), + client_message_id: Some(UuidFilter { + eq: Some(client_message_id), + ..Default::default() + }), + ..Default::default() + }) + .limit(1) + .fetch_one() + .await + .map_err(OrmPublicError::from)?; + if let Some(existing) = existing { + if existing.content_hash.as_deref() != Some(content_hash.as_str()) { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + return existing + .run_id + .map(|run_id| SendAiMessagePayload { + message_id: existing.id, + run_id, + }) + .ok_or_else(|| OrmPublicError::new(OrmErrorCode::Conflict)); + } + + let current = tx + .find_by_id::(&session_id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + if current.owner_principal_kind != principal_kind + || current.owner_subject != principal_subject + { + return Err(OrmPublicError::not_found()); + } + if current.state != "active" { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + for attachment_id in &attachments { + let attachment = tx + .find_by_id::(attachment_id) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + if attachment.owner_principal_kind != principal_kind + || attachment.owner_subject != principal_subject + || attachment.session_id != session_id + || attachment.deleted_at.is_some() + || attachment.quarantine_state != "released" + || attachment.scan_state != "clean" + { + return Err(OrmPublicError::not_found()); + } + } + + let message_sequence = current.message_head.saturating_add(1); + let event_sequence = current.stream_head.saturating_add(1); + let outcome = tx + .compare_and_swap::( + ¤t.id, + current.row_version, + AiSessionRecordWhereInput::default(), + UpdateAiSessionRecordInput { + message_head: Some(message_sequence), + stream_head: Some(event_sequence), + last_activity_at: Some(now), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)?; + if !matches!(outcome, ConditionalUpdateOutcome::Updated(_)) { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + + tx.insert::(CreateAiMessageRecordInput { + id: message_id, + session_id, + sequence: message_sequence, + message_role: "user".to_owned(), + author_principal_kind: Some(principal_kind.clone()), + author_subject: Some(principal_subject.clone()), + client_message_id: Some(client_message_id), + content_hash: Some(content_hash), + run_id: Some(run_id), + provider_kind: None, + provider_model: None, + protected_preview, + block_count: 1, + completion_state: "complete".to_owned(), + finalized_at: Some(now), + }) + .await + .map_err(OrmPublicError::from)?; + tx.insert::(CreateAiMessageBlockRecordInput { + id: block_id, + message_id, + block_index: 0, + block_kind: "text".to_owned(), + protected_content, + byte_count, + line_count, + }) + .await + .map_err(OrmPublicError::from)?; + tx.insert::(CreateAiRunRecordInput { + id: run_id, + session_id, + input_message_id: message_id, + principal_reference, + state: "queued".to_owned(), + attempt_id: None, + lease_owner: None, + lease_generation: 0, + lease_expires_at: None, + lease_heartbeat_at: None, + retry_count: 0, + next_attempt_at: Some(now), + error_code: None, + }) + .await + .map_err(OrmPublicError::from)?; + tx.insert::(CreateAiSessionEventRecordInput { + id: event_id, + session_id, + sequence: event_sequence, + event_type: "message_queued".to_owned(), + run_id: Some(run_id), + causation_id: Some(client_message_id.to_string()), + correlation_id: client_message_id.to_string(), + protected_payload: protected_event, + }) + .await + .map_err(OrmPublicError::from)?; + tx.queue_event(AiSessionWakeup { + session_id, + sequence: event_sequence, + }); + for attachment_id in attachments { + let updated = tx + .update_by_id::( + &attachment_id, + UpdateAiAttachmentRecordInput { + message_id: Some(Some(message_id)), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)?; + if updated.is_none() { + return Err(OrmPublicError::not_found()); + } + } + Ok(SendAiMessagePayload { message_id, run_id }) + }) + }) + .await + .map_err(map_transaction) + } +} + +impl OrmAiSessionService { + async fn transition_session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + expected_state: &'static str, + next_state: &'static str, + archive: bool, + ) -> Result { + self.require_session_policy(principal, session_id, AiSessionAction::Archive) + .await?; + let (expected_kind, expected_subject) = principal_identity(principal); + let expected_subject = expected_subject.to_owned(); + let now = unix_seconds(); + let record = self + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + let current = tx + .find_by_id::(&session_id.0) + .await + .map_err(OrmPublicError::from)? + .ok_or_else(OrmPublicError::not_found)?; + if current.owner_principal_kind != expected_kind + || current.owner_subject != expected_subject + { + return Err(OrmPublicError::not_found()); + } + if current.state != expected_state { + return Err(OrmPublicError::new(OrmErrorCode::Conflict)); + } + let outcome = tx + .compare_and_swap::( + ¤t.id, + current.row_version, + AiSessionRecordWhereInput::default(), + UpdateAiSessionRecordInput { + state: Some(next_state.to_owned()), + archived_at: Some(archive.then_some(now)), + last_activity_at: Some(now), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)?; + match outcome { + ConditionalUpdateOutcome::Updated(record) => Ok(record), + ConditionalUpdateOutcome::NotFound => Err(OrmPublicError::not_found()), + ConditionalUpdateOutcome::Conflict => { + Err(OrmPublicError::new(OrmErrorCode::Conflict)) + } + } + }) + }) + .await + .map_err(map_transaction)?; + Ok(session_view(&record)) + } +} + +fn principal_identity(principal: &AuthPrincipal) -> (String, &str) { + let kind = match principal { + AuthPrincipal::User(_) => "user".to_owned(), + AuthPrincipal::ApiToken(token) => { + format!("api_token:{}", token.principal_kind.as_str()) + } + }; + (kind, principal.subject()) +} + +fn is_owner(principal: &AuthPrincipal, session: &AiSessionRecord) -> bool { + let (kind, subject) = principal_identity(principal); + session.owner_principal_kind == kind && session.owner_subject == subject +} + +fn record_scope(session: &AiSessionRecord) -> AiScope { + AiScope { + kind: session.scope_kind.clone(), + id: session.scope_id.clone(), + tenant_id: session.tenant_id.clone(), + } +} + +fn validate_scope(scope: &AiScope) -> Result<(), AiError> { + if scope.kind.trim().is_empty() + || scope.id.trim().is_empty() + || scope.kind.len() > 128 + || scope.id.len() > 512 + || scope + .tenant_id + .as_ref() + .is_some_and(|tenant| tenant.trim().is_empty() || tenant.len() > 512) + { + return Err(AiError::InvalidInput("invalid AI scope".to_owned())); + } + Ok(()) +} + +fn session_view(record: &AiSessionRecord) -> AiSessionView { + AiSessionView { + id: record.id, + scope_kind: record.scope_kind.clone(), + scope_id: record.scope_id.clone(), + title: record.title.clone(), + state: record.state.clone(), + stream_head: record.stream_head, + last_activity_at: record.last_activity_at, + archived_at: record.archived_at, + } +} + +fn message_view(record: &AiMessageRecord, preview: String) -> AiMessageView { + AiMessageView { + id: record.id, + session_id: record.session_id, + sequence: record.sequence, + role: record.message_role.clone(), + author_subject: record.author_subject.clone(), + run_id: record.run_id, + preview, + block_count: record.block_count, + completion_state: record.completion_state.clone(), + created_at: record.created_at, + } +} + +fn page_input( + page: &ValidatedKeysetConnection, + include_total_count: bool, +) -> KeysetConnectionInput { + match page.direction { + KeysetWindowDirection::Forward => KeysetConnectionInput { + after: page.cursor.clone(), + first: Some(page.limit), + include_total_count, + ..Default::default() + }, + KeysetWindowDirection::Backward => KeysetConnectionInput { + before: page.cursor.clone(), + last: Some(page.limit), + include_total_count, + ..Default::default() + }, + } +} + +fn content_context( + entity: &str, + row_id: Uuid, + field: &str, + scope: &AiScope, +) -> ContentProtectionContext { + ContentProtectionContext { + entity: entity.to_owned(), + row_id: row_id.to_string(), + field: field.to_owned(), + scope: scope.clone(), + } +} + +fn bounded_prefix(value: &str, maximum_bytes: usize) -> &str { + if value.len() <= maximum_bytes { + return value; + } + let mut end = maximum_bytes; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +fn message_content_hash(text: &str, attachment_ids: &[Uuid]) -> String { + let mut hash = Sha256::new(); + hash.update(b"graphql-orm-ai/message/v1\0"); + hash.update((text.len() as u64).to_be_bytes()); + hash.update(text.as_bytes()); + for id in attachment_ids { + hash.update(id.as_bytes()); + } + hex::encode(hash.finalize()) +} + +fn unix_seconds() -> i64 { + time::OffsetDateTime::now_utc().unix_timestamp() +} + +fn map_protection(error: crate::ContentProtectionError) -> AiError { + match error { + crate::ContentProtectionError::PolicyNotReady => AiError::RuntimeNotReady, + _ => AiError::PersistenceFailed, + } +} + +fn map_transaction(error: TransactionError) -> AiError { + map_orm(error.public_error().clone()) +} + +fn map_orm(error: OrmPublicError) -> AiError { + match error.code { + OrmErrorCode::InvalidInput + | OrmErrorCode::CursorInvalid + | OrmErrorCode::PageLimitExceeded => AiError::InvalidInput(error.message), + OrmErrorCode::Unauthenticated | OrmErrorCode::Forbidden => AiError::Forbidden, + OrmErrorCode::NotFound => AiError::NotFound, + OrmErrorCode::Conflict | OrmErrorCode::ConstraintViolation => AiError::Conflict, + OrmErrorCode::ServiceUnavailable + | OrmErrorCode::InternalError + | OrmErrorCode::AuthorizationMisconfigured => AiError::PersistenceFailed, + } +} diff --git a/crates/graphql-orm-ai/src/orm_subscriptions.rs b/crates/graphql-orm-ai/src/orm_subscriptions.rs new file mode 100644 index 00000000..41b9c8cb --- /dev/null +++ b/crates/graphql-orm-ai/src/orm_subscriptions.rs @@ -0,0 +1,198 @@ +//! ORM-backed catch-up-to-watermark durable subscriptions. + +#![cfg(any(feature = "sqlite", feature = "postgres"))] + +use std::sync::Arc; +use std::time::Duration; + +use agql_auth::CurrentPrincipalResolver; +use async_trait::async_trait; +use tokio::sync::broadcast::error::RecvError; +use tokio::time::{Instant, MissedTickBehavior}; + +use crate::{ + AiError, AiSessionEventEnvelope, AiSessionEventStream, AiSessionId, AiSessionService, + AiSessionWakeup, AiSubscriptionService, OrmAiSessionService, +}; + +/// Durable subscription service. Broadcast events are commit-only wakeup hints; +/// every client item is re-read from protected durable storage. +pub struct OrmAiSubscriptionService { + sessions: Arc, + principal_resolver: Arc, + reauthorization_interval: Duration, + replay_page_size: i64, +} + +impl OrmAiSubscriptionService { + /// Creates a service with a 30-second reauthorization interval and bounded + /// 100-event replay pages. + pub fn new( + sessions: Arc, + principal_resolver: Arc, + ) -> Self { + Self { + sessions, + principal_resolver, + reauthorization_interval: Duration::from_secs(30), + replay_page_size: 100, + } + } + + /// Overrides the reauthorization interval. Zero is rejected when opening + /// a stream. + #[must_use] + pub fn with_reauthorization_interval(mut self, interval: Duration) -> Self { + self.reauthorization_interval = interval; + self + } + + /// Overrides the durable replay page size, bounded to 1..=500 when a stream + /// opens. + #[must_use] + pub fn with_replay_page_size(mut self, page_size: i64) -> Self { + self.replay_page_size = page_size; + self + } +} + +#[async_trait] +impl AiSubscriptionService for OrmAiSubscriptionService { + async fn session_events( + &self, + principal: agql_auth::AuthPrincipal, + session_id: AiSessionId, + after_sequence: i64, + ) -> Result { + if after_sequence < 0 + || self.reauthorization_interval.is_zero() + || !(1..=500).contains(&self.replay_page_size) + { + return Err(AiError::InvalidConfiguration( + "invalid AI subscription bounds".to_owned(), + )); + } + let principal_reference = principal.reference(); + let mut wakeups = self + .sessions + .database() + .ensure_event_sender::() + .subscribe(); + let sessions = self.sessions.clone(); + let resolver = self.principal_resolver.clone(); + let reauthorization_interval = self.reauthorization_interval; + let replay_page_size = self.replay_page_size; + + Ok(Box::pin(async_stream::try_stream! { + let mut current_principal = principal; + let mut delivered_sequence = after_sequence; + let mut replay_required = true; + let mut reauthorize = tokio::time::interval_at( + Instant::now() + reauthorization_interval, + reauthorization_interval, + ); + reauthorize.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + if replay_required { + let mut page = sessions + .session_event_page( + ¤t_principal, + session_id, + delivered_sequence, + replay_page_size, + ) + .await?; + let target_watermark = page.watermark; + if target_watermark < delivered_sequence || page.reset_required { + yield AiSessionEventEnvelope { + event: None, + watermark: target_watermark, + reset_required: true, + }; + return; + } + + loop { + let mut crossed_watermark = false; + for event in page.events { + if event.sequence > target_watermark { + crossed_watermark = true; + break; + } + if event.sequence <= delivered_sequence { + continue; + } + delivered_sequence = event.sequence; + yield AiSessionEventEnvelope { + event: Some(event), + watermark: target_watermark, + reset_required: false, + }; + } + if delivered_sequence >= target_watermark || crossed_watermark { + break; + } + if !page.has_more { + yield AiSessionEventEnvelope { + event: None, + watermark: target_watermark, + reset_required: true, + }; + return; + } + page = sessions + .session_event_page( + ¤t_principal, + session_id, + delivered_sequence, + replay_page_size, + ) + .await?; + if page.reset_required { + yield AiSessionEventEnvelope { + event: None, + watermark: target_watermark, + reset_required: true, + }; + return; + } + } + replay_required = false; + } + + let should_reauthorize = tokio::select! { + _ = reauthorize.tick() => Some(true), + wakeup = wakeups.recv() => { + match wakeup { + Ok(wakeup) + if wakeup.session_id == session_id.0 + && wakeup.sequence > delivered_sequence => + { + replay_required = true; + } + Ok(_) => {} + Err(RecvError::Lagged(_)) => replay_required = true, + Err(RecvError::Closed) => return, + } + Some(false) + } + }; + if should_reauthorize == Some(true) { + let resolved = resolver + .resolve(&principal_reference) + .await + .map_err(|_| AiError::ReauthorizationFailed)?; + current_principal = resolved.into_principal(); + if sessions + .session(¤t_principal, session_id) + .await? + .is_none() + { + Err(AiError::Forbidden)?; + } + } + } + })) + } +} diff --git a/crates/graphql-orm-ai/src/persistence.rs b/crates/graphql-orm-ai/src/persistence.rs new file mode 100644 index 00000000..46f8ceb3 --- /dev/null +++ b/crates/graphql-orm-ai/src/persistence.rs @@ -0,0 +1,1564 @@ +//! Schema-only AI persistence metadata. +//! +//! These entities participate in migrations and backups through +//! [`AiSchemaModule`] without adding generated CRUD fields to the host schema. +//! Generated repository/input helpers are crate-internal even when the derive +//! layer emits them with public visibility. + +#![allow(missing_docs)] + +use std::sync::OnceLock; + +use graphql_orm::graphql::orm::{ + Entity, EntityMetadata, OrmSchemaModule, SchemaModuleDescriptor, SchemaModuleRestoreHook, + SchemaModuleRestorePhase, +}; +use graphql_orm::prelude::*; + +/// Per-scope runtime and maturity policy. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_scope_policies", + plural = "GraphqlOrmAiScopePolicies", + default_sort = "updated_at DESC" +)] +pub(crate) struct AiScopePolicyRecord { + /// Policy ID. + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Scope kind. + pub scope_kind: String, + /// Scope ID. + pub scope_id: String, + /// Optional tenant. + pub tenant_id: Option, + /// AI enabled state. + pub enabled: bool, + /// Maximum tool maturity enum value. + pub maximum_tool_maturity: String, + /// Serialized bounded capability configuration. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub capabilities: serde_json::Value, + /// CAS version. + #[graphql_orm(version, default = "0")] + pub row_version: i64, + /// Update timestamp (Unix seconds). + #[sortable] + pub updated_at: i64, +} + +/// Scoped provider endpoint and credential-reference configuration. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_provider_profiles", + plural = "GraphqlOrmAiProviderProfiles", + default_sort = "display_name ASC" +)] +pub(crate) struct AiProviderProfileRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Canonical non-secret hash of kind, ID, and tenant boundary. + #[filterable(type = "string")] + pub scope_key: String, + #[filterable(type = "string")] + pub scope_kind: String, + #[filterable(type = "string")] + pub scope_id: String, + pub tenant_id: Option, + #[filterable(type = "string")] + pub provider_kind: String, + pub display_name: String, + /// Empty for providers with a deployment-fixed endpoint. + pub base_url: Option, + /// Non-secret reference only; credential plaintext never enters this row. + #[backup(redact)] + pub credential_reference: Option, + pub enabled: bool, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub data_policy: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub limits: serde_json::Value, + #[graphql_orm(version, default = "0")] + pub row_version: i64, + #[sortable] + pub updated_at: i64, +} + +/// Task-to-model route and bounded fallbacks. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_model_routes", + plural = "GraphqlOrmAiModelRoutes", + default_sort = "priority ASC" +)] +pub(crate) struct AiModelRouteRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + pub scope_kind: String, + pub scope_id: String, + pub tenant_id: Option, + #[filterable(type = "string")] + pub task_kind: String, + #[sortable] + pub priority: i64, + pub provider_profile_id: graphql_orm::uuid::Uuid, + pub model: String, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub fallback_route_ids: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub model_parameters: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub required_capabilities: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub budget: serde_json::Value, + pub enabled: bool, + #[graphql_orm(version, default = "0")] + pub row_version: i64, + pub updated_at: i64, +} + +/// Required per-scope content-protection choice and migration readiness. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_content_protection_policies", + plural = "GraphqlOrmAiContentProtectionPolicies", + default_sort = "effective_at DESC" +)] +pub(crate) struct AiContentProtectionPolicyRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Canonical non-secret hash of kind, ID, and tenant boundary. + #[unique] + #[filterable(type = "string")] + pub scope_key: String, + #[filterable(type = "string")] + pub scope_kind: String, + #[filterable(type = "string")] + pub scope_id: String, + pub tenant_id: Option, + pub protection_mode: String, + pub key_policy_reference: Option, + pub key_version: Option, + pub migration_state: String, + pub ready: bool, + #[sortable] + pub effective_at: i64, + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Scope-controlled egress restrictions intersected with deployment limits. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_egress_policies", + plural = "GraphqlOrmAiEgressPolicies", + default_sort = "updated_at DESC" +)] +pub(crate) struct AiEgressPolicyRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + pub scope_kind: String, + pub scope_id: String, + pub tenant_id: Option, + pub enabled: bool, + pub maximum_classification: String, + pub consent_rule: String, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub allowed_destinations: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub allowed_capabilities: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub residency_retention_limits: serde_json::Value, + pub policy_version: String, + #[graphql_orm(version, default = "0")] + pub row_version: i64, + #[sortable] + pub updated_at: i64, +} + +/// Revocable purpose-bound egress consent containing no transferred content. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_egress_consents", + plural = "GraphqlOrmAiEgressConsents", + default_sort = "granted_at DESC" +)] +pub(crate) struct AiEgressConsentRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[filterable(type = "string")] + pub principal_subject: String, + pub scope_kind: String, + pub scope_id: String, + pub tenant_id: Option, + pub destination: String, + pub capability: String, + pub purpose: String, + pub purpose_grant_reference: String, + pub manifest_constraints_hash: String, + pub assurance: String, + #[sortable] + pub granted_at: i64, + pub expires_at: i64, + pub revoked_at: Option, + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Default-deny tool exposure policy bound to an exact descriptor fingerprint. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_tool_policies", + plural = "GraphqlOrmAiToolPolicies", + default_sort = "updated_at DESC" +)] +pub(crate) struct AiToolPolicyRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + pub scope_kind: String, + pub scope_id: String, + pub tenant_id: Option, + #[filterable(type = "string")] + pub tool_id: String, + pub tool_fingerprint: String, + pub enabled: bool, + pub maximum_maturity: String, + pub risk_override: Option, + pub approval_rule: String, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub constraints: serde_json::Value, + pub maximum_calls: i64, + pub maximum_output_bytes: i64, + #[graphql_orm(version, default = "0")] + pub row_version: i64, + #[sortable] + pub updated_at: i64, +} + +/// Retention and purge behavior for one scope. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_retention_policies", + plural = "GraphqlOrmAiRetentionPolicies", + default_sort = "updated_at DESC" +)] +pub(crate) struct AiRetentionPolicyRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + pub scope_kind: String, + pub scope_id: String, + pub tenant_id: Option, + pub message_retention_seconds: Option, + pub delta_retention_seconds: i64, + pub raw_payload_retention_seconds: i64, + pub audit_retention_seconds: i64, + pub deleted_content_purge_seconds: i64, + pub provider_file_delete_required: bool, + #[graphql_orm(version, default = "0")] + pub row_version: i64, + #[sortable] + pub updated_at: i64, +} + +/// Scope/user budget counters and hard limits. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_budget_policies", + plural = "GraphqlOrmAiBudgetPolicies", + default_sort = "updated_at DESC" +)] +pub(crate) struct AiBudgetPolicyRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + pub scope_kind: String, + pub scope_id: String, + pub tenant_id: Option, + pub principal_subject: Option, + pub interval_kind: String, + pub maximum_input_tokens: Option, + pub maximum_output_tokens: Option, + pub maximum_cost_microunits: Option, + pub maximum_runs: Option, + pub enabled: bool, + #[graphql_orm(version, default = "0")] + pub row_version: i64, + #[sortable] + pub updated_at: i64, +} + +/// Atomically maintained budget usage for one policy/time window. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_budget_counters", + plural = "GraphqlOrmAiBudgetCounters", + default_sort = "period_started_at DESC" +)] +pub(crate) struct AiBudgetCounterRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[filterable(type = "uuid")] + pub budget_policy_id: graphql_orm::uuid::Uuid, + pub policy_version: i64, + pub period_started_at: i64, + pub period_ends_at: i64, + pub reserved_input_tokens: i64, + pub reserved_output_tokens: i64, + pub reserved_cost_microunits: i64, + pub reserved_runs: i64, + pub committed_input_tokens: i64, + pub committed_output_tokens: i64, + pub committed_cost_microunits: i64, + pub committed_runs: i64, + #[graphql_orm(version, default = "0")] + pub row_version: i64, + #[sortable] + pub updated_at: i64, +} + +/// Exact provider-call capacity held across all applicable budget counters. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_budget_reservations", + plural = "GraphqlOrmAiBudgetReservations", + default_sort = "created_at DESC" +)] +pub(crate) struct AiBudgetReservationRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub budget_counter_ids: serde_json::Value, + pub scope_kind: String, + pub scope_id: String, + pub tenant_id: Option, + pub principal_subject: String, + pub session_id: graphql_orm::uuid::Uuid, + #[filterable(type = "uuid")] + pub run_id: graphql_orm::uuid::Uuid, + pub attempt_id: graphql_orm::uuid::Uuid, + pub lease_generation: i64, + pub provider_kind: String, + pub provider_model: String, + pub pricing_policy_version: String, + pub reserved_input_tokens: i64, + pub reserved_output_tokens: i64, + pub reserved_tool_units: i64, + pub reserved_image_units: i64, + pub reserved_cost_microunits: i64, + pub reserved_runs: i64, + pub actual_input_tokens: Option, + pub actual_output_tokens: Option, + pub actual_tool_units: Option, + pub actual_image_units: Option, + pub actual_cost_microunits: Option, + pub idempotency_key: String, + #[filterable(type = "string")] + pub state: String, + pub expires_at: i64, + #[sortable] + pub created_at: i64, + pub reconciled_at: Option, + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Per-user conversational session metadata. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_sessions", + plural = "GraphqlOrmAiSessions", + default_sort = "last_activity_at DESC", + keyset = "last_activity_at desc, id desc" +)] +pub(crate) struct AiSessionRecord { + /// Session ID. + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Owning principal class (`user` or a host-defined API-token kind). + #[filterable(type = "string")] + pub owner_principal_kind: String, + /// Owning principal subject. + #[filterable(type = "string")] + pub owner_subject: String, + /// Optional tenant. + pub tenant_id: Option, + /// Scope kind. + pub scope_kind: String, + /// Scope ID. + pub scope_id: String, + /// User-visible title. + pub title: String, + /// Lifecycle state. + pub state: String, + /// Current durable stream head. + pub stream_head: i64, + /// Current durable message head. + pub message_head: i64, + /// Last activity timestamp. + #[sortable] + pub last_activity_at: i64, + /// Archive timestamp. + pub archived_at: Option, + /// Deletion timestamp. + pub deleted_at: Option, + /// CAS version. + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Session ownership/participation record. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_session_participants", + plural = "GraphqlOrmAiSessionParticipants", + default_sort = "created_at ASC" +)] +pub(crate) struct AiSessionParticipantRecord { + /// Participant record ID. + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Session ID. + #[filterable(type = "uuid")] + pub session_id: graphql_orm::uuid::Uuid, + /// Principal class. + #[filterable(type = "string")] + pub principal_kind: String, + /// Principal subject. + #[filterable(type = "string")] + pub principal_subject: String, + /// Owner/editor/viewer role. + pub participant_role: String, + /// Created timestamp. + #[sortable] + pub created_at: i64, + /// CAS version. + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Durable per-session event row used as the subscription source of truth. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_session_events", + plural = "GraphqlOrmAiSessionEvents", + default_sort = "sequence ASC", + keyset = "sequence asc, id asc" +)] +pub(crate) struct AiSessionEventRecord { + /// Event ID. + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Session ID. + #[filterable(type = "uuid")] + pub session_id: graphql_orm::uuid::Uuid, + /// Per-session monotonic sequence. + #[filterable(type = "number")] + #[sortable] + pub sequence: i64, + /// Stable event type. + #[filterable(type = "string")] + pub event_type: String, + /// Optional run. + pub run_id: Option, + /// Causation reference. + pub causation_id: Option, + /// Correlation reference. + pub correlation_id: String, + /// Protected/ciphertext payload envelope. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_payload: serde_json::Value, + /// Created timestamp. + pub created_at: i64, +} + +/// Durable per-principal cross-session notification event. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_inbox_events", + plural = "GraphqlOrmAiInboxEvents", + default_sort = "sequence ASC", + keyset = "sequence asc, id asc" +)] +pub(crate) struct AiInboxEventRecord { + /// Event ID. + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Principal class stream owner. + #[filterable(type = "string")] + pub principal_kind: String, + /// Principal subject stream owner. + #[filterable(type = "string")] + pub principal_subject: String, + /// Per-principal monotonic sequence. + #[filterable(type = "number")] + #[sortable] + pub sequence: i64, + /// Optional session. + pub session_id: Option, + /// Stable event type. + pub event_type: String, + /// Protected/ciphertext payload envelope. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_payload: serde_json::Value, + /// Created timestamp. + pub created_at: i64, +} + +/// Bounded message metadata; large content lives in block rows. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_messages", + plural = "GraphqlOrmAiMessages", + default_sort = "sequence ASC", + keyset = "sequence asc, id asc", + unique_index = "session_id, client_message_id" +)] +pub(crate) struct AiMessageRecord { + /// Message ID. + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Session ID. + #[filterable(type = "uuid")] + pub session_id: graphql_orm::uuid::Uuid, + /// Stable per-session message order. + #[filterable(type = "number")] + #[sortable] + pub sequence: i64, + /// User/assistant/tool/system role. + #[filterable(type = "string")] + pub message_role: String, + /// Safe author principal class. + pub author_principal_kind: Option, + /// Safe author subject. + pub author_subject: Option, + /// Client idempotency reference for user messages. + #[filterable(type = "uuid")] + pub client_message_id: Option, + /// Hash binding the idempotency reference to text and attachment IDs. + pub content_hash: Option, + /// Producing run. + pub run_id: Option, + /// Provider kind/model metadata. + pub provider_kind: Option, + /// Provider model metadata. + pub provider_model: Option, + /// Protected bounded preview envelope. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_preview: serde_json::Value, + /// Number of separately windowed content blocks. + pub block_count: i64, + /// Completion state. + pub completion_state: String, + /// Created timestamp. + pub created_at: i64, + /// Finalized timestamp. + pub finalized_at: Option, +} + +/// Windowable content block capped by runtime policy. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_message_blocks", + plural = "GraphqlOrmAiMessageBlocks", + default_sort = "block_index ASC", + keyset = "block_index asc, id asc" +)] +pub(crate) struct AiMessageBlockRecord { + /// Block ID. + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Parent message. + #[filterable(type = "uuid")] + pub message_id: graphql_orm::uuid::Uuid, + /// Stable block order. + #[filterable(type = "number")] + #[sortable] + pub block_index: i64, + /// Text/json/tool/citation block kind. + pub block_kind: String, + /// Protected/ciphertext content envelope. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_content: serde_json::Value, + /// Original uncompressed byte count. + pub byte_count: i64, + /// Original line count. + pub line_count: i64, + /// Created timestamp. + pub created_at: i64, +} + +/// Quarantined/final attachment metadata; blob keys remain opaque. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_attachments", + plural = "GraphqlOrmAiAttachments", + default_sort = "created_at DESC", + keyset = "created_at desc, id desc" +)] +pub(crate) struct AiAttachmentRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[filterable(type = "string")] + pub owner_principal_kind: String, + #[filterable(type = "string")] + pub owner_subject: String, + #[filterable(type = "uuid")] + pub session_id: graphql_orm::uuid::Uuid, + pub message_id: Option, + /// Storage-provider opaque reference, never a user-controlled path. + #[backup(redact)] + pub blob_reference: String, + pub safe_filename: String, + pub declared_mime: Option, + pub detected_mime: String, + pub byte_count: i64, + pub sha256: String, + pub quarantine_state: String, + pub scan_state: String, + pub processing_state: String, + #[sortable] + pub created_at: i64, + pub finalized_at: Option, + pub deleted_at: Option, + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Derived OCR, thumbnail, transcript, extracted text, or provider-file data. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_attachment_artifacts", + plural = "GraphqlOrmAiAttachmentArtifacts", + default_sort = "created_at ASC" +)] +pub(crate) struct AiAttachmentArtifactRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[filterable(type = "uuid")] + pub attachment_id: graphql_orm::uuid::Uuid, + pub artifact_kind: String, + #[backup(redact)] + pub blob_reference: Option, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_content: Option, + pub detected_mime: Option, + pub byte_count: i64, + pub sha256: Option, + pub provider_reference: Option, + pub provider_expires_at: Option, + #[sortable] + pub created_at: i64, + pub deleted_at: Option, + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Durable agent run and current fenced lease state. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_runs", + plural = "GraphqlOrmAiRuns", + default_sort = "created_at ASC" +)] +pub(crate) struct AiRunRecord { + /// Run ID. + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Session ID. + #[filterable(type = "uuid")] + pub session_id: graphql_orm::uuid::Uuid, + /// User message that initiated this run. + #[filterable(type = "uuid")] + pub input_message_id: graphql_orm::uuid::Uuid, + /// Safe principal reference; never bearer credentials. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub principal_reference: serde_json::Value, + /// Durable run state. + #[filterable(type = "string")] + pub state: String, + /// Current attempt ID. + pub attempt_id: Option, + /// Current lease owner. + pub lease_owner: Option, + /// Monotonic lease generation/fencing token. + pub lease_generation: i64, + /// Lease expiry timestamp. + pub lease_expires_at: Option, + /// Last heartbeat timestamp. + pub lease_heartbeat_at: Option, + /// Retry count. + pub retry_count: i64, + /// Next eligible attempt timestamp. + pub next_attempt_at: Option, + /// Safe error code. + pub error_code: Option, + /// Created timestamp. + #[sortable] + pub created_at: i64, + /// CAS version. + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Immutable run-attempt/fence history. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_run_attempts", + plural = "GraphqlOrmAiRunAttempts", + default_sort = "claimed_at ASC", + append_only = true +)] +pub(crate) struct AiRunAttemptRecord { + /// Attempt ID. + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Run ID. + #[filterable(type = "uuid")] + pub run_id: graphql_orm::uuid::Uuid, + /// Fencing generation. + pub lease_generation: i64, + /// Worker owner. + pub worker_id: String, + /// Claim time. + #[sortable] + pub claimed_at: i64, + /// Finish time. + pub finished_at: Option, + /// Provider response reference. + pub provider_response_id: Option, + /// Safe recovery classification/outcome. + pub outcome_code: Option, +} + +/// Ordered run step. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_run_steps", + plural = "GraphqlOrmAiRunSteps", + default_sort = "step_index ASC" +)] +pub(crate) struct AiRunStepRecord { + /// Step ID. + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Run ID. + #[filterable(type = "uuid")] + pub run_id: graphql_orm::uuid::Uuid, + /// Stable step order. + #[filterable(type = "number")] + #[sortable] + pub step_index: i64, + /// Provider/tool/approval/context step kind. + pub step_kind: String, + /// Durable state. + pub state: String, + /// Attempt/fencing generation that owns the result. + pub lease_generation: i64, + /// Start timestamp. + pub started_at: Option, + /// Finish timestamp. + pub finished_at: Option, + /// Safe error code. + pub error_code: Option, + /// CAS version. + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Model-requested tool invocation and protected result. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_tool_calls", + plural = "GraphqlOrmAiToolCalls", + default_sort = "created_at ASC" +)] +pub(crate) struct AiToolCallRecord { + /// Tool-call ID. + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Run ID. + #[filterable(type = "uuid")] + pub run_id: graphql_orm::uuid::Uuid, + /// Stable tool ID. + pub tool_id: String, + /// Exact descriptor fingerprint. + pub tool_fingerprint: String, + /// Protected arguments. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_arguments: serde_json::Value, + /// Canonical argument hash used for approvals/idempotency. + pub argument_hash: String, + /// Protected result. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_result: Option, + /// Risk class. + pub risk: String, + /// Authorization decision code. + pub authorization_code: Option, + /// Approval ID. + pub approval_id: Option, + /// Stable idempotency key when supported. + pub idempotency_key: Option, + /// Attempt/fencing generation that owns the result. + pub lease_generation: i64, + /// Durable state. + pub state: String, + /// Created timestamp. + #[sortable] + pub created_at: i64, + /// Completed timestamp. + pub completed_at: Option, + /// CAS version. + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Expiring, argument-bound tool approval. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_approvals", + plural = "GraphqlOrmAiApprovals", + default_sort = "created_at ASC" +)] +pub(crate) struct AiApprovalRecord { + /// Approval ID. + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Tool call. + #[filterable(type = "uuid")] + pub tool_call_id: graphql_orm::uuid::Uuid, + /// Session. + pub session_id: graphql_orm::uuid::Uuid, + /// Principal subject requesting/approving. + pub principal_subject: String, + /// Fingerprint of the safe durable principal reference. + pub principal_reference_fingerprint: String, + /// Original/delegated actor subject. + pub delegated_actor_subject: Option, + /// Safe delegation/grant reference, never a credential. + pub delegation_reference: Option, + /// Bound canonical argument hash. + pub argument_hash: String, + /// Bound tool fingerprint. + pub tool_fingerprint: String, + /// Complete action-envelope hash. + pub binding_hash: String, + /// Logical local/remote execution target. + pub execution_target_id: String, + /// Exact target schema fingerprint. + pub target_schema_fingerprint: String, + /// Exact server-authored operation name. + pub operation_name: String, + /// Exact server-authored operation-document hash. + pub operation_document_hash: String, + /// Exact result-projection fingerprint. + pub result_projection_fingerprint: String, + /// Exact static disclosure-schema fingerprint. + pub disclosure_schema_fingerprint: String, + /// Current tool/scope/application policy version. + pub policy_version: String, + /// Safe authorization-state/precondition digest. + pub authorization_state_digest: String, + /// Protected exact resource/version bindings. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_resource_bindings: serde_json::Value, + /// Protected server-generated canonical action preview. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_action_preview: serde_json::Value, + /// Canonical action-preview hash. + pub action_preview_hash: String, + /// Pending/approved/denied/expired/revoked state. + #[filterable(type = "string")] + pub state: String, + /// Recent-MFA requirement. + pub recent_mfa_required: bool, + /// Approver subject. + pub approver_subject: Option, + /// Created timestamp. + #[sortable] + pub created_at: i64, + /// Expiry timestamp. + pub expires_at: i64, + /// Decision timestamp. + pub decided_at: Option, + /// Maximum atomic consumption count; one for one-shot approvals. + pub maximum_uses: i64, + /// Current atomic consumption count. + pub consumed_uses: i64, + /// One-shot consumption timestamp. + pub consumed_at: Option, + /// CAS version. + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// AI-owned structured suggestion envelope. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_proposals", + plural = "GraphqlOrmAiProposals", + default_sort = "created_at DESC" +)] +pub(crate) struct AiProposalRecord { + /// Proposal ID. + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Session ID. + #[filterable(type = "uuid")] + pub session_id: graphql_orm::uuid::Uuid, + /// Run ID. + #[filterable(type = "uuid")] + pub run_id: graphql_orm::uuid::Uuid, + /// Scope kind. + pub scope_kind: String, + /// Scope ID. + pub scope_id: String, + /// Registered proposal type. + pub proposal_type: String, + /// Registered schema version. + pub schema_version: String, + /// Protected/ciphertext structured payload envelope. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_payload: serde_json::Value, + /// Redacted source references. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub source_references: serde_json::Value, + /// Lifecycle state. + #[filterable(type = "string")] + pub state: String, + /// Model/user creator subject. + pub created_by_subject: String, + /// Human reviewer subject. + pub reviewed_by_subject: Option, + /// Application resource reference after a normal mutation commits. + pub applied_resource_ref: Option, + /// Authoritative application audit reference. + pub application_audit_ref: Option, + /// Created timestamp. + #[sortable] + pub created_at: i64, + /// Reviewed timestamp. + pub reviewed_at: Option, + /// Expiry timestamp. + pub expires_at: Option, + /// CAS version. + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Optional bounded proposal item for per-field human review. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_proposal_items", + plural = "GraphqlOrmAiProposalItems", + default_sort = "item_index ASC", + keyset = "item_index asc, id asc" +)] +pub(crate) struct AiProposalItemRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[filterable(type = "uuid")] + pub proposal_id: graphql_orm::uuid::Uuid, + #[filterable(type = "number")] + #[sortable] + pub item_index: i64, + pub stable_path: String, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_suggested_value: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_rationale: Option, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub source_references: serde_json::Value, + pub review_decision: Option, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_review_value: Option, + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Protected compacted context through a stable session sequence. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_context_checkpoints", + plural = "GraphqlOrmAiContextCheckpoints", + default_sort = "through_sequence DESC", + keyset = "through_sequence desc, id desc" +)] +pub(crate) struct AiContextCheckpointRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[filterable(type = "uuid")] + pub session_id: graphql_orm::uuid::Uuid, + #[sortable] + pub through_sequence: i64, + pub source_hash: String, + pub token_estimate: i64, + pub provider_kind: String, + pub provider_model: String, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_summary: serde_json::Value, + pub invalidated_at: Option, + pub created_at: i64, + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Scoped skill identity. Skill instructions live in immutable versions. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_skills", + plural = "GraphqlOrmAiSkills", + default_sort = "name ASC" +)] +pub(crate) struct AiSkillRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + pub scope_kind: String, + pub scope_id: String, + pub tenant_id: Option, + #[filterable(type = "string")] + #[sortable] + pub name: String, + pub description: String, + pub enabled: bool, + pub current_version_id: Option, + pub created_by_subject: String, + pub created_at: i64, + pub updated_at: i64, + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Immutable skill instructions, tool fingerprints, policy, and provenance. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_skill_versions", + plural = "GraphqlOrmAiSkillVersions", + default_sort = "created_at DESC", + append_only = true +)] +pub(crate) struct AiSkillVersionRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[filterable(type = "uuid")] + pub skill_id: graphql_orm::uuid::Uuid, + pub version: String, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub protected_instructions: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub allowed_tools: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub data_policy: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub activation_rule: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub schemas: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub budgets: serde_json::Value, + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub provenance: serde_json::Value, + pub checksum: String, + pub published: bool, + pub author_subject: String, + #[sortable] + pub created_at: i64, +} + +/// Append-oriented provider/model usage and cost fact. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_usage_entries", + plural = "GraphqlOrmAiUsageEntries", + default_sort = "created_at DESC", + append_only = true, + keyset = "created_at desc, id desc" +)] +pub(crate) struct AiUsageEntryRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + pub scope_kind: String, + pub scope_id: String, + pub tenant_id: Option, + pub principal_subject: String, + pub session_id: Option, + #[filterable(type = "uuid")] + pub run_id: Option, + pub provider_kind: String, + pub provider_model: String, + pub input_tokens: i64, + pub cached_input_tokens: i64, + pub output_tokens: i64, + pub tool_units: i64, + pub image_units: i64, + pub cost_microunits: Option, + #[sortable] + pub created_at: i64, +} + +/// Idempotent receipt for a provider background/webhook event. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_provider_webhook_receipts", + plural = "GraphqlOrmAiProviderWebhookReceipts", + default_sort = "received_at DESC" +)] +pub(crate) struct AiProviderWebhookReceiptRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + #[filterable(type = "string")] + pub provider_kind: String, + #[filterable(type = "string")] + pub provider_event_id: String, + pub provider_response_id: Option, + pub run_id: Option, + pub attempt_id: Option, + pub signature_verified: bool, + pub state: String, + pub safe_error_code: Option, + #[sortable] + pub received_at: i64, + pub processed_at: Option, + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Immutable redacted action/audit fact containing no prompts or arguments. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_audit_events", + plural = "GraphqlOrmAiAuditEvents", + default_sort = "created_at DESC", + append_only = true, + keyset = "created_at desc, id desc" +)] +pub(crate) struct AiAuditEventRecord { + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + pub actor_principal_kind: String, + pub actor_subject: String, + pub action: String, + pub resource_kind: String, + pub resource_reference: String, + pub outcome: String, + pub reason_code: String, + pub correlation_id: String, + pub causation_id: Option, + pub policy_version: Option, + #[sortable] + pub created_at: i64, +} + +/// Durable cleanup command for an obsolete or compensating secret reference. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_secret_cleanup", + plural = "GraphqlOrmAiSecretCleanup", + default_sort = "created_at ASC" +)] +pub(crate) struct AiSecretCleanupRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Opaque reference only. It is redacted from backups and never exposed. + #[backup(redact)] + pub secret_reference: String, + pub reason_code: String, + #[filterable(type = "string")] + pub state: String, + pub retry_count: i64, + pub next_attempt_at: Option, + pub completed_at: Option, + #[graphql_orm(version, default = "0")] + pub row_version: i64, + #[sortable] + pub created_at: i64, +} + +/// Redacted immutable external-transfer decision. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_egress_events", + plural = "GraphqlOrmAiEgressEvents", + default_sort = "created_at ASC", + append_only = true +)] +pub(crate) struct AiEgressEventRecord { + /// Decision ID. + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Optional run. + #[filterable(type = "uuid")] + pub run_id: Option, + /// Principal subject. + pub principal_subject: String, + /// Scope kind. + pub scope_kind: String, + /// Scope ID. + pub scope_id: String, + /// Exact redacted manifest hash. + pub manifest_hash: String, + /// Provider/destination class. + pub destination: String, + /// Capability. + pub capability: String, + /// Maximum classification. + pub classification: String, + /// Allow/deny outcome. + pub outcome: String, + /// Stable reason code. + pub reason_code: String, + /// Applied policy version. + pub policy_version: String, + /// Estimated bytes. + pub estimated_bytes: i64, + /// Estimated tokens. + pub estimated_tokens: i64, + /// Created timestamp. + #[sortable] + pub created_at: i64, +} + +/// Restore/recovery epoch and runtime start gate. +#[cfg_attr(feature = "mssql", derive(GraphQLSchemaEntity))] +#[cfg_attr( + any(feature = "sqlite", feature = "postgres"), + derive(GraphQLEntity, GraphQLOperations) +)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[graphql_entity( + table = "graphql_orm_ai_runtime_recovery", + plural = "GraphqlOrmAiRuntimeRecovery", + default_sort = "created_at DESC" +)] +pub(crate) struct AiRuntimeRecoveryRecord { + /// Recovery epoch ID. + #[primary_key] + #[filterable(type = "uuid")] + pub id: graphql_orm::uuid::Uuid, + /// Schema module version. + pub module_version: String, + /// Schema module fingerprint. + pub module_fingerprint: String, + /// Dry-run/applied state. + pub state: String, + /// Runtime start gate. + pub start_gate_open: bool, + /// Fatal issue count. + pub fatal_issue_count: i64, + /// Warning count. + pub warning_count: i64, + /// Redacted action counts. + #[graphql_orm(json, read = false, filter = false, order = false, subscribe = false)] + pub action_counts: serde_json::Value, + /// Operator subject. + pub operator_subject: Option, + /// Created timestamp. + #[sortable] + pub created_at: i64, + /// Completed timestamp. + pub completed_at: Option, + /// CAS version. + #[graphql_orm(version, default = "0")] + pub row_version: i64, +} + +/// Stable schema module ID. +pub const AI_SCHEMA_MODULE_ID: &str = "com.dastari.graphql-orm-ai"; +/// Current AI schema module version. +pub const AI_SCHEMA_MODULE_VERSION: &str = "0.5.0"; +/// Reserved table namespace. +pub const AI_TABLE_NAMESPACE: &str = "graphql_orm_ai_"; + +static AI_SCHEMA_DESCRIPTOR: SchemaModuleDescriptor = SchemaModuleDescriptor::new( + AI_SCHEMA_MODULE_ID, + AI_SCHEMA_MODULE_VERSION, + AI_TABLE_NAMESPACE, +); + +static AI_RESTORE_HOOKS: [SchemaModuleRestoreHook; 4] = [ + SchemaModuleRestoreHook { + hook_id: "ai-restore-preflight", + phase: SchemaModuleRestorePhase::Preflight, + }, + SchemaModuleRestoreHook { + hook_id: "ai-runtime-reconcile", + phase: SchemaModuleRestorePhase::Reconcile, + }, + SchemaModuleRestoreHook { + hook_id: "ai-runtime-validate", + phase: SchemaModuleRestorePhase::Validate, + }, + SchemaModuleRestoreHook { + hook_id: "ai-runtime-readiness", + phase: SchemaModuleRestorePhase::Readiness, + }, +]; + +/// AI-owned migration/backup/restore module. +#[derive(Clone, Copy, Debug, Default)] +pub struct AiSchemaModule; + +impl OrmSchemaModule for AiSchemaModule { + fn descriptor(&self) -> &SchemaModuleDescriptor { + &AI_SCHEMA_DESCRIPTOR + } + + fn entities(&self) -> &[&'static EntityMetadata] { + static ENTITIES: OnceLock> = OnceLock::new(); + ENTITIES.get_or_init(|| { + vec![ + AiScopePolicyRecord::metadata(), + AiProviderProfileRecord::metadata(), + AiModelRouteRecord::metadata(), + AiContentProtectionPolicyRecord::metadata(), + AiEgressPolicyRecord::metadata(), + AiEgressConsentRecord::metadata(), + AiToolPolicyRecord::metadata(), + AiRetentionPolicyRecord::metadata(), + AiBudgetPolicyRecord::metadata(), + AiBudgetCounterRecord::metadata(), + AiBudgetReservationRecord::metadata(), + AiSessionRecord::metadata(), + AiSessionParticipantRecord::metadata(), + AiSessionEventRecord::metadata(), + AiInboxEventRecord::metadata(), + AiMessageRecord::metadata(), + AiMessageBlockRecord::metadata(), + AiAttachmentRecord::metadata(), + AiAttachmentArtifactRecord::metadata(), + AiRunRecord::metadata(), + AiRunAttemptRecord::metadata(), + AiRunStepRecord::metadata(), + AiToolCallRecord::metadata(), + AiApprovalRecord::metadata(), + AiProposalRecord::metadata(), + AiProposalItemRecord::metadata(), + AiContextCheckpointRecord::metadata(), + AiSkillRecord::metadata(), + AiSkillVersionRecord::metadata(), + AiUsageEntryRecord::metadata(), + AiProviderWebhookReceiptRecord::metadata(), + AiAuditEventRecord::metadata(), + AiSecretCleanupRecord::metadata(), + AiEgressEventRecord::metadata(), + AiRuntimeRecoveryRecord::metadata(), + ] + }) + } + + fn restore_hooks(&self) -> &[SchemaModuleRestoreHook] { + &AI_RESTORE_HOOKS + } +} diff --git a/crates/graphql-orm-ai/src/proposals.rs b/crates/graphql-orm-ai/src/proposals.rs new file mode 100644 index 00000000..b2f55c39 --- /dev/null +++ b/crates/graphql-orm-ai/src/proposals.rs @@ -0,0 +1,252 @@ +//! AI-owned structured proposal staging contracts. + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::{AiDataSourceRef, AiError, AiProposalId, AiRunId, AiScope, AiSessionId}; + +const JSON_SCHEMA_2020_12: &str = "https://json-schema.org/draft/2020-12/schema"; + +/// Stable validated proposal-type identifier. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AiProposalTypeId(String); + +impl AiProposalTypeId { + /// Parses a lower-case namespaced proposal type. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + let valid = !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b'-') + }); + if !valid { + return Err(AiError::InvalidConfiguration( + "proposal type IDs must be lower-case ASCII names".to_owned(), + )); + } + Ok(Self(value)) + } + + /// Returns the type identifier. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Host-registered project-specific proposal contract. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AiProposalTypeDescriptor { + /// Stable proposal type. + pub id: AiProposalTypeId, + /// Immutable schema version. + pub schema_version: String, + /// JSON Schema 2020-12 payload contract. + pub schema: serde_json::Value, + /// Safe UI labels/hints; never route code. + pub display_metadata: serde_json::Value, + /// Maximum serialized payload bytes. + pub maximum_payload_bytes: u64, + /// Maximum logical review items. + pub maximum_items: u32, + /// Required source kinds. + pub required_source_kinds: Vec, +} + +impl AiProposalTypeDescriptor { + /// Creates and validates a proposal descriptor. + /// + /// # Errors + /// + /// Returns an error unless the schema explicitly declares JSON Schema + /// 2020-12 and compiles successfully. + pub fn new( + id: impl Into, + schema_version: impl Into, + schema: serde_json::Value, + ) -> Result { + let id = AiProposalTypeId::parse(id)?; + let schema_version = schema_version.into(); + if schema_version.trim().is_empty() { + return Err(AiError::InvalidConfiguration( + "proposal schema version must not be empty".to_owned(), + )); + } + if schema.get("$schema").and_then(serde_json::Value::as_str) != Some(JSON_SCHEMA_2020_12) { + return Err(AiError::InvalidConfiguration( + "proposal schemas must declare JSON Schema 2020-12".to_owned(), + )); + } + jsonschema::validator_for(&schema).map_err(|_| { + AiError::InvalidConfiguration("proposal JSON Schema is invalid".to_owned()) + })?; + + Ok(Self { + id, + schema_version, + schema, + display_metadata: serde_json::json!({}), + maximum_payload_bytes: 256 * 1024, + maximum_items: 100, + required_source_kinds: Vec::new(), + }) + } + + /// Sets safe display metadata. + pub fn with_display_metadata(mut self, metadata: serde_json::Value) -> Self { + self.display_metadata = metadata; + self + } + + /// Sets payload/item limits. + pub fn with_limits(mut self, maximum_payload_bytes: u64, maximum_items: u32) -> Self { + self.maximum_payload_bytes = maximum_payload_bytes; + self.maximum_items = maximum_items; + self + } + + /// Requires provenance from the listed source kinds. + pub fn with_required_source_kinds(mut self, kinds: Vec) -> Self { + self.required_source_kinds = kinds; + self + } +} + +/// AI-produced proposal draft before schema/provenance validation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AiProposalDraft { + /// Registered proposal type. + pub proposal_type: AiProposalTypeId, + /// Session. + pub session_id: AiSessionId, + /// Run. + pub run_id: AiRunId, + /// Application scope. + pub scope: AiScope, + /// Structured suggestion payload. + pub payload: serde_json::Value, + /// Redacted provenance references. + pub sources: Vec, + /// Logical item count supplied by the runtime adapter. + pub item_count: u32, +} + +/// Schema/provenance-validated proposal ready for protected persistence. +#[derive(Clone, Debug)] +pub struct ValidatedAiProposal { + /// Assigned proposal ID. + pub id: AiProposalId, + /// Registered descriptor. + pub descriptor: AiProposalTypeDescriptor, + /// Validated draft. + pub draft: AiProposalDraft, +} + +/// Proposal registry. Registration never grants application mutation access. +#[derive(Clone, Debug, Default)] +pub struct AiProposalCatalog { + descriptors: BTreeMap, +} + +impl AiProposalCatalog { + /// Creates an empty catalog. + pub fn new() -> Self { + Self::default() + } + + /// Registers a proposal contract. + pub fn register(&mut self, descriptor: AiProposalTypeDescriptor) -> Result<(), AiError> { + if self.descriptors.contains_key(&descriptor.id) { + return Err(AiError::AlreadyExists(descriptor.id.as_str().to_owned())); + } + self.descriptors.insert(descriptor.id.clone(), descriptor); + Ok(()) + } + + /// Validates model output and provenance against a registered contract. + pub fn validate(&self, draft: AiProposalDraft) -> Result { + let descriptor = self + .descriptors + .get(&draft.proposal_type) + .ok_or(AiError::NotFound)?; + let payload_bytes = serde_json::to_vec(&draft.payload).map_err(|_| { + AiError::InvalidInput("proposal payload is not serializable".to_owned()) + })?; + if payload_bytes.len() as u64 > descriptor.maximum_payload_bytes + || draft.item_count > descriptor.maximum_items + { + return Err(AiError::InvalidInput( + "proposal payload exceeds configured limits".to_owned(), + )); + } + + let validator = jsonschema::validator_for(&descriptor.schema).map_err(|_| { + AiError::InvalidConfiguration("registered proposal schema is invalid".to_owned()) + })?; + if !validator.is_valid(&draft.payload) { + return Err(AiError::InvalidInput( + "proposal payload does not match the registered schema".to_owned(), + )); + } + + for required_kind in &descriptor.required_source_kinds { + if !draft + .sources + .iter() + .any(|source| source.kind == *required_kind) + { + return Err(AiError::InvalidInput( + "proposal is missing required provenance".to_owned(), + )); + } + } + + Ok(ValidatedAiProposal { + id: AiProposalId::new(), + descriptor: descriptor.clone(), + draft, + }) + } +} + +/// Human review state for a proposal/item. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiProposalReviewDecision { + /// Accepted as proposed. + Accept, + /// Accepted after a human edit. + AcceptEdited, + /// Rejected. + Reject, +} + +/// Trusted application outcome after its normal domain mutation commits. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiProposalAppliedOutcome { + /// Proposal being linked. + pub proposal_id: AiProposalId, + /// Application resource type. + pub resource_type: String, + /// Application resource ID. + pub resource_id: String, + /// Authoritative application audit reference. + pub application_audit_ref: String, + /// Current human reviewer/applying subject. + pub applied_by_subject: String, +} + +/// Trusted service for recording an outcome after the host's ordinary domain +/// mutation succeeds. It never performs the domain mutation itself. +#[async_trait] +pub trait AiProposalOutcomeRecorder: Send + Sync { + /// Links a committed domain outcome to its reviewed proposal. + async fn record_applied_outcome( + &self, + outcome: AiProposalAppliedOutcome, + ) -> Result<(), AiError>; +} diff --git a/crates/graphql-orm-ai/src/provider.rs b/crates/graphql-orm-ai/src/provider.rs new file mode 100644 index 00000000..be0eab81 --- /dev/null +++ b/crates/graphql-orm-ai/src/provider.rs @@ -0,0 +1,547 @@ +//! Provider-neutral model adapter contract. + +use std::collections::BTreeSet; +use std::pin::Pin; + +use async_trait::async_trait; +use futures::Stream; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; + +use crate::{ + AiBudgetReservationId, AiEgressCapability, AiEgressManifest, AiError, AiRunId, AiSessionId, + AuthorizedBudgetReservation, AuthorizedEgress, +}; + +/// Supported provider family. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + /// OpenAI native API. + OpenAi, + /// Anthropic native API. + Anthropic, + /// xAI/Grok native API. + Xai, + /// Ollama local/native API. + Ollama, + /// Explicitly configured OpenAI-compatible endpoint. + OpenAiCompatible, +} + +impl ProviderKind { + /// Stable configuration/manifest value. + pub const fn as_str(&self) -> &'static str { + match self { + Self::OpenAi => "openai", + Self::Anthropic => "anthropic", + Self::Xai => "xai", + Self::Ollama => "ollama", + Self::OpenAiCompatible => "openai_compatible", + } + } +} + +/// Capability declaration used for safe route selection. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderCapabilities { + /// Streaming text output. + pub streaming: bool, + /// Image input. + pub image_input: bool, + /// File input. + pub file_input: bool, + /// Custom application tools. + pub custom_tools: bool, + /// Parallel custom tool calls. + pub parallel_tool_calls: bool, + /// JSON-schema structured output. + pub structured_output: bool, + /// Provider web search. + pub web_search: bool, + /// Provider file search/retention. + pub file_search: bool, + /// Provider code execution. + pub code_execution: bool, + /// Image generation. + pub image_generation: bool, + /// Embeddings. + pub embeddings: bool, + /// Background processing/webhooks. + pub background: bool, + /// Executes locally within the configured deployment boundary. + pub local: bool, + /// Maximum context tokens when known. + pub maximum_context_tokens: Option, + /// Maximum output tokens when known. + pub maximum_output_tokens: Option, +} + +/// Canonical model input block. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ModelInputBlock { + /// Text content. + Text { + /// Text value after policy/egress authorization. + text: String, + }, + /// Opaque attachment reference resolved by the adapter pipeline. + Attachment { + /// AI-owned attachment ID. + attachment_id: String, + /// Safe detected MIME type. + mime: String, + }, + /// Structured JSON content. + Json { + /// JSON value. + value: serde_json::Value, + }, +} + +/// Provider-neutral request. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ModelRequest { + /// Provider model identifier. + pub model: String, + /// Trusted runtime instructions. + pub instructions: Vec, + /// Bounded canonical context/input. + pub input: Vec, + /// Enabled custom tools already filtered by local policy. + pub tools: Vec, + /// Enabled provider built-ins, each separately approved for egress. + pub builtin_tools: Vec, + /// Optional structured-output schema. + pub output_schema: Option, + /// Maximum requested output tokens. + pub maximum_output_tokens: Option, +} + +impl ModelRequest { + /// Validates bounded, provider-neutral request invariants. + /// + /// Provider adapters still apply their own capability and protocol limits. + pub fn validate(&self) -> Result<(), ProviderError> { + if self.model.is_empty() || self.model.len() > 200 { + return Err(ProviderError::InvalidRequest); + } + if self.instructions.len() > 32 || self.input.len() > 256 || self.tools.len() > 128 { + return Err(ProviderError::InvalidRequest); + } + let mut provider_names = BTreeSet::new(); + let mut tool_ids = BTreeSet::new(); + for tool in &self.tools { + tool.validate()?; + if !provider_names.insert(tool.provider_name.as_str()) + || !tool_ids.insert(tool.tool_id.as_str()) + { + return Err(ProviderError::InvalidRequest); + } + } + Ok(()) + } + + fn estimated_payload_bytes(&self) -> u64 { + let instruction_bytes: usize = self.instructions.iter().map(String::len).sum(); + let input_bytes: usize = self + .input + .iter() + .map(|block| match block { + ModelInputBlock::Text { text } => text.len(), + ModelInputBlock::Attachment { + attachment_id, + mime, + } => attachment_id.len() + mime.len(), + ModelInputBlock::Json { value } => value.to_string().len(), + }) + .sum(); + instruction_bytes.saturating_add(input_bytes) as u64 + } +} + +/// Custom function definition sent to a provider after local authorization. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ModelToolDefinition { + /// Stable local catalog ID. + pub tool_id: String, + /// Provider-safe function name used only for this model request. + pub provider_name: String, + /// Exact local descriptor fingerprint. + pub fingerprint: String, + /// Bounded model-facing description. + pub description: String, + /// JSON Schema for arguments. + pub parameters: serde_json::Value, + /// Request provider-side strict schema enforcement when supported. + pub strict: bool, +} + +impl ModelToolDefinition { + fn validate(&self) -> Result<(), ProviderError> { + let provider_name_valid = !self.provider_name.is_empty() + && self.provider_name.len() <= 64 + && self + .provider_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); + let schema_is_object = self + .parameters + .as_object() + .and_then(|schema| schema.get("type")) + .and_then(serde_json::Value::as_str) + == Some("object"); + if self.tool_id.is_empty() + || self.tool_id.len() > 200 + || !provider_name_valid + || self.fingerprint.is_empty() + || self.description.len() > 2_000 + || !schema_is_object + { + return Err(ProviderError::InvalidRequest); + } + Ok(()) + } +} + +/// Provider-hosted tool requested for one model call. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ModelBuiltinTool { + /// Provider-hosted web search. + WebSearch { + /// Optional administrator-approved domain restriction. + allowed_domains: Vec, + }, + /// Provider-hosted search over already-authorized provider stores. + FileSearch { + /// Non-secret provider store references. + store_ids: Vec, + /// Bounded result count. + maximum_results: Option, + }, + /// Provider-hosted code interpreter. + CodeInterpreter, + /// Provider-hosted image generation. + ImageGeneration, +} + +/// One exact egress manifest paired with its unforgeable allow proof. +#[derive(Clone, Debug)] +struct AuthorizedProviderTransfer { + manifest: AiEgressManifest, + proof: AuthorizedEgress, +} + +/// Safe context accompanying a provider call. +/// +/// Fields are private so an authorized manifest cannot be swapped after this +/// context is created. Provider adapters must call [`Self::validate_request`] +/// immediately before transport egress. +#[derive(Clone, Debug)] +pub struct ProviderRequestContext { + session_id: AiSessionId, + run_id: AiRunId, + correlation_id: String, + budget: AuthorizedBudgetReservation, + transfers: Vec, +} + +impl ProviderRequestContext { + /// Creates a context with its required model-inference transfer. + /// + /// # Errors + /// + /// Returns [`AiError::EgressDenied`] when proof, session, or run does not + /// match the manifest exactly. + pub fn new( + session_id: AiSessionId, + run_id: AiRunId, + correlation_id: impl Into, + budget: AuthorizedBudgetReservation, + manifest: AiEgressManifest, + proof: AuthorizedEgress, + ) -> Result { + Self { + session_id, + run_id, + correlation_id: correlation_id.into(), + budget, + transfers: Vec::new(), + } + .with_authorized_transfer(manifest, proof) + } + + /// Adds a separately authorized built-in or attachment transfer. + /// + /// # Errors + /// + /// Returns [`AiError::EgressDenied`] for a mismatched proof/session/run. + pub fn with_authorized_transfer( + mut self, + manifest: AiEgressManifest, + proof: AuthorizedEgress, + ) -> Result { + if manifest.session_id != Some(self.session_id) + || manifest.run_id != Some(self.run_id) + || manifest.stable_hash() != proof.manifest_hash() + { + return Err(AiError::EgressDenied); + } + self.transfers + .push(AuthorizedProviderTransfer { manifest, proof }); + Ok(self) + } + + /// Session ID. + pub fn session_id(&self) -> AiSessionId { + self.session_id + } + + /// Run ID. + pub fn run_id(&self) -> AiRunId { + self.run_id + } + + /// Correlation ID. + pub fn correlation_id(&self) -> &str { + &self.correlation_id + } + + /// Decision IDs suitable for redacted audit linkage. + pub fn egress_decision_ids(&self) -> impl Iterator + '_ { + self.transfers + .iter() + .map(|transfer| transfer.proof.decision_id()) + } + + /// Budget reservation identifier suitable for usage/audit linkage. + pub fn budget_reservation_id(&self) -> AiBudgetReservationId { + self.budget.reservation_id() + } + + /// Validates that each request capability has a matching exact transfer. + pub fn validate_request( + &self, + provider_kind: &ProviderKind, + request: &ModelRequest, + ) -> Result<(), ProviderError> { + request.validate()?; + let requested_maximum_output_tokens = request.maximum_output_tokens.unwrap_or(0); + if !self.budget.matches( + self.run_id, + provider_kind, + &request.model, + requested_maximum_output_tokens, + OffsetDateTime::now_utc(), + ) { + return Err(ProviderError::BudgetDenied); + } + let attachment_count = request + .input + .iter() + .filter(|block| matches!(block, ModelInputBlock::Attachment { .. })) + .count() as u32; + let estimated_bytes = request.estimated_payload_bytes(); + + self.require_capability( + provider_kind, + request, + AiEgressCapability::ModelInference, + attachment_count, + estimated_bytes, + )?; + for block in &request.input { + if let ModelInputBlock::Attachment { mime, .. } = block { + let capability = if mime.starts_with("image/") { + AiEgressCapability::ImageAnalysis + } else { + AiEgressCapability::ProviderFile + }; + self.require_capability(provider_kind, request, capability, 1, estimated_bytes)?; + } + } + for builtin in &request.builtin_tools { + let capability = match builtin { + ModelBuiltinTool::WebSearch { .. } => AiEgressCapability::WebSearch, + ModelBuiltinTool::FileSearch { .. } => AiEgressCapability::ProviderFile, + ModelBuiltinTool::CodeInterpreter => AiEgressCapability::CodeExecution, + ModelBuiltinTool::ImageGeneration => AiEgressCapability::ImageGeneration, + }; + self.require_capability( + provider_kind, + request, + capability, + attachment_count, + estimated_bytes, + )?; + } + Ok(()) + } + + fn require_capability( + &self, + provider_kind: &ProviderKind, + request: &ModelRequest, + capability: AiEgressCapability, + attachment_count: u32, + estimated_bytes: u64, + ) -> Result<(), ProviderError> { + let allowed = self.transfers.iter().any(|transfer| { + transfer.manifest.provider_kind == provider_kind.as_str() + && transfer.manifest.model == request.model + && transfer.manifest.capability == capability + && transfer.manifest.attachment_count >= attachment_count + && transfer.manifest.estimated_bytes >= estimated_bytes + && transfer.manifest.stable_hash() == transfer.proof.manifest_hash() + }); + if allowed { + Ok(()) + } else { + Err(ProviderError::EgressDenied) + } + } +} + +/// Normalized provider event. Unknown provider events remain non-fatal. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ProviderEvent { + /// Provider accepted/started a response. + ResponseStarted { + /// Provider response reference. + response_id: Option, + }, + /// Visible text delta. + TextDelta { + /// Delta text. + text: String, + }, + /// Provider-supported visible reasoning summary; never hidden chain of thought. + ReasoningSummaryDelta { + /// Summary delta. + text: String, + }, + /// Custom tool call began. + ToolCallStarted { + /// Provider call ID. + call_id: String, + /// Stable local tool ID. + tool_id: String, + }, + /// Partial custom-tool arguments. + ToolArgumentsDelta { + /// Provider call ID. + call_id: String, + /// Partial serialized arguments. + delta: String, + }, + /// Provider completed a custom tool call request. + ToolCallCompleted { + /// Provider call ID. + call_id: String, + /// Complete parsed arguments. + arguments: serde_json::Value, + }, + /// Provider built-in started. + BuiltinToolStarted { + /// Provider call ID. + call_id: String, + /// Built-in kind. + kind: String, + }, + /// Provider built-in completed. + BuiltinToolCompleted { + /// Provider call ID. + call_id: String, + /// Redacted normalized result metadata. + result: serde_json::Value, + }, + /// Citation emitted by the provider. + Citation { + /// Safe source URL/reference. + source: String, + /// Optional display title. + title: Option, + }, + /// Usage counters. + Usage { + /// Input tokens. + input_tokens: u64, + /// Output tokens. + output_tokens: u64, + /// Cached input tokens. + cached_input_tokens: u64, + }, + /// Successful completion. + ResponseCompleted { + /// Provider response reference. + response_id: Option, + }, + /// Unknown forward-compatible event metadata. + Unknown { + /// Provider event type. + event_type: String, + }, +} + +/// Provider adapter error. Diagnostic text must never contain credentials or +/// raw sensitive payloads. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ProviderError { + /// Provider configuration is invalid. + #[error("invalid provider configuration: {0}")] + InvalidConfiguration(String), + /// Request failed bounded provider-neutral validation. + #[error("invalid provider request")] + InvalidRequest, + /// Credential resolution failed closed. + #[error("provider credential unavailable")] + CredentialUnavailable, + /// Exact provider/built-in/attachment egress proof was absent or stale. + #[error("provider egress denied")] + EgressDenied, + /// Exact atomic budget-reservation proof was absent, stale, or mismatched. + #[error("provider budget denied")] + BudgetDenied, + /// Capability is not supported by this adapter/model. + #[error("provider capability unsupported")] + Unsupported, + /// Request was rate limited. + #[error("provider rate limited")] + RateLimited, + /// Retryable remote/transport failure. + #[error("provider temporarily unavailable")] + Unavailable, + /// Provider rejected safe request metadata. + #[error("provider rejected request")] + Rejected, + /// Stream was cancelled. + #[error("provider stream cancelled")] + Cancelled, +} + +/// Provider event stream. +pub type ProviderEventStream = + Pin> + Send + 'static>>; + +/// Provider-neutral adapter. +#[async_trait] +pub trait AiProvider: Send + Sync { + /// Provider family. + fn provider_kind(&self) -> ProviderKind; + + /// Adapter/model capabilities used before routing. + fn capabilities(&self) -> ProviderCapabilities; + + /// Starts a streaming request. The context can only be constructed from an + /// allowed exact egress decision. + async fn stream( + &self, + request: ModelRequest, + context: ProviderRequestContext, + ) -> Result; +} diff --git a/crates/graphql-orm-ai/src/providers.rs b/crates/graphql-orm-ai/src/providers.rs new file mode 100644 index 00000000..875d3263 --- /dev/null +++ b/crates/graphql-orm-ai/src/providers.rs @@ -0,0 +1,11 @@ +//! Built-in provider adapter implementations. + +mod mock; + +#[cfg(feature = "provider-openai")] +mod openai; + +pub use mock::MockProvider; + +#[cfg(feature = "provider-openai")] +pub use openai::{OpenAiProvider, OpenAiProviderConfig}; diff --git a/crates/graphql-orm-ai/src/providers/mock.rs b/crates/graphql-orm-ai/src/providers/mock.rs new file mode 100644 index 00000000..72e8cfe8 --- /dev/null +++ b/crates/graphql-orm-ai/src/providers/mock.rs @@ -0,0 +1,80 @@ +//! Deterministic provider used for orchestration and security tests. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use async_trait::async_trait; +use futures::stream; + +use crate::{ + AiProvider, ModelRequest, ProviderCapabilities, ProviderError, ProviderEvent, + ProviderEventStream, ProviderKind, ProviderRequestContext, +}; + +/// Deterministic, network-free provider fixture. +#[derive(Clone, Debug)] +pub struct MockProvider { + kind: ProviderKind, + capabilities: ProviderCapabilities, + events: Arc<[ProviderEvent]>, + request_count: Arc, +} + +impl MockProvider { + /// Creates a local mock provider yielding the supplied events in order. + pub fn new(events: impl Into>) -> Self { + Self { + kind: ProviderKind::OpenAiCompatible, + capabilities: ProviderCapabilities { + streaming: true, + custom_tools: true, + structured_output: true, + local: true, + ..ProviderCapabilities::default() + }, + events: events.into().into(), + request_count: Arc::new(AtomicU64::new(0)), + } + } + + /// Changes the provider family exposed by the fixture. + pub fn with_kind(mut self, kind: ProviderKind) -> Self { + self.kind = kind; + self + } + + /// Changes the declared capabilities exposed by the fixture. + pub fn with_capabilities(mut self, capabilities: ProviderCapabilities) -> Self { + self.capabilities = capabilities; + self + } + + /// Returns the number of accepted requests without retaining prompt data. + pub fn request_count(&self) -> u64 { + self.request_count.load(Ordering::Acquire) + } +} + +#[async_trait] +impl AiProvider for MockProvider { + fn provider_kind(&self) -> ProviderKind { + self.kind.clone() + } + + fn capabilities(&self) -> ProviderCapabilities { + self.capabilities.clone() + } + + async fn stream( + &self, + request: ModelRequest, + context: ProviderRequestContext, + ) -> Result { + context.validate_request(&self.kind, &request)?; + self.request_count.fetch_add(1, Ordering::AcqRel); + let events = self.events.clone(); + Ok(Box::pin(stream::iter( + events.iter().cloned().map(Ok).collect::>(), + ))) + } +} diff --git a/crates/graphql-orm-ai/src/providers/openai.rs b/crates/graphql-orm-ai/src/providers/openai.rs new file mode 100644 index 00000000..1b6ecbc5 --- /dev/null +++ b/crates/graphql-orm-ai/src/providers/openai.rs @@ -0,0 +1,995 @@ +//! Native OpenAI Responses API adapter. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use secrecy::ExposeSecret; +use serde_json::{Value, json}; + +use crate::{ + AiProvider, AiSecretStore, ModelBuiltinTool, ModelInputBlock, ModelRequest, + ProviderCapabilities, ProviderError, ProviderEvent, ProviderEventStream, ProviderKind, + ProviderRequestContext, SecretRef, +}; + +const OPENAI_RESPONSES_ENDPOINT: &str = "https://api.openai.com/v1/responses"; +const MAXIMUM_SSE_EVENT_BYTES: usize = 2 * 1024 * 1024; + +/// Native OpenAI adapter configuration. Credential plaintext is never stored +/// in this structure. +#[derive(Clone, Debug)] +pub struct OpenAiProviderConfig { + /// Secret-store reference resolved immediately before each request. + pub credential: SecretRef, + /// Optional OpenAI organization header. + pub organization: Option, + /// Optional OpenAI project header. + pub project: Option, + /// Overall HTTP request/stream timeout. + pub timeout: Duration, + /// Whether OpenAI may retain the response object. Defaults to false so the + /// local session remains canonical. + pub store_responses: bool, +} + +impl OpenAiProviderConfig { + /// Creates secure defaults for the native Responses endpoint. + pub fn new(credential: SecretRef) -> Self { + Self { + credential, + organization: None, + project: None, + timeout: Duration::from_secs(120), + store_responses: false, + } + } +} + +/// Native OpenAI Responses API provider. +pub struct OpenAiProvider { + config: OpenAiProviderConfig, + secrets: Arc, + client: reqwest::Client, + endpoint: String, +} + +impl std::fmt::Debug for OpenAiProvider { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OpenAiProvider") + .field("config", &self.config) + .field("endpoint", &self.endpoint) + .finish_non_exhaustive() + } +} + +impl OpenAiProvider { + /// Builds a provider fixed to OpenAI's official HTTPS endpoint with + /// redirects disabled. + /// + /// # Errors + /// + /// Returns [`ProviderError::InvalidConfiguration`] for invalid safe header + /// metadata or an HTTP client construction failure. + pub fn new( + config: OpenAiProviderConfig, + secrets: Arc, + ) -> Result { + Self::build(config, secrets, OPENAI_RESPONSES_ENDPOINT.to_owned()) + } + + fn build( + config: OpenAiProviderConfig, + secrets: Arc, + endpoint: String, + ) -> Result { + validate_optional_header(config.organization.as_deref())?; + validate_optional_header(config.project.as_deref())?; + if config.timeout.is_zero() || config.timeout > Duration::from_secs(600) { + return Err(ProviderError::InvalidConfiguration( + "OpenAI timeout must be between one millisecond and ten minutes".to_owned(), + )); + } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(config.timeout) + .build() + .map_err(|_| { + ProviderError::InvalidConfiguration( + "OpenAI HTTP client could not be constructed".to_owned(), + ) + })?; + Ok(Self { + config, + secrets, + client, + endpoint, + }) + } + + #[cfg(test)] + fn for_loopback_test( + config: OpenAiProviderConfig, + secrets: Arc, + endpoint: String, + ) -> Result { + if !endpoint.starts_with("http://127.0.0.1:") { + return Err(ProviderError::InvalidConfiguration( + "test endpoint must use IPv4 loopback".to_owned(), + )); + } + Self::build(config, secrets, endpoint) + } + + fn request_headers(&self) -> Result { + let mut headers = HeaderMap::new(); + insert_optional_header( + &mut headers, + HeaderName::from_static("openai-organization"), + self.config.organization.as_deref(), + )?; + insert_optional_header( + &mut headers, + HeaderName::from_static("openai-project"), + self.config.project.as_deref(), + )?; + Ok(headers) + } + + fn request_body(&self, request: &ModelRequest) -> Result { + if request.input.is_empty() { + return Err(ProviderError::InvalidRequest); + } + let mut content = Vec::with_capacity(request.input.len()); + for block in &request.input { + match block { + ModelInputBlock::Text { text } => { + content.push(json!({"type": "input_text", "text": text})); + } + ModelInputBlock::Json { value } => { + content.push(json!({ + "type": "input_text", + "text": value.to_string() + })); + } + ModelInputBlock::Attachment { .. } => { + // Attachment IDs are local opaque references. A separate + // authorized upload/resolution pipeline must turn them into + // provider file/image inputs before this adapter accepts + // them. + return Err(ProviderError::Unsupported); + } + } + } + + let mut tools = Vec::with_capacity(request.tools.len() + request.builtin_tools.len()); + for tool in &request.tools { + tools.push(json!({ + "type": "function", + "name": tool.provider_name, + "description": tool.description, + "parameters": tool.parameters, + "strict": tool.strict + })); + } + for builtin in &request.builtin_tools { + tools.push(openai_builtin(builtin)?); + } + + let mut body = json!({ + "model": request.model, + "input": [{"role": "user", "content": content}], + "stream": true, + "store": self.config.store_responses, + "tools": tools, + "parallel_tool_calls": false + }); + if !request.instructions.is_empty() { + body["instructions"] = Value::String(request.instructions.join("\n\n")); + } + if let Some(maximum_output_tokens) = request.maximum_output_tokens { + body["max_output_tokens"] = Value::from(maximum_output_tokens); + } + if let Some(schema) = &request.output_schema { + body["text"] = json!({ + "format": { + "type": "json_schema", + "name": "graphql_orm_ai_response", + "strict": true, + "schema": schema + } + }); + } + Ok(body) + } +} + +#[async_trait] +impl AiProvider for OpenAiProvider { + fn provider_kind(&self) -> ProviderKind { + ProviderKind::OpenAi + } + + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + streaming: true, + image_input: false, + file_input: false, + custom_tools: true, + parallel_tool_calls: false, + structured_output: true, + web_search: true, + file_search: true, + code_execution: true, + image_generation: true, + embeddings: false, + background: false, + local: false, + maximum_context_tokens: None, + maximum_output_tokens: None, + } + } + + async fn stream( + &self, + request: ModelRequest, + context: ProviderRequestContext, + ) -> Result { + context.validate_request(&ProviderKind::OpenAi, &request)?; + let body = self.request_body(&request)?; + let secret = self + .secrets + .resolve(&self.config.credential) + .await + .map_err(|_| ProviderError::CredentialUnavailable)?; + let response = self + .client + .post(&self.endpoint) + .headers(self.request_headers()?) + .bearer_auth(secret.expose_secret()) + .json(&body) + .send() + .await + .map_err(|_| ProviderError::Unavailable)?; + let status = response.status(); + if let Some(error) = openai_http_error(status) { + return Err(error); + } + + let mut bytes = response.bytes_stream(); + let tool_ids = request + .tools + .iter() + .map(|tool| (tool.provider_name.clone(), tool.tool_id.clone())) + .collect::>(); + let output = async_stream::try_stream! { + let mut decoder = SseDecoder::default(); + let mut normalizer = OpenAiEventNormalizer::new(tool_ids); + while let Some(chunk) = bytes.next().await { + let chunk = chunk.map_err(|_| ProviderError::Unavailable)?; + for payload in decoder.push(&chunk)? { + let value: Value = serde_json::from_str(&payload) + .map_err(|_| ProviderError::Rejected)?; + for event in normalizer.normalize(&value)? { + yield event; + } + } + } + decoder.finish()?; + }; + Ok(Box::pin(output)) + } +} + +fn openai_http_error(status: reqwest::StatusCode) -> Option { + if status.is_success() { + None + } else if status == reqwest::StatusCode::UNAUTHORIZED { + Some(ProviderError::CredentialUnavailable) + } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + Some(ProviderError::RateLimited) + } else if status.is_server_error() { + Some(ProviderError::Unavailable) + } else { + Some(ProviderError::Rejected) + } +} + +fn validate_optional_header(value: Option<&str>) -> Result<(), ProviderError> { + if let Some(value) = value + && (value.is_empty() || value.len() > 200 || HeaderValue::from_str(value).is_err()) + { + return Err(ProviderError::InvalidConfiguration( + "OpenAI organization/project header is invalid".to_owned(), + )); + } + Ok(()) +} + +fn insert_optional_header( + headers: &mut HeaderMap, + name: HeaderName, + value: Option<&str>, +) -> Result<(), ProviderError> { + if let Some(value) = value { + let value = HeaderValue::from_str(value).map_err(|_| { + ProviderError::InvalidConfiguration( + "OpenAI organization/project header is invalid".to_owned(), + ) + })?; + headers.insert(name, value); + } + Ok(()) +} + +fn openai_builtin(tool: &ModelBuiltinTool) -> Result { + match tool { + ModelBuiltinTool::WebSearch { allowed_domains } => { + if allowed_domains.len() > 100 + || allowed_domains + .iter() + .any(|domain| domain.is_empty() || domain.len() > 253) + { + return Err(ProviderError::InvalidRequest); + } + if allowed_domains.is_empty() { + Ok(json!({"type": "web_search"})) + } else { + Ok(json!({ + "type": "web_search", + "filters": {"allowed_domains": allowed_domains} + })) + } + } + ModelBuiltinTool::FileSearch { + store_ids, + maximum_results, + } => { + if store_ids.is_empty() + || store_ids.len() > 20 + || store_ids.iter().any(|id| id.is_empty() || id.len() > 200) + || maximum_results.is_some_and(|value| value == 0 || value > 50) + { + return Err(ProviderError::InvalidRequest); + } + let mut value = json!({ + "type": "file_search", + "vector_store_ids": store_ids + }); + if let Some(maximum_results) = maximum_results { + value["max_num_results"] = Value::from(*maximum_results); + } + Ok(value) + } + ModelBuiltinTool::CodeInterpreter => Ok(json!({ + "type": "code_interpreter", + "container": {"type": "auto"} + })), + ModelBuiltinTool::ImageGeneration => Ok(json!({"type": "image_generation"})), + } +} + +#[derive(Default)] +struct SseDecoder { + buffer: Vec, +} + +impl SseDecoder { + fn push(&mut self, bytes: &[u8]) -> Result, ProviderError> { + self.buffer.extend_from_slice(bytes); + if self.buffer.len() > MAXIMUM_SSE_EVENT_BYTES { + return Err(ProviderError::Rejected); + } + let mut payloads = Vec::new(); + while let Some((position, delimiter_length)) = find_sse_delimiter(&self.buffer) { + let frame = self.buffer.drain(..position).collect::>(); + self.buffer.drain(..delimiter_length); + if let Some(payload) = decode_sse_frame(&frame)? { + payloads.push(payload); + } + } + Ok(payloads) + } + + fn finish(&self) -> Result<(), ProviderError> { + if self.buffer.iter().all(u8::is_ascii_whitespace) { + Ok(()) + } else { + Err(ProviderError::Unavailable) + } + } +} + +fn find_sse_delimiter(bytes: &[u8]) -> Option<(usize, usize)> { + bytes + .windows(2) + .position(|window| window == b"\n\n") + .map(|position| (position, 2)) + .or_else(|| { + bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| (position, 4)) + }) +} + +fn decode_sse_frame(frame: &[u8]) -> Result, ProviderError> { + let frame = std::str::from_utf8(frame).map_err(|_| ProviderError::Rejected)?; + let mut data = String::new(); + for line in frame.lines() { + if let Some(value) = line.strip_prefix("data:") { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(value.strip_prefix(' ').unwrap_or(value)); + } + } + if data.is_empty() || data == "[DONE]" { + Ok(None) + } else { + Ok(Some(data)) + } +} + +#[derive(Clone, Debug)] +struct FunctionCallState { + call_id: String, +} + +struct OpenAiEventNormalizer { + tool_ids: BTreeMap, + function_calls: BTreeMap, + builtin_calls: BTreeMap, + completed_calls: BTreeSet, +} + +impl OpenAiEventNormalizer { + fn new(tool_ids: BTreeMap) -> Self { + Self { + tool_ids, + function_calls: BTreeMap::new(), + builtin_calls: BTreeMap::new(), + completed_calls: BTreeSet::new(), + } + } + + fn normalize(&mut self, event: &Value) -> Result, ProviderError> { + let event_type = event + .get("type") + .and_then(Value::as_str) + .ok_or(ProviderError::Rejected)?; + match event_type { + "response.created" => Ok(vec![ProviderEvent::ResponseStarted { + response_id: event + .pointer("/response/id") + .and_then(Value::as_str) + .map(str::to_owned), + }]), + "response.output_text.delta" => Ok(vec![ProviderEvent::TextDelta { + text: required_string(event, "delta")?, + }]), + "response.reasoning_summary_text.delta" => { + Ok(vec![ProviderEvent::ReasoningSummaryDelta { + text: required_string(event, "delta")?, + }]) + } + "response.output_item.added" => self.output_item_added(event), + "response.function_call_arguments.delta" => { + let item_id = required_string(event, "item_id")?; + let state = self + .function_calls + .get(&item_id) + .ok_or(ProviderError::Rejected)?; + Ok(vec![ProviderEvent::ToolArgumentsDelta { + call_id: state.call_id.clone(), + delta: required_string(event, "delta")?, + }]) + } + "response.function_call_arguments.done" => { + let item_id = required_string(event, "item_id")?; + let arguments = required_string(event, "arguments")?; + self.complete_function(&item_id, &arguments) + } + "response.output_item.done" => self.output_item_done(event), + "response.output_text.annotation.added" => { + let annotation = event.get("annotation").ok_or(ProviderError::Rejected)?; + if annotation.get("type").and_then(Value::as_str) == Some("url_citation") { + Ok(vec![ProviderEvent::Citation { + source: required_string(annotation, "url")?, + title: annotation + .get("title") + .and_then(Value::as_str) + .map(str::to_owned), + }]) + } else { + Ok(vec![ProviderEvent::Unknown { + event_type: event_type.to_owned(), + }]) + } + } + "response.web_search_call.completed" + | "response.file_search_call.completed" + | "response.code_interpreter_call.completed" + | "response.image_generation_call.completed" => self.complete_builtin(event), + "response.completed" => { + let response = event.get("response").ok_or(ProviderError::Rejected)?; + let mut events = Vec::with_capacity(2); + if let Some(usage) = response.get("usage") { + events.push(ProviderEvent::Usage { + input_tokens: optional_u64(usage, "input_tokens"), + output_tokens: optional_u64(usage, "output_tokens"), + cached_input_tokens: usage + .pointer("/input_tokens_details/cached_tokens") + .and_then(Value::as_u64) + .unwrap_or(0), + }); + } + events.push(ProviderEvent::ResponseCompleted { + response_id: response + .get("id") + .and_then(Value::as_str) + .map(str::to_owned), + }); + Ok(events) + } + "response.failed" | "error" => Err(openai_stream_error(event)), + _ => Ok(vec![ProviderEvent::Unknown { + event_type: event_type.to_owned(), + }]), + } + } + + fn output_item_added(&mut self, event: &Value) -> Result, ProviderError> { + let item = event.get("item").ok_or(ProviderError::Rejected)?; + let item_type = required_string(item, "type")?; + let item_id = required_string(item, "id")?; + if item_type == "function_call" { + let provider_name = required_string(item, "name")?; + let tool_id = self + .tool_ids + .get(&provider_name) + .ok_or(ProviderError::Rejected)? + .clone(); + let call_id = required_string(item, "call_id")?; + self.function_calls.insert( + item_id, + FunctionCallState { + call_id: call_id.clone(), + }, + ); + return Ok(vec![ProviderEvent::ToolCallStarted { call_id, tool_id }]); + } + if let Some(kind) = builtin_kind(&item_type) { + let call_id = item + .get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .ok_or(ProviderError::Rejected)? + .to_owned(); + self.builtin_calls + .insert(item_id, (call_id.clone(), kind.to_owned())); + return Ok(vec![ProviderEvent::BuiltinToolStarted { + call_id, + kind: kind.to_owned(), + }]); + } + Ok(Vec::new()) + } + + fn output_item_done(&mut self, event: &Value) -> Result, ProviderError> { + let item = event.get("item").ok_or(ProviderError::Rejected)?; + match item.get("type").and_then(Value::as_str) { + Some("function_call") => { + let item_id = required_string(item, "id")?; + let arguments = required_string(item, "arguments")?; + self.complete_function(&item_id, &arguments) + } + Some(item_type) if builtin_kind(item_type).is_some() => { + self.complete_builtin_item(item) + } + _ => Ok(Vec::new()), + } + } + + fn complete_function( + &mut self, + item_id: &str, + arguments: &str, + ) -> Result, ProviderError> { + let state = self + .function_calls + .get(item_id) + .ok_or(ProviderError::Rejected)?; + if !self.completed_calls.insert(state.call_id.clone()) { + return Ok(Vec::new()); + } + let arguments = serde_json::from_str(arguments).map_err(|_| ProviderError::Rejected)?; + Ok(vec![ProviderEvent::ToolCallCompleted { + call_id: state.call_id.clone(), + arguments, + }]) + } + + fn complete_builtin(&mut self, event: &Value) -> Result, ProviderError> { + let item_id = event + .get("item_id") + .or_else(|| event.get("id")) + .and_then(Value::as_str) + .ok_or(ProviderError::Rejected)?; + self.emit_builtin_completion(item_id) + } + + fn complete_builtin_item(&mut self, item: &Value) -> Result, ProviderError> { + let item_id = required_string(item, "id")?; + self.emit_builtin_completion(&item_id) + } + + fn emit_builtin_completion( + &mut self, + item_id: &str, + ) -> Result, ProviderError> { + let (call_id, kind) = self + .builtin_calls + .get(item_id) + .ok_or(ProviderError::Rejected)?; + if !self.completed_calls.insert(call_id.clone()) { + return Ok(Vec::new()); + } + Ok(vec![ProviderEvent::BuiltinToolCompleted { + call_id: call_id.clone(), + result: json!({"kind": kind, "status": "completed"}), + }]) + } +} + +fn required_string(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or(ProviderError::Rejected) +} + +fn optional_u64(value: &Value, field: &str) -> u64 { + value.get(field).and_then(Value::as_u64).unwrap_or(0) +} + +fn openai_stream_error(event: &Value) -> ProviderError { + match event + .pointer("/response/error/code") + .or_else(|| event.pointer("/error/code")) + .or_else(|| event.get("code")) + .and_then(Value::as_str) + { + Some("rate_limit_exceeded" | "insufficient_quota") => ProviderError::RateLimited, + _ => ProviderError::Rejected, + } +} + +fn builtin_kind(item_type: &str) -> Option<&'static str> { + match item_type { + "web_search_call" => Some("web_search"), + "file_search_call" => Some("file_search"), + "code_interpreter_call" => Some("code_interpreter"), + "image_generation_call" => Some("image_generation"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use async_trait::async_trait; + use futures::TryStreamExt; + use secrecy::SecretString; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + use crate::{ + AiBudgetAmounts, AiBudgetReservation, AiBudgetReservationId, AiDataSourceRef, + AiDestinationTrust, AiEgressCapability, AiEgressDecision, AiEgressManifest, AiRunId, + AiScope, AiSessionId, AiSourceTrust, DataClassification, SecretError, + }; + + struct TestSecrets(SecretRef, String); + + struct LiveFileSecrets(SecretRef, PathBuf); + + #[async_trait] + impl AiSecretStore for TestSecrets { + async fn resolve(&self, reference: &SecretRef) -> Result { + if reference == &self.0 { + Ok(SecretString::from(self.1.clone())) + } else { + Err(SecretError::Unavailable) + } + } + + async fn put( + &self, + _reference: Option<&SecretRef>, + _value: SecretString, + ) -> Result { + Err(SecretError::ReadOnly) + } + + async fn delete(&self, _reference: &SecretRef) -> Result<(), SecretError> { + Err(SecretError::ReadOnly) + } + } + + #[async_trait] + impl AiSecretStore for LiveFileSecrets { + async fn resolve(&self, reference: &SecretRef) -> Result { + if reference != &self.0 { + return Err(SecretError::Unavailable); + } + let value = tokio::fs::read_to_string(&self.1) + .await + .map_err(|_| SecretError::Unavailable)?; + let value = value.trim(); + if !value.starts_with("sk-") + || value.len() > 512 + || value.bytes().any(|byte| byte.is_ascii_whitespace()) + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(SecretError::Unavailable); + } + Ok(SecretString::from(value.to_owned())) + } + + async fn put( + &self, + _reference: Option<&SecretRef>, + _value: SecretString, + ) -> Result { + Err(SecretError::ReadOnly) + } + + async fn delete(&self, _reference: &SecretRef) -> Result<(), SecretError> { + Err(SecretError::ReadOnly) + } + } + + async fn mock_server(body: &'static str) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("request should connect"); + let mut request = vec![0_u8; 32 * 1024]; + let _ = socket + .read(&mut request) + .await + .expect("request should read"); + let headers = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + socket + .write_all(headers.as_bytes()) + .await + .expect("headers should write"); + socket + .write_all(body.as_bytes()) + .await + .expect("body should write"); + }); + (format!("http://{address}/v1/responses"), task) + } + + fn context(model: &str, estimated_bytes: u64) -> ProviderRequestContext { + let session_id = AiSessionId::new(); + let run_id = AiRunId::new(); + let attempt_id = uuid::Uuid::new_v4(); + let manifest = AiEgressManifest { + provider_profile_id: "profile-1".to_owned(), + provider_kind: "openai".to_owned(), + model: model.to_owned(), + destination: "openai".to_owned(), + destination_trust: AiDestinationTrust::ManagedProvider, + capability: AiEgressCapability::ModelInference, + scope: AiScope::new("project", "test"), + session_id: Some(session_id), + run_id: Some(run_id), + sources: vec![AiDataSourceRef { + kind: "message".to_owned(), + reference: "synthetic".to_owned(), + classification: DataClassification::Public, + trust: AiSourceTrust::UserProvided, + }], + estimated_bytes, + estimated_tokens: 100, + attachment_count: 0, + purpose: "test".to_owned(), + retention: "none".to_owned(), + residency: None, + policy_version: "test".to_owned(), + consent_reference: None, + }; + let proof = AiEgressDecision::allow(&manifest, "test", "test-user") + .authorize(&manifest) + .expect("manifest should authorize"); + let budget = AiBudgetReservation::new_reserved( + AiBudgetReservationId::new(), + run_id, + attempt_id, + 1, + ProviderKind::OpenAi, + model, + "test-pricing-v1", + AiBudgetAmounts { + input_tokens: 1_000, + output_tokens: 1_000, + runs: 1, + ..AiBudgetAmounts::default() + }, + time::OffsetDateTime::now_utc() + time::Duration::hours(1), + ) + .expect("budget should validate") + .authorize_provider_call( + run_id, + attempt_id, + 1, + &ProviderKind::OpenAi, + model, + 1_000, + time::OffsetDateTime::now_utc(), + ) + .expect("budget should authorize"); + ProviderRequestContext::new(session_id, run_id, "test", budget, manifest, proof) + .expect("context should validate") + } + + #[tokio::test] + async fn responses_sse_is_normalized_without_retaining_secret_or_raw_body() { + let sse = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_test\"}}\n\n", + "event: response.output_text.delta\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"usage\":{\"input_tokens\":3,\"output_tokens\":1,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n" + ); + let (endpoint, server) = mock_server(sse).await; + let reference = SecretRef::parse("openai/test").expect("reference should parse"); + let provider = OpenAiProvider::for_loopback_test( + OpenAiProviderConfig::new(reference.clone()), + Arc::new(TestSecrets(reference, "not-a-real-key".to_owned())), + endpoint, + ) + .expect("provider should build"); + let request = ModelRequest { + model: "test-model".to_owned(), + instructions: vec!["Respond briefly.".to_owned()], + input: vec![ModelInputBlock::Text { + text: "synthetic hello".to_owned(), + }], + tools: vec![], + builtin_tools: vec![], + output_schema: None, + maximum_output_tokens: Some(32), + }; + let events = provider + .stream(request, context("test-model", 1_000)) + .await + .expect("stream should start") + .try_collect::>() + .await + .expect("stream should normalize"); + server.await.expect("server task should finish"); + + assert_eq!( + events, + vec![ + ProviderEvent::ResponseStarted { + response_id: Some("resp_test".to_owned()) + }, + ProviderEvent::TextDelta { + text: "hello".to_owned() + }, + ProviderEvent::Usage { + input_tokens: 3, + output_tokens: 1, + cached_input_tokens: 1 + }, + ProviderEvent::ResponseCompleted { + response_id: Some("resp_test".to_owned()) + } + ] + ); + } + + #[tokio::test] + #[ignore = "explicit opt-in live OpenAI smoke test; synthetic text only"] + async fn live_openai_synthetic_text_smoke_test() { + let key_file = std::env::var_os("GRAPHQL_ORM_AI_OPENAI_KEY_FILE") + .map(PathBuf::from) + .expect("set GRAPHQL_ORM_AI_OPENAI_KEY_FILE for the ignored live test"); + let model = + std::env::var("GRAPHQL_ORM_AI_OPENAI_MODEL").unwrap_or_else(|_| "gpt-5.4".to_owned()); + let reference = SecretRef::parse("openai/live-smoke").expect("reference should parse"); + let provider = OpenAiProvider::new( + OpenAiProviderConfig::new(reference.clone()), + Arc::new(LiveFileSecrets(reference, key_file)), + ) + .expect("provider should build"); + let request = ModelRequest { + model: model.clone(), + instructions: vec!["Reply with exactly the uppercase word OK.".to_owned()], + input: vec![ModelInputBlock::Text { + text: "This is a synthetic provider smoke test.".to_owned(), + }], + tools: vec![], + builtin_tools: vec![], + output_schema: None, + maximum_output_tokens: Some(64), + }; + let events = provider + .stream(request, context(&model, 1_000)) + .await + .expect("live stream should start") + .try_collect::>() + .await + .expect("live stream should complete"); + + assert!(events.iter().any(|event| matches!( + event, + ProviderEvent::ResponseCompleted { + response_id: Some(_) + } + ))); + assert!(events.iter().any(|event| matches!( + event, + ProviderEvent::TextDelta { text } if !text.is_empty() + ))); + } + + #[test] + fn sse_decoder_handles_chunk_boundaries_and_crlf() { + let mut decoder = SseDecoder::default(); + assert!( + decoder + .push(b"event: x\r\ndata: {\"type\":\"x\"") + .expect("partial frame should buffer") + .is_empty() + ); + assert_eq!( + decoder + .push(b"}\r\n\r\n") + .expect("completed frame should decode"), + vec!["{\"type\":\"x\"}"] + ); + assert!(decoder.finish().is_ok()); + } + + #[test] + fn stream_quota_errors_are_safely_classified_as_rate_limited() { + let event = json!({ + "type": "error", + "error": {"code": "insufficient_quota", "message": "not retained"} + }); + assert!(matches!( + openai_stream_error(&event), + ProviderError::RateLimited + )); + } + + #[test] + fn unauthorized_http_status_is_safely_classified_as_credential_unavailable() { + assert!(matches!( + openai_http_error(reqwest::StatusCode::UNAUTHORIZED), + Some(ProviderError::CredentialUnavailable) + )); + } +} diff --git a/crates/graphql-orm-ai/src/restore.rs b/crates/graphql-orm-ai/src/restore.rs new file mode 100644 index 00000000..4e9f09fb --- /dev/null +++ b/crates/graphql-orm-ai/src/restore.rs @@ -0,0 +1,234 @@ +//! Side-effect-safe restore reconciliation planning. + +use serde::{Deserialize, Serialize}; + +use crate::{AiRunId, AiRunState, AiRuntimeReadinessReport}; + +/// External side-effect certainty captured for an interrupted run. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiExternalEffectState { + /// No external call/tool could have occurred. + None, + /// Interrupted work is proven idempotent under a stable key. + ProvenIdempotent, + /// A non-idempotent or unknown external effect may have occurred. + Uncertain, + /// External effect is confirmed and must not be repeated automatically. + Confirmed, +} + +/// Restored run facts needed for reconciliation; payloads are intentionally +/// absent. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiRestoredRun { + /// Run ID. + pub run_id: AiRunId, + /// State captured in the backup. + pub state: AiRunState, + /// External-effect certainty. + pub external_effect: AiExternalEffectState, + /// Whether a provider continuation reference exists. + pub has_provider_continuation: bool, + /// Whether a provider file reference exists. + pub has_provider_file: bool, +} + +/// Preflight facts for one restored snapshot. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiRestoreSnapshotFacts { + /// Backup module fingerprint. + pub module_fingerprint: String, + /// Required encryption key versions missing from the deployment. + pub missing_key_versions: Vec, + /// Runs requiring reconciliation. + pub runs: Vec, + /// Number of pending approvals to expire/revalidate. + pub pending_approval_count: u64, + /// Number of pending egress consents to expire/revalidate. + pub pending_egress_consent_count: u64, + /// Missing/corrupt attachment references. + pub invalid_attachment_count: u64, + /// Duplicate durable stream sequence count. + pub duplicate_stream_sequence_count: u64, + /// Retention/known stream gap count. + pub stream_gap_count: u64, +} + +/// Planned recovery disposition for one run. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiRestoredRunDisposition { + /// Preserve a terminal state. + PreserveTerminal, + /// Requeue using a new attempt and fencing generation. + RequeueWithNewAttempt, + /// Require manual recovery review and never replay automatically. + RecoveryRequired, +} + +/// Redacted planned run repair. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiRestoredRunAction { + /// Run ID. + pub run_id: AiRunId, + /// Recovery disposition. + pub disposition: AiRestoredRunDisposition, + /// Lease owner/attempt/expiry/heartbeat must be cleared. + pub clear_lease: bool, + /// Provider continuation must be reverified before use. + pub reverify_provider_continuation: bool, + /// Provider file must be reverified before use. + pub reverify_provider_file: bool, +} + +/// Stable restore issue severity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiRestoreIssueSeverity { + /// Prevents runtime startup. + Fatal, + /// Requires reset/review but can be represented safely. + Warning, +} + +/// Redacted restore issue. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiRestoreIssue { + /// Stable issue code. + pub code: String, + /// Severity. + pub severity: AiRestoreIssueSeverity, + /// Affected safe reference when useful. + pub resource_ref: Option, +} + +/// Dry-run restore reconciliation plan. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiRestorePlan { + /// Expected compiled module fingerprint. + pub expected_module_fingerprint: String, + /// Run repairs. + pub run_actions: Vec, + /// Pending approvals to expire/revalidate. + pub approvals_to_revalidate: u64, + /// Pending egress consents to expire/revalidate. + pub consents_to_revalidate: u64, + /// Redacted issues. + pub issues: Vec, +} + +impl AiRestorePlan { + /// Returns fatal issue count. + pub fn fatal_issue_count(&self) -> u64 { + self.issues + .iter() + .filter(|issue| issue.severity == AiRestoreIssueSeverity::Fatal) + .count() as u64 + } + + /// Produces start-gate evidence after a trusted persistence adapter has + /// applied and validated this exact plan. + pub fn readiness_report_after_apply(&self, executor_bound: bool) -> AiRuntimeReadinessReport { + AiRuntimeReadinessReport { + module_fingerprint: self.expected_module_fingerprint.clone(), + executor_bound, + restore_reconciled: true, + fatal_issue_count: self.fatal_issue_count(), + } + } +} + +/// Pure reconciler. It plans database repairs but performs no I/O and no +/// external calls. +#[derive(Clone, Debug)] +pub struct AiRestoreReconciler { + expected_module_fingerprint: String, +} + +impl AiRestoreReconciler { + /// Creates a reconciler for the compiled AI schema module. + pub fn new(expected_module_fingerprint: impl Into) -> Self { + Self { + expected_module_fingerprint: expected_module_fingerprint.into(), + } + } + + /// Builds a dry-run plan. This method never resumes provider work or + /// invokes application tools. + pub fn plan(&self, facts: &AiRestoreSnapshotFacts) -> AiRestorePlan { + let mut issues = Vec::new(); + if facts.module_fingerprint != self.expected_module_fingerprint { + issues.push(AiRestoreIssue { + code: "AI_RESTORE_SCHEMA_FINGERPRINT_MISMATCH".to_owned(), + severity: AiRestoreIssueSeverity::Fatal, + resource_ref: None, + }); + } + for key_version in &facts.missing_key_versions { + issues.push(AiRestoreIssue { + code: "AI_RESTORE_ENCRYPTION_KEY_MISSING".to_owned(), + severity: AiRestoreIssueSeverity::Fatal, + resource_ref: Some(key_version.clone()), + }); + } + if facts.invalid_attachment_count > 0 { + issues.push(AiRestoreIssue { + code: "AI_RESTORE_ATTACHMENT_INVALID".to_owned(), + severity: AiRestoreIssueSeverity::Fatal, + resource_ref: None, + }); + } + if facts.duplicate_stream_sequence_count > 0 { + issues.push(AiRestoreIssue { + code: "AI_RESTORE_STREAM_SEQUENCE_DUPLICATE".to_owned(), + severity: AiRestoreIssueSeverity::Fatal, + resource_ref: None, + }); + } + if facts.stream_gap_count > 0 { + issues.push(AiRestoreIssue { + code: "AI_RESTORE_STREAM_GAP_RESET_REQUIRED".to_owned(), + severity: AiRestoreIssueSeverity::Warning, + resource_ref: None, + }); + } + + let run_actions = facts + .runs + .iter() + .map(|run| AiRestoredRunAction { + run_id: run.run_id, + disposition: restored_run_disposition(run), + clear_lease: true, + reverify_provider_continuation: run.has_provider_continuation, + reverify_provider_file: run.has_provider_file, + }) + .collect(); + + AiRestorePlan { + expected_module_fingerprint: self.expected_module_fingerprint.clone(), + run_actions, + approvals_to_revalidate: facts.pending_approval_count, + consents_to_revalidate: facts.pending_egress_consent_count, + issues, + } + } +} + +fn restored_run_disposition(run: &AiRestoredRun) -> AiRestoredRunDisposition { + if run.state.is_terminal() { + return AiRestoredRunDisposition::PreserveTerminal; + } + if run.state == AiRunState::RecoveryRequired { + return AiRestoredRunDisposition::RecoveryRequired; + } + match run.external_effect { + AiExternalEffectState::None | AiExternalEffectState::ProvenIdempotent => { + AiRestoredRunDisposition::RequeueWithNewAttempt + } + AiExternalEffectState::Uncertain | AiExternalEffectState::Confirmed => { + AiRestoredRunDisposition::RecoveryRequired + } + } +} diff --git a/crates/graphql-orm-ai/src/run_state.rs b/crates/graphql-orm-ai/src/run_state.rs new file mode 100644 index 00000000..642a68e2 --- /dev/null +++ b/crates/graphql-orm-ai/src/run_state.rs @@ -0,0 +1,175 @@ +//! Durable run states and fenced worker transitions. + +use graphql_orm::graphql::orm::{FencedLeaseState, LeaseError, LeaseProof}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Durable agent-run state. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiRunState { + /// Eligible for a worker claim. + Queued, + /// Claimed but provider work has not started. + Leased, + /// Provider/orchestration work is active. + Running, + /// Waiting for an argument-bound approval. + WaitingApproval, + /// Waiting for an application/internal tool result. + WaitingTool, + /// Waiting for the principal to reauthenticate. + WaitingReauth, + /// Eligible after a retry deadline. + RetryScheduled, + /// Restore/crash left an uncertain side effect requiring review. + RecoveryRequired, + /// Successful terminal state. + Completed, + /// Failed terminal state. + Failed, + /// Cancelled terminal state. + Cancelled, +} + +impl AiRunState { + /// Returns whether no further worker transition is allowed. + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } + + /// Returns whether a worker may perform the transition. + pub const fn can_transition_to(self, next: Self) -> bool { + match self { + Self::Queued | Self::RetryScheduled => matches!(next, Self::Leased | Self::Cancelled), + Self::Leased => matches!(next, Self::Running | Self::Cancelled | Self::Failed), + Self::Running => matches!( + next, + Self::WaitingApproval + | Self::WaitingTool + | Self::WaitingReauth + | Self::RetryScheduled + | Self::Completed + | Self::Failed + | Self::Cancelled + | Self::RecoveryRequired + ), + Self::WaitingApproval => matches!( + next, + Self::Running + | Self::WaitingReauth + | Self::Cancelled + | Self::Failed + | Self::RecoveryRequired + ), + Self::WaitingTool => matches!( + next, + Self::Running + | Self::RetryScheduled + | Self::Cancelled + | Self::Failed + | Self::RecoveryRequired + ), + Self::WaitingReauth => matches!( + next, + Self::Queued | Self::Cancelled | Self::Failed | Self::RecoveryRequired + ), + Self::RecoveryRequired | Self::Completed | Self::Failed | Self::Cancelled => false, + } + } +} + +/// Pure representation of a durable run row's state and fenced lease fields. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiRunLeaseMachine { + /// Current run state. + pub state: AiRunState, + /// Portable lease/CAS state. + pub lease: FencedLeaseState, +} + +impl AiRunLeaseMachine { + /// Creates a queued run. + pub fn queued(run_id: impl Into, row_version: i64) -> Self { + Self { + state: AiRunState::Queued, + lease: FencedLeaseState::new(run_id, row_version), + } + } + + /// Claims queued/retry-scheduled work and transitions it to `Leased`. + pub fn claim( + &mut self, + worker_id: impl Into, + attempt_id: Uuid, + now_ms: i64, + lease_ttl_ms: i64, + expected_row_version: i64, + ) -> Result { + if !matches!(self.state, AiRunState::Queued | AiRunState::RetryScheduled) { + return Err(AiRunTransitionError::InvalidTransition { + from: self.state, + to: AiRunState::Leased, + }); + } + let proof = self.lease.claim( + worker_id, + attempt_id, + now_ms, + lease_ttl_ms, + expected_row_version, + )?; + self.state = AiRunState::Leased; + Ok(proof) + } + + /// Applies a fenced durable state transition. + pub fn transition( + &mut self, + proof: &LeaseProof, + next: AiRunState, + now_ms: i64, + expected_row_version: i64, + ) -> Result { + if !self.state.can_transition_to(next) { + return Err(AiRunTransitionError::InvalidTransition { + from: self.state, + to: next, + }); + } + let version = self + .lease + .commit_fenced_write(proof, now_ms, expected_row_version)?; + self.state = next; + Ok(version) + } + + /// Authorizes and versions a durable event/tool/provider child append + /// without changing run state. + pub fn commit_child_write( + &mut self, + proof: &LeaseProof, + now_ms: i64, + expected_row_version: i64, + ) -> Result { + Ok(self + .lease + .commit_fenced_write(proof, now_ms, expected_row_version)?) + } +} + +/// Run transition error. +#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)] +pub enum AiRunTransitionError { + /// State transition is not allowed. + #[error("invalid AI run transition from {from:?} to {to:?}")] + InvalidTransition { + /// Current state. + from: AiRunState, + /// Requested state. + to: AiRunState, + }, + /// Lease/CAS/fencing validation failed. + #[error(transparent)] + Lease(#[from] LeaseError), +} diff --git a/crates/graphql-orm-ai/src/runtime.rs b/crates/graphql-orm-ai/src/runtime.rs new file mode 100644 index 00000000..fd21dfab --- /dev/null +++ b/crates/graphql-orm-ai/src/runtime.rs @@ -0,0 +1,482 @@ +//! Runtime construction, hard boundaries, and startup readiness. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use agql_auth::{CurrentPrincipalResolver, PrincipalReference}; + +use crate::{ + AiAccessPolicy, AiContentProtectionPolicyResolver, AiContentProtector, + AiDeploymentEgressBoundary, AiDisclosureEvaluation, AiEgressDecision, AiEgressManifest, + AiEgressPolicy, AiError, AiProposalCatalog, AiProvider, AiSchemaModule, AiSecretStore, + AiToolAuthorizationPolicy, AiToolCatalog, AiToolId, AuthenticatedGraphqlExecutor, + AuthenticatedToolBridge, GraphqlExecutionTargetRegistry, GraphqlRequestContextFactory, + ModelRequest, ProviderError, ProviderEventStream, ProviderKind, ProviderRequestContext, + ToolGraphqlRequest, ToolGraphqlResponse, ToolMaturity, +}; +use graphql_orm::graphql::orm::{OrmSchemaModule, SchemaModuleCatalog}; + +/// Evidence required to open the runtime start gate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiRuntimeReadinessReport { + /// Compiled module fingerprint validated against managed schema/restore. + pub module_fingerprint: String, + /// Finished application schema/executor is bound. + pub executor_bound: bool, + /// Restore/reconciliation completed or was not required for a new store. + pub restore_reconciled: bool, + /// Fatal validation/recovery issue count. + pub fatal_issue_count: u64, +} + +/// Fail-closed runtime start gate. +#[derive(Debug)] +pub struct AiRuntimeStartGate { + expected_module_fingerprint: String, + ready: AtomicBool, +} + +impl AiRuntimeStartGate { + fn new(expected_module_fingerprint: String) -> Self { + Self { + expected_module_fingerprint, + ready: AtomicBool::new(false), + } + } + + /// Opens the gate only for matching schema, bound execution, completed + /// restore reconciliation, and zero fatal issues. + pub fn open(&self, report: &AiRuntimeReadinessReport) -> Result<(), AiError> { + if report.module_fingerprint != self.expected_module_fingerprint + || !report.executor_bound + || !report.restore_reconciled + || report.fatal_issue_count != 0 + { + return Err(AiError::RuntimeNotReady); + } + self.ready.store(true, Ordering::Release); + Ok(()) + } + + /// Closes the gate immediately, for restore, shutdown, or fatal drift. + pub fn close(&self) { + self.ready.store(false, Ordering::Release); + } + + /// Returns whether workers/provider calls may start. + pub fn is_ready(&self) -> bool { + self.ready.load(Ordering::Acquire) + } + + /// Returns the compiled module fingerprint. + pub fn expected_module_fingerprint(&self) -> &str { + &self.expected_module_fingerprint + } +} + +/// Built project-agnostic runtime. +pub struct AiRuntime { + principal_resolver: Arc, + access_policy: Arc, + tool_bridge: AuthenticatedToolBridge, + egress_policy: Arc, + deployment_egress: AiDeploymentEgressBoundary, + maximum_tool_maturity: ToolMaturity, + tool_catalog: AiToolCatalog, + proposal_catalog: AiProposalCatalog, + secret_store: Arc, + content_protection_policy_resolver: Arc, + content_protector: Arc, + providers: BTreeMap>, + start_gate: AiRuntimeStartGate, +} + +/// Registered, freshly authorized, and statically disclosure-validated tool result. +#[derive(Clone, Debug)] +pub struct AiToolExecutionResult { + response: ToolGraphqlResponse, + disclosure: AiDisclosureEvaluation, + tool_fingerprint: String, + policy_version: String, + authorization_state_digest: String, +} + +impl AiToolExecutionResult { + /// Returns the bounded projected GraphQL response. + pub fn response(&self) -> &ToolGraphqlResponse { + &self.response + } + + /// Returns the static disclosure evaluation required for egress planning. + pub const fn disclosure(&self) -> AiDisclosureEvaluation { + self.disclosure + } + + /// Returns the exact registered tool fingerprint used for execution. + pub fn tool_fingerprint(&self) -> &str { + &self.tool_fingerprint + } + + /// Returns the current host tool-policy version used for authorization. + pub fn policy_version(&self) -> &str { + &self.policy_version + } + + /// Returns the safe current authorization-state digest for approval binding. + pub fn authorization_state_digest(&self) -> &str { + &self.authorization_state_digest + } +} + +impl AiRuntime { + /// Starts constructing a runtime. + pub fn builder() -> AiRuntimeBuilder { + AiRuntimeBuilder::default() + } + + /// Returns the runtime start gate. + pub fn start_gate(&self) -> &AiRuntimeStartGate { + &self.start_gate + } + + /// Returns registered tool metadata. Exposure still requires a separate + /// scope policy and the deployment maturity cap. + pub fn tool_catalog(&self) -> &AiToolCatalog { + &self.tool_catalog + } + + /// Returns registered proposal contracts. + pub fn proposal_catalog(&self) -> &AiProposalCatalog { + &self.proposal_catalog + } + + /// Returns the immutable deployment maturity ceiling. + pub fn maximum_tool_maturity(&self) -> ToolMaturity { + self.maximum_tool_maturity + } + + /// Returns the host application access policy used by session/service + /// implementations. + pub fn access_policy(&self) -> &Arc { + &self.access_policy + } + + /// Returns the configured credential/key indirection store. + pub fn secret_store(&self) -> &Arc { + &self.secret_store + } + + /// Returns the configured conversational content protector. + pub fn content_protector(&self) -> &Arc { + &self.content_protector + } + + /// Returns the current per-scope content-protection policy resolver. + pub fn content_protection_policy_resolver( + &self, + ) -> &Arc { + &self.content_protection_policy_resolver + } + + /// Applies deployment hard limits and scope policy to an exact egress + /// manifest after current-principal rehydration. + pub async fn authorize_egress( + &self, + principal_reference: &PrincipalReference, + manifest: &AiEgressManifest, + ) -> Result { + if let Err(reason) = self.deployment_egress.evaluate(manifest) { + return Ok(AiEgressDecision::deny( + manifest, + reason, + "deployment-boundary", + &principal_reference.subject, + )); + } + let principal = self + .principal_resolver + .resolve(principal_reference) + .await + .map_err(|_| AiError::ReauthorizationFailed)?; + Ok(self.egress_policy.authorize(&principal, manifest).await) + } + + /// Executes an exact registered application tool through fresh host tool + /// policy and the canonical current-principal request-context path. + /// + /// The returned result has passed the tool's static disclosure schema, but + /// still requires a separate egress decision before external disclosure. + /// + /// # Errors + /// + /// Fails closed when the runtime is not ready, registration/arguments/ + /// maturity are stale, current tool policy denies, resolver execution + /// fails, output limits are exceeded, or static disclosure validation + /// fails. + pub async fn execute_tool( + &self, + principal_reference: &PrincipalReference, + tool_id: &AiToolId, + request: ToolGraphqlRequest, + ) -> Result { + if !self.start_gate.is_ready() { + return Err(AiError::RuntimeNotReady); + } + let (descriptor, disclosure_schema) = self.tool_catalog.validate_execution_request( + tool_id, + &request, + self.maximum_tool_maturity, + )?; + let (response, authorization) = self + .tool_bridge + .execute(principal_reference, descriptor, request) + .await + .map_err(|_| AiError::ToolExecutionFailed)?; + let response_bytes = serde_json::to_vec(&response.data) + .map_err(|_| AiError::ToolExecutionFailed)? + .len() as u64; + if response_bytes > descriptor.maximum_result_bytes { + return Err(AiError::ToolExecutionFailed); + } + let disclosure = disclosure_schema + .evaluate(&response.data) + .map_err(|_| AiError::ToolExecutionFailed)?; + Ok(AiToolExecutionResult { + response, + disclosure, + tool_fingerprint: descriptor.fingerprint.clone(), + policy_version: authorization.policy_version, + authorization_state_digest: authorization.authorization_state_digest, + }) + } + + /// Calls a registered provider only after start readiness and exact egress + /// authorization. + pub async fn stream_provider( + &self, + provider_kind: &ProviderKind, + request: ModelRequest, + context: ProviderRequestContext, + ) -> Result { + if !self.start_gate.is_ready() { + return Err(ProviderError::InvalidConfiguration( + "AI runtime is not ready".to_owned(), + )); + } + context.validate_request(provider_kind, &request)?; + let provider = self + .providers + .get(provider_kind) + .ok_or(ProviderError::Unsupported)?; + if provider.provider_kind() != *provider_kind { + return Err(ProviderError::InvalidConfiguration( + "provider registry kind mismatch".to_owned(), + )); + } + provider.stream(request, context).await + } +} + +/// Runtime builder with fail-closed required dependencies. +#[derive(Default)] +#[must_use] +pub struct AiRuntimeBuilder { + principal_resolver: Option>, + tool_authorization_policy: Option>, + access_policy: Option>, + context_factory: Option>, + graphql_executor: Option>, + graphql_targets: Option, + egress_policy: Option>, + deployment_egress: Option, + maximum_tool_maturity: Option, + tool_catalog: AiToolCatalog, + proposal_catalog: AiProposalCatalog, + secret_store: Option>, + content_protection_policy_resolver: Option>, + content_protector: Option>, + providers: BTreeMap>, +} + +impl AiRuntimeBuilder { + /// Sets current-principal rehydration. + pub fn principal_resolver(mut self, resolver: Arc) -> Self { + self.principal_resolver = Some(resolver); + self + } + + /// Sets host application scope/session access policy. + pub fn access_policy(mut self, policy: Arc) -> Self { + self.access_policy = Some(policy); + self + } + + /// Sets fresh current-principal authorization for registered application tools. + pub fn tool_authorization_policy(mut self, policy: Arc) -> Self { + self.tool_authorization_policy = Some(policy); + self + } + + /// Sets the canonical host request-context factory. + pub fn request_context_factory( + mut self, + factory: Arc, + ) -> Self { + self.context_factory = Some(factory); + self + } + + /// Sets composed host GraphQL execution. + pub fn graphql_executor(mut self, executor: Arc) -> Self { + self.graphql_executor = Some(executor); + self + } + + /// Sets immutable deployment registration for local/remote GraphQL targets. + pub fn graphql_targets(mut self, targets: GraphqlExecutionTargetRegistry) -> Self { + self.graphql_targets = Some(targets); + self + } + + /// Sets scope/application egress policy. + pub fn egress_policy(mut self, policy: Arc) -> Self { + self.egress_policy = Some(policy); + self + } + + /// Sets immutable deployment egress limits. + pub fn deployment_egress(mut self, boundary: AiDeploymentEgressBoundary) -> Self { + self.deployment_egress = Some(boundary); + self + } + + /// Sets immutable deployment tool-maturity cap. + pub fn maximum_tool_maturity(mut self, maturity: ToolMaturity) -> Self { + self.maximum_tool_maturity = Some(maturity); + self + } + + /// Sets registered tool metadata. + pub fn tool_catalog(mut self, catalog: AiToolCatalog) -> Self { + self.tool_catalog = catalog; + self + } + + /// Sets registered proposal contracts. + pub fn proposal_catalog(mut self, catalog: AiProposalCatalog) -> Self { + self.proposal_catalog = catalog; + self + } + + /// Sets provider credential/key indirection. + pub fn secret_store(mut self, store: Arc) -> Self { + self.secret_store = Some(store); + self + } + + /// Sets per-scope conversational content protection. + pub fn content_protector(mut self, protector: Arc) -> Self { + self.content_protector = Some(protector); + self + } + + /// Sets authorized GraphQL-managed per-scope protection-policy lookup. + pub fn content_protection_policy_resolver( + mut self, + resolver: Arc, + ) -> Self { + self.content_protection_policy_resolver = Some(resolver); + self + } + + /// Registers a provider adapter. + pub fn provider(mut self, provider: Arc) -> Result { + let kind = provider.provider_kind(); + if self.providers.insert(kind.clone(), provider).is_some() { + return Err(AiError::AlreadyExists(format!("provider {kind:?}"))); + } + Ok(self) + } + + /// Validates required security seams and builds a closed runtime. + /// + /// # Errors + /// + /// Returns an error if a required security dependency or schema-module + /// contract is missing/invalid. + pub fn build(self) -> Result { + let principal_resolver = self.principal_resolver.ok_or_else(|| { + AiError::InvalidConfiguration("current-principal resolver is required".to_owned()) + })?; + let access_policy = self.access_policy.ok_or_else(|| { + AiError::InvalidConfiguration("AI access policy is required".to_owned()) + })?; + let tool_authorization_policy = self.tool_authorization_policy.ok_or_else(|| { + AiError::InvalidConfiguration("AI tool authorization policy is required".to_owned()) + })?; + let context_factory = self.context_factory.ok_or_else(|| { + AiError::InvalidConfiguration("GraphQL request-context factory is required".to_owned()) + })?; + let graphql_executor = self.graphql_executor.ok_or_else(|| { + AiError::InvalidConfiguration("authenticated GraphQL executor is required".to_owned()) + })?; + let graphql_targets = self.graphql_targets.ok_or_else(|| { + AiError::InvalidConfiguration("GraphQL target registry is required".to_owned()) + })?; + let egress_policy = self.egress_policy.ok_or_else(|| { + AiError::InvalidConfiguration("explicit egress policy is required".to_owned()) + })?; + let deployment_egress = self.deployment_egress.ok_or_else(|| { + AiError::InvalidConfiguration("deployment egress boundary is required".to_owned()) + })?; + let maximum_tool_maturity = self.maximum_tool_maturity.ok_or_else(|| { + AiError::InvalidConfiguration("deployment tool-maturity cap is required".to_owned()) + })?; + let secret_store = self.secret_store.ok_or_else(|| { + AiError::InvalidConfiguration("AI secret store is required".to_owned()) + })?; + let content_protector = self.content_protector.ok_or_else(|| { + AiError::InvalidConfiguration("AI content protector is required".to_owned()) + })?; + let content_protection_policy_resolver = + self.content_protection_policy_resolver.ok_or_else(|| { + AiError::InvalidConfiguration( + "AI content-protection policy resolver is required".to_owned(), + ) + })?; + + let schema_module = AiSchemaModule; + let catalog = SchemaModuleCatalog::compose(&[&schema_module as &dyn OrmSchemaModule]) + .map_err(|error| AiError::InvalidConfiguration(error.to_string()))?; + let fingerprint = catalog + .modules() + .first() + .ok_or_else(|| AiError::InvalidConfiguration("AI schema module is empty".to_owned()))? + .fingerprint + .clone(); + let tool_bridge = AuthenticatedToolBridge::new( + principal_resolver.clone(), + tool_authorization_policy, + context_factory, + graphql_executor, + graphql_targets, + ); + + Ok(AiRuntime { + principal_resolver, + access_policy, + tool_bridge, + egress_policy, + deployment_egress, + maximum_tool_maturity, + tool_catalog: self.tool_catalog, + proposal_catalog: self.proposal_catalog, + secret_store, + content_protection_policy_resolver, + content_protector, + providers: self.providers, + start_gate: AiRuntimeStartGate::new(fingerprint), + }) + } +} diff --git a/crates/graphql-orm-ai/src/secrets.rs b/crates/graphql-orm-ai/src/secrets.rs new file mode 100644 index 00000000..259086cd --- /dev/null +++ b/crates/graphql-orm-ai/src/secrets.rs @@ -0,0 +1,161 @@ +//! Provider credential indirection and secret-store contracts. +//! +//! Durable records contain only [`SecretRef`] values. Secret plaintext is +//! deliberately non-serializable and is resolved immediately before a remote +//! request so credential rotation does not require rewriting provider rows. + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Opaque, non-secret reference to provider credentials or key material. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SecretRef(String); + +impl SecretRef { + /// Parses a bounded reference suitable for persistence and audit metadata. + /// + /// # Errors + /// + /// Returns [`SecretError::InvalidReference`] when the value is empty, + /// unreasonably long, or contains characters outside the stable reference + /// alphabet. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + let valid = !value.is_empty() + && value.len() <= 200 + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/') + }); + if !valid { + return Err(SecretError::InvalidReference); + } + Ok(Self(value)) + } + + /// Returns the non-secret opaque reference. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for SecretRef { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_tuple("SecretRef").field(&self.0).finish() + } +} + +/// Secret-store failure with no plaintext or backend diagnostic exposure. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum SecretError { + /// A reference is malformed. + #[error("invalid secret reference")] + InvalidReference, + /// The referenced secret does not exist or is not visible. + #[error("secret unavailable")] + Unavailable, + /// This store is read-only. + #[error("secret store is read-only")] + ReadOnly, + /// The external secret backend failed closed. + #[error("secret store temporarily unavailable")] + BackendUnavailable, +} + +/// Secret storage abstraction for encrypted ORM stores, KMS/Vault adapters, +/// and read-only deployment bootstrap sources. +#[async_trait] +pub trait AiSecretStore: Send + Sync { + /// Resolves current secret plaintext. Implementations must not log values. + async fn resolve(&self, reference: &SecretRef) -> Result; + + /// Stores or rotates a value and returns its durable non-secret reference. + /// + /// When `reference` is `None`, mutable stores must allocate a fresh, + /// unguessable reference rather than overwrite an existing secret. This + /// enables configuration services to compensate safely if their database + /// transaction fails. Stores should expire unreferenced fresh values so a + /// failed compensating delete cannot leave an indefinite orphan. + async fn put( + &self, + reference: Option<&SecretRef>, + value: SecretString, + ) -> Result; + + /// Deletes or revokes a referenced value. + async fn delete(&self, reference: &SecretRef) -> Result<(), SecretError>; +} + +/// Read-only bootstrap store mapping explicit secret references to explicit +/// environment variable names. +/// +/// This store never interprets a caller-provided reference as an environment +/// variable name. The host must register every mapping at construction time, +/// preventing model/configuration input from probing the process environment. +#[derive(Clone, Debug, Default)] +pub struct EnvironmentSecretStore { + variables: BTreeMap, +} + +impl EnvironmentSecretStore { + /// Creates an empty default-deny mapping. + pub fn new() -> Self { + Self::default() + } + + /// Registers one deployment-owned reference-to-variable mapping. + /// + /// # Errors + /// + /// Returns [`SecretError::InvalidReference`] for an invalid environment + /// variable name. + pub fn register( + mut self, + reference: SecretRef, + variable_name: impl Into, + ) -> Result { + let variable_name = variable_name.into(); + let valid = !variable_name.is_empty() + && variable_name.len() <= 200 + && variable_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if !valid { + return Err(SecretError::InvalidReference); + } + self.variables.insert(reference, variable_name); + Ok(self) + } +} + +#[async_trait] +impl AiSecretStore for EnvironmentSecretStore { + async fn resolve(&self, reference: &SecretRef) -> Result { + let variable_name = self + .variables + .get(reference) + .ok_or(SecretError::Unavailable)?; + let value = std::env::var(variable_name).map_err(|_| SecretError::Unavailable)?; + if value.is_empty() { + return Err(SecretError::Unavailable); + } + Ok(SecretString::from(value)) + } + + async fn put( + &self, + _reference: Option<&SecretRef>, + _value: SecretString, + ) -> Result { + Err(SecretError::ReadOnly) + } + + async fn delete(&self, _reference: &SecretRef) -> Result<(), SecretError> { + Err(SecretError::ReadOnly) + } +} diff --git a/crates/graphql-orm-ai/src/sessions.rs b/crates/graphql-orm-ai/src/sessions.rs new file mode 100644 index 00000000..8926e477 --- /dev/null +++ b/crates/graphql-orm-ai/src/sessions.rs @@ -0,0 +1,487 @@ +//! Bounded per-user session GraphQL contract. + +use std::sync::Arc; + +use agql_auth::AuthPrincipal; +use async_graphql::{Context, ErrorExtensions, InputObject, Object, SimpleObject}; +use async_trait::async_trait; +use graphql_orm::graphql::pagination::{ + KeysetConnectionInput, PageInfo, ValidatedKeysetConnection, +}; +use uuid::Uuid; + +use crate::{AiError, AiScope, AiSessionId}; + +/// Scope input for session creation/configuration. +#[derive(Clone, Debug, InputObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiScopeInput { + /// Host-defined scope kind. + pub kind: String, + /// Host-defined scope ID. + pub id: String, + /// Optional tenant ID. + pub tenant_id: Option, +} + +impl From for AiScope { + fn from(value: AiScopeInput) -> Self { + Self { + kind: value.kind, + id: value.id, + tenant_id: value.tenant_id, + } + } +} + +/// Bounded session shell. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiSessionView { + /// Session ID. + pub id: Uuid, + /// Scope kind. + pub scope_kind: String, + /// Scope ID. + pub scope_id: String, + /// User-visible title. + pub title: String, + /// Active/archived/deleting state. + pub state: String, + /// Durable event stream head. + pub stream_head: i64, + /// Last activity timestamp in Unix seconds. + pub last_activity_at: i64, + /// Archive timestamp. + pub archived_at: Option, +} + +/// Session connection edge. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiSessionEdge { + /// Session node. + pub node: AiSessionView, + /// Opaque keyset cursor. + pub cursor: String, +} + +/// Bounded session connection. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiSessionConnection { + /// Bounded edges. + pub edges: Vec, + /// Relay page metadata. + pub page_info: PageInfo, +} + +/// Message shell; large content remains in separately windowed blocks. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiMessageView { + /// Message ID. + pub id: Uuid, + /// Session ID. + pub session_id: Uuid, + /// Stable session sequence. + pub sequence: i64, + /// User/assistant/tool/system role. + pub role: String, + /// Safe author reference. + pub author_subject: Option, + /// Producing run. + pub run_id: Option, + /// Protected/decrypted bounded preview, maximum 4 KiB. + pub preview: String, + /// Number of separately fetched blocks. + pub block_count: i64, + /// Completion state. + pub completion_state: String, + /// Creation timestamp. + pub created_at: i64, +} + +/// Message connection edge. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiMessageEdge { + /// Message shell. + pub node: AiMessageView, + /// Opaque keyset cursor. + pub cursor: String, +} + +/// Bounded bidirectional message connection. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiMessageConnection { + /// Bounded edges. + pub edges: Vec, + /// Relay page metadata. + pub page_info: PageInfo, +} + +/// One bounded message content block. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiMessageBlockView { + /// Block ID. + pub id: Uuid, + /// Parent message. + pub message_id: Uuid, + /// Stable block order. + pub block_index: i64, + /// Block kind. + pub kind: String, + /// Authorized/decrypted JSON content. + pub content: async_graphql::Json, + /// Original byte count. + pub byte_count: i64, + /// Original line count. + pub line_count: i64, +} + +/// Durable event view. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiSessionEventView { + /// Event ID. + pub id: Uuid, + /// Session sequence. + pub sequence: i64, + /// Stable event type. + pub event_type: String, + /// Optional run. + pub run_id: Option, + /// Correlation identifier. + pub correlation_id: String, + /// Authorized/decrypted event payload. + pub payload: async_graphql::Json, + /// Creation timestamp. + pub created_at: i64, +} + +/// Bounded durable event catch-up page. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiSessionEventPage { + /// Events after the requested sequence. + pub events: Vec, + /// Watermark captured for replay/live handoff. + pub watermark: i64, + /// Whether another bounded page remains before the watermark. + pub has_more: bool, + /// Whether retention removed the requested sequence. + pub reset_required: bool, +} + +/// Session creation input. +#[derive(Clone, Debug, InputObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct CreateAiSessionInput { + /// Application scope. + pub scope: AiScopeInput, + /// Optional initial title. + pub title: Option, +} + +/// Message submission input. +#[derive(Clone, Debug, InputObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct SendAiMessageInput { + /// Session ID. + pub session_id: Uuid, + /// User text, bounded by service policy. + pub text: String, + /// Already-authorized AI attachment IDs. + #[graphql(default)] + pub attachment_ids: Vec, + /// Client idempotency ID. + pub client_message_id: Uuid, +} + +/// Accepted message/run references. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct SendAiMessagePayload { + /// Persisted user message. + pub message_id: Uuid, + /// Queued run. + pub run_id: Uuid, +} + +/// Owner/scope-aware session backend. Implementations must use keyset-bounded +/// queries and never return data owned by another principal. +#[async_trait] +pub trait AiSessionService: Send + Sync { + /// Lists visible session shells. + async fn sessions( + &self, + principal: &AuthPrincipal, + page: ValidatedKeysetConnection, + ) -> Result; + + /// Loads one visible session shell. + async fn session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + ) -> Result, AiError>; + + /// Loads a bounded bidirectional message-shell window. + async fn messages( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + page: ValidatedKeysetConnection, + ) -> Result; + + /// Loads a bounded block window for one visible message. + async fn message_blocks( + &self, + principal: &AuthPrincipal, + message_id: Uuid, + after_block_index: Option, + first: i64, + ) -> Result, AiError>; + + /// Loads durable events for reconnect/catch-up. + async fn session_event_page( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + after_sequence: i64, + first: i64, + ) -> Result; + + /// Creates an owner-only session. + async fn create_session( + &self, + principal: &AuthPrincipal, + input: CreateAiSessionInput, + ) -> Result; + + /// Archives a visible owned session. + async fn archive_session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + ) -> Result; + + /// Restores an archived owned session. + async fn restore_session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + ) -> Result; + + /// Starts content/blob purge for an owned session. + async fn delete_session( + &self, + principal: &AuthPrincipal, + session_id: AiSessionId, + ) -> Result; + + /// Persists a user message and queues its fenced run atomically. + async fn send_message( + &self, + principal: &AuthPrincipal, + input: SendAiMessageInput, + ) -> Result; +} + +/// Composable AI query root. +#[derive(Clone, Copy, Debug, Default)] +pub struct AiQueryRoot; + +#[cfg_attr( + feature = "graphql-case-pascal", + Object(rename_fields = "PascalCase", rename_args = "PascalCase") +)] +#[cfg_attr(not(feature = "graphql-case-pascal"), Object)] +impl AiQueryRoot { + /// Returns bounded session shells. + async fn ai_sessions( + &self, + context: &Context<'_>, + #[graphql(default)] page: KeysetConnectionInput, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + let page = page.validate(50, 200).map_err(|error| (&error).extend())?; + service(context)? + .sessions(&principal, page) + .await + .map_err(extend) + } + + /// Returns one session shell, never full history. + async fn ai_session( + &self, + context: &Context<'_>, + id: Uuid, + ) -> async_graphql::Result> { + let principal = agql_auth::principal_from_ctx(context)?; + service(context)? + .session(&principal, AiSessionId(id)) + .await + .map_err(extend) + } + + /// Returns a bounded bidirectional message window. + async fn ai_messages( + &self, + context: &Context<'_>, + session_id: Uuid, + #[graphql(default)] page: KeysetConnectionInput, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + let page = if page == KeysetConnectionInput::default() { + KeysetConnectionInput { + last: Some(50), + ..KeysetConnectionInput::default() + } + } else { + page + }; + let page = page.validate(50, 200).map_err(|error| (&error).extend())?; + service(context)? + .messages(&principal, AiSessionId(session_id), page) + .await + .map_err(extend) + } + + /// Returns a bounded message-block window. + async fn ai_message_blocks( + &self, + context: &Context<'_>, + message_id: Uuid, + after_block_index: Option, + first: Option, + ) -> async_graphql::Result> { + let principal = agql_auth::principal_from_ctx(context)?; + let first = first.unwrap_or(20); + if !(1..=100).contains(&first) || after_block_index.is_some_and(|value| value < 0) { + return Err(AiError::InvalidInput("invalid message-block window".to_owned()).extend()); + } + service(context)? + .message_blocks(&principal, message_id, after_block_index, first) + .await + .map_err(extend) + } + + /// Returns a bounded durable catch-up page. + async fn ai_session_event_page( + &self, + context: &Context<'_>, + session_id: Uuid, + after_sequence: Option, + first: Option, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + let after_sequence = after_sequence.unwrap_or(0); + let first = first.unwrap_or(100); + if after_sequence < 0 || !(1..=500).contains(&first) { + return Err(AiError::InvalidInput("invalid event window".to_owned()).extend()); + } + service(context)? + .session_event_page(&principal, AiSessionId(session_id), after_sequence, first) + .await + .map_err(extend) + } +} + +/// Composable AI mutation root. +#[derive(Clone, Copy, Debug, Default)] +pub struct AiMutationRoot; + +#[cfg_attr( + feature = "graphql-case-pascal", + Object(rename_fields = "PascalCase", rename_args = "PascalCase") +)] +#[cfg_attr(not(feature = "graphql-case-pascal"), Object)] +impl AiMutationRoot { + /// Creates a private owner-only session. + async fn create_ai_session( + &self, + context: &Context<'_>, + input: CreateAiSessionInput, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + service(context)? + .create_session(&principal, input) + .await + .map_err(extend) + } + + /// Archives a session. + async fn archive_ai_session( + &self, + context: &Context<'_>, + id: Uuid, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + service(context)? + .archive_session(&principal, AiSessionId(id)) + .await + .map_err(extend) + } + + /// Restores an archived session. + async fn restore_ai_session( + &self, + context: &Context<'_>, + id: Uuid, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + service(context)? + .restore_session(&principal, AiSessionId(id)) + .await + .map_err(extend) + } + + /// Starts session content/blob purge. + async fn delete_ai_session( + &self, + context: &Context<'_>, + id: Uuid, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + service(context)? + .delete_session(&principal, AiSessionId(id)) + .await + .map_err(extend) + } + + /// Persists a message and queues a run. + async fn send_ai_message( + &self, + context: &Context<'_>, + input: SendAiMessageInput, + ) -> async_graphql::Result { + let principal = agql_auth::principal_from_ctx(context)?; + if input.text.is_empty() || input.text.len() > 256 * 1024 || input.attachment_ids.len() > 10 + { + return Err( + AiError::InvalidInput("message exceeds configured limits".to_owned()).extend(), + ); + } + service(context)? + .send_message(&principal, input) + .await + .map_err(extend) + } +} + +fn service(context: &Context<'_>) -> async_graphql::Result> { + context + .data::>() + .cloned() + .map_err(|_| { + AiError::InvalidConfiguration("AI session service is not installed".to_owned()).extend() + }) +} + +fn extend(error: AiError) -> async_graphql::Error { + error.extend() +} diff --git a/crates/graphql-orm-ai/src/subscriptions.rs b/crates/graphql-orm-ai/src/subscriptions.rs new file mode 100644 index 00000000..298c25e9 --- /dev/null +++ b/crates/graphql-orm-ai/src/subscriptions.rs @@ -0,0 +1,96 @@ +//! Resumable durable-session subscription GraphQL contract. + +use std::pin::Pin; +use std::sync::Arc; + +use agql_auth::AuthPrincipal; +use async_graphql::{Context, ErrorExtensions, SimpleObject, Subscription}; +use async_trait::async_trait; +use futures::{Stream, StreamExt}; +use uuid::Uuid; + +use crate::{AiError, AiSessionEventView, AiSessionId}; + +/// Commit-only wakeup hint. The durable event table remains the source of +/// truth; consumers never deliver this value directly to clients. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AiSessionWakeup { + /// Session whose durable stream advanced. + pub session_id: Uuid, + /// Sequence observed in the committing transaction. + pub sequence: i64, +} + +/// Subscription item supporting explicit retention-gap reset signaling. +#[derive(Clone, Debug, SimpleObject)] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiSessionEventEnvelope { + /// Durable event, absent only for a reset signal. + pub event: Option, + /// Replay watermark associated with this delivery. + pub watermark: i64, + /// Whether retention removed required history and the client must reload. + pub reset_required: bool, +} + +/// Type-erased bounded event stream. +pub type AiSessionEventStream = + Pin> + Send>>; + +/// Backend for catch-up-to-watermark plus live durable subscriptions. +#[async_trait] +pub trait AiSubscriptionService: Send + Sync { + /// Starts after an exclusive durable sequence. + async fn session_events( + &self, + principal: AuthPrincipal, + session_id: AiSessionId, + after_sequence: i64, + ) -> Result; +} + +/// Composable AI subscription root. +#[derive(Clone, Copy, Debug, Default)] +pub struct AiSubscriptionRoot; + +#[cfg_attr( + feature = "graphql-case-pascal", + Subscription(rename_fields = "PascalCase", rename_args = "PascalCase") +)] +#[cfg_attr(not(feature = "graphql-case-pascal"), Subscription)] +impl AiSubscriptionRoot { + /// Replays durable events, then follows commit-only wakeup hints while + /// periodically reauthorizing the principal. + async fn ai_session_events( + &self, + context: &Context<'_>, + session_id: Uuid, + after_sequence: Option, + ) -> async_graphql::Result< + Pin> + Send>>, + > { + let after_sequence = after_sequence.unwrap_or(0); + if after_sequence < 0 { + return Err(AiError::InvalidInput("invalid event sequence".to_owned()).extend()); + } + let principal = agql_auth::principal_from_ctx(context)?; + let stream = subscription_service(context)? + .session_events(principal, AiSessionId(session_id), after_sequence) + .await + .map_err(|error| error.extend())?; + Ok(Box::pin( + stream.map(|item| item.map_err(|error| error.extend())), + )) + } +} + +fn subscription_service( + context: &Context<'_>, +) -> async_graphql::Result> { + context + .data_opt::>() + .cloned() + .ok_or_else(|| { + AiError::InvalidConfiguration("AI subscription service is missing".to_owned()).extend() + }) +} diff --git a/crates/graphql-orm-ai/src/tools.rs b/crates/graphql-orm-ai/src/tools.rs new file mode 100644 index 00000000..4271848a --- /dev/null +++ b/crates/graphql-orm-ai/src/tools.rs @@ -0,0 +1,632 @@ +//! Default-deny GraphQL tool descriptors and policy. + +use std::collections::BTreeMap; + +use agql_auth::ResolvedPrincipal; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{AiDisclosureSchema, AiError, AiScope, DataClassification}; +use crate::{GraphqlOperationContract, ToolGraphqlRequest}; + +const JSON_SCHEMA_2020_12: &str = "https://json-schema.org/draft/2020-12/schema"; + +/// Stable validated tool identifier. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AiToolId(String); + +impl AiToolId { + /// Parses a stable lower-case namespaced tool identifier. + /// + /// # Errors + /// + /// Returns [`AiError::InvalidConfiguration`] for an empty or unsafe ID. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + let valid = !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b'-') + }); + if !valid { + return Err(AiError::InvalidConfiguration( + "tool IDs must be lower-case ASCII names".to_owned(), + )); + } + Ok(Self(value)) + } + + /// Returns the identifier. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// GraphQL operation kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiToolOperationKind { + /// Query operation. + Query, + /// Mutation operation. + Mutation, + /// Subscription/watch operation. + Subscription, + /// AI-owned internal operation such as proposal emission. + Internal, +} + +/// Ownership domain used to prevent recursive AI control-plane invocation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiToolOperationDomain { + /// Host application operation executed through ordinary authorization. + Application, + /// AI-owned structured proposal staging operation. + ProposalStaging, + /// AI session/configuration/approval/tool-discovery control plane. + AiControlPlane, + /// GraphQL schema introspection or discovery operation. + SchemaIntrospection, +} + +/// Rollout maturity ceiling for agent capabilities. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolMaturity { + /// Read-only application operations. + ReadOnly, + /// Writes only AI-owned structured proposals. + ProposalOnly, + /// Explicitly registered, supervised application mutation. + SupervisedWrite, + /// Future autonomous application writes; disabled by default. + AutonomousWrite, +} + +/// Default risk class. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiToolRisk { + /// Bounded internal read. + ReadOnly, + /// AI-owned proposal staging. + Proposal, + /// Proven idempotent low-impact write. + LowRiskWrite, + /// Non-idempotent application write. + NonIdempotentWrite, + /// Publish, delete, permission, external send, or similar impact. + HighImpact, + /// Credential or secret operation; not model-callable by default. + Secret, +} + +/// Approval rule. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiApprovalRule { + /// No per-call approval after explicit tool enablement. + None, + /// Policy decides using context. + Policy, + /// Expiring argument-bound one-shot approval. + OneShot, + /// Operation is never model-callable. + Never, +} + +/// Server-authored application tool descriptor. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AiToolDescriptor { + /// Stable ID. + pub id: AiToolId, + /// Human/model-facing description without sensitive schema details. + pub description: String, + /// Operation kind. + pub operation_kind: AiToolOperationKind, + /// Owning operation domain used for recursion prevention. + pub operation_domain: AiToolOperationDomain, + /// Server-authored GraphQL document. Empty only for internal tools. + pub document: String, + /// JSON Schema 2020-12 argument schema. + pub argument_schema: serde_json::Value, + /// Result projection identifier/expression controlled by the server. + pub result_projection: String, + /// Exact local/remote GraphQL contract for non-internal tools. + pub graphql_contract: Option, + /// Capability maturity. + pub maturity: ToolMaturity, + /// Default risk. + pub risk: AiToolRisk, + /// Approval rule. + pub approval: AiApprovalRule, + /// Maximum result bytes before artifacting/truncation. + pub maximum_result_bytes: u64, + /// Maximum result records. + pub maximum_result_records: u32, + /// Maximum model-facing data classification. + pub maximum_classification: DataClassification, + /// Whether retries are safe with a stable idempotency key. + pub idempotent: bool, + /// Stable fingerprint over the complete contract. + pub fingerprint: String, +} + +impl AiToolDescriptor { + /// Creates a descriptor with secure defaults. + /// + /// # Errors + /// + /// Returns an error for an invalid ID, missing description, or a + /// non-internal operation without a server-authored document. + pub fn new( + id: impl Into, + description: impl Into, + operation_kind: AiToolOperationKind, + document: impl Into, + argument_schema: serde_json::Value, + ) -> Result { + let id = AiToolId::parse(id)?; + let description = description.into(); + let document = document.into(); + if description.trim().is_empty() { + return Err(AiError::InvalidConfiguration( + "tool description must not be empty".to_owned(), + )); + } + if operation_kind != AiToolOperationKind::Internal && document.trim().is_empty() { + return Err(AiError::InvalidConfiguration( + "GraphQL tools require a server-authored document".to_owned(), + )); + } + + let mut descriptor = Self { + id, + description, + operation_kind, + operation_domain: if operation_kind == AiToolOperationKind::Internal { + AiToolOperationDomain::ProposalStaging + } else { + AiToolOperationDomain::Application + }, + document, + argument_schema, + result_projection: String::new(), + graphql_contract: None, + maturity: ToolMaturity::ReadOnly, + risk: AiToolRisk::ReadOnly, + approval: AiApprovalRule::None, + maximum_result_bytes: 64 * 1024, + maximum_result_records: 100, + maximum_classification: DataClassification::Internal, + idempotent: true, + fingerprint: String::new(), + }; + descriptor.refresh_fingerprint(); + Ok(descriptor) + } + + /// Sets maturity and refreshes the fingerprint. + pub fn with_maturity(mut self, maturity: ToolMaturity) -> Self { + self.maturity = maturity; + self.refresh_fingerprint(); + self + } + + /// Sets risk and approval behavior. + pub fn with_risk(mut self, risk: AiToolRisk, approval: AiApprovalRule) -> Self { + self.risk = risk; + self.approval = approval; + self.refresh_fingerprint(); + self + } + + /// Sets a bounded result projection. + pub fn with_result_projection(mut self, projection: impl Into) -> Self { + self.result_projection = projection.into(); + self.refresh_fingerprint(); + self + } + + /// Binds the tool to an exact local/remote target and static operation contract. + pub fn with_graphql_contract(mut self, contract: GraphqlOperationContract) -> Self { + self.graphql_contract = Some(contract); + self.refresh_fingerprint(); + self + } + + /// Sets the reviewed operation ownership domain. + pub fn with_operation_domain(mut self, domain: AiToolOperationDomain) -> Self { + self.operation_domain = domain; + self.refresh_fingerprint(); + self + } + + /// Sets output limits. + pub fn with_output_limits(mut self, bytes: u64, records: u32) -> Self { + self.maximum_result_bytes = bytes; + self.maximum_result_records = records; + self.refresh_fingerprint(); + self + } + + fn refresh_fingerprint(&mut self) { + self.fingerprint.clear(); + let encoded = serde_json::to_vec(self) + .expect("AiToolDescriptor consists only of serializable values"); + self.fingerprint = hex::encode(Sha256::digest(encoded)); + } +} + +/// Registered tool catalog. Registration does not enable tools. +#[derive(Clone, Debug, Default)] +pub struct AiToolCatalog { + tools: BTreeMap, +} + +#[derive(Clone, Debug)] +struct RegisteredAiTool { + descriptor: AiToolDescriptor, + disclosure_schema: Option, +} + +impl AiToolCatalog { + /// Creates an empty catalog. + pub fn new() -> Self { + Self::default() + } + + /// Registers a descriptor without exposing it. + /// + /// # Errors + /// + /// Returns [`AiError::AlreadyExists`] for duplicate stable IDs. + pub fn register(&mut self, descriptor: AiToolDescriptor) -> Result<(), AiError> { + if descriptor.operation_kind != AiToolOperationKind::Internal { + return Err(AiError::InvalidConfiguration( + "application tools require a static disclosure schema".to_owned(), + )); + } + self.register_validated(descriptor, None) + } + + /// Registers a GraphQL tool with its exact static disclosure schema. + /// Registration remains discovery only and does not enable the tool. + /// + /// # Errors + /// + /// Returns a safe error for duplicate IDs, forbidden operation domains, + /// introspection/AI-control-plane documents, or stale contract bindings. + pub fn register_with_disclosure( + &mut self, + descriptor: AiToolDescriptor, + disclosure_schema: AiDisclosureSchema, + ) -> Result<(), AiError> { + if descriptor.operation_kind == AiToolOperationKind::Internal { + return Err(AiError::InvalidConfiguration( + "internal tools do not accept GraphQL disclosure schemas".to_owned(), + )); + } + let contract = descriptor.graphql_contract.as_ref().ok_or_else(|| { + AiError::InvalidConfiguration( + "application tools require an exact GraphQL operation contract".to_owned(), + ) + })?; + if contract.disclosure_schema_fingerprint != disclosure_schema.fingerprint + || contract.operation_name.trim().is_empty() + || descriptor.result_projection.trim().is_empty() + || disclosure_schema.maximum_list_bound() > descriptor.maximum_result_records + { + return Err(AiError::InvalidConfiguration( + "tool disclosure or projection contract is stale".to_owned(), + )); + } + self.register_validated(descriptor, Some(disclosure_schema)) + } + + fn register_validated( + &mut self, + descriptor: AiToolDescriptor, + disclosure_schema: Option, + ) -> Result<(), AiError> { + if descriptor + .argument_schema + .get("$schema") + .and_then(serde_json::Value::as_str) + != Some(JSON_SCHEMA_2020_12) + || jsonschema::validator_for(&descriptor.argument_schema).is_err() + { + return Err(AiError::InvalidConfiguration( + "tool arguments must use a valid JSON Schema 2020-12 contract".to_owned(), + )); + } + if matches!( + descriptor.operation_domain, + AiToolOperationDomain::AiControlPlane | AiToolOperationDomain::SchemaIntrospection + ) || contains_forbidden_graphql_name(&descriptor.document) + { + return Err(AiError::InvalidConfiguration( + "AI control-plane and introspection operations cannot be tools".to_owned(), + )); + } + if self.tools.contains_key(&descriptor.id) { + return Err(AiError::AlreadyExists(descriptor.id.as_str().to_owned())); + } + self.tools.insert( + descriptor.id.clone(), + RegisteredAiTool { + descriptor, + disclosure_schema, + }, + ); + Ok(()) + } + + /// Returns a descriptor by ID. This is discovery, not authorization. + pub fn descriptor(&self, id: &AiToolId) -> Option<&AiToolDescriptor> { + self.tools.get(id).map(|tool| &tool.descriptor) + } + + /// Returns the static disclosure schema for a registered GraphQL tool. + pub fn disclosure_schema(&self, id: &AiToolId) -> Option<&AiDisclosureSchema> { + self.tools + .get(id) + .and_then(|tool| tool.disclosure_schema.as_ref()) + } + + /// Returns all registered descriptors. + pub fn descriptors(&self) -> impl Iterator { + self.tools.values().map(|tool| &tool.descriptor) + } + + pub(crate) fn validate_execution_request( + &self, + id: &AiToolId, + request: &ToolGraphqlRequest, + maximum_maturity: ToolMaturity, + ) -> Result<(&AiToolDescriptor, &AiDisclosureSchema), AiError> { + let registered = self.tools.get(id).ok_or(AiError::Forbidden)?; + let descriptor = ®istered.descriptor; + let disclosure = registered + .disclosure_schema + .as_ref() + .ok_or(AiError::Forbidden)?; + if descriptor.maturity > maximum_maturity + || descriptor.operation_kind == AiToolOperationKind::Internal + || descriptor.document != request.document + || descriptor.graphql_contract.as_ref() != Some(&request.contract) + || request.operation_name != request.contract.operation_name + || descriptor.result_projection != request.contract.result_projection_fingerprint + || disclosure.fingerprint != request.contract.disclosure_schema_fingerprint + { + return Err(AiError::Forbidden); + } + let validator = jsonschema::validator_for(&descriptor.argument_schema).map_err(|_| { + AiError::InvalidConfiguration("registered tool argument schema is invalid".to_owned()) + })?; + if !validator.is_valid(&request.variables) { + return Err(AiError::InvalidInput( + "tool arguments do not match the registered schema".to_owned(), + )); + } + Ok((descriptor, disclosure)) + } +} + +fn contains_forbidden_graphql_name(document: &str) -> bool { + const FORBIDDEN: &[&str] = &[ + "aisessions", + "aisession", + "aimessages", + "aimessageblocks", + "aisessioneventpage", + "aisessionevents", + "aiproviderprofiles", + "aicontentprotectionpolicy", + "createaisession", + "archiveaisession", + "restoreaisession", + "deleteaisession", + "sendaimessage", + "upsertaiproviderprofile", + "setaiprovidercredential", + "removeaiprovidercredential", + "setaicontentprotectionpolicy", + "aitooldiscovery", + "aitools", + "aiapprovals", + ]; + + graphql_names(document).any(|name| { + if name.starts_with("__") && name != "__typename" { + return true; + } + let normalized: String = name + .bytes() + .filter(|byte| *byte != b'_') + .map(|byte| byte.to_ascii_lowercase() as char) + .collect(); + FORBIDDEN.contains(&normalized.as_str()) + }) +} + +fn graphql_names(document: &str) -> impl Iterator { + let bytes = document.as_bytes(); + let mut names = Vec::new(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + index += 1; + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'"' => { + let triple = bytes.get(index..index + 3) == Some(b"\"\"\""); + index += if triple { 3 } else { 1 }; + while index < bytes.len() { + if triple && bytes.get(index..index + 3) == Some(b"\"\"\"") { + index += 3; + break; + } + if !triple && bytes[index] == b'"' { + index += 1; + break; + } + if bytes[index] == b'\\' && !triple { + index = (index + 2).min(bytes.len()); + } else { + index += 1; + } + } + } + byte if byte == b'_' || byte.is_ascii_alphabetic() => { + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index] == b'_' + || bytes[index].is_ascii_alphabetic() + || bytes[index].is_ascii_digit()) + { + index += 1; + } + names.push(&document[start..index]); + } + _ => index += 1, + } + } + names.into_iter() +} + +/// Persisted policy binding for one exact descriptor fingerprint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiToolPolicyBinding { + /// Stable tool ID. + pub tool_id: AiToolId, + /// Reviewed descriptor fingerprint. + pub fingerprint: String, + /// Explicit enablement. + pub enabled: bool, +} + +/// Current host authorization outcome for one exact registered tool request. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiToolAuthorizationDecision { + allowed: bool, + /// Stable non-sensitive reason code for audit and diagnostics. + pub reason_code: String, + /// Current host policy version used for the decision. + pub policy_version: String, + /// Current safe authorization-state digest used by approval workflows. + pub authorization_state_digest: String, +} + +impl AiToolAuthorizationDecision { + /// Creates an allowed current-principal decision. + pub fn allow( + reason_code: impl Into, + policy_version: impl Into, + authorization_state_digest: impl Into, + ) -> Self { + Self { + allowed: true, + reason_code: reason_code.into(), + policy_version: policy_version.into(), + authorization_state_digest: authorization_state_digest.into(), + } + } + + /// Creates a denied current-principal decision. + pub fn deny(reason_code: impl Into, policy_version: impl Into) -> Self { + Self { + allowed: false, + reason_code: reason_code.into(), + policy_version: policy_version.into(), + authorization_state_digest: String::new(), + } + } + + /// Returns whether current host policy allowed this exact request. + pub const fn is_allowed(&self) -> bool { + self.allowed + } + + pub(crate) fn is_complete_allow(&self) -> bool { + self.allowed + && !self.reason_code.trim().is_empty() + && !self.policy_version.trim().is_empty() + && !self.authorization_state_digest.trim().is_empty() + } +} + +/// Fresh, principal-aware host policy for registered application tool calls. +/// +/// This is evaluated after principal rehydration for every execution. The +/// ordinary resolver authorization path still runs afterward and remains +/// authoritative. +#[async_trait] +pub trait AiToolAuthorizationPolicy: Send + Sync { + /// Authorizes the exact registered descriptor, scope, and validated + /// variables using the freshly resolved principal. + async fn authorize( + &self, + principal: &ResolvedPrincipal, + scope: &AiScope, + descriptor: &AiToolDescriptor, + variables: &serde_json::Value, + ) -> AiToolAuthorizationDecision; +} + +/// Fail-closed tool policy suitable as an explicit disabled implementation. +#[derive(Clone, Copy, Debug, Default)] +pub struct DenyAllAiToolAuthorizationPolicy; + +#[async_trait] +impl AiToolAuthorizationPolicy for DenyAllAiToolAuthorizationPolicy { + async fn authorize( + &self, + _principal: &ResolvedPrincipal, + _scope: &AiScope, + _descriptor: &AiToolDescriptor, + _variables: &serde_json::Value, + ) -> AiToolAuthorizationDecision { + AiToolAuthorizationDecision::deny("default_deny", "deny-all") + } +} + +/// Scope tool policy. Absence always means disabled. +#[derive(Clone, Debug)] +pub struct AiToolPolicySet { + maximum_maturity: ToolMaturity, + bindings: BTreeMap, +} + +impl AiToolPolicySet { + /// Creates an empty, default-deny policy with a deployment/scope maturity + /// ceiling. + pub fn new(maximum_maturity: ToolMaturity) -> Self { + Self { + maximum_maturity, + bindings: BTreeMap::new(), + } + } + + /// Adds/replaces an explicit policy binding. + pub fn bind(&mut self, binding: AiToolPolicyBinding) { + self.bindings.insert(binding.tool_id.clone(), binding); + } + + /// Returns whether the exact current descriptor is enabled within the + /// maturity ceiling. + pub fn allows(&self, descriptor: &AiToolDescriptor) -> bool { + descriptor.maturity <= self.maximum_maturity + && self.bindings.get(&descriptor.id).is_some_and(|binding| { + binding.enabled && binding.fingerprint == descriptor.fingerprint + }) + } +} diff --git a/crates/graphql-orm-ai/tests/configuration_graphql.rs b/crates/graphql-orm-ai/tests/configuration_graphql.rs new file mode 100644 index 00000000..6ef887a1 --- /dev/null +++ b/crates/graphql-orm-ai/tests/configuration_graphql.rs @@ -0,0 +1,156 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use agql_auth::{AccessTokenMetadata, AuthPrincipal, AuthUser, SessionContext}; +use async_graphql::{EmptySubscription, Request, Schema}; +use async_trait::async_trait; +use graphql_orm_ai::*; +use secrecy::{ExposeSecret, SecretString}; +use uuid::Uuid; + +struct ConfigurationService { + expected_secret_received: AtomicBool, +} + +impl ConfigurationService { + fn profile(profile_id: Uuid) -> AiProviderProfileView { + AiProviderProfileView { + id: profile_id, + scope_kind: "project".to_owned(), + scope_id: "project-1".to_owned(), + tenant_id: Some("tenant-1".to_owned()), + provider_kind: "openai".to_owned(), + display_name: "OpenAI".to_owned(), + base_url: None, + credential_configured: true, + enabled: true, + row_version: 2, + updated_at: 1, + } + } +} + +#[async_trait] +impl AiConfigurationService for ConfigurationService { + async fn provider_profiles( + &self, + _principal: &AuthPrincipal, + _scope: AiScope, + ) -> Result, AiError> { + Ok(Vec::new()) + } + + async fn content_protection_policy( + &self, + _principal: &AuthPrincipal, + _scope: AiScope, + ) -> Result, AiError> { + Ok(None) + } + + async fn upsert_provider_profile( + &self, + _principal: &AuthPrincipal, + input: UpsertAiProviderProfileInput, + ) -> Result { + Ok(Self::profile(input.id.unwrap_or_else(Uuid::new_v4))) + } + + async fn set_provider_credential( + &self, + _principal: &AuthPrincipal, + profile_id: Uuid, + credential: SecretString, + _expected_version: i64, + ) -> Result { + self.expected_secret_received.store( + credential.expose_secret() == "synthetic-test-secret", + Ordering::Release, + ); + Ok(Self::profile(profile_id)) + } + + async fn remove_provider_credential( + &self, + _principal: &AuthPrincipal, + input: RemoveAiProviderCredentialInput, + ) -> Result { + let mut view = Self::profile(input.profile_id); + view.credential_configured = false; + Ok(view) + } + + async fn set_content_protection_policy( + &self, + _principal: &AuthPrincipal, + input: SetAiContentProtectionPolicyInput, + ) -> Result { + Ok(AiContentProtectionPolicyView { + scope_kind: input.scope.kind, + scope_id: input.scope.id, + tenant_id: input.scope.tenant_id, + protection_mode: "database_managed".to_owned(), + ready: true, + row_version: 1, + effective_at: 1, + }) + } +} + +fn principal() -> AuthPrincipal { + AuthPrincipal::User(AuthUser { + user_id: "admin-1".to_owned(), + session_id: Uuid::from_u128(1), + roles: vec!["admin".to_owned()], + scopes: vec!["ai:configure".to_owned()], + session: SessionContext::default(), + token_claims: AccessTokenMetadata::default(), + }) +} + +#[tokio::test] +async fn credential_mutation_returns_only_redacted_state() { + let service = Arc::new(ConfigurationService { + expected_secret_received: AtomicBool::new(false), + }); + let service_data: Arc = service.clone(); + let schema = Schema::build( + AiConfigurationQueryRoot, + AiConfigurationMutationRoot, + EmptySubscription, + ) + .data(service_data) + .finish(); + let profile_id = Uuid::new_v4(); + let request = Request::new(format!( + "mutation {{ setAiProviderCredential(input: {{ profileId: \"{profile_id}\", credential: \"synthetic-test-secret\", expectedVersion: 1 }}) {{ id credentialConfigured rowVersion }} }}" + )) + .data(principal()); + let response = schema.execute(request).await; + + assert!(response.errors.is_empty()); + assert!(service.expected_secret_received.load(Ordering::Acquire)); + let serialized = serde_json::to_string(&response.data).expect("response should serialize"); + assert!(!serialized.contains("synthetic-test-secret")); + assert!(!serialized.contains("credentialReference")); + assert!(!schema.sdl().contains("credentialReference")); +} + +#[tokio::test] +async fn configuration_roots_fail_closed_without_authentication() { + let service: Arc = Arc::new(ConfigurationService { + expected_secret_received: AtomicBool::new(false), + }); + let schema = Schema::build( + AiConfigurationQueryRoot, + AiConfigurationMutationRoot, + EmptySubscription, + ) + .data(service) + .finish(); + let response = schema + .execute("{ aiProviderProfiles(scope: { kind: \"project\", id: \"1\" }) { id } }") + .await; + + assert!(!response.errors.is_empty()); +} diff --git a/crates/graphql-orm-ai/tests/graphql_naming.rs b/crates/graphql-orm-ai/tests/graphql_naming.rs new file mode 100644 index 00000000..aecaffc6 --- /dev/null +++ b/crates/graphql-orm-ai/tests/graphql_naming.rs @@ -0,0 +1,27 @@ +use async_graphql::Schema; +use graphql_orm_ai::{AiMutationRoot, AiQueryRoot, AiSubscriptionRoot}; + +#[test] +fn configured_graphql_case_is_coherent_without_aliases() { + let sdl = Schema::build(AiQueryRoot, AiMutationRoot, AiSubscriptionRoot) + .finish() + .sdl(); + + #[cfg(not(feature = "graphql-case-pascal"))] + { + assert!(sdl.contains("aiSessions(")); + assert!(sdl.contains("aiMessages(sessionId:")); + assert!(sdl.contains("createAiSession(input:")); + assert!(sdl.contains("aiSessionEvents(sessionId:")); + assert!(!sdl.contains("AiSessions(")); + } + + #[cfg(feature = "graphql-case-pascal")] + { + assert!(sdl.contains("AiSessions(")); + assert!(sdl.contains("AiMessages(SessionId:")); + assert!(sdl.contains("CreateAiSession(Input:")); + assert!(sdl.contains("AiSessionEvents(SessionId:")); + assert!(!sdl.contains("aiSessions(")); + } +} diff --git a/crates/graphql-orm-ai/tests/orm_configuration.rs b/crates/graphql-orm-ai/tests/orm_configuration.rs new file mode 100644 index 00000000..16282d29 --- /dev/null +++ b/crates/graphql-orm-ai/tests/orm_configuration.rs @@ -0,0 +1,350 @@ +#![cfg(feature = "sqlite")] + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use agql_auth::{ + AccessTokenMetadata, AssuranceMatchMode, AuthPrincipal, AuthUser, FixedClock, MfaAcceptance, + RecentMfaPolicy, SessionAssurance, SessionContext, +}; +use async_trait::async_trait; +use graphql_orm::graphql::orm::{ApplyOptions, OrmSchemaModule}; +use graphql_orm::prelude::{Database, SqliteBackend}; +use graphql_orm_ai::*; +use secrecy::SecretString; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; + +struct AllowConfiguration; + +#[async_trait] +impl AiConfigurationAccessPolicy for AllowConfiguration { + async fn can_configure( + &self, + _principal: &AuthPrincipal, + _scope: &AiScope, + _action: AiConfigurationAction, + ) -> bool { + true + } +} + +struct LocalEndpointPolicy; + +impl AiProviderEndpointPolicy for LocalEndpointPolicy { + fn authorize_endpoint(&self, provider_kind: AiProviderKindInput, normalized_url: &str) -> bool { + provider_kind == AiProviderKindInput::Ollama && normalized_url == "http://127.0.0.1:11434/" + } +} + +#[derive(Default)] +struct MemorySecretStore { + next: AtomicU64, + references: std::sync::Mutex>, +} + +impl MemorySecretStore { + fn count(&self) -> usize { + self.references.lock().expect("secret lock").len() + } +} + +#[async_trait] +impl AiSecretStore for MemorySecretStore { + async fn resolve(&self, _reference: &SecretRef) -> Result { + Err(SecretError::Unavailable) + } + + async fn put( + &self, + reference: Option<&SecretRef>, + _value: SecretString, + ) -> Result { + assert!( + reference.is_none(), + "configuration uses fresh secret references" + ); + let value = self.next.fetch_add(1, Ordering::SeqCst); + let reference = SecretRef::parse(format!("memory:{value}"))?; + self.references + .lock() + .expect("secret lock") + .insert(reference.clone()); + Ok(reference) + } + + async fn delete(&self, reference: &SecretRef) -> Result<(), SecretError> { + self.references + .lock() + .expect("secret lock") + .remove(reference); + Ok(()) + } +} + +fn recent_principal(now: OffsetDateTime) -> AuthPrincipal { + let assurance = SessionAssurance::new( + now, + ["otp", "pwd"], + Some("urn:test:loa:2".to_owned()), + Some("test".to_owned()), + MfaAcceptance::Satisfied, + ) + .expect("valid assurance"); + AuthPrincipal::User(AuthUser { + user_id: "admin-1".to_owned(), + session_id: Uuid::new_v4(), + roles: vec!["admin".to_owned()], + scopes: vec![], + session: SessionContext::default().with_assurance(assurance), + token_claims: AccessTokenMetadata { + auth_time: Some(now.unix_timestamp()), + amr: Some(vec!["otp".to_owned(), "pwd".to_owned()]), + acr: Some("urn:test:loa:2".to_owned()), + tenant_id: Some("tenant-1".to_owned()), + ..AccessTokenMetadata::default() + }, + }) +} + +fn no_mfa_principal() -> AuthPrincipal { + AuthPrincipal::User(AuthUser { + user_id: "admin-1".to_owned(), + session_id: Uuid::new_v4(), + roles: vec!["admin".to_owned()], + scopes: vec![], + session: SessionContext::default(), + token_claims: AccessTokenMetadata::default(), + }) +} + +fn scope() -> AiScope { + AiScope::new("tenant", "tenant-1").with_tenant_id("tenant-1") +} + +fn scope_input() -> AiScopeInput { + AiScopeInput { + kind: "tenant".to_owned(), + id: "tenant-1".to_owned(), + tenant_id: Some("tenant-1".to_owned()), + } +} + +async fn service() -> ( + OrmAiConfigurationService, + Arc, + OffsetDateTime, +) { + let database = Database::::connect_sqlite("sqlite::memory:") + .await + .expect("in-memory SQLite opens"); + let module = AiSchemaModule; + let plan = database + .schema() + .plan_migration_to_entities( + "ai-configuration-test-v1", + "AI configuration service test", + module.entities(), + ) + .await + .expect("schema plans"); + database + .schema() + .apply_migration(&plan, ApplyOptions::default()) + .await + .expect("schema applies to in-memory SQLite"); + let now = OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("fixed time"); + let secrets = Arc::new(MemorySecretStore::default()); + let service = OrmAiConfigurationService::new( + database, + Arc::new(AllowConfiguration), + Arc::new(LocalEndpointPolicy), + RecentMfaPolicy { + maximum_age: Duration::minutes(5), + clock_skew: Duration::seconds(30), + allowed_amr: vec!["otp".to_owned()], + allowed_acr: vec!["urn:test:loa:2".to_owned()], + match_mode: AssuranceMatchMode::All, + }, + Arc::new(FixedClock::new(now)), + secrets.clone(), + ); + (service, secrets, now) +} + +#[tokio::test] +async fn profile_and_credential_mutations_require_recent_mfa_and_cas() { + let (service, secrets, now) = service().await; + assert!(matches!( + service + .upsert_provider_profile( + &no_mfa_principal(), + UpsertAiProviderProfileInput { + id: None, + scope: scope_input(), + provider_kind: AiProviderKindInput::OpenAi, + display_name: "OpenAI".to_owned(), + base_url: None, + enabled: true, + expected_version: None, + }, + ) + .await, + Err(AiError::RecentMfaRequired) + )); + let principal = recent_principal(now); + let profile = service + .upsert_provider_profile( + &principal, + UpsertAiProviderProfileInput { + id: None, + scope: scope_input(), + provider_kind: AiProviderKindInput::OpenAi, + display_name: "OpenAI".to_owned(), + base_url: None, + enabled: true, + expected_version: None, + }, + ) + .await + .expect("profile is created with recent MFA"); + assert_eq!(profile.row_version, 0); + assert!(!profile.credential_configured); + assert!(matches!( + service + .upsert_provider_profile( + &principal, + UpsertAiProviderProfileInput { + id: Some(profile.id), + scope: scope_input(), + provider_kind: AiProviderKindInput::OpenAi, + display_name: "stale".to_owned(), + base_url: None, + enabled: true, + expected_version: Some(99), + }, + ) + .await, + Err(AiError::Conflict) + )); + + let credential = service + .set_provider_credential( + &principal, + profile.id, + SecretString::from("test-secret-one".to_owned()), + 0, + ) + .await + .expect("credential reference is committed"); + assert!(credential.credential_configured); + assert_eq!(credential.row_version, 1); + assert_eq!(secrets.count(), 1); + let rotated = service + .set_provider_credential( + &principal, + profile.id, + SecretString::from("test-secret-two".to_owned()), + 1, + ) + .await + .expect("rotation replaces and cleans the old reference"); + assert_eq!(rotated.row_version, 2); + assert_eq!(secrets.count(), 1); + let removed = service + .remove_provider_credential( + &principal, + RemoveAiProviderCredentialInput { + profile_id: profile.id, + expected_version: 2, + }, + ) + .await + .expect("credential removal records cleanup"); + assert!(!removed.credential_configured); + assert_eq!(removed.row_version, 3); + assert_eq!(secrets.count(), 0); + + let profiles = service + .provider_profiles(&principal, scope()) + .await + .expect("redacted profile query"); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].id, profile.id); +} + +#[tokio::test] +async fn endpoint_policy_and_content_protection_readiness_fail_closed() { + let (service, _secrets, now) = service().await; + let principal = recent_principal(now); + assert!(matches!( + service + .upsert_provider_profile( + &principal, + UpsertAiProviderProfileInput { + id: None, + scope: scope_input(), + provider_kind: AiProviderKindInput::OpenAiCompatible, + display_name: "unsafe".to_owned(), + base_url: Some("http://169.254.169.254/latest?token=x".to_owned()), + enabled: true, + expected_version: None, + }, + ) + .await, + Err(AiError::InvalidInput(_)) | Err(AiError::Forbidden) + )); + let local = service + .upsert_provider_profile( + &principal, + UpsertAiProviderProfileInput { + id: None, + scope: scope_input(), + provider_kind: AiProviderKindInput::Ollama, + display_name: "Local Ollama".to_owned(), + base_url: Some("http://127.0.0.1:11434".to_owned()), + enabled: true, + expected_version: None, + }, + ) + .await + .expect("deployment policy permits exact local endpoint"); + assert_eq!(local.base_url.as_deref(), Some("http://127.0.0.1:11434/")); + + let database_managed = service + .set_content_protection_policy( + &principal, + SetAiContentProtectionPolicyInput { + scope: scope_input(), + mode: AiContentProtectionModeInput::DatabaseManaged, + key_policy_reference: None, + expected_version: None, + }, + ) + .await + .expect("database-managed policy is immediately ready"); + assert!(database_managed.ready); + let resolved = AiContentProtectionPolicyResolver::resolve(&service, &principal, &scope()) + .await + .expect("ready policy resolves"); + assert!(resolved.ready); + + let application_encrypted = service + .set_content_protection_policy( + &principal, + SetAiContentProtectionPolicyInput { + scope: scope_input(), + mode: AiContentProtectionModeInput::ApplicationEncrypted, + key_policy_reference: Some("kms:tenant-1/chat".to_owned()), + expected_version: Some(0), + }, + ) + .await + .expect("mode change records a pending migration"); + assert!(!application_encrypted.ready); + let resolved = AiContentProtectionPolicyResolver::resolve(&service, &principal, &scope()) + .await + .expect("pending policy remains inspectable"); + assert!(!resolved.ready); +} diff --git a/crates/graphql-orm-ai/tests/orm_sessions.rs b/crates/graphql-orm-ai/tests/orm_sessions.rs new file mode 100644 index 00000000..9022f0bd --- /dev/null +++ b/crates/graphql-orm-ai/tests/orm_sessions.rs @@ -0,0 +1,278 @@ +#![cfg(feature = "sqlite")] + +use std::sync::Arc; + +use agql_auth::{AccessTokenMetadata, AuthPrincipal, AuthUser, SessionContext}; +use async_trait::async_trait; +use graphql_orm::graphql::orm::{ApplyOptions, OrmSchemaModule}; +use graphql_orm::graphql::pagination::KeysetConnectionInput; +use graphql_orm::prelude::{Database, SqliteBackend}; +use graphql_orm_ai::*; +use uuid::Uuid; + +struct AllowAll; + +#[async_trait] +impl AiAccessPolicy for AllowAll { + async fn can_access_scope( + &self, + _principal: &AuthPrincipal, + _scope: &AiScope, + _action: AiSessionAction, + ) -> AiAccessDecision { + AiAccessDecision::allow("test", "test-v1") + } + + async fn can_access_session( + &self, + _principal: &AuthPrincipal, + _session_id: AiSessionId, + _action: AiSessionAction, + ) -> AiAccessDecision { + AiAccessDecision::allow("test", "test-v1") + } +} + +struct ProtectionPolicy; + +#[async_trait] +impl AiContentProtectionPolicyResolver for ProtectionPolicy { + async fn resolve( + &self, + _principal: &AuthPrincipal, + scope: &AiScope, + ) -> Result { + Ok(AiContentProtectionPolicy { + scope: scope.clone(), + mode: AiContentProtectionMode::DatabaseManaged, + key_policy_reference: None, + version: 1, + ready: true, + }) + } +} + +fn principal(subject: &str) -> AuthPrincipal { + AuthPrincipal::User(AuthUser { + user_id: subject.to_owned(), + session_id: Uuid::new_v4(), + roles: vec![], + scopes: vec![], + session: SessionContext::default(), + token_claims: AccessTokenMetadata { + tenant_id: Some("tenant-1".to_owned()), + ..AccessTokenMetadata::default() + }, + }) +} + +async fn service() -> OrmAiSessionService { + let database = Database::::connect_sqlite("sqlite::memory:") + .await + .expect("in-memory SQLite should open"); + let module = AiSchemaModule; + let plan = database + .schema() + .plan_migration_to_entities( + "ai-session-test-v1", + "AI session service test", + module.entities(), + ) + .await + .expect("AI schema migration should plan"); + database + .schema() + .apply_migration(&plan, ApplyOptions::default()) + .await + .expect("AI schema migration should apply to in-memory SQLite"); + OrmAiSessionService::new( + database, + Arc::new(AllowAll), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + ) +} + +fn scope_input() -> AiScopeInput { + AiScopeInput { + kind: "collection".to_owned(), + id: "54".to_owned(), + tenant_id: Some("tenant-1".to_owned()), + } +} + +#[tokio::test] +async fn owner_isolation_atomic_send_idempotency_and_windowed_reads() { + let service = service().await; + let owner = principal("owner"); + let stranger = principal("stranger"); + let session = service + .create_session( + &owner, + CreateAiSessionInput { + scope: scope_input(), + title: Some("Research".to_owned()), + }, + ) + .await + .expect("owner creates a session"); + assert_eq!(session.stream_head, 0); + assert!( + service + .session(&stranger, AiSessionId(session.id)) + .await + .expect("cross-owner lookup is safely handled") + .is_none() + ); + + let client_message_id = Uuid::new_v4(); + let first = service + .send_message( + &owner, + SendAiMessageInput { + session_id: session.id, + text: "Find records containing talisman".to_owned(), + attachment_ids: vec![], + client_message_id, + }, + ) + .await + .expect("message and run are committed atomically"); + let replay = service + .send_message( + &owner, + SendAiMessageInput { + session_id: session.id, + text: "Find records containing talisman".to_owned(), + attachment_ids: vec![], + client_message_id, + }, + ) + .await + .expect("same idempotency input returns the committed result"); + assert_eq!(first.message_id, replay.message_id); + assert_eq!(first.run_id, replay.run_id); + assert!(matches!( + service + .send_message( + &owner, + SendAiMessageInput { + session_id: session.id, + text: "Different content".to_owned(), + attachment_ids: vec![], + client_message_id, + }, + ) + .await, + Err(AiError::Conflict) + )); + + let messages = service + .messages( + &owner, + AiSessionId(session.id), + KeysetConnectionInput { + last: Some(20), + ..Default::default() + } + .validate(20, 100) + .expect("valid keyset request"), + ) + .await + .expect("bounded message window loads"); + assert_eq!(messages.edges.len(), 1); + assert_eq!( + messages.edges[0].node.preview, + "Find records containing talisman" + ); + + let blocks = service + .message_blocks(&owner, first.message_id, None, 20) + .await + .expect("bounded block window loads"); + assert_eq!(blocks.len(), 1); + assert_eq!( + blocks[0].content.0["text"], + "Find records containing talisman" + ); + + let events = service + .session_event_page(&owner, AiSessionId(session.id), 0, 100) + .await + .expect("durable event catch-up loads"); + assert_eq!(events.watermark, 1); + assert_eq!(events.events.len(), 1); + assert_eq!(events.events[0].event_type, "message_queued"); + assert_eq!( + events.events[0].payload.0["runId"], + first.run_id.to_string() + ); +} + +#[tokio::test] +async fn archive_restore_and_session_keyset_are_bounded() { + let service = service().await; + let owner = principal("owner"); + let first = service + .create_session( + &owner, + CreateAiSessionInput { + scope: scope_input(), + title: Some("First".to_owned()), + }, + ) + .await + .expect("first session"); + service + .create_session( + &owner, + CreateAiSessionInput { + scope: scope_input(), + title: Some("Second".to_owned()), + }, + ) + .await + .expect("second session"); + + let archived = service + .archive_session(&owner, AiSessionId(first.id)) + .await + .expect("archive uses CAS"); + assert_eq!(archived.state, "archived"); + assert!(matches!( + service + .send_message( + &owner, + SendAiMessageInput { + session_id: first.id, + text: "cannot send while archived".to_owned(), + attachment_ids: vec![], + client_message_id: Uuid::new_v4(), + }, + ) + .await, + Err(AiError::Conflict) + )); + let restored = service + .restore_session(&owner, AiSessionId(first.id)) + .await + .expect("restore uses CAS"); + assert_eq!(restored.state, "active"); + + let page = service + .sessions( + &owner, + KeysetConnectionInput { + first: Some(1), + include_total_count: true, + ..Default::default() + } + .validate(10, 50) + .expect("valid page"), + ) + .await + .expect("session page loads"); + assert_eq!(page.edges.len(), 1); + assert!(page.page_info.has_next_page); + assert!(page.page_info.total_count.is_none()); +} diff --git a/crates/graphql-orm-ai/tests/orm_subscriptions.rs b/crates/graphql-orm-ai/tests/orm_subscriptions.rs new file mode 100644 index 00000000..2d5111e5 --- /dev/null +++ b/crates/graphql-orm-ai/tests/orm_subscriptions.rs @@ -0,0 +1,231 @@ +#![cfg(feature = "sqlite")] + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use agql_auth::{ + AccessTokenMetadata, AuthPrincipal, AuthUser, CurrentPrincipalResolver, PrincipalReference, + ResolvedPrincipal, SessionContext, +}; +use async_trait::async_trait; +use futures::StreamExt; +use graphql_orm::graphql::orm::{ApplyOptions, OrmSchemaModule}; +use graphql_orm::prelude::{Database, SqliteBackend}; +use graphql_orm_ai::*; +use time::OffsetDateTime; +use uuid::Uuid; + +struct AllowAll; + +#[async_trait] +impl AiAccessPolicy for AllowAll { + async fn can_access_scope( + &self, + _principal: &AuthPrincipal, + _scope: &AiScope, + _action: AiSessionAction, + ) -> AiAccessDecision { + AiAccessDecision::allow("test", "test-v1") + } + + async fn can_access_session( + &self, + _principal: &AuthPrincipal, + _session_id: AiSessionId, + _action: AiSessionAction, + ) -> AiAccessDecision { + AiAccessDecision::allow("test", "test-v1") + } +} + +struct ProtectionPolicy; + +#[async_trait] +impl AiContentProtectionPolicyResolver for ProtectionPolicy { + async fn resolve( + &self, + _principal: &AuthPrincipal, + scope: &AiScope, + ) -> Result { + Ok(AiContentProtectionPolicy { + scope: scope.clone(), + mode: AiContentProtectionMode::DatabaseManaged, + key_policy_reference: None, + version: 1, + ready: true, + }) + } +} + +struct ToggleResolver { + principal: AuthPrincipal, + active: Arc, +} + +#[async_trait] +impl CurrentPrincipalResolver for ToggleResolver { + async fn resolve( + &self, + reference: &PrincipalReference, + ) -> agql_auth::AuthResult { + if !self.active.load(Ordering::SeqCst) { + return Err(agql_auth::AuthError::Forbidden); + } + ResolvedPrincipal::new( + reference.clone(), + self.principal.clone(), + OffsetDateTime::now_utc(), + ) + } +} + +fn principal() -> AuthPrincipal { + AuthPrincipal::User(AuthUser { + user_id: "owner".to_owned(), + session_id: Uuid::new_v4(), + roles: vec![], + scopes: vec![], + session: SessionContext::default(), + token_claims: AccessTokenMetadata { + tenant_id: Some("tenant-1".to_owned()), + ..AccessTokenMetadata::default() + }, + }) +} + +async fn services( + reauthorization_interval: Duration, +) -> ( + Arc, + OrmAiSubscriptionService, + AuthPrincipal, + Arc, +) { + let database = Database::::connect_sqlite("sqlite::memory:") + .await + .expect("in-memory SQLite opens"); + let module = AiSchemaModule; + let plan = database + .schema() + .plan_migration_to_entities( + "ai-subscription-test-v1", + "AI subscription service test", + module.entities(), + ) + .await + .expect("schema plans"); + database + .schema() + .apply_migration(&plan, ApplyOptions::default()) + .await + .expect("schema applies"); + let sessions = Arc::new(OrmAiSessionService::new( + database, + Arc::new(AllowAll), + Arc::new(ProtectionPolicy), + Arc::new(DatabaseManagedContentProtector), + )); + let principal = principal(); + let active = Arc::new(AtomicBool::new(true)); + let subscriptions = OrmAiSubscriptionService::new( + sessions.clone(), + Arc::new(ToggleResolver { + principal: principal.clone(), + active: active.clone(), + }), + ) + .with_reauthorization_interval(reauthorization_interval) + .with_replay_page_size(1); + (sessions, subscriptions, principal, active) +} + +async fn create_session( + sessions: &OrmAiSessionService, + principal: &AuthPrincipal, +) -> AiSessionView { + sessions + .create_session( + principal, + CreateAiSessionInput { + scope: AiScopeInput { + kind: "collection".to_owned(), + id: "54".to_owned(), + tenant_id: Some("tenant-1".to_owned()), + }, + title: Some("Subscription".to_owned()), + }, + ) + .await + .expect("session is created") +} + +async fn send( + sessions: &OrmAiSessionService, + principal: &AuthPrincipal, + session_id: Uuid, + text: &str, +) { + sessions + .send_message( + principal, + SendAiMessageInput { + session_id, + text: text.to_owned(), + attachment_ids: vec![], + client_message_id: Uuid::new_v4(), + }, + ) + .await + .expect("message commits"); +} + +#[tokio::test] +async fn receiver_attaches_before_replay_and_delivers_durable_wakeups() { + let (sessions, subscriptions, principal, _active) = services(Duration::from_secs(60)).await; + let session = create_session(&sessions, &principal).await; + let mut stream = subscriptions + .session_events(principal.clone(), AiSessionId(session.id), 0) + .await + .expect("subscription opens"); + send(&sessions, &principal, session.id, "first").await; + let item = tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .expect("subscription wakes") + .expect("stream item") + .expect("event delivery"); + assert!(!item.reset_required); + assert_eq!(item.event.expect("durable event").sequence, 1); +} + +#[tokio::test] +async fn replay_is_paged_to_a_watermark_and_revocation_closes_stream() { + let (sessions, subscriptions, principal, active) = services(Duration::from_millis(20)).await; + let session = create_session(&sessions, &principal).await; + send(&sessions, &principal, session.id, "first").await; + send(&sessions, &principal, session.id, "second").await; + let mut stream = subscriptions + .session_events(principal.clone(), AiSessionId(session.id), 0) + .await + .expect("subscription opens"); + let first = stream + .next() + .await + .expect("first item") + .expect("first event"); + let second = stream + .next() + .await + .expect("second item") + .expect("second event"); + assert_eq!(first.event.expect("event").sequence, 1); + assert_eq!(second.event.expect("event").sequence, 2); + + active.store(false, Ordering::SeqCst); + let revoked = tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .expect("reauthorization runs") + .expect("terminal error item"); + assert!(matches!(revoked, Err(AiError::ReauthorizationFailed))); + assert!(stream.next().await.is_none()); +} diff --git a/crates/graphql-orm-ai/tests/project_boundaries.rs b/crates/graphql-orm-ai/tests/project_boundaries.rs new file mode 100644 index 00000000..5787d169 --- /dev/null +++ b/crates/graphql-orm-ai/tests/project_boundaries.rs @@ -0,0 +1,50 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +fn rust_files(root: &Path) -> Vec { + let mut pending = vec![root.to_path_buf()]; + let mut files = Vec::new(); + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory).expect("source directory should be readable") { + let path = entry.expect("source entry should be readable").path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().is_some_and(|extension| extension == "rs") { + files.push(path); + } + } + } + files +} + +#[test] +fn crate_source_has_no_direct_database_or_consumer_dependency() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let manifest = + fs::read_to_string(root.join("Cargo.toml")).expect("manifest should be readable"); + assert!(!manifest.contains("sqlx =")); + assert!(!manifest.contains("tiberius =")); + for consumer_name in ["digitse", "gema", "fame-service", "cosmo"] { + assert!(!manifest.to_ascii_lowercase().contains(consumer_name)); + } + + for path in rust_files(&root.join("src")) { + let source = fs::read_to_string(&path).expect("Rust source should be readable"); + for forbidden in [ + "sqlx::", + "tiberius::", + "DATABASE_URL", + "TEST_DATABASE_URL", + "digitse", + "gema", + "fame-service", + "cosmo", + ] { + assert!( + !source.to_ascii_lowercase().contains(forbidden), + "{} contains forbidden boundary reference {forbidden}", + path.display() + ); + } + } +} diff --git a/crates/graphql-orm-ai/tests/provider_and_content_security.rs b/crates/graphql-orm-ai/tests/provider_and_content_security.rs new file mode 100644 index 00000000..c0836e53 --- /dev/null +++ b/crates/graphql-orm-ai/tests/provider_and_content_security.rs @@ -0,0 +1,232 @@ +use futures::TryStreamExt; +use graphql_orm_ai::*; +use serde_json::json; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; + +fn request(model: &str) -> ModelRequest { + ModelRequest { + model: model.to_owned(), + instructions: vec!["Use only authorized tools.".to_owned()], + input: vec![ModelInputBlock::Text { + text: "synthetic input".to_owned(), + }], + tools: vec![], + builtin_tools: vec![], + output_schema: None, + maximum_output_tokens: Some(64), + } +} + +fn manifest( + session_id: AiSessionId, + run_id: AiRunId, + model: &str, + capability: AiEgressCapability, +) -> AiEgressManifest { + AiEgressManifest { + provider_profile_id: "test-profile".to_owned(), + provider_kind: "openai_compatible".to_owned(), + model: model.to_owned(), + destination: "local-test".to_owned(), + destination_trust: AiDestinationTrust::Local, + capability, + scope: AiScope::new("test", "scope"), + session_id: Some(session_id), + run_id: Some(run_id), + sources: vec![AiDataSourceRef { + kind: "message".to_owned(), + reference: "synthetic".to_owned(), + classification: DataClassification::Public, + trust: AiSourceTrust::UserProvided, + }], + estimated_bytes: 10_000, + estimated_tokens: 1_000, + attachment_count: 0, + purpose: "test".to_owned(), + retention: "none".to_owned(), + residency: None, + policy_version: "test".to_owned(), + consent_reference: None, + } +} + +fn proof(manifest: &AiEgressManifest) -> AuthorizedEgress { + AiEgressDecision::allow(manifest, "test", "test-user") + .authorize(manifest) + .expect("test manifest should authorize") +} + +fn budget( + run_id: AiRunId, + provider_kind: ProviderKind, + model: &str, +) -> AuthorizedBudgetReservation { + let attempt_id = Uuid::new_v4(); + AiBudgetReservation::new_reserved( + AiBudgetReservationId::new(), + run_id, + attempt_id, + 1, + provider_kind.clone(), + model, + "test-pricing-v1", + AiBudgetAmounts { + input_tokens: 1_000, + output_tokens: 64, + runs: 1, + ..AiBudgetAmounts::default() + }, + OffsetDateTime::now_utc() + Duration::hours(1), + ) + .expect("test budget should validate") + .authorize_provider_call( + run_id, + attempt_id, + 1, + &provider_kind, + model, + 64, + OffsetDateTime::now_utc(), + ) + .expect("test budget should authorize") +} + +#[tokio::test] +async fn provider_context_rejects_model_swap_before_mock_receives_request() { + let session_id = AiSessionId::new(); + let run_id = AiRunId::new(); + let authorized = manifest( + session_id, + run_id, + "authorized-model", + AiEgressCapability::ModelInference, + ); + let context = ProviderRequestContext::new( + session_id, + run_id, + "correlation", + budget(run_id, ProviderKind::OpenAiCompatible, "authorized-model"), + authorized.clone(), + proof(&authorized), + ) + .expect("context should validate"); + let provider = MockProvider::new(vec![ProviderEvent::ResponseCompleted { + response_id: Some("mock".to_owned()), + }]); + + assert!(matches!( + provider.stream(request("swapped-model"), context).await, + Err(ProviderError::BudgetDenied) + )); + assert_eq!(provider.request_count(), 0); +} + +#[tokio::test] +async fn each_provider_builtin_requires_its_own_egress_capability() { + let session_id = AiSessionId::new(); + let run_id = AiRunId::new(); + let inference = manifest( + session_id, + run_id, + "test-model", + AiEgressCapability::ModelInference, + ); + let base_context = ProviderRequestContext::new( + session_id, + run_id, + "correlation", + budget(run_id, ProviderKind::OpenAiCompatible, "test-model"), + inference.clone(), + proof(&inference), + ) + .expect("context should validate"); + let mut web_request = request("test-model"); + web_request.builtin_tools = vec![ModelBuiltinTool::WebSearch { + allowed_domains: vec!["example.com".to_owned()], + }]; + let provider = MockProvider::new(vec![ProviderEvent::ResponseCompleted { + response_id: Some("mock".to_owned()), + }]); + + assert!(matches!( + provider + .stream(web_request.clone(), base_context.clone()) + .await, + Err(ProviderError::EgressDenied) + )); + + let web = manifest( + session_id, + run_id, + "test-model", + AiEgressCapability::WebSearch, + ); + let context = base_context + .with_authorized_transfer(web.clone(), proof(&web)) + .expect("separate web grant should bind"); + let events = provider + .stream(web_request, context) + .await + .expect("fully authorized request should start") + .try_collect::>() + .await + .expect("mock stream should complete"); + + assert_eq!(events.len(), 1); + assert_eq!(provider.request_count(), 1); +} + +#[tokio::test] +async fn database_managed_protection_refuses_wrong_mode_and_unready_policy() { + let protector = DatabaseManagedContentProtector; + let context = ContentProtectionContext { + entity: "message_block".to_owned(), + row_id: "row-1".to_owned(), + field: "content".to_owned(), + scope: AiScope::new("test", "scope"), + }; + let mut policy = AiContentProtectionPolicy { + scope: context.scope.clone(), + mode: AiContentProtectionMode::DatabaseManaged, + key_policy_reference: None, + version: 1, + ready: false, + }; + assert_eq!( + protector + .protect(&policy, &context, json!({"text": "private"})) + .await, + Err(ContentProtectionError::PolicyNotReady) + ); + + policy.ready = true; + let envelope = protector + .protect(&policy, &context, json!({"text": "private"})) + .await + .expect("ready database policy should protect"); + assert_eq!( + protector + .open(&policy, &context, &envelope) + .await + .expect("matching mode should open"), + json!({"text": "private"}) + ); + + policy.mode = AiContentProtectionMode::ApplicationEncrypted; + assert_eq!( + protector.open(&policy, &context, &envelope).await, + Err(ContentProtectionError::ValidationFailed) + ); +} + +#[tokio::test] +async fn bootstrap_secret_store_is_explicitly_mapped_and_read_only() { + let reference = SecretRef::parse("provider/openai/test").expect("reference should parse"); + let store = EnvironmentSecretStore::new(); + assert!(matches!( + store.resolve(&reference).await, + Err(SecretError::Unavailable) + )); + assert_eq!(store.delete(&reference).await, Err(SecretError::ReadOnly)); +} diff --git a/crates/graphql-orm-ai/tests/run_and_restore.rs b/crates/graphql-orm-ai/tests/run_and_restore.rs new file mode 100644 index 00000000..e36f16a9 --- /dev/null +++ b/crates/graphql-orm-ai/tests/run_and_restore.rs @@ -0,0 +1,105 @@ +use graphql_orm::graphql::orm::LeaseError; +use graphql_orm_ai::*; +use uuid::Uuid; + +#[test] +fn reclaimed_worker_cannot_transition_or_append() { + let mut run = AiRunLeaseMachine::queued("run-1", 0); + let worker_a = run + .claim("worker-a", Uuid::from_u128(1), 1_000, 100, 0) + .expect("worker A should claim"); + run.transition(&worker_a, AiRunState::Running, 1_001, 1) + .expect("worker A should start"); + + run.state = AiRunState::RetryScheduled; + let worker_b = run + .claim("worker-b", Uuid::from_u128(2), 1_101, 100, 2) + .expect("worker B should reclaim expired work"); + + assert!(matches!( + run.commit_child_write(&worker_a, 1_102, 3), + Err(AiRunTransitionError::Lease(LeaseError::StaleFence)) + )); + assert_eq!( + run.transition(&worker_b, AiRunState::Running, 1_102, 3) + .expect("current worker should transition"), + 4 + ); +} + +#[test] +fn restore_never_replays_uncertain_external_effect() { + let fingerprint = "module-fingerprint"; + let reconciler = AiRestoreReconciler::new(fingerprint); + let uncertain_run_id = AiRunId::new(); + let safe_run_id = AiRunId::new(); + let plan = reconciler.plan(&AiRestoreSnapshotFacts { + module_fingerprint: fingerprint.to_owned(), + missing_key_versions: vec![], + runs: vec![ + AiRestoredRun { + run_id: uncertain_run_id, + state: AiRunState::WaitingTool, + external_effect: AiExternalEffectState::Uncertain, + has_provider_continuation: true, + has_provider_file: false, + }, + AiRestoredRun { + run_id: safe_run_id, + state: AiRunState::Running, + external_effect: AiExternalEffectState::ProvenIdempotent, + has_provider_continuation: false, + has_provider_file: true, + }, + ], + pending_approval_count: 2, + pending_egress_consent_count: 3, + invalid_attachment_count: 0, + duplicate_stream_sequence_count: 0, + stream_gap_count: 1, + }); + + let uncertain = plan + .run_actions + .iter() + .find(|action| action.run_id == uncertain_run_id) + .expect("uncertain action should exist"); + assert_eq!( + uncertain.disposition, + AiRestoredRunDisposition::RecoveryRequired + ); + assert!(uncertain.clear_lease); + assert!(uncertain.reverify_provider_continuation); + + let safe = plan + .run_actions + .iter() + .find(|action| action.run_id == safe_run_id) + .expect("safe action should exist"); + assert_eq!( + safe.disposition, + AiRestoredRunDisposition::RequeueWithNewAttempt + ); + assert!(safe.reverify_provider_file); + assert_eq!(plan.approvals_to_revalidate, 2); + assert_eq!(plan.consents_to_revalidate, 3); + assert_eq!(plan.fatal_issue_count(), 0); +} + +#[test] +fn restore_fatal_checks_keep_start_gate_closed() { + let reconciler = AiRestoreReconciler::new("expected"); + let plan = reconciler.plan(&AiRestoreSnapshotFacts { + module_fingerprint: "wrong".to_owned(), + missing_key_versions: vec!["key-v1".to_owned()], + runs: vec![], + pending_approval_count: 0, + pending_egress_consent_count: 0, + invalid_attachment_count: 1, + duplicate_stream_sequence_count: 1, + stream_gap_count: 0, + }); + + assert_eq!(plan.fatal_issue_count(), 4); + assert_eq!(plan.readiness_report_after_apply(true).fatal_issue_count, 4); +} diff --git a/crates/graphql-orm-ai/tests/runtime_contracts.rs b/crates/graphql-orm-ai/tests/runtime_contracts.rs new file mode 100644 index 00000000..238af792 --- /dev/null +++ b/crates/graphql-orm-ai/tests/runtime_contracts.rs @@ -0,0 +1,412 @@ +use std::collections::BTreeSet; +use std::sync::Arc; + +use agql_auth::{ + AccessTokenMetadata, AuthPrincipal, AuthUser, CurrentPrincipalResolver, PrincipalReference, + ResolvedPrincipal, SessionContext, +}; +use async_trait::async_trait; +use graphql_orm_ai::*; +use serde_json::json; +use time::OffsetDateTime; +use uuid::Uuid; + +fn principal(scopes: &[&str]) -> AuthPrincipal { + AuthPrincipal::User(AuthUser { + user_id: "user-1".to_owned(), + session_id: Uuid::from_u128(1), + roles: vec![], + scopes: scopes.iter().map(|scope| (*scope).to_owned()).collect(), + session: SessionContext::default(), + token_claims: AccessTokenMetadata { + tenant_id: Some("tenant-1".to_owned()), + resource_type: Some("project".to_owned()), + resource_id: Some("project-1".to_owned()), + ..AccessTokenMetadata::default() + }, + }) +} + +struct Resolver(AuthPrincipal); + +#[async_trait] +impl CurrentPrincipalResolver for Resolver { + async fn resolve( + &self, + reference: &PrincipalReference, + ) -> agql_auth::AuthResult { + ResolvedPrincipal::new( + reference.clone(), + self.0.clone(), + OffsetDateTime::UNIX_EPOCH, + ) + } +} + +struct ContextFactory; + +#[async_trait] +impl GraphqlRequestContextFactory for ContextFactory { + async fn build( + &self, + principal: &ResolvedPrincipal, + _target: &GraphqlExecutionTarget, + _invocation: &GraphqlInvocationContext, + ) -> Result { + Ok(GraphqlRequestContext::new( + principal.principal().scopes().to_vec(), + )) + } +} + +struct Executor; + +#[async_trait] +impl AuthenticatedGraphqlExecutor for Executor { + async fn execute( + &self, + context: GraphqlRequestContext, + request: ToolGraphqlRequest, + ) -> Result { + let scopes = context + .downcast_ref::>() + .ok_or(ToolExecutionError::RequestContext)?; + let data = if request + .variables + .get("emitUnknown") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + json!({"scopes": scopes, "credential": "must-not-escape"}) + } else { + json!({"scopes": scopes}) + }; + Ok(ToolGraphqlResponse { + data, + error_codes: vec![], + application_audit_ref: Some("audit-1".to_owned()), + }) + } +} + +struct AllowEgress; + +struct AllowAccess; + +struct AllowTools; + +struct ProtectionPolicy; + +#[async_trait] +impl AiContentProtectionPolicyResolver for ProtectionPolicy { + async fn resolve( + &self, + _principal: &AuthPrincipal, + scope: &AiScope, + ) -> Result { + Ok(AiContentProtectionPolicy { + scope: scope.clone(), + mode: AiContentProtectionMode::DatabaseManaged, + key_policy_reference: None, + version: 1, + ready: true, + }) + } +} + +#[async_trait] +impl AiAccessPolicy for AllowAccess { + async fn can_access_scope( + &self, + _principal: &AuthPrincipal, + _scope: &AiScope, + _action: AiSessionAction, + ) -> AiAccessDecision { + AiAccessDecision::allow("test", "test-1") + } + + async fn can_access_session( + &self, + _principal: &AuthPrincipal, + _session_id: AiSessionId, + _action: AiSessionAction, + ) -> AiAccessDecision { + AiAccessDecision::allow("test", "test-1") + } +} + +#[async_trait] +impl AiToolAuthorizationPolicy for AllowTools { + async fn authorize( + &self, + principal: &ResolvedPrincipal, + _scope: &AiScope, + _descriptor: &AiToolDescriptor, + variables: &serde_json::Value, + ) -> AiToolAuthorizationDecision { + if variables + .get("incompleteAuthorization") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + return AiToolAuthorizationDecision::allow("", "", ""); + } + AiToolAuthorizationDecision::allow( + "test", + "tool-policy-v1", + format!("auth-state:{}", principal.principal().subject()), + ) + } +} + +#[async_trait] +impl AiEgressPolicy for AllowEgress { + async fn authorize( + &self, + principal: &ResolvedPrincipal, + manifest: &AiEgressManifest, + ) -> AiEgressDecision { + AiEgressDecision::allow(manifest, "scope-policy-1", principal.principal().subject()) + } +} + +fn runtime() -> AiRuntime { + let document = "query Current { current { scopes } }"; + let disclosure = AiDisclosureSchema::new( + "current-v1", + AiDisclosureShape::object( + AiDisclosureRule::exportable(DataClassification::Internal), + [( + "scopes".to_owned(), + AiDisclosureShape::list( + AiDisclosureRule::exportable(DataClassification::Internal), + 32, + AiDisclosureShape::scalar(AiDisclosureRule::exportable( + DataClassification::Internal, + )), + ), + )], + ), + ) + .expect("disclosure schema should validate"); + let contract = GraphqlOperationContract::new( + GraphqlExecutionTargetId::parse("local-app").expect("target ID"), + "schema-v1", + "Current", + document, + "projection-v1", + disclosure.fingerprint.clone(), + ) + .expect("contract should validate"); + let descriptor = AiToolDescriptor::new( + "records.current", + "Read the current principal's visible records", + AiToolOperationKind::Query, + document, + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "emitUnknown": { "type": "boolean" }, + "incompleteAuthorization": { "type": "boolean" } + }, + "additionalProperties": false + }), + ) + .expect("descriptor should validate") + .with_result_projection("projection-v1") + .with_graphql_contract(contract); + let mut tool_catalog = AiToolCatalog::new(); + tool_catalog + .register_with_disclosure(descriptor, disclosure) + .expect("tool should register"); + let mut targets = GraphqlExecutionTargetRegistry::new(); + targets + .register(GraphqlExecutionTarget { + id: GraphqlExecutionTargetId::parse("local-app").expect("target ID"), + class: GraphqlExecutionTargetClass::Local, + audience: None, + resource_type: None, + resource_id: None, + schema_fingerprint: "schema-v1".to_owned(), + }) + .expect("target should register"); + AiRuntime::builder() + .principal_resolver(Arc::new(Resolver(principal(&["records:read"])))) + .access_policy(Arc::new(AllowAccess)) + .tool_authorization_policy(Arc::new(AllowTools)) + .request_context_factory(Arc::new(ContextFactory)) + .graphql_executor(Arc::new(Executor)) + .graphql_targets(targets) + .egress_policy(Arc::new(AllowEgress)) + .deployment_egress(AiDeploymentEgressBoundary { + allowed_destination_trust: BTreeSet::from([AiDestinationTrust::ManagedProvider]), + allowed_capabilities: BTreeSet::from([AiEgressCapability::ModelInference]), + maximum_classification: DataClassification::Internal, + maximum_bytes: 1_000, + maximum_attachments: 0, + }) + .maximum_tool_maturity(ToolMaturity::ProposalOnly) + .tool_catalog(tool_catalog) + .secret_store(Arc::new(EnvironmentSecretStore::new())) + .content_protection_policy_resolver(Arc::new(ProtectionPolicy)) + .content_protector(Arc::new(DatabaseManagedContentProtector)) + .build() + .expect("runtime configuration should validate") +} + +fn current_request(runtime: &AiRuntime, variables: serde_json::Value) -> ToolGraphqlRequest { + let document = "query Current { current { scopes } }"; + ToolGraphqlRequest { + document: document.to_owned(), + operation_name: "Current".to_owned(), + contract: GraphqlOperationContract::new( + GraphqlExecutionTargetId::parse("local-app").expect("target ID"), + "schema-v1", + "Current", + document, + "projection-v1", + runtime + .tool_catalog() + .disclosure_schema( + &AiToolId::parse("records.current").expect("tool ID should validate"), + ) + .expect("disclosure schema should be registered") + .fingerprint + .clone(), + ) + .expect("contract should validate"), + variables, + invocation: GraphqlInvocationContext { + run_id: AiRunId::new(), + tool_call_id: AiToolCallId::new(), + scope: AiScope::new("project", "project-1"), + correlation_id: "correlation-1".to_owned(), + causation_id: "command-1".to_owned(), + delegation_reference: None, + idempotency_key: None, + }, + } +} + +fn open_runtime(runtime: &AiRuntime) { + runtime + .start_gate() + .open(&AiRuntimeReadinessReport { + module_fingerprint: runtime + .start_gate() + .expected_module_fingerprint() + .to_owned(), + executor_bound: true, + restore_reconciled: true, + fatal_issue_count: 0, + }) + .expect("matching readiness should open runtime"); +} + +#[tokio::test] +async fn runtime_is_closed_until_matching_readiness_evidence() { + let runtime = runtime(); + let principal_reference = principal(&["stale:scope"]).reference(); + let request = current_request(&runtime, json!({})); + + assert!(matches!( + runtime + .execute_tool( + &principal_reference, + &AiToolId::parse("records.current").expect("tool ID"), + request.clone(), + ) + .await, + Err(AiError::RuntimeNotReady) + )); + assert!( + runtime + .start_gate() + .open(&AiRuntimeReadinessReport { + module_fingerprint: "wrong".to_owned(), + executor_bound: true, + restore_reconciled: true, + fatal_issue_count: 0, + }) + .is_err() + ); + + open_runtime(&runtime); + let response = runtime + .execute_tool( + &principal_reference, + &AiToolId::parse("records.current").expect("tool ID"), + request, + ) + .await + .expect("ready runtime should execute through the bridge"); + + assert_eq!( + response.response().data, + json!({"scopes": ["records:read"]}) + ); + assert_eq!( + response.response().application_audit_ref.as_deref(), + Some("audit-1") + ); + assert_eq!(response.policy_version(), "tool-policy-v1"); + assert_eq!( + response.disclosure().maximum_classification, + DataClassification::Internal + ); +} + +#[tokio::test] +async fn runtime_rejects_invalid_arguments_and_non_disclosed_resolver_fields() { + let runtime = runtime(); + open_runtime(&runtime); + let principal_reference = principal(&["records:read"]).reference(); + let tool_id = AiToolId::parse("records.current").expect("tool ID"); + + assert!(matches!( + runtime + .execute_tool( + &principal_reference, + &tool_id, + current_request(&runtime, json!({"unknown": true})), + ) + .await, + Err(AiError::InvalidInput(_)) + )); + assert!(matches!( + runtime + .execute_tool( + &principal_reference, + &tool_id, + current_request(&runtime, json!({"emitUnknown": true})), + ) + .await, + Err(AiError::ToolExecutionFailed) + )); + assert!(matches!( + runtime + .execute_tool( + &principal_reference, + &tool_id, + current_request(&runtime, json!({"incompleteAuthorization": true})), + ) + .await, + Err(AiError::ToolExecutionFailed) + )); + + let mut stale_schema = current_request(&runtime, json!({})); + stale_schema.contract.schema_fingerprint = "schema-v2".to_owned(); + assert!(matches!( + runtime + .execute_tool(&principal_reference, &tool_id, stale_schema) + .await, + Err(AiError::Forbidden) + )); +} + +#[test] +fn runtime_builder_requires_every_security_boundary() { + let result = AiRuntime::builder().build(); + assert!(matches!(result, Err(AiError::InvalidConfiguration(_)))); +} diff --git a/crates/graphql-orm-ai/tests/schema_module.rs b/crates/graphql-orm-ai/tests/schema_module.rs new file mode 100644 index 00000000..0d655184 --- /dev/null +++ b/crates/graphql-orm-ai/tests/schema_module.rs @@ -0,0 +1,32 @@ +use graphql_orm::graphql::orm::SchemaModuleCatalog; +use graphql_orm_ai::{AI_TABLE_NAMESPACE, AiSchemaModule}; + +#[test] +fn ai_schema_module_owns_only_reserved_namespace_tables() { + let module = AiSchemaModule; + let catalog = SchemaModuleCatalog::compose(&[&module]).expect("AI module should validate"); + + assert_eq!(catalog.modules().len(), 1); + assert_eq!(catalog.entities().len(), 35); + assert!( + catalog + .entities() + .iter() + .all(|entity| entity.table_name.starts_with(AI_TABLE_NAMESPACE)) + ); + assert!( + catalog + .entities() + .iter() + .filter(|entity| matches!( + entity.table_name, + "graphql_orm_ai_run_attempts" + | "graphql_orm_ai_skill_versions" + | "graphql_orm_ai_usage_entries" + | "graphql_orm_ai_audit_events" + | "graphql_orm_ai_egress_events" + )) + .all(|entity| entity.append_only) + ); + assert_eq!(catalog.modules()[0].restore_hooks.len(), 4); +} diff --git a/crates/graphql-orm-ai/tests/security_contracts.rs b/crates/graphql-orm-ai/tests/security_contracts.rs new file mode 100644 index 00000000..b39c5d6d --- /dev/null +++ b/crates/graphql-orm-ai/tests/security_contracts.rs @@ -0,0 +1,433 @@ +use std::collections::BTreeSet; + +use graphql_orm_ai::*; +use serde_json::json; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; + +fn source(classification: DataClassification) -> AiDataSourceRef { + AiDataSourceRef { + kind: "message_block".to_owned(), + reference: "block-1".to_owned(), + classification, + trust: AiSourceTrust::UserProvided, + } +} + +fn manifest(classification: DataClassification) -> AiEgressManifest { + AiEgressManifest { + provider_profile_id: "profile-1".to_owned(), + provider_kind: "openai".to_owned(), + model: "model-1".to_owned(), + destination: "managed-provider".to_owned(), + destination_trust: AiDestinationTrust::ManagedProvider, + capability: AiEgressCapability::ModelInference, + scope: AiScope::new("project", "project-7"), + session_id: Some(AiSessionId::new()), + run_id: Some(AiRunId::new()), + sources: vec![source(classification)], + estimated_bytes: 100, + estimated_tokens: 25, + attachment_count: 0, + purpose: "assistant_response".to_owned(), + retention: "zero-retention".to_owned(), + residency: Some("au".to_owned()), + policy_version: "policy-1".to_owned(), + consent_reference: None, + } +} + +fn disclosure_schema() -> AiDisclosureSchema { + AiDisclosureSchema::new( + "records-v1", + AiDisclosureShape::object( + AiDisclosureRule::exportable(DataClassification::Internal), + [( + "records".to_owned(), + AiDisclosureShape::list( + AiDisclosureRule::exportable(DataClassification::Internal), + 100, + AiDisclosureShape::object( + AiDisclosureRule::exportable(DataClassification::Internal), + [( + "id".to_owned(), + AiDisclosureShape::scalar(AiDisclosureRule::exportable( + DataClassification::Internal, + )), + )], + ), + ), + )], + ), + ) + .expect("disclosure schema should validate") +} + +fn contract(document: &str, disclosure: &AiDisclosureSchema) -> GraphqlOperationContract { + GraphqlOperationContract::new( + GraphqlExecutionTargetId::parse("application").expect("target ID"), + "schema-v1", + "Search", + document, + "records-projection-v1", + disclosure.fingerprint.clone(), + ) + .expect("operation contract should validate") +} + +#[test] +fn changed_egress_manifest_invalidates_allow_decision() { + let original = manifest(DataClassification::Internal); + let decision = AiEgressDecision::allow(&original, "policy-1", "user-1"); + assert!(decision.authorize(&original).is_ok()); + + let mut changed = original.clone(); + changed.estimated_bytes += 1; + assert!(matches!( + decision.authorize(&changed), + Err(AiError::EgressDenied) + )); +} + +#[test] +fn remote_graphql_targets_require_exact_audience_resource_and_schema_bindings() { + let mut targets = GraphqlExecutionTargetRegistry::new(); + assert!(matches!( + targets.register(GraphqlExecutionTarget { + id: GraphqlExecutionTargetId::parse("private-router").expect("target ID"), + class: GraphqlExecutionTargetClass::PrivateRouted, + audience: None, + resource_type: Some("project".to_owned()), + resource_id: Some("project-7".to_owned()), + schema_fingerprint: "schema-v1".to_owned(), + }), + Err(ToolExecutionError::InvalidTarget) + )); + targets + .register(GraphqlExecutionTarget { + id: GraphqlExecutionTargetId::parse("private-router").expect("target ID"), + class: GraphqlExecutionTargetClass::PrivateRouted, + audience: Some("private-graphql".to_owned()), + resource_type: Some("project".to_owned()), + resource_id: Some("project-7".to_owned()), + schema_fingerprint: "schema-v1".to_owned(), + }) + .expect("fully bound remote target should register"); +} + +#[test] +fn deployment_boundary_always_denies_secrets() { + let boundary = AiDeploymentEgressBoundary { + allowed_destination_trust: BTreeSet::from([AiDestinationTrust::ManagedProvider]), + allowed_capabilities: BTreeSet::from([AiEgressCapability::ModelInference]), + maximum_classification: DataClassification::Secret, + maximum_bytes: u64::MAX, + maximum_attachments: u32::MAX, + }; + + assert_eq!( + boundary.evaluate(&manifest(DataClassification::Secret)), + Err(AiEgressReason::SecretDataDenied) + ); +} + +#[test] +fn tool_catalog_is_discovery_not_enablement() { + let disclosure = disclosure_schema(); + let document = "query Search($term: String!) { records(term: $term) { id } }"; + let descriptor = AiToolDescriptor::new( + "records.search", + "Search readable records", + AiToolOperationKind::Query, + document, + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "term": { "type": "string" } }, + "required": ["term"], + "additionalProperties": false + }), + ) + .expect("descriptor should validate") + .with_result_projection("records-projection-v1") + .with_graphql_contract(contract(document, &disclosure)); + let mut catalog = AiToolCatalog::new(); + catalog + .register_with_disclosure(descriptor.clone(), disclosure) + .expect("registration should succeed"); + + let mut policy = AiToolPolicySet::new(ToolMaturity::ReadOnly); + assert!(!policy.allows(&descriptor)); + + policy.bind(AiToolPolicyBinding { + tool_id: descriptor.id.clone(), + fingerprint: "stale".to_owned(), + enabled: true, + }); + assert!(!policy.allows(&descriptor)); + + policy.bind(AiToolPolicyBinding { + tool_id: descriptor.id.clone(), + fingerprint: descriptor.fingerprint.clone(), + enabled: true, + }); + assert!(policy.allows(&descriptor)); +} + +#[test] +fn static_disclosure_rejects_unknown_and_never_export_fields() { + let schema = disclosure_schema(); + let allowed = schema + .evaluate(&json!({"records": [{"id": "54"}]})) + .expect("known projection should validate"); + assert_eq!(allowed.maximum_classification, DataClassification::Internal); + assert_eq!( + allowed + .tighten(DataClassification::Confidential) + .maximum_classification, + DataClassification::Confidential + ); + assert_eq!( + schema.evaluate(&json!({"records": [{"id": "54", "secret": "no"}]})), + Err(AiDisclosureError::UnknownField) + ); + + let forbidden = AiDisclosureSchema::new( + "forbidden-v1", + AiDisclosureShape::object( + AiDisclosureRule::exportable(DataClassification::Internal), + [( + "credential".to_owned(), + AiDisclosureShape::scalar(AiDisclosureRule::never_export( + DataClassification::Secret, + )), + )], + ), + ) + .expect("schema should validate"); + assert_eq!( + forbidden.evaluate(&json!({"credential": null})), + Err(AiDisclosureError::NeverExport) + ); +} + +#[test] +fn tool_catalog_rejects_ai_control_plane_and_introspection() { + let disclosure = disclosure_schema(); + for document in [ + "query Search { aiSessions { id } }", + "query Search { __schema { queryType { name } } }", + ] { + let descriptor = AiToolDescriptor::new( + "unsafe.search", + "Unsafe recursive operation", + AiToolOperationKind::Query, + document, + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" + }), + ) + .expect("descriptor construction is separate from catalog admission") + .with_result_projection("records-projection-v1") + .with_graphql_contract(contract(document, &disclosure)); + let mut catalog = AiToolCatalog::new(); + assert!(matches!( + catalog.register_with_disclosure(descriptor, disclosure.clone()), + Err(AiError::InvalidConfiguration(_)) + )); + } +} + +#[test] +fn budget_proof_is_bound_to_exact_provider_model_and_output_ceiling() { + let run_id = AiRunId::new(); + let attempt_id = Uuid::new_v4(); + let now = OffsetDateTime::now_utc(); + let reservation = AiBudgetReservation::new_reserved( + AiBudgetReservationId::new(), + run_id, + attempt_id, + 7, + ProviderKind::OpenAi, + "model-a", + "pricing-v1", + AiBudgetAmounts { + output_tokens: 256, + runs: 1, + ..AiBudgetAmounts::default() + }, + now + Duration::minutes(5), + ) + .expect("reservation should validate"); + + assert!( + reservation + .authorize_provider_call( + run_id, + attempt_id, + 7, + &ProviderKind::OpenAi, + "model-a", + 256, + now, + ) + .is_ok() + ); + assert!(matches!( + reservation.authorize_provider_call( + run_id, + attempt_id, + 7, + &ProviderKind::OpenAi, + "model-b", + 256, + now, + ), + Err(ProviderError::BudgetDenied) + )); + assert!(matches!( + reservation.authorize_provider_call( + run_id, + attempt_id, + 7, + &ProviderKind::OpenAi, + "model-a", + 257, + now, + ), + Err(ProviderError::BudgetDenied) + )); +} + +#[test] +fn approval_invalidates_when_resource_or_policy_binding_changes() { + let disclosure = disclosure_schema(); + let document = "mutation Search($id: ID!) { updateRecord(id: $id) { id } }"; + let resource = AiApprovalResourceBinding { + resource_type: "record".to_owned(), + resource_id: "54".to_owned(), + expected_version: "7".to_owned(), + }; + let preview = AiCanonicalActionPreview { + action_kind: "update_record".to_owned(), + title: "Update record 54".to_owned(), + targets: vec![resource.clone()], + details: json!({"fields": ["title"]}), + }; + let binding = AiApprovalBinding { + tool_call_id: AiToolCallId::new(), + session_id: AiSessionId::new(), + scope: AiScope::new("collection", "9").with_tenant_id("tenant-a"), + tool_fingerprint: "tool-v1".to_owned(), + argument_hash: "arguments-v1".to_owned(), + operation: contract(document, &disclosure), + principal_reference_fingerprint: "principal-v1".to_owned(), + delegated_actor_subject: Some("user-a".to_owned()), + delegation_reference: Some("grant-1".to_owned()), + policy_version: "policy-v1".to_owned(), + authorization_state_digest: "auth-v1".to_owned(), + resources: vec![resource], + preview_hash: preview.stable_hash(), + }; + binding.validate(&preview).expect("binding should validate"); + let now = OffsetDateTime::now_utc(); + let grant = AiApprovalGrant { + id: AiApprovalId::new(), + binding_hash: binding.stable_hash(), + approver_subject: "user-a".to_owned(), + state: AiApprovalState::Approved, + approved_at: now, + expires_at: now + Duration::minutes(5), + }; + assert!(grant.authorize(&binding, now).is_ok()); + + let mut changed = binding.clone(); + changed.resources[0].expected_version = "8".to_owned(); + assert!(matches!( + grant.authorize(&changed, now), + Err(AiError::Forbidden) + )); + changed = binding.clone(); + changed.policy_version = "policy-v2".to_owned(); + assert!(matches!( + grant.authorize(&changed, now), + Err(AiError::Forbidden) + )); +} + +#[test] +fn proposal_only_ceiling_rejects_application_mutation_descriptor() { + let descriptor = AiToolDescriptor::new( + "records.publish", + "Publish a record", + AiToolOperationKind::Mutation, + "mutation Publish($id: ID!) { publish(id: $id) { id } }", + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" + }), + ) + .expect("descriptor should validate") + .with_maturity(ToolMaturity::SupervisedWrite) + .with_risk(AiToolRisk::HighImpact, AiApprovalRule::OneShot); + let mut policy = AiToolPolicySet::new(ToolMaturity::ProposalOnly); + policy.bind(AiToolPolicyBinding { + tool_id: descriptor.id.clone(), + fingerprint: descriptor.fingerprint.clone(), + enabled: true, + }); + + assert!(!policy.allows(&descriptor)); +} + +#[test] +fn proposals_require_schema_and_provenance() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { "type": "string", "minLength": 1 } + }, + "required": ["title"], + "additionalProperties": false + }); + let descriptor = AiProposalTypeDescriptor::new("records.metadata.v1", "1", schema) + .expect("proposal schema should compile") + .with_required_source_kinds(vec!["resolver_result".to_owned()]); + let proposal_type = descriptor.id.clone(); + let mut catalog = AiProposalCatalog::new(); + catalog + .register(descriptor) + .expect("descriptor registration should succeed"); + + let invalid = AiProposalDraft { + proposal_type: proposal_type.clone(), + session_id: AiSessionId::new(), + run_id: AiRunId::new(), + scope: AiScope::new("project", "7"), + payload: json!({"title": "suggested"}), + sources: vec![source(DataClassification::Internal)], + item_count: 1, + }; + assert!(matches!( + catalog.validate(invalid), + Err(AiError::InvalidInput(_)) + )); + + let valid = AiProposalDraft { + proposal_type, + session_id: AiSessionId::new(), + run_id: AiRunId::new(), + scope: AiScope::new("project", "7"), + payload: json!({"title": "suggested"}), + sources: vec![AiDataSourceRef { + kind: "resolver_result".to_owned(), + reference: "tool-artifact-1".to_owned(), + classification: DataClassification::Internal, + trust: AiSourceTrust::ResolverResult, + }], + item_count: 1, + }; + assert!(catalog.validate(valid).is_ok()); +} diff --git a/crates/graphql-orm-ai/tests/session_graphql.rs b/crates/graphql-orm-ai/tests/session_graphql.rs new file mode 100644 index 00000000..f0d1c4ab --- /dev/null +++ b/crates/graphql-orm-ai/tests/session_graphql.rs @@ -0,0 +1,249 @@ +use std::sync::{Arc, Mutex}; + +use agql_auth::{AccessTokenMetadata, AuthPrincipal, AuthUser, SessionContext}; +use async_graphql::{EmptySubscription, Request, Schema}; +use async_trait::async_trait; +use graphql_orm::graphql::pagination::{ + KeysetWindowDirection, PageInfo, ValidatedKeysetConnection, +}; +use graphql_orm_ai::*; +use serde_json::json; +use uuid::Uuid; + +fn principal(subject: &str) -> AuthPrincipal { + AuthPrincipal::User(AuthUser { + user_id: subject.to_owned(), + session_id: Uuid::new_v4(), + roles: vec![], + scopes: vec!["ai:chat".to_owned()], + session: SessionContext::default(), + token_claims: AccessTokenMetadata::default(), + }) +} + +fn page_info() -> PageInfo { + PageInfo { + has_next_page: false, + has_previous_page: false, + start_cursor: None, + end_cursor: None, + total_count: None, + } +} + +#[derive(Default)] +struct RecordingSessionService { + message_page: Mutex>, + principal_subject: Mutex>, +} + +#[async_trait] +impl AiSessionService for RecordingSessionService { + async fn sessions( + &self, + principal: &AuthPrincipal, + _page: ValidatedKeysetConnection, + ) -> Result { + *self + .principal_subject + .lock() + .expect("test mutex should not be poisoned") = Some(principal.subject().to_owned()); + Ok(AiSessionConnection { + edges: vec![], + page_info: page_info(), + }) + } + + async fn session( + &self, + _principal: &AuthPrincipal, + _session_id: AiSessionId, + ) -> Result, AiError> { + Ok(None) + } + + async fn messages( + &self, + principal: &AuthPrincipal, + _session_id: AiSessionId, + page: ValidatedKeysetConnection, + ) -> Result { + *self + .message_page + .lock() + .expect("test mutex should not be poisoned") = Some(page); + *self + .principal_subject + .lock() + .expect("test mutex should not be poisoned") = Some(principal.subject().to_owned()); + Ok(AiMessageConnection { + edges: vec![], + page_info: page_info(), + }) + } + + async fn message_blocks( + &self, + _principal: &AuthPrincipal, + _message_id: Uuid, + _after_block_index: Option, + _first: i64, + ) -> Result, AiError> { + Ok(vec![]) + } + + async fn session_event_page( + &self, + _principal: &AuthPrincipal, + _session_id: AiSessionId, + _after_sequence: i64, + _first: i64, + ) -> Result { + Ok(AiSessionEventPage { + events: vec![], + watermark: 0, + has_more: false, + reset_required: false, + }) + } + + async fn create_session( + &self, + principal: &AuthPrincipal, + input: CreateAiSessionInput, + ) -> Result { + Ok(AiSessionView { + id: Uuid::new_v4(), + scope_kind: input.scope.kind, + scope_id: input.scope.id, + title: input.title.unwrap_or_else(|| "New chat".to_owned()), + state: "active".to_owned(), + stream_head: 0, + last_activity_at: 0, + archived_at: None, + }) + .inspect(|_| { + *self + .principal_subject + .lock() + .expect("test mutex should not be poisoned") = Some(principal.subject().to_owned()); + }) + } + + async fn archive_session( + &self, + _principal: &AuthPrincipal, + _session_id: AiSessionId, + ) -> Result { + Err(AiError::NotFound) + } + + async fn restore_session( + &self, + _principal: &AuthPrincipal, + _session_id: AiSessionId, + ) -> Result { + Err(AiError::NotFound) + } + + async fn delete_session( + &self, + _principal: &AuthPrincipal, + _session_id: AiSessionId, + ) -> Result { + Ok(false) + } + + async fn send_message( + &self, + _principal: &AuthPrincipal, + _input: SendAiMessageInput, + ) -> Result { + Ok(SendAiMessagePayload { + message_id: Uuid::new_v4(), + run_id: Uuid::new_v4(), + }) + } +} + +fn schema( + service: Arc, +) -> Schema { + Schema::build(AiQueryRoot, AiMutationRoot, EmptySubscription) + .data(service as Arc) + .finish() +} + +#[tokio::test] +async fn message_query_defaults_to_bounded_tail_and_passes_current_principal() { + let service = Arc::new(RecordingSessionService::default()); + let schema = schema(service.clone()); + let session_id = Uuid::new_v4(); + let response = schema + .execute( + Request::new(format!( + "{{ aiMessages(sessionId: \"{session_id}\") {{ edges {{ cursor }} pageInfo {{ hasNextPage }} }} }}" + )) + .data(principal("user-a")), + ) + .await; + + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data.into_json().expect("response JSON"), + json!({ + "aiMessages": { + "edges": [], + "pageInfo": {"hasNextPage": false} + } + }) + ); + let page = service + .message_page + .lock() + .expect("test mutex should not be poisoned") + .clone() + .expect("service should receive a page"); + assert_eq!(page.direction, KeysetWindowDirection::Backward); + assert_eq!(page.limit, 50); + assert_eq!( + service + .principal_subject + .lock() + .expect("test mutex should not be poisoned") + .as_deref(), + Some("user-a") + ); +} + +#[tokio::test] +async fn session_roots_fail_closed_without_authentication() { + let schema = schema(Arc::new(RecordingSessionService::default())); + let response = schema.execute("{ aiSessions { edges { cursor } } }").await; + + assert_eq!(response.errors.len(), 1); +} + +#[tokio::test] +async fn event_page_rejects_unbounded_requests_before_service_execution() { + let service = Arc::new(RecordingSessionService::default()); + let schema = schema(service.clone()); + let response = schema + .execute( + Request::new(format!( + "{{ aiSessionEventPage(sessionId: \"{}\", first: 501) {{ watermark }} }}", + Uuid::new_v4() + )) + .data(principal("user-a")), + ) + .await; + + assert_eq!(response.errors.len(), 1); + assert_eq!( + response.errors[0] + .extensions + .as_ref() + .and_then(|extensions| extensions.get("code")), + Some(&async_graphql::Value::from("AI_INVALID_INPUT")) + ); +} From 5543426ac39949b19c7da5246674ad26aec819cc Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 13 Jul 2026 16:50:33 +1000 Subject: [PATCH 025/108] Initial public release --- .../.agents/skills/agql-auth/SKILL.md | 162 + .../skills/graphql-orm-macros/SKILL.md | 134 + .../.agents/skills/rust-skills/AGENTS.md | 335 ++ .../.agents/skills/rust-skills/CLAUDE.md | 335 ++ .../.agents/skills/rust-skills/LICENSE | 21 + .../.agents/skills/rust-skills/README.md | 196 + .../.agents/skills/rust-skills/SKILL.md | 335 ++ .../rust-skills/rules/anti-clone-excessive.md | 124 + .../rules/anti-collect-intermediate.md | 131 + .../rust-skills/rules/anti-empty-catch.md | 132 + .../rust-skills/rules/anti-expect-lazy.md | 95 + .../rust-skills/rules/anti-format-hot-path.md | 141 + .../rust-skills/rules/anti-index-over-iter.md | 125 + .../rules/anti-lock-across-await.md | 127 + .../rules/anti-over-abstraction.md | 120 + .../rust-skills/rules/anti-panic-expected.md | 131 + .../rules/anti-premature-optimize.md | 156 + .../rust-skills/rules/anti-string-for-str.md | 122 + .../rust-skills/rules/anti-stringly-typed.md | 167 + .../rust-skills/rules/anti-type-erasure.md | 134 + .../rust-skills/rules/anti-unwrap-abuse.md | 143 + .../rust-skills/rules/anti-vec-for-slice.md | 121 + .../rust-skills/rules/api-builder-must-use.md | 143 + .../rust-skills/rules/api-builder-pattern.md | 187 + .../rust-skills/rules/api-common-traits.md | 165 + .../rust-skills/rules/api-default-impl.md | 177 + .../rust-skills/rules/api-extension-trait.md | 163 + .../rust-skills/rules/api-from-not-into.md | 146 + .../rust-skills/rules/api-impl-asref.md | 142 + .../skills/rust-skills/rules/api-impl-into.md | 160 + .../skills/rust-skills/rules/api-must-use.md | 125 + .../rust-skills/rules/api-newtype-safety.md | 162 + .../rust-skills/rules/api-non-exhaustive.md | 177 + .../rules/api-parse-dont-validate.md | 184 + .../rust-skills/rules/api-sealed-trait.md | 168 + .../rust-skills/rules/api-serde-optional.md | 182 + .../skills/rust-skills/rules/api-typestate.md | 199 + .../rules/async-bounded-channel.md | 175 + .../rules/async-broadcast-pubsub.md | 185 + .../rules/async-cancellation-token.md | 203 + .../rules/async-clone-before-await.md | 171 + .../rust-skills/rules/async-join-parallel.md | 158 + .../rules/async-joinset-structured.md | 195 + .../rust-skills/rules/async-mpsc-queue.md | 171 + .../rust-skills/rules/async-no-lock-await.md | 156 + .../rules/async-oneshot-response.md | 191 + .../rust-skills/rules/async-select-racing.md | 198 + .../rust-skills/rules/async-spawn-blocking.md | 154 + .../rust-skills/rules/async-tokio-fs.md | 167 + .../rust-skills/rules/async-tokio-runtime.md | 169 + .../rust-skills/rules/async-try-join.md | 172 + .../rust-skills/rules/async-watch-latest.md | 189 + .../rust-skills/rules/doc-all-public.md | 113 + .../rust-skills/rules/doc-cargo-metadata.md | 147 + .../rust-skills/rules/doc-errors-section.md | 122 + .../rust-skills/rules/doc-examples-section.md | 161 + .../rust-skills/rules/doc-hidden-setup.md | 149 + .../rust-skills/rules/doc-intra-links.md | 138 + .../rust-skills/rules/doc-link-types.md | 169 + .../rust-skills/rules/doc-module-inner.md | 116 + .../rust-skills/rules/doc-panics-section.md | 128 + .../rust-skills/rules/doc-question-mark.md | 136 + .../rust-skills/rules/doc-safety-section.md | 131 + .../rust-skills/rules/err-anyhow-app.md | 179 + .../rust-skills/rules/err-context-chain.md | 144 + .../rust-skills/rules/err-custom-type.md | 152 + .../rust-skills/rules/err-doc-errors.md | 145 + .../rust-skills/rules/err-expect-bugs-only.md | 133 + .../skills/rust-skills/rules/err-from-impl.md | 152 + .../rust-skills/rules/err-lowercase-msg.md | 124 + .../rust-skills/rules/err-no-unwrap-prod.md | 115 + .../rust-skills/rules/err-question-mark.md | 151 + .../rules/err-result-over-panic.md | 130 + .../rust-skills/rules/err-source-chain.md | 155 + .../rust-skills/rules/err-thiserror-lib.md | 171 + .../rust-skills/rules/lint-cargo-metadata.md | 138 + .../rules/lint-deny-correctness.md | 107 + .../rust-skills/rules/lint-missing-docs.md | 154 + .../rules/lint-pedantic-selective.md | 118 + .../rust-skills/rules/lint-rustfmt-check.md | 157 + .../rust-skills/rules/lint-unsafe-doc.md | 133 + .../rust-skills/rules/lint-warn-complexity.md | 131 + .../rust-skills/rules/lint-warn-perf.md | 136 + .../rust-skills/rules/lint-warn-style.md | 135 + .../rust-skills/rules/lint-warn-suspicious.md | 122 + .../rust-skills/rules/lint-workspace-lints.md | 172 + .../rust-skills/rules/mem-arena-allocator.md | 168 + .../skills/rust-skills/rules/mem-arrayvec.md | 142 + .../rust-skills/rules/mem-assert-type-size.md | 168 + .../rust-skills/rules/mem-avoid-format.md | 147 + .../rules/mem-box-large-variant.md | 158 + .../rust-skills/rules/mem-boxed-slice.md | 139 + .../rust-skills/rules/mem-clone-from.md | 147 + .../rust-skills/rules/mem-compact-string.md | 149 + .../rules/mem-reuse-collections.md | 174 + .../rust-skills/rules/mem-smaller-integers.md | 159 + .../skills/rust-skills/rules/mem-smallvec.md | 138 + .../skills/rust-skills/rules/mem-thinvec.md | 142 + .../rust-skills/rules/mem-with-capacity.md | 156 + .../rules/mem-write-over-format.md | 172 + .../skills/rust-skills/rules/mem-zero-copy.md | 164 + .../rust-skills/rules/name-acronym-word.md | 99 + .../skills/rust-skills/rules/name-as-free.md | 104 + .../rules/name-consts-screaming.md | 94 + .../rust-skills/rules/name-crate-no-rs.md | 78 + .../rust-skills/rules/name-funcs-snake.md | 76 + .../rust-skills/rules/name-into-ownership.md | 123 + .../rust-skills/rules/name-is-has-bool.md | 127 + .../rust-skills/rules/name-iter-convention.md | 129 + .../rust-skills/rules/name-iter-method.md | 131 + .../rust-skills/rules/name-iter-type-match.md | 142 + .../rust-skills/rules/name-lifetime-short.md | 86 + .../rust-skills/rules/name-no-get-prefix.md | 154 + .../rust-skills/rules/name-to-expensive.md | 118 + .../rules/name-type-param-single.md | 92 + .../rust-skills/rules/name-types-camel.md | 65 + .../rust-skills/rules/name-variants-camel.md | 101 + .../rust-skills/rules/opt-bounds-check.md | 161 + .../rust-skills/rules/opt-cache-friendly.md | 187 + .../rust-skills/rules/opt-codegen-units.md | 142 + .../rust-skills/rules/opt-cold-unlikely.md | 152 + .../rules/opt-inline-always-rare.md | 141 + .../rules/opt-inline-never-cold.md | 181 + .../rust-skills/rules/opt-inline-small.md | 160 + .../rust-skills/rules/opt-likely-hint.md | 171 + .../rust-skills/rules/opt-lto-release.md | 130 + .../rust-skills/rules/opt-pgo-profile.md | 167 + .../rust-skills/rules/opt-simd-portable.md | 144 + .../rust-skills/rules/opt-target-cpu.md | 154 + .../rust-skills/rules/own-arc-shared.md | 141 + .../rules/own-borrow-over-clone.md | 95 + .../rust-skills/rules/own-clone-explicit.md | 135 + .../rust-skills/rules/own-copy-small.md | 124 + .../rust-skills/rules/own-cow-conditional.md | 135 + .../rust-skills/rules/own-lifetime-elision.md | 134 + .../rust-skills/rules/own-move-large.md | 134 + .../rust-skills/rules/own-mutex-interior.md | 105 + .../rust-skills/rules/own-rc-single-thread.md | 65 + .../rust-skills/rules/own-refcell-interior.md | 97 + .../rust-skills/rules/own-rwlock-readers.md | 122 + .../rust-skills/rules/own-slice-over-vec.md | 119 + .../rust-skills/rules/perf-black-box-bench.md | 153 + .../rust-skills/rules/perf-chain-avoid.md | 136 + .../rust-skills/rules/perf-collect-into.md | 133 + .../rust-skills/rules/perf-collect-once.md | 120 + .../rust-skills/rules/perf-drain-reuse.md | 137 + .../rust-skills/rules/perf-entry-api.md | 134 + .../rust-skills/rules/perf-extend-batch.md | 150 + .../rust-skills/rules/perf-iter-lazy.md | 123 + .../rust-skills/rules/perf-iter-over-index.md | 113 + .../rust-skills/rules/perf-profile-first.md | 175 + .../rust-skills/rules/perf-release-profile.md | 149 + .../skills/rust-skills/rules/proj-bin-dir.md | 142 + .../rust-skills/rules/proj-flat-small.md | 133 + .../rust-skills/rules/proj-lib-main-split.md | 148 + .../rust-skills/rules/proj-mod-by-feature.md | 130 + .../rust-skills/rules/proj-mod-rs-dir.md | 120 + .../rust-skills/rules/proj-prelude-module.md | 155 + .../rules/proj-pub-crate-internal.md | 139 + .../rules/proj-pub-super-parent.md | 135 + .../rules/proj-pub-use-reexport.md | 162 + .../rust-skills/rules/proj-workspace-deps.md | 186 + .../rust-skills/rules/proj-workspace-large.md | 162 + .../rules/test-arrange-act-assert.md | 160 + .../rust-skills/rules/test-cfg-test-module.md | 151 + .../rust-skills/rules/test-criterion-bench.md | 171 + .../rules/test-descriptive-names.md | 142 + .../rules/test-doctest-examples.md | 168 + .../rust-skills/rules/test-fixture-raii.md | 151 + .../rust-skills/rules/test-integration-dir.md | 144 + .../rust-skills/rules/test-mock-traits.md | 189 + .../rust-skills/rules/test-mockall-mocking.md | 226 + .../rules/test-proptest-properties.md | 161 + .../rust-skills/rules/test-should-panic.md | 130 + .../rust-skills/rules/test-tokio-async.md | 154 + .../rust-skills/rules/test-use-super.md | 127 + .../rust-skills/rules/type-enum-states.md | 154 + .../rust-skills/rules/type-generic-bounds.md | 142 + .../rust-skills/rules/type-never-diverge.md | 146 + .../rust-skills/rules/type-newtype-ids.md | 160 + .../rules/type-newtype-validated.md | 159 + .../rust-skills/rules/type-no-stringly.md | 144 + .../rust-skills/rules/type-option-nullable.md | 137 + .../rust-skills/rules/type-phantom-marker.md | 188 + .../rules/type-repr-transparent.md | 143 + .../rust-skills/rules/type-result-fallible.md | 131 + .../graphql-orm-ai/.github/workflows/ci.yml | 74 + crates/graphql-orm-ai/.gitignore | 9 + crates/graphql-orm-ai/AGENTS.md | 73 + crates/graphql-orm-ai/CHANGELOG.md | 70 + crates/graphql-orm-ai/Cargo.lock | 4459 +++++++++++++++++ crates/graphql-orm-ai/Cargo.toml | 53 + crates/graphql-orm-ai/LICENSE | 21 + crates/graphql-orm-ai/MIGRATION.md | 84 + crates/graphql-orm-ai/README.md | 218 + crates/graphql-orm-ai/docs/README.md | 16 + crates/graphql-orm-ai/docs/architecture.md | 49 + crates/graphql-orm-ai/docs/development.md | 55 + crates/graphql-orm-ai/docs/getting-started.md | 45 + .../docs/implementation-status.md | 181 + crates/graphql-orm-ai/docs/release-process.md | 44 + crates/graphql-orm-ai/docs/security.md | 49 + .../scripts/check-release-policy.sh | 71 + crates/graphql-orm-ai/src/access.rs | 119 + crates/graphql-orm-ai/src/approvals.rs | 216 + crates/graphql-orm-ai/src/budget.rs | 280 ++ crates/graphql-orm-ai/src/configuration.rs | 388 ++ .../graphql-orm-ai/src/content_protection.rs | 167 + crates/graphql-orm-ai/src/data.rs | 46 + crates/graphql-orm-ai/src/disclosure.rs | 321 ++ crates/graphql-orm-ai/src/domain.rs | 70 + crates/graphql-orm-ai/src/egress.rs | 316 ++ crates/graphql-orm-ai/src/error.rs | 78 + crates/graphql-orm-ai/src/execution.rs | 395 ++ crates/graphql-orm-ai/src/lib.rs | 91 + .../graphql-orm-ai/src/orm_configuration.rs | 869 ++++ crates/graphql-orm-ai/src/orm_sessions.rs | 1072 ++++ .../graphql-orm-ai/src/orm_subscriptions.rs | 198 + crates/graphql-orm-ai/src/persistence.rs | 1564 ++++++ crates/graphql-orm-ai/src/proposals.rs | 252 + crates/graphql-orm-ai/src/provider.rs | 547 ++ crates/graphql-orm-ai/src/providers.rs | 11 + crates/graphql-orm-ai/src/providers/mock.rs | 80 + crates/graphql-orm-ai/src/providers/openai.rs | 995 ++++ crates/graphql-orm-ai/src/restore.rs | 234 + crates/graphql-orm-ai/src/run_state.rs | 175 + crates/graphql-orm-ai/src/runtime.rs | 482 ++ crates/graphql-orm-ai/src/secrets.rs | 161 + crates/graphql-orm-ai/src/sessions.rs | 487 ++ crates/graphql-orm-ai/src/subscriptions.rs | 96 + crates/graphql-orm-ai/src/tools.rs | 632 +++ .../tests/configuration_graphql.rs | 156 + crates/graphql-orm-ai/tests/graphql_naming.rs | 27 + .../graphql-orm-ai/tests/orm_configuration.rs | 350 ++ crates/graphql-orm-ai/tests/orm_sessions.rs | 278 + .../graphql-orm-ai/tests/orm_subscriptions.rs | 231 + .../tests/project_boundaries.rs | 38 + .../tests/provider_and_content_security.rs | 232 + .../graphql-orm-ai/tests/run_and_restore.rs | 105 + .../graphql-orm-ai/tests/runtime_contracts.rs | 412 ++ crates/graphql-orm-ai/tests/schema_module.rs | 32 + .../tests/security_contracts.rs | 433 ++ .../graphql-orm-ai/tests/session_graphql.rs | 249 + 243 files changed, 45892 insertions(+) create mode 100644 crates/graphql-orm-ai/.agents/skills/agql-auth/SKILL.md create mode 100644 crates/graphql-orm-ai/.agents/skills/graphql-orm-macros/SKILL.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/AGENTS.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/CLAUDE.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/LICENSE create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/README.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/SKILL.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-clone-excessive.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-collect-intermediate.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-empty-catch.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-expect-lazy.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-format-hot-path.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-index-over-iter.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-lock-across-await.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-over-abstraction.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-panic-expected.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-premature-optimize.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-string-for-str.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-stringly-typed.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-type-erasure.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-vec-for-slice.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-must-use.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-pattern.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-common-traits.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-default-impl.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-extension-trait.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-from-not-into.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-asref.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-into.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-must-use.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-newtype-safety.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-non-exhaustive.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-parse-dont-validate.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-sealed-trait.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-serde-optional.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-typestate.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-bounded-channel.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-cancellation-token.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-clone-before-await.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-join-parallel.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-joinset-structured.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-mpsc-queue.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-no-lock-await.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-oneshot-response.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-select-racing.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-spawn-blocking.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-fs.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-runtime.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-try-join.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-watch-latest.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-all-public.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-cargo-metadata.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-errors-section.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-examples-section.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-hidden-setup.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-intra-links.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-link-types.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-module-inner.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-panics-section.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-question-mark.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-safety-section.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-anyhow-app.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-context-chain.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-custom-type.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-doc-errors.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-expect-bugs-only.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-from-impl.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-lowercase-msg.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-question-mark.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-result-over-panic.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-source-chain.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-thiserror-lib.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-cargo-metadata.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-deny-correctness.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-missing-docs.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-pedantic-selective.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-rustfmt-check.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-unsafe-doc.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-complexity.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-perf.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-style.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-suspicious.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-workspace-lints.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arena-allocator.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arrayvec.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-assert-type-size.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-avoid-format.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-box-large-variant.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-boxed-slice.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-clone-from.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-compact-string.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-reuse-collections.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smaller-integers.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-smallvec.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-thinvec.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-with-capacity.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-write-over-format.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-zero-copy.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-acronym-word.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-as-free.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-consts-screaming.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-crate-no-rs.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-funcs-snake.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-into-ownership.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-is-has-bool.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-convention.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-method.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-iter-type-match.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-lifetime-short.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-no-get-prefix.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-to-expensive.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-type-param-single.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-types-camel.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/name-variants-camel.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-bounds-check.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cache-friendly.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-codegen-units.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-cold-unlikely.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-always-rare.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-never-cold.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-inline-small.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-likely-hint.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-lto-release.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-pgo-profile.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-simd-portable.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/opt-target-cpu.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-arc-shared.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-borrow-over-clone.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-clone-explicit.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-copy-small.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-cow-conditional.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-lifetime-elision.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-move-large.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-mutex-interior.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rc-single-thread.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-refcell-interior.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-rwlock-readers.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/own-slice-over-vec.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-black-box-bench.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-chain-avoid.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-into.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-collect-once.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-drain-reuse.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-entry-api.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-extend-batch.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-lazy.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-iter-over-index.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-profile-first.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/perf-release-profile.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-bin-dir.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-flat-small.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-lib-main-split.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-by-feature.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-mod-rs-dir.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-prelude-module.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-crate-internal.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-super-parent.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-pub-use-reexport.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-deps.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/proj-workspace-large.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-arrange-act-assert.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-cfg-test-module.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-criterion-bench.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-descriptive-names.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-doctest-examples.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-fixture-raii.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-integration-dir.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mock-traits.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-mockall-mocking.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-proptest-properties.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-should-panic.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-tokio-async.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/test-use-super.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-enum-states.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-generic-bounds.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-never-diverge.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-ids.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-newtype-validated.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-no-stringly.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-option-nullable.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-phantom-marker.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-repr-transparent.md create mode 100644 crates/graphql-orm-ai/.agents/skills/rust-skills/rules/type-result-fallible.md create mode 100644 crates/graphql-orm-ai/.github/workflows/ci.yml create mode 100644 crates/graphql-orm-ai/.gitignore create mode 100644 crates/graphql-orm-ai/AGENTS.md create mode 100644 crates/graphql-orm-ai/CHANGELOG.md create mode 100644 crates/graphql-orm-ai/Cargo.lock create mode 100644 crates/graphql-orm-ai/Cargo.toml create mode 100644 crates/graphql-orm-ai/LICENSE create mode 100644 crates/graphql-orm-ai/MIGRATION.md create mode 100644 crates/graphql-orm-ai/README.md create mode 100644 crates/graphql-orm-ai/docs/README.md create mode 100644 crates/graphql-orm-ai/docs/architecture.md create mode 100644 crates/graphql-orm-ai/docs/development.md create mode 100644 crates/graphql-orm-ai/docs/getting-started.md create mode 100644 crates/graphql-orm-ai/docs/implementation-status.md create mode 100644 crates/graphql-orm-ai/docs/release-process.md create mode 100644 crates/graphql-orm-ai/docs/security.md create mode 100755 crates/graphql-orm-ai/scripts/check-release-policy.sh create mode 100644 crates/graphql-orm-ai/src/access.rs create mode 100644 crates/graphql-orm-ai/src/approvals.rs create mode 100644 crates/graphql-orm-ai/src/budget.rs create mode 100644 crates/graphql-orm-ai/src/configuration.rs create mode 100644 crates/graphql-orm-ai/src/content_protection.rs create mode 100644 crates/graphql-orm-ai/src/data.rs create mode 100644 crates/graphql-orm-ai/src/disclosure.rs create mode 100644 crates/graphql-orm-ai/src/domain.rs create mode 100644 crates/graphql-orm-ai/src/egress.rs create mode 100644 crates/graphql-orm-ai/src/error.rs create mode 100644 crates/graphql-orm-ai/src/execution.rs create mode 100644 crates/graphql-orm-ai/src/lib.rs create mode 100644 crates/graphql-orm-ai/src/orm_configuration.rs create mode 100644 crates/graphql-orm-ai/src/orm_sessions.rs create mode 100644 crates/graphql-orm-ai/src/orm_subscriptions.rs create mode 100644 crates/graphql-orm-ai/src/persistence.rs create mode 100644 crates/graphql-orm-ai/src/proposals.rs create mode 100644 crates/graphql-orm-ai/src/provider.rs create mode 100644 crates/graphql-orm-ai/src/providers.rs create mode 100644 crates/graphql-orm-ai/src/providers/mock.rs create mode 100644 crates/graphql-orm-ai/src/providers/openai.rs create mode 100644 crates/graphql-orm-ai/src/restore.rs create mode 100644 crates/graphql-orm-ai/src/run_state.rs create mode 100644 crates/graphql-orm-ai/src/runtime.rs create mode 100644 crates/graphql-orm-ai/src/secrets.rs create mode 100644 crates/graphql-orm-ai/src/sessions.rs create mode 100644 crates/graphql-orm-ai/src/subscriptions.rs create mode 100644 crates/graphql-orm-ai/src/tools.rs create mode 100644 crates/graphql-orm-ai/tests/configuration_graphql.rs create mode 100644 crates/graphql-orm-ai/tests/graphql_naming.rs create mode 100644 crates/graphql-orm-ai/tests/orm_configuration.rs create mode 100644 crates/graphql-orm-ai/tests/orm_sessions.rs create mode 100644 crates/graphql-orm-ai/tests/orm_subscriptions.rs create mode 100644 crates/graphql-orm-ai/tests/project_boundaries.rs create mode 100644 crates/graphql-orm-ai/tests/provider_and_content_security.rs create mode 100644 crates/graphql-orm-ai/tests/run_and_restore.rs create mode 100644 crates/graphql-orm-ai/tests/runtime_contracts.rs create mode 100644 crates/graphql-orm-ai/tests/schema_module.rs create mode 100644 crates/graphql-orm-ai/tests/security_contracts.rs create mode 100644 crates/graphql-orm-ai/tests/session_graphql.rs diff --git a/crates/graphql-orm-ai/.agents/skills/agql-auth/SKILL.md b/crates/graphql-orm-ai/.agents/skills/agql-auth/SKILL.md new file mode 100644 index 00000000..8a820ab0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/agql-auth/SKILL.md @@ -0,0 +1,162 @@ +--- +name: agql-auth +description: > + Use when working on authentication, authorization, principal references, + current-principal rehydration, delegation, recent-MFA, async-graphql context + wiring, or long-lived subscription authorization in graphql-orm-ai. +--- + +# agql-auth Skill + +## Use This Skill When + +- accepting `AuthPrincipal` from an authenticated GraphQL request +- persisting a non-secret principal reference for background AI work +- rehydrating current roles, scopes, tenant membership, and assurance +- checking token/session revocation during a run +- wiring authenticated websocket subscriptions +- enforcing recent MFA for high-impact approvals or secret configuration +- using audience/resource-bound API or service tokens +- designing bounded delegation for disconnected/background tasks +- deciding what auth behavior belongs in `agql-auth` versus `graphql-orm-ai` + +## Crate + +- Dependency: `agql-auth` +- Local repo: `../agql-auth` +- Upstream repo: `https://github.com/Dastari/agql-auth` +- The name refers to async-graphql integration, not to the ORM layer. + +## Preferred Usage + +Use `agql_auth::prelude::*` unless narrower imports make a public module clearer. + +Important existing types: + +- `AuthPrincipal` +- `AuthUser` +- `ApiTokenPrincipal` +- `AccessTokenValidator` +- `TokenStatusChecker` +- `TokenStatusRequest` +- `ReauthorizationPolicy` +- `SessionAssurance` +- `RecentMfaPolicy` +- `AuthorizationDecision` + +Planned reusable additions: + +- `PrincipalReference` +- `CurrentPrincipalResolver` +- bounded `DelegationGrant` +- reusable long-lived connection authorization state + +## Boundary + +`agql-auth` is the reusable authentication and principal-lifecycle runtime. + +It should own: + +- access/session/API-token validation +- revocation and expiry status contracts +- safe, serializable principal references +- current-principal rehydration contracts +- scope subset and audience/resource binding for delegations +- recent-MFA and assurance aging +- GraphQL request-context and websocket reauthorization helpers +- generic guards, status checks, and redacted authorization decisions + +`graphql-orm-ai` is the reusable agent runtime. + +It should own: + +- AI sessions, runs, messages, tools, approvals, and budgets +- tool-risk classification and argument-bound approval records +- AI-specific delegation constraints such as tool allowlists and cost ceilings +- provider egress and data-classification policy +- decisions to pause a run as `WAITING_REAUTH` + +Host applications should own: + +- concrete user/session/token persistence +- implementations that rehydrate current principals +- tenant/project membership and application resource policy +- HTTP/cookie/bearer extraction +- application GraphQL schema composition +- record- and field-level authorization + +Do not make `agql-auth` depend directly on `graphql-orm` unless there is a +deliberate shared-library design decision. Integrate through traits and safe +principal types. + +## Integration Rules + +1. Never persist bearer tokens. +Store only a safe `PrincipalReference` containing subject, session/token IDs, +tenant/resource binding, actor, correlation, and expiry metadata. + +2. Never trust stale role or scope snapshots. +Rehydrate the principal before provider egress, every application tool call, +after approval, and at long-run checkpoints. + +3. Reauthorize long-lived subscriptions. +Authenticate `connection_init`, schedule fail-closed status checks, age recent +MFA, and close or pause on revocation, expiry, or permission loss. + +4. Delegation cannot add authority. +Delegated scopes must be a subset of the current principal, have bounded +expiry, preserve actor/correlation identity, and remain revocable. + +5. Keep application authorization authoritative. +`agql-auth` provides authentication, coarse scopes, token lifecycle, and +assurance. The host's GraphQL resolver plus entity/row/field policies decide +whether a particular operation and record are allowed. + +6. Bind high-impact approvals to current assurance. +Publish, delete, permission, credential, and other sensitive operations should +require recent MFA when configured and must reauthorize after approval. + +7. Use resource-bound service principals for scheduled work. +Do not keep a user bearer token alive or silently convert user work into +unbounded system access. + +8. Keep audits redacted. +Record principal references, requirements, resource, result, reason code, and +correlation. Never include tokens, provider keys, prompts, or tool arguments in +auth audit structures. + +9. Keep MCP tokens audience-bound. +An optional MCP facade must authenticate its caller and must never pass an +application access token through to a downstream MCP server. + +10. Keep database tests isolated. +Any PostgreSQL or MSSQL auth integration test must use a disposable Docker +container and must never connect to a live local database. + +## When Not To Use + +- provider streaming or model event normalization +- ORM schema generation or database migration work +- tool discovery with no authentication impact +- frontend login UI with no backend contract change + +## Request Execution Pattern + +1. The transport authenticates a bearer token, cookie, or connection-init value. +2. It inserts `AuthPrincipal` and related safe auth context into the + async-graphql request. +3. `graphql-orm-ai` stores only `PrincipalReference` with durable work. +4. `CurrentPrincipalResolver` reconstructs current authority before execution. +5. The AI runtime evaluates its tool and approval policy. +6. The host executes the server-owned GraphQL document as that principal. +7. Normal resolver/entity/row/field policies make the final authorization + decision. + +## Project Guidance + +- expand `agql-auth` when the primitive is reusable across projects +- keep tool approval, model policy, and AI budgets in `graphql-orm-ai` +- keep application record policy in the host schema +- fail closed on status-check or rehydration errors +- preserve recent-MFA semantics across long-lived connections +- never trade a disconnected browser for broader background authority diff --git a/crates/graphql-orm-ai/.agents/skills/graphql-orm-macros/SKILL.md b/crates/graphql-orm-ai/.agents/skills/graphql-orm-macros/SKILL.md new file mode 100644 index 00000000..37b8e06e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/graphql-orm-macros/SKILL.md @@ -0,0 +1,134 @@ +--- +name: graphql-orm-macros +description: > + Use when working on the graphql-orm runtime plus graphql-orm-macros derive + layer for GraphQL entities, relations, CRUD operations, schema modules, + resolver metadata, migrations, pagination, durable streams, encryption, and + backend integration in graphql-orm-ai. +--- + +# graphql-orm Skill + +## Use This Skill When + +- deriving `GraphQLEntity`, `GraphQLRelations`, or `GraphQLOperations` +- composing schema roots with `schema_roots!` +- adding AI persistence entities without exposing unsafe generated CRUD +- changing resolver-operation metadata or schema-module integration +- implementing bidirectional keyset pagination or durable event streams +- reviewing relation loading or N+1 behavior +- changing runtime metadata, query rendering, schema diffing, migrations, or backup descriptors +- adding encrypted-field support +- implementing SQLite, PostgreSQL, or MSSQL backend behavior + +## Crates + +- Application-facing dependency: `graphql-orm` +- Runtime and macro repo: `../graphql-orm` +- Upstream runtime repo: `https://github.com/Dastari/graphql-orm` + +## Preferred Usage + +Import through the runtime crate: + +- `use graphql_orm::prelude::*;` +- `use graphql_orm::mutation_result;` +- use derive macros by name on structs + +`graphql-orm-ai` should normally depend only on `graphql-orm`. Do not add a +direct `graphql-orm-macros` dependency unless explicitly developing or +debugging the proc-macro crate. + +## Integration Rules + +1. Use the runtime-plus-macro split correctly. +Generated code comes from re-exported macros. Runtime behavior, metadata, query +rendering, relation loading, policy enforcement, migrations, and backend SQL +belong to `graphql-orm`. + +2. Keep all database syntax in `graphql-orm`. +`graphql-orm-ai` must use generated repository, transaction, migration, +pagination, stream, and backup APIs. Do not issue raw SQL or depend directly on +SQLx or Tiberius database execution APIs. + +3. Use macros for persistence boilerplate, not agent policy. +Model routing, tool policy, approvals, data classification, provider behavior, +and session orchestration belong in `graphql-orm-ai`. + +4. Keep generated types aligned with async-graphql. +Ensure generated output/input types remain compatible with async-graphql and +that sensitive/private fields are not accidentally exposed. + +5. Treat resolver metadata as discovery, not authorization. +Generated operation descriptors may describe every resolver, but AI tool +exposure remains default-deny and runtime resolver policies remain +authoritative. + +6. Keep subscriptions fail-closed. +Do not expose generated subscriptions as AI tools until row/field filtering, +durable replay, lag recovery, and long-lived reauthorization are implemented. + +7. Use stable keysets for large timelines. +Chat and event history must use bounded bidirectional keyset connections, never +unbounded lists or offset pagination for deep history. + +8. Keep persistence backend-agnostic at the AI layer. +Backend-specific SQL rendering, MSSQL write support, migration planning, vector +queries, and schema introspection belong in `graphql-orm`. + +9. Use schema modules for internal entities. +AI entities should contribute migration and backup metadata without forcing +ordinary generated CRUD fields into the host's public schema. + +10. Preserve ordinary authorization paths. +Application tools execute through the composed GraphQL schema with current auth +context. Do not replace this with trusted repository or system access. + +11. Keep database tests isolated. +SQLite may use temporary databases. PostgreSQL and MSSQL integration tests must +use disposable Docker containers and must never connect to live local +databases. + +## When Not To Use + +- provider HTTP/SSE protocol work with no ORM impact +- authentication, token lifecycle, or principal rehydration +- frontend-only GraphQL documents +- simple handwritten types where a derive would add unnecessary coupling + +## Common Pattern + +```rust +use graphql_orm::prelude::*; + +#[derive( + GraphQLEntity, + GraphQLOperations, + Clone, + Debug, + serde::Serialize, + serde::Deserialize, +)] +#[graphql_entity( + table = "ai_sessions", + plural = "AiSessions", + keyset = "updated_at desc, id desc" +)] +struct AiSession { + #[primary_key] + id: graphql_orm::uuid::Uuid, + + owner_subject: String, + updated_at: i64, +} +``` + +## Project Guidance + +- keep `graphql-orm-ai` project-agnostic +- use `graphql-orm` as the normal runtime and macro re-export surface +- contribute reusable persistence primitives back to `graphql-orm` +- do not work around missing ORM features with raw SQL in this crate +- preserve backward compatibility for existing generated resolver clients +- treat resolver metadata, encryption, streams, vector search, and MSSQL writes + as shared ORM concerns when they benefit multiple consumers diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/AGENTS.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/AGENTS.md new file mode 100644 index 00000000..8f5af34b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/AGENTS.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/CLAUDE.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/CLAUDE.md new file mode 100644 index 00000000..8f5af34b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/CLAUDE.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/LICENSE b/crates/graphql-orm-ai/.agents/skills/rust-skills/LICENSE new file mode 100644 index 00000000..3f270707 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Leonardo Maldonado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/README.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/README.md new file mode 100644 index 00000000..4fcace72 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/README.md @@ -0,0 +1,196 @@ +# Rust Skills + +179 Rust rules your AI coding agent can use to write better code. + +Works with Claude Code, Cursor, Windsurf, Copilot, Codex, Aider, Zed, Amp, Cline, and pretty much any other agent that supports skills. + +## Install + +```bash +npx add-skill leonardomso/rust-skills +``` + +That's it. The CLI figures out which agents you have and installs the skill to the right place. + +## How to use it + +After installing, just ask your agent: + +``` +/rust-skills review this function +``` + +``` +/rust-skills is my error handling idiomatic? +``` + +``` +/rust-skills check for memory issues +``` + +The agent loads the relevant rules and applies them to your code. + +## What's in here + +179 rules split into 14 categories: + +| Category | Rules | What it covers | +|----------|-------|----------------| +| **Ownership & Borrowing** | 12 | When to borrow vs clone, Arc/Rc, lifetimes | +| **Error Handling** | 12 | thiserror for libs, anyhow for apps, the `?` operator | +| **Memory** | 15 | SmallVec, arenas, avoiding allocations | +| **API Design** | 15 | Builder pattern, newtypes, sealed traits | +| **Async** | 15 | Tokio patterns, channels, spawn_blocking | +| **Optimization** | 12 | LTO, inlining, PGO, SIMD | +| **Naming** | 16 | Following Rust API Guidelines | +| **Type Safety** | 10 | Newtypes, parse don't validate | +| **Testing** | 13 | Proptest, mockall, criterion | +| **Docs** | 11 | Doc examples, intra-doc links | +| **Performance** | 11 | Iterators, entry API, collect patterns | +| **Project Structure** | 11 | Workspaces, module layout | +| **Linting** | 11 | Clippy config, CI setup | +| **Anti-patterns** | 15 | Common mistakes and how to fix them | + +Each rule has: +- Why it matters +- Bad code example +- Good code example +- Links to official docs when relevant + +## Manual install + +If `add-skill` doesn't work for your setup, here's how to install manually: + +
+Claude Code + +Global (applies to all projects): +```bash +git clone https://github.com/leonardomso/rust-skills.git ~/.claude/skills/rust-skills +``` + +Or just for one project: +```bash +git clone https://github.com/leonardomso/rust-skills.git .claude/skills/rust-skills +``` +
+ +
+OpenCode + +```bash +git clone https://github.com/leonardomso/rust-skills.git .opencode/skills/rust-skills +``` +
+ +
+Cursor + +```bash +git clone https://github.com/leonardomso/rust-skills.git .cursor/skills/rust-skills +``` + +Or just grab the skill file: +```bash +curl -o .cursorrules https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+Windsurf + +```bash +mkdir -p .windsurf/rules +curl -o .windsurf/rules/rust-skills.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+OpenAI Codex + +```bash +git clone https://github.com/leonardomso/rust-skills.git .codex/skills/rust-skills +``` + +Or use the AGENTS.md standard: +```bash +curl -o AGENTS.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+GitHub Copilot + +```bash +mkdir -p .github +curl -o .github/copilot-instructions.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+Aider + +Add to `.aider.conf.yml`: +```yaml +read: path/to/rust-skills/SKILL.md +``` + +Or pass it directly: +```bash +aider --read path/to/rust-skills/SKILL.md +``` +
+ +
+Zed + +```bash +curl -o AGENTS.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+Amp + +```bash +git clone https://github.com/leonardomso/rust-skills.git .agents/skills/rust-skills +``` +
+ +
+Cline / Roo Code + +```bash +mkdir -p .clinerules +curl -o .clinerules/rust-skills.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +
+Other agents (AGENTS.md) + +If your agent supports the [AGENTS.md](https://agents.md) standard: +```bash +curl -o AGENTS.md https://raw.githubusercontent.com/leonardomso/rust-skills/master/SKILL.md +``` +
+ +## All rules + +See [SKILL.md](./SKILL.md) for the full list with links to each rule file. + +## Where these rules come from + +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Real code from ripgrep, tokio, serde, polars, axum +- Clippy docs + +## Contributing + +PRs welcome. Just follow the format of existing rules. + +## License + +MIT diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/SKILL.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/SKILL.md new file mode 100644 index 00000000..8f5af34b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/SKILL.md @@ -0,0 +1,335 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 179 rules across 14 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, API design, memory optimization, performance, + testing, and common anti-patterns. Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.0.0" + sources: + - Rust API Guidelines + - Rust Performance Book + - ripgrep, tokio, serde, polars codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 179 rules across 14 categories, prioritized by impact to guide LLMs in code generation and refactoring. + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 15 | +| 4 | API Design | HIGH | `api-` | 15 | +| 5 | Async/Await | HIGH | `async-` | 15 | +| 6 | Compiler Optimization | HIGH | `opt-` | 12 | +| 7 | Naming Conventions | MEDIUM | `name-` | 16 | +| 8 | Type Safety | MEDIUM | `type-` | 10 | +| 9 | Testing | MEDIUM | `test-` | 13 | +| 10 | Documentation | MEDIUM | `doc-` | 11 | +| 11 | Performance Patterns | MEDIUM | `perf-` | 11 | +| 12 | Project Structure | LOW | `proj-` | 11 | +| 13 | Clippy & Linting | LOW | `lint-` | 11 | +| 14 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for single-threaded sharing +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability (single-thread) +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability (multi-thread) +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads dominate writes +- [`own-copy-small`](rules/own-copy-small.md) - Derive `Copy` for small, trivial types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Make `Clone` explicit, avoid implicit copies +- [`own-move-large`](rules/own-move-large.md) - Move large data instead of cloning +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision when possible + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result`, don't panic on expected errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Never use `.unwrap()` in production code +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `.expect()` only for programming errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Use `#[from]` for automatic error conversion +- [`err-source-chain`](rules/err-source-chain.md) - Use `#[source]` to chain underlying errors +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Error messages: lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document errors with `# Errors` section +- [`err-custom-type`](rules/err-custom-type.md) - Create custom error types, not `Box` + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for bounded-size collections +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce type size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` when fixed +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for often-empty vectors +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Reuse collections with `clear()` in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` instead of `format!()` +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use `CompactString` for small string optimization +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use smallest integer type that fits +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Assert hot type sizes to prevent regressions + +### 4. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Add `#[must_use]` to builder types +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes for type-safe distinctions +- [`api-typestate`](rules/api-typestate.md) - Use typestate for compile-time state machines +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Seal traits to prevent external implementations +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to foreign types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible string inputs +- [`api-impl-asref`](rules/api-impl-asref.md) - Accept `impl AsRef` for borrowed inputs +- [`api-must-use`](rules/api-must-use.md) - Add `#[must_use]` to `Result` returning functions +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` for future-proof enums/structs +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` (auto-derived) +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for sensible defaults +- [`api-common-traits`](rules/api-common-traits.md) - Implement `Debug`, `Clone`, `PartialEq` eagerly +- [`api-serde-optional`](rules/api-serde-optional.md) - Gate `Serialize`/`Deserialize` behind feature flag + +### 5. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Use Tokio for production async runtime +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` not `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `tokio::join!` for parallel operations +- [`async-try-join`](rules/async-try-join.md) - Use `tokio::try_join!` for fallible parallel ops +- [`async-select-racing`](rules/async-select-racing.md) - Use `tokio::select!` for racing/timeouts +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels for backpressure +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` for work queues +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` for pub/sub patterns +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` for latest-value sharing +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` for request/response +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for dynamic task groups +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone data before await, release locks + +### 6. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` for cold paths +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Use `#[cold]` for error/unlikely paths +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use `likely()`/`unlikely()` for branch hints +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Use `codegen-units = 1` for max optimization +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use PGO for production builds +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Set `target-cpu=native` for local builds +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators to avoid bounds checks +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for data-parallel ops +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Design cache-friendly data layouts (SoA) + +### 7. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, enums +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants/statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short lowercase lifetimes: `'a`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase for type params: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - `to_` prefix: expensive conversion +- [`name-into-ownership`](rules/name-into-ownership.md) - `into_` prefix: ownership transfer +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - No `get_` prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_` for boolean methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use `iter`/`iter_mut`/`into_iter` for iterators +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Iterator type names match method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words: `Uuid` not `UUID` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Crate names: no `-rs` suffix + +### 8. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Newtypes for validated data: `Email`, `Url` +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for nullable values +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for fallible operations +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` for type-level markers +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` type for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs, use enums/newtypes +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for FFI newtypes + +### 9. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Use `#[cfg(test)] mod tests { }` +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests as arrange/act/assert +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use `proptest` for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use `mockall` for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop) for test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` for panic tests +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep doc examples as executable tests + +### 10. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` for panicking functions +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links: `[Vec]` +- [`doc-link-types`](rules/doc-link-types.md) - Link related types and functions in docs +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata + +### 11. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect() only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't `collect()` intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use `entry()` API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use `drain()` to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use `extend()` for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid `chain()` in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use `collect_into()` for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use `black_box()` in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing + +### 12. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use `mod.rs` for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use `pub(crate)` for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use `pub(super)` for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use `pub use` for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create `prelude` module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in `src/bin/` +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance + +### 13. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - `#![warn(clippy::suspicious)]` +- [`lint-warn-style`](rules/lint-warn-style.md) - `#![warn(clippy::style)]` +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - `#![warn(clippy::complexity)]` +- [`lint-warn-perf`](rules/lint-warn-perf.md) - `#![warn(clippy::perf)]` +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable `clippy::pedantic` selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - `#![warn(missing_docs)]` +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - `#![warn(clippy::undocumented_unsafe_blocks)]` +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - `#![warn(clippy::cargo)]` for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run `cargo fmt --check` in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level + +### 14. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use `.expect()` for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across `.await` +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept `&String` when `&str` works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept `&Vec` when `&[T]` works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected/recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't use empty `if let Err(_) = ...` blocks +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use `Box` when `impl Trait` works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use `format!()` in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't `collect()` intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings for structured data + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-` | +| New struct/API | `api-`, `type-`, `doc-` | +| Async code | `async-`, `own-` | +| Error handling | `err-`, `api-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources + +This skill synthesizes best practices from: +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Rust Performance Book](https://nnethercote.github.io/perf-book/) +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) +- Production codebases: ripgrep, tokio, serde, polars, axum, deno +- Clippy lint documentation +- Community conventions (2024-2025) diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-clone-excessive.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-clone-excessive.md new file mode 100644 index 00000000..539dac2b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-clone-excessive.md @@ -0,0 +1,124 @@ +# anti-clone-excessive + +> Don't clone when borrowing works + +## Why It Matters + +`.clone()` allocates memory and copies data. When you only need to read data, borrowing (`&T`) is free. Excessive cloning wastes memory, CPU cycles, and often indicates misunderstanding of ownership. + +## Bad + +```rust +// Cloning to pass to a function that only reads +fn print_name(name: String) { // Takes ownership + println!("{}", name); +} +let name = "Alice".to_string(); +print_name(name.clone()); // Unnecessary clone +print_name(name); // Could have just done this + +// Cloning in a loop +for item in items.clone() { // Clones entire Vec + process(&item); +} + +// Cloning for comparison +if input.clone() == expected { // Pointless clone + // ... +} + +// Cloning struct fields +fn get_name(&self) -> String { + self.name.clone() // Caller might not need ownership +} +``` + +## Good + +```rust +// Accept reference if only reading +fn print_name(name: &str) { + println!("{}", name); +} +let name = "Alice".to_string(); +print_name(&name); // Borrow, no clone + +// Iterate by reference +for item in &items { + process(item); +} + +// Compare by reference +if input == expected { + // ... +} + +// Return reference when possible +fn get_name(&self) -> &str { + &self.name +} +``` + +## When to Clone + +```rust +// Need owned data for async move +let name = name.clone(); +tokio::spawn(async move { + process(name).await; +}); + +// Storing in a new struct +struct Cache { + data: String, +} +impl Cache { + fn store(&mut self, data: &str) { + self.data = data.to_string(); // Must own + } +} + +// Multiple owners (use Arc instead if frequent) +let shared = data.clone(); +thread::spawn(move || use_data(shared)); +``` + +## Alternatives to Clone + +| Instead of | Use | +|------------|-----| +| `s.clone()` for reading | `&s` | +| `vec.clone()` for iteration | `&vec` or `vec.iter()` | +| `Clone` for shared ownership | `Arc` | +| Clone in hot loop | Move outside loop | +| `s.to_string()` from `&str` | Accept `&str` if possible | + +## Pattern: Clone on Write + +```rust +use std::borrow::Cow; + +fn process(input: Cow) -> Cow { + if needs_modification(&input) { + Cow::Owned(modify(&input)) // Clone only if needed + } else { + input // No clone + } +} +``` + +## Detecting Excessive Clones + +```toml +# Cargo.toml +[lints.clippy] +clone_on_copy = "warn" +clone_on_ref_ptr = "warn" +redundant_clone = "warn" +``` + +## See Also + +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Borrowing patterns +- [own-cow-conditional](./own-cow-conditional.md) - Clone on write +- [own-arc-shared](./own-arc-shared.md) - Shared ownership diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-collect-intermediate.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-collect-intermediate.md new file mode 100644 index 00000000..888c9ab9 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-collect-intermediate.md @@ -0,0 +1,131 @@ +# anti-collect-intermediate + +> Don't collect intermediate iterators + +## Why It Matters + +Each `.collect()` allocates a new collection. Collecting intermediate results in a chain creates unnecessary allocations and prevents iterator fusion. Keep the chain lazy; collect only at the end. + +## Bad + +```rust +// Three allocations, three passes +fn process(data: Vec) -> Vec { + let step1: Vec<_> = data.into_iter() + .filter(|x| *x > 0) + .collect(); + + let step2: Vec<_> = step1.into_iter() + .map(|x| x * 2) + .collect(); + + step2.into_iter() + .filter(|x| *x < 100) + .collect() +} + +// Collecting just to check length +fn has_valid_items(items: &[Item]) -> bool { + let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + !valid.is_empty() +} + +// Collecting to iterate again +fn sum_valid(items: &[Item]) -> i64 { + let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + valid.iter().map(|i| i.value).sum() +} +``` + +## Good + +```rust +// Single allocation, single pass +fn process(data: Vec) -> Vec { + data.into_iter() + .filter(|x| *x > 0) + .map(|x| x * 2) + .filter(|x| *x < 100) + .collect() +} + +// No allocation - iterator short-circuits +fn has_valid_items(items: &[Item]) -> bool { + items.iter().any(|i| i.is_valid()) +} + +// No intermediate allocation +fn sum_valid(items: &[Item]) -> i64 { + items.iter() + .filter(|i| i.is_valid()) + .map(|i| i.value) + .sum() +} +``` + +## When Collection Is Needed + +```rust +// Need to iterate twice +let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); +let count = valid.len(); +for item in &valid { + process(item); +} + +// Need to sort (requires concrete collection) +let mut sorted: Vec<_> = items.iter() + .filter(|i| i.is_active()) + .collect(); +sorted.sort_by_key(|i| i.priority); + +// Need random access +let indexed: Vec<_> = items.iter().collect(); +let middle = indexed.get(indexed.len() / 2); +``` + +## Iterator Methods That Avoid Collection + +| Instead of Collecting to... | Use | +|-----------------------------|-----| +| Check if empty | `.any(|_| true)` or `.next().is_some()` | +| Check if any match | `.any(predicate)` | +| Check if all match | `.all(predicate)` | +| Count elements | `.count()` | +| Sum elements | `.sum()` | +| Find first | `.find(predicate)` | +| Get first | `.next()` | +| Get last | `.last()` | + +## Pattern: Deferred Collection + +```rust +// Return iterator, let caller collect if needed +fn valid_items(items: &[Item]) -> impl Iterator { + items.iter().filter(|i| i.is_valid()) +} + +// Caller decides +let count = valid_items(&items).count(); // No collection +let vec: Vec<_> = valid_items(&items).collect(); // Collection when needed +``` + +## Comparison + +| Pattern | Allocations | Passes | +|---------|-------------|--------| +| `.collect()` each step | N | N | +| Single chain, one `.collect()` | 1 | 1 | +| No collection (streaming) | 0 | 1 | + +## See Also + +- [perf-collect-once](./perf-collect-once.md) - Single collect +- [perf-iter-lazy](./perf-iter-lazy.md) - Lazy evaluation +- [perf-iter-over-index](./perf-iter-over-index.md) - Iterator patterns diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-empty-catch.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-empty-catch.md new file mode 100644 index 00000000..314bda5e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-empty-catch.md @@ -0,0 +1,132 @@ +# anti-empty-catch + +> Don't silently ignore errors + +## Why It Matters + +Empty error handling (`if let Err(_) = ...`, `let _ = result`, `.ok()`) silently discards errors. Failures go unnoticed, bugs hide, and debugging becomes impossible. Every error deserves acknowledgment—even if just logging. + +## Bad + +```rust +// Silently ignores errors +let _ = write_to_file(data); + +// Discards error completely +if let Err(_) = send_notification() { + // Nothing - error vanishes +} + +// Converts Result to Option, losing error info +let value = risky_operation().ok(); + +// Match with empty arm +match database.save(record) { + Ok(_) => println!("saved"), + Err(_) => {} // Silent failure +} + +// Ignored in loop +for item in items { + let _ = process(item); // Failures unnoticed +} +``` + +## Good + +```rust +// Log the error +if let Err(e) = write_to_file(data) { + error!("failed to write file: {}", e); +} + +// Propagate if possible +send_notification()?; + +// Or handle explicitly +match send_notification() { + Ok(_) => info!("notification sent"), + Err(e) => warn!("notification failed: {}", e), +} + +// Collect errors in batch operations +let (successes, failures): (Vec<_>, Vec<_>) = items + .into_iter() + .map(process) + .partition(Result::is_ok); + +if !failures.is_empty() { + warn!("{} items failed to process", failures.len()); +} + +// Explicit documentation when ignoring +// Intentionally ignored: cleanup failure is not critical +let _ = cleanup_temp_file(); // Add comment explaining why +``` + +## Acceptable Ignoring (Documented) + +```rust +// Close errors often ignored, but document it +// INTENTIONAL: TCP close errors are not actionable +let _ = stream.shutdown(Shutdown::Both); + +// Mutex poisoning recovery +// INTENTIONAL: We'll reset the state anyway +let guard = mutex.lock().unwrap_or_else(|e| e.into_inner()); +``` + +## Pattern: Collect and Report + +```rust +fn process_batch(items: Vec) -> BatchResult { + let mut errors = Vec::new(); + + for item in items { + if let Err(e) = process_item(&item) { + errors.push((item.id, e)); + } + } + + if errors.is_empty() { + BatchResult::AllSucceeded + } else { + BatchResult::PartialFailure(errors) + } +} +``` + +## Pattern: Best-Effort Operations + +```rust +// Metrics/telemetry can fail without affecting main flow +fn report_metric(name: &str, value: f64) { + if let Err(e) = metrics_client.record(name, value) { + // Log but don't propagate - metrics are not critical + debug!("failed to record metric {}: {}", name, e); + } +} +``` + +## Clippy Lint + +```toml +[lints.clippy] +let_underscore_drop = "warn" +ignored_unit_patterns = "warn" +``` + +## Decision Guide + +| Situation | Action | +|-----------|--------| +| Critical operation | `?` or handle explicitly | +| Non-critical, debugging needed | Log the error | +| Truly ignorable (rare) | `let _ =` with comment | +| Batch operation | Collect errors, report | + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Proper error handling +- [err-context-chain](./err-context-chain.md) - Adding context +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap issues diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-expect-lazy.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-expect-lazy.md new file mode 100644 index 00000000..24e2b3db --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-expect-lazy.md @@ -0,0 +1,95 @@ +# anti-expect-lazy + +> Don't use expect for recoverable errors + +## Why It Matters + +`.expect()` panics with a custom message, but it's still a panic. Using it for errors that could reasonably occur in production (network failures, file not found, invalid input) crashes the program instead of handling the error gracefully. + +Reserve `.expect()` for programming errors where panic is appropriate. + +## Bad + +```rust +// Network failures are expected - don't panic +let response = client.get(url).await.expect("failed to fetch"); + +// Files might not exist +let config = fs::read_to_string("config.toml").expect("config not found"); + +// User input can be invalid +let age: u32 = input.parse().expect("invalid age"); + +// Database queries can fail +let user = db.find_user(id).await.expect("user not found"); +``` + +## Good + +```rust +// Handle recoverable errors properly +let response = client.get(url).await + .context("failed to fetch URL")?; + +// Return error if file doesn't exist +let config = fs::read_to_string("config.toml") + .context("failed to read config file")?; + +// Validate and return error +let age: u32 = input.parse() + .map_err(|_| Error::InvalidInput("age must be a number"))?; + +// Handle missing data +let user = db.find_user(id).await? + .ok_or(Error::NotFound("user"))?; +``` + +## When expect() Is Appropriate + +Use `.expect()` for invariants that indicate bugs: + +```rust +// Mutex poisoning indicates a bug elsewhere +let guard = mutex.lock().expect("mutex poisoned"); + +// Regex is known valid at compile time +let re = Regex::new(r"^\d{4}$").expect("invalid regex"); + +// Thread spawn failure is unrecoverable +let handle = thread::spawn(|| work()).expect("failed to spawn thread"); + +// Static data that must be valid +let config: Config = toml::from_str(EMBEDDED_CONFIG) + .expect("embedded config is invalid"); +``` + +## Pattern: expect() vs unwrap() + +```rust +// unwrap: no context, hard to debug +let x = option.unwrap(); + +// expect: gives context, still panics +let x = option.expect("value should exist after validation"); + +// ?: proper error handling +let x = option.ok_or(Error::MissingValue)?; +``` + +## Decision Guide + +| Situation | Use | +|-----------|-----| +| User input | `?` with error | +| File/network I/O | `?` with error | +| Database operations | `?` with error | +| Parsed constants | `.expect()` | +| Thread/mutex operations | `.expect()` | +| After validation check | `.expect()` with explanation | +| Never expected to fail | `.expect()` documenting invariant | + +## See Also + +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to use expect +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoiding unwrap +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap anti-pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-format-hot-path.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-format-hot-path.md new file mode 100644 index 00000000..368627f6 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-format-hot-path.md @@ -0,0 +1,141 @@ +# anti-format-hot-path + +> Don't use format! in hot paths + +## Why It Matters + +`format!()` allocates a new `String` every call. In hot paths (loops, frequently called functions), this creates allocation churn that impacts performance. Pre-allocate, reuse buffers, or use `write!()` to an existing buffer. + +## Bad + +```rust +// format! in loop - allocates every iteration +fn log_events(events: &[Event]) { + for event in events { + let message = format!("[{}] {}: {}", event.level, event.source, event.message); + logger.log(&message); + } +} + +// format! for building parts +fn build_url(base: &str, path: &str, params: &[(&str, &str)]) -> String { + let mut url = format!("{}{}", base, path); + for (key, value) in params { + url = format!("{}{}={}&", url, key, value); // New allocation each time + } + url +} + +// format! for simple concatenation +fn greet(name: &str) -> String { + format!("Hello, {}!", name) // Fine for one-off, bad if called 1M times +} +``` + +## Good + +```rust +use std::fmt::Write; + +// Reuse buffer across iterations +fn log_events(events: &[Event]) { + let mut buffer = String::with_capacity(256); + for event in events { + buffer.clear(); + write!(buffer, "[{}] {}: {}", event.level, event.source, event.message).unwrap(); + logger.log(&buffer); + } +} + +// Build incrementally in single buffer +fn build_url(base: &str, path: &str, params: &[(&str, &str)]) -> String { + let mut url = String::with_capacity(base.len() + path.len() + params.len() * 20); + url.push_str(base); + url.push_str(path); + for (key, value) in params { + write!(url, "{}={}&", key, value).unwrap(); + } + url +} + +// For truly hot paths, avoid allocation entirely +fn greet_to_buf(name: &str, buffer: &mut String) { + buffer.clear(); + buffer.push_str("Hello, "); + buffer.push_str(name); + buffer.push('!'); +} +``` + +## Comparison + +| Approach | Allocations | Performance | +|----------|-------------|-------------| +| `format!()` in loop | N | Slow | +| `write!()` to reused buffer | 1 | Fast | +| `push_str()` + `push()` | 1 | Fastest | +| Pre-sized `String::with_capacity()` | 1 (no realloc) | Fast | + +## When format! Is Fine + +```rust +// One-time initialization +let config_path = format!("{}/config.toml", home_dir); + +// Error messages (not hot path) +return Err(format!("invalid input: {}", input)); + +// Debug output +println!("Debug: {:?}", value); +``` + +## Pattern: Formatter Buffer Pool + +```rust +use std::cell::RefCell; + +thread_local! { + static BUFFER: RefCell = RefCell::new(String::with_capacity(256)); +} + +fn format_event(event: &Event) -> String { + BUFFER.with(|buf| { + let mut buf = buf.borrow_mut(); + buf.clear(); + write!(buf, "[{}] {}", event.level, event.message).unwrap(); + buf.clone() // Still one allocation per call, but no parsing + }) +} +``` + +## Pattern: Display Implementation + +```rust +struct Event { + level: Level, + message: String, +} + +impl std::fmt::Display for Event { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.level, self.message) + } +} + +// Caller controls allocation +let mut buf = String::new(); +write!(buf, "{}", event)?; +``` + +## Clippy Lint + +```toml +[lints.clippy] +format_in_format_args = "warn" +``` + +## See Also + +- [mem-avoid-format](./mem-avoid-format.md) - Avoiding format +- [mem-write-over-format](./mem-write-over-format.md) - Using write! +- [mem-reuse-collections](./mem-reuse-collections.md) - Buffer reuse diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-index-over-iter.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-index-over-iter.md new file mode 100644 index 00000000..c22d85f0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-index-over-iter.md @@ -0,0 +1,125 @@ +# anti-index-over-iter + +> Don't use indexing when iterators work + +## Why It Matters + +Manual indexing (`for i in 0..len`) requires bounds checks on every access, prevents SIMD optimization, and introduces off-by-one error risks. Iterators eliminate these issues and are more idiomatic Rust. + +## Bad + +```rust +// Manual indexing - bounds checked every access +fn sum_squares(data: &[i32]) -> i64 { + let mut result = 0i64; + for i in 0..data.len() { + result += (data[i] as i64) * (data[i] as i64); + } + result +} + +// Index-based with multiple arrays +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + let mut sum = 0.0; + for i in 0..a.len().min(b.len()) { + sum += a[i] * b[i]; + } + sum +} + +// Mutation with indices +fn normalize(data: &mut [f64]) { + let max = data.iter().cloned().fold(0.0, f64::max); + for i in 0..data.len() { + data[i] /= max; + } +} +``` + +## Good + +```rust +// Iterator - no bounds checks, SIMD-friendly +fn sum_squares(data: &[i32]) -> i64 { + data.iter() + .map(|&x| (x as i64) * (x as i64)) + .sum() +} + +// Zip - handles length mismatch automatically +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| x * y) + .sum() +} + +// Mutable iteration +fn normalize(data: &mut [f64]) { + let max = data.iter().cloned().fold(0.0, f64::max); + for x in data.iter_mut() { + *x /= max; + } +} +``` + +## When Indices Are Needed + +Sometimes you genuinely need indices: + +```rust +// Need index in output +for (i, item) in items.iter().enumerate() { + println!("{}: {}", i, item); +} + +// Non-sequential access +for i in (0..len).step_by(2) { + swap(&mut data[i], &mut data[i + 1]); +} + +// Multi-dimensional iteration +for i in 0..rows { + for j in 0..cols { + matrix[i][j] = i * cols + j; + } +} +``` + +## Comparison + +| Pattern | Bounds Checks | SIMD | Safety | +|---------|---------------|------|--------| +| `for i in 0..len { data[i] }` | Every access | Limited | Off-by-one risk | +| `for x in &data` | None | Good | Safe | +| `for x in data.iter()` | None | Good | Safe | +| `data.iter().enumerate()` | None | Good | Safe | + +## Common Conversions + +| Index Pattern | Iterator Pattern | +|---------------|------------------| +| `for i in 0..v.len()` | `for x in &v` | +| `v[0]` | `v.first()` | +| `v[v.len()-1]` | `v.last()` | +| `for i in 0..a.len() { a[i] + b[i] }` | `a.iter().zip(&b)` | +| `for i in 0..v.len() { v[i] *= 2 }` | `for x in &mut v { *x *= 2 }` | + +## Performance Note + +```rust +// Iterator version can auto-vectorize +let sum: i32 = data.iter().sum(); + +// Manual indexing prevents vectorization +let mut sum = 0; +for i in 0..data.len() { + sum += data[i]; +} +``` + +## See Also + +- [perf-iter-over-index](./perf-iter-over-index.md) - Performance details +- [opt-bounds-check](./opt-bounds-check.md) - Bounds check elimination +- [perf-iter-lazy](./perf-iter-lazy.md) - Lazy iterators diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-lock-across-await.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-lock-across-await.md new file mode 100644 index 00000000..8e0ae457 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-lock-across-await.md @@ -0,0 +1,127 @@ +# anti-lock-across-await + +> Don't hold locks across await points + +## Why It Matters + +Holding a `Mutex` or `RwLock` guard across an `.await` causes the lock to be held while the task is suspended. Other tasks waiting for the lock block indefinitely. With `std::sync::Mutex`, this is even worse—it can deadlock the entire runtime. + +## Bad + +```rust +use std::sync::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +// DEADLOCK RISK: std::sync::Mutex held across await +async fn bad_std_mutex(data: &Mutex>) { + let mut guard = data.lock().unwrap(); + do_async_work().await; // Lock held during await! + guard.push(42); +} + +// BLOCKS OTHER TASKS: tokio Mutex held across await +async fn bad_async_mutex(data: &AsyncMutex>) { + let mut guard = data.lock().await; + slow_network_call().await; // Lock held for entire call! + guard.push(42); +} +``` + +## Good + +```rust +use std::sync::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +// Release lock before await +async fn good_approach(data: &Mutex>) { + let value = { + let guard = data.lock().unwrap(); + guard.last().copied() // Extract what you need + }; // Lock released here + + let result = do_async_work(value).await; + + { + let mut guard = data.lock().unwrap(); + guard.push(result); + } +} + +// Minimize lock scope with async mutex +async fn good_async_mutex(data: &AsyncMutex>, item: i32) { + // Quick lock, quick release + data.lock().await.push(item); + + // Async work without lock + let result = slow_network_call().await; + + // Quick lock again + data.lock().await.push(result); +} +``` + +## Pattern: Clone Before Await + +```rust +async fn process(data: &AsyncMutex) -> Result<()> { + // Clone inside lock scope + let config = data.lock().await.clone(); + + // Now use config freely across awaits + let result = fetch_data(&config.url).await?; + process_result(&config, result).await?; + + Ok(()) +} +``` + +## Pattern: Restructure to Avoid Lock + +```rust +// Instead of locking a shared map +struct Service { + data: AsyncMutex>, +} + +// Use channels or owned data +struct BetterService { + // Each task owns its data via channels + sender: mpsc::Sender, +} + +impl BetterService { + async fn request(&self, key: String) -> Data { + let (tx, rx) = oneshot::channel(); + self.sender.send(Request { key, respond: tx }).await?; + rx.await? + } +} +``` + +## What Can Cross Await + +| Type | Safe Across Await? | +|------|--------------------| +| `std::sync::Mutex` guard | **NO** - can deadlock | +| `std::sync::RwLock` guard | **NO** - can deadlock | +| `tokio::sync::Mutex` guard | Allowed but blocks tasks | +| `tokio::sync::RwLock` guard | Allowed but blocks tasks | +| Owned values | Yes | +| `Arc` | Yes | +| References | Depends on lifetime | + +## Detection + +```toml +# Cargo.toml +[lints.clippy] +await_holding_lock = "deny" +await_holding_refcell_ref = "deny" +``` + +## See Also + +- [async-no-lock-await](./async-no-lock-await.md) - Async lock patterns +- [async-clone-before-await](./async-clone-before-await.md) - Clone pattern +- [own-mutex-interior](./own-mutex-interior.md) - Mutex usage diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-over-abstraction.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-over-abstraction.md new file mode 100644 index 00000000..44a12f02 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-over-abstraction.md @@ -0,0 +1,120 @@ +# anti-over-abstraction + +> Don't over-abstract with excessive generics + +## Why It Matters + +Generics and traits are powerful but come at a cost: compile times, binary size, and cognitive load. Over-abstraction—making everything generic "for flexibility"—often adds complexity without benefit. Start concrete; generalize when you have real use cases. + +## Bad + +```rust +// Overly generic for a simple function +fn add(a: T, b: U) -> R +where + T: Into, + U: Into, + R: std::ops::Add, +{ + a.into() + b.into() +} + +// Just call add(1, 2) - why make it this complex? + +// Trait explosion +trait Readable {} +trait Writable {} +trait ReadWritable: Readable + Writable {} +trait AsyncReadable {} +trait AsyncWritable {} +trait AsyncReadWritable: AsyncReadable + AsyncWritable {} + +// Abstract factory pattern (Java flashback) +trait Factory { + fn create(&self) -> T; +} +trait FactoryFactory, T> { + fn create_factory(&self) -> F; +} +``` + +## Good + +```rust +// Concrete implementation - clear and simple +fn add_i32(a: i32, b: i32) -> i32 { + a + b +} + +// Generic when actually needed (e.g., library code) +fn add>(a: T, b: T) -> T { + a + b +} + +// Simple traits for actual polymorphism needs +trait Storage { + fn save(&self, key: &str, value: &[u8]) -> Result<(), Error>; + fn load(&self, key: &str) -> Result, Error>; +} + +// Concrete types first +struct FileStorage { path: PathBuf } +struct MemoryStorage { data: HashMap> } +``` + +## Signs of Over-Abstraction + +| Sign | Symptom | +|------|---------| +| Single implementation | Generic trait with only one impl | +| Type parameter soup | `T, U, V, W` everywhere | +| Marker traits | Traits with no methods | +| Deep trait bounds | `where T: A + B + C + D + E` | +| Phantom generics | Type parameters not used meaningfully | + +## When to Generalize + +Generalize when: +- You have 2+ concrete types that share behavior +- You're writing library code for public consumption +- Performance requires static dispatch +- The abstraction simplifies the API + +Don't generalize when: +- You "might need it later" (YAGNI) +- Only one type will ever implement it +- It makes code harder to understand + +## Rule of Three + +Wait until you have three similar concrete implementations before abstracting: + +```rust +// Version 1: Just FileStorage +struct FileStorage { /* ... */ } + +// Version 2: Added MemoryStorage, similar interface +struct MemoryStorage { /* ... */ } + +// Version 3: Now Redis too - time to abstract +trait Storage { + fn save(&self, key: &str, value: &[u8]) -> Result<()>; + fn load(&self, key: &str) -> Result>; +} +``` + +## Prefer Concrete Types in Private Code + +```rust +// Internal function - concrete type is fine +fn process_orders(db: &PostgresDb, orders: Vec) { } + +// Public API - might benefit from abstraction +pub fn process_orders(storage: &S, orders: Vec) { } +``` + +## See Also + +- [type-generic-bounds](./type-generic-bounds.md) - Minimal bounds +- [api-sealed-trait](./api-sealed-trait.md) - Controlled extension +- [anti-type-erasure](./anti-type-erasure.md) - When Box is wrong diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-panic-expected.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-panic-expected.md new file mode 100644 index 00000000..cecb34cb --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-panic-expected.md @@ -0,0 +1,131 @@ +# anti-panic-expected + +> Don't panic on expected or recoverable errors + +## Why It Matters + +Panics crash the program. They're for unrecoverable situations—bugs, corrupted state, invariant violations. Using panic for expected conditions (network failures, file not found, invalid input) makes programs fragile and forces callers to catch panics or die. + +Use `Result` for recoverable errors. + +## Bad + +```rust +// Network failures are expected +fn fetch_data(url: &str) -> Data { + let response = reqwest::blocking::get(url) + .expect("network error"); // Crashes on timeout + response.json().expect("invalid json") // Crashes on bad response +} + +// User input is often invalid +fn parse_config(input: &str) -> Config { + toml::from_str(input).expect("invalid config") // Crashes on typo +} + +// Files may not exist +fn load_settings() -> Settings { + let content = fs::read_to_string("settings.json") + .expect("settings not found"); // Crashes if missing + serde_json::from_str(&content).expect("invalid settings") +} + +// Custom panic for validation +fn process_age(age: i32) { + if age < 0 { + panic!("age cannot be negative"); // Should return error + } +} +``` + +## Good + +```rust +// Return errors for expected failures +fn fetch_data(url: &str) -> Result { + let response = reqwest::blocking::get(url) + .context("failed to connect")?; + let data = response.json() + .context("failed to parse response")?; + Ok(data) +} + +// Validate and return Result +fn parse_config(input: &str) -> Result { + toml::from_str(input).map_err(ConfigError::Parse) +} + +// Handle missing files gracefully +fn load_settings() -> Result { + let content = fs::read_to_string("settings.json")?; + let settings = serde_json::from_str(&content)?; + Ok(settings) +} + +// Return error for validation failure +fn process_age(age: i32) -> Result<(), ValidationError> { + if age < 0 { + return Err(ValidationError::NegativeAge); + } + Ok(()) +} +``` + +## When to Panic + +Panic IS appropriate for: + +```rust +// Bug detection - invariant violated +fn get_unchecked(&self, index: usize) -> &T { + assert!(index < self.len(), "index out of bounds - this is a bug"); + unsafe { self.data.get_unchecked(index) } +} + +// Unrecoverable state +fn init() { + if !CAN_PROCEED { + panic!("system requirements not met"); + } +} + +// Tests +#[test] +fn test_fails() { + panic!("expected panic in test"); +} +``` + +## Decision Guide + +| Condition | Action | +|-----------|--------| +| Invalid user input | Return `Err` | +| Network failure | Return `Err` | +| File not found | Return `Err` | +| Malformed data | Return `Err` | +| Bug/impossible state | `panic!` or `unreachable!` | +| Failed assertion in test | `panic!` | +| Unrecoverable init failure | `panic!` | + +## Anti-pattern: panic! for Control Flow + +```rust +// BAD: Using panic for control flow +fn find_or_die(items: &[Item], id: u64) -> &Item { + items.iter() + .find(|i| i.id == id) + .unwrap_or_else(|| panic!("item {} not found", id)) +} + +// GOOD: Return Option or Result +fn find(items: &[Item], id: u64) -> Option<&Item> { + items.iter().find(|i| i.id == id) +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Use Result +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap anti-pattern +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to expect diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-premature-optimize.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-premature-optimize.md new file mode 100644 index 00000000..b3e720d9 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-premature-optimize.md @@ -0,0 +1,156 @@ +# anti-premature-optimize + +> Don't optimize before profiling + +## Why It Matters + +Premature optimization wastes time, complicates code, and often targets the wrong bottlenecks. Most code isn't performance-critical; the hot 10% matters. Profile first, then optimize the actual bottlenecks with data-driven decisions. + +## Bad + +```rust +// "Optimizing" without measurement +fn sum(data: &[i32]) -> i32 { + // Using unsafe "for performance" without profiling + unsafe { + let mut sum = 0; + for i in 0..data.len() { + sum += *data.get_unchecked(i); + } + sum + } +} + +// Complex caching with no evidence it's needed +lazy_static! { + static ref CACHE: RwLock>> = + RwLock::new(HashMap::new()); +} + +// Hand-rolled data structures "for speed" +struct MyVec { + ptr: *mut T, + len: usize, + cap: usize, +} +``` + +## Good + +```rust +// Simple, idiomatic - let compiler optimize +fn sum(data: &[i32]) -> i32 { + data.iter().sum() +} + +// Profile, then optimize if needed +fn sum_optimized(data: &[i32]) -> i32 { + // After profiling showed this is a bottleneck, + // we measured that manual SIMD gives 3x speedup + #[cfg(target_arch = "x86_64")] + { + // SIMD implementation with benchmark data + } + #[cfg(not(target_arch = "x86_64"))] + { + data.iter().sum() + } +} + +// Use standard library - it's well-optimized +let cache: HashMap = HashMap::new(); +``` + +## Profiling Workflow + +```bash +# 1. Write correct code first +cargo build --release + +# 2. Profile with real workloads +cargo flamegraph --bin my_app -- --real-args +# or +cargo bench + +# 3. Identify hotspots (top 10% of time) + +# 4. Measure before optimizing +# 5. Optimize ONE thing +# 6. Measure after - verify improvement +# 7. Repeat if still slow +``` + +## Optimization Principles + +| Do | Don't | +|----|-------| +| Profile first | Guess at bottlenecks | +| Optimize hotspots | Optimize everything | +| Measure improvement | Assume it's faster | +| Keep it simple | Add complexity speculatively | +| Trust the compiler | Outsmart the compiler | + +## When to Optimize + +```rust +// AFTER profiling shows this is 40% of runtime +#[inline] +fn hot_function(data: &[u8]) -> u64 { + // Optimized implementation justified by benchmarks +} + +// Clear, measurable benefit documented +/// Pre-allocated buffer for repeated formatting. +/// Benchmarks show 3x speedup for >1000 calls/sec workloads. +struct FormatterPool { + buffers: Vec, +} +``` + +## Common Premature Optimizations + +| Premature | Reality | +|-----------|---------| +| `#[inline(always)]` everywhere | Compiler usually knows better | +| `unsafe` for bounds check removal | Iterator does this safely | +| Custom allocator | Default is usually fine | +| Object pooling | Allocator is fast enough | +| Manual SIMD | Auto-vectorization works | + +## Profile Tools + +```bash +# Sampling profiler +perf record ./target/release/app && perf report + +# Flamegraph +cargo install flamegraph +cargo flamegraph + +# Criterion benchmarks +cargo bench + +# Memory profiling +valgrind --tool=massif ./target/release/app +``` + +## Document Optimizations + +```rust +/// Lookup table for fast character classification. +/// +/// # Performance +/// +/// Benchmarked with criterion (benchmarks/char_class.rs): +/// - Table lookup: 2.3ns/op +/// - Match statement: 8.7ns/op +/// +/// Justified for hot path in parser (called 10M+ times). +static CHAR_CLASS: [CharClass; 256] = [/* ... */]; +``` + +## See Also + +- [perf-profile-first](./perf-profile-first.md) - Profile before optimize +- [test-criterion-bench](./test-criterion-bench.md) - Benchmarking +- [opt-inline-small](./opt-inline-small.md) - Inline guidelines diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-string-for-str.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-string-for-str.md new file mode 100644 index 00000000..a35723b3 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-string-for-str.md @@ -0,0 +1,122 @@ +# anti-string-for-str + +> Don't accept &String when &str works + +## Why It Matters + +`&String` is strictly less flexible than `&str`. A `&str` can be created from `String`, `&str`, literals, and slices. A `&String` requires exactly a `String`. This forces callers to allocate when they might not need to. + +## Bad + +```rust +// Forces callers to have a String +fn greet(name: &String) { + println!("Hello, {}", name); +} + +// Caller must allocate +greet(&"Alice".to_string()); // Unnecessary allocation +greet(&name); // Only works if name is String + +// In struct +struct Config { + name: String, +} + +impl Config { + fn set_name(&mut self, name: &String) { // Too restrictive + self.name = name.clone(); + } +} +``` + +## Good + +```rust +// Accept &str - works with String, &str, literals +fn greet(name: &str) { + println!("Hello, {}", name); +} + +// All these work +greet("Alice"); // String literal +greet(&name); // &String coerces to &str +greet(name.as_str()); // Explicit &str + +// In struct +impl Config { + fn set_name(&mut self, name: &str) { + self.name = name.to_string(); + } + + // Or accept owned String if caller usually has one + fn set_name_owned(&mut self, name: String) { + self.name = name; + } + + // Or be generic + fn set_name_into(&mut self, name: impl Into) { + self.name = name.into(); + } +} +``` + +## Deref Coercion + +`String` implements `Deref`, so `&String` automatically coerces to `&str`: + +```rust +fn takes_str(s: &str) { } + +let owned = String::from("hello"); +takes_str(&owned); // &String -> &str via Deref +``` + +## When to Accept &String + +Rarely. Maybe if you need `String`-specific methods: + +```rust +fn needs_capacity(s: &String) -> usize { + s.capacity() // Only String has capacity() +} +``` + +But usually you'd take `&str` and let the caller manage the `String`. + +## Pattern: Flexible APIs + +```rust +// Most flexible: accept anything that can become &str +fn process(input: impl AsRef) { + let s: &str = input.as_ref(); + // ... +} + +process("literal"); +process(String::from("owned")); +process(&some_string); +``` + +## Similar Anti-patterns + +| Anti-pattern | Better | +|--------------|--------| +| `&String` | `&str` | +| `&Vec` | `&[T]` | +| `&Box` | `&T` | +| `&PathBuf` | `&Path` | +| `&OsString` | `&OsStr` | + +## Clippy Detection + +```toml +[lints.clippy] +ptr_arg = "warn" # Catches &String, &Vec, &PathBuf +``` + +## See Also + +- [anti-vec-for-slice](./anti-vec-for-slice.md) - Similar pattern for Vec +- [own-slice-over-vec](./own-slice-over-vec.md) - Slice patterns +- [api-impl-asref](./api-impl-asref.md) - AsRef pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-stringly-typed.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-stringly-typed.md new file mode 100644 index 00000000..e786aa40 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-stringly-typed.md @@ -0,0 +1,167 @@ +# anti-stringly-typed + +> Don't use strings where enums or newtypes would provide type safety + +## Why It Matters + +Strings are the most primitive way to represent data—they accept any value, provide no validation, and offer no IDE support. When you have a fixed set of valid values or a semantic type, use enums or newtypes. The compiler catches mistakes at compile time instead of runtime. + +## Bad + +```rust +fn process_order(status: &str, priority: &str) { + // What are valid statuses? "pending"? "Pending"? "PENDING"? + // What are valid priorities? "high"? "1"? "urgent"? + match status { + "pending" => { ... } + "completed" => { ... } + _ => panic!("unknown status"), // Runtime error + } +} + +struct User { + email: String, // Any string, even "not an email" + phone: String, // Any string, even "hello" + user_id: String, // Could be confused with other string IDs +} + +// Easy to make mistakes +process_order("complete", "high"); // Typo: "complete" vs "completed" +process_order("high", "pending"); // Swapped arguments - compiles! +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OrderStatus { + Pending, + Processing, + Completed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Priority { + Low, + Medium, + High, + Critical, +} + +fn process_order(status: OrderStatus, priority: Priority) { + match status { + OrderStatus::Pending => { ... } + OrderStatus::Processing => { ... } + OrderStatus::Completed => { ... } + OrderStatus::Cancelled => { ... } + } // Exhaustive - compiler checks all cases +} + +// Validated newtypes +struct Email(String); +struct PhoneNumber(String); +struct UserId(u64); + +impl Email { + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(ValidationError::InvalidEmail) + } + } +} + +struct User { + email: Email, // Must be valid email + phone: PhoneNumber, // Must be valid phone + user_id: UserId, // Can't confuse with other IDs +} + +// Compile errors catch mistakes +process_order(OrderStatus::Completed, Priority::High); // Clear and correct +process_order(Priority::High, OrderStatus::Pending); // Compile error! +``` + +## Parsing Strings to Types + +```rust +use std::str::FromStr; + +#[derive(Debug, Clone, Copy)] +enum OrderStatus { + Pending, + Processing, + Completed, + Cancelled, +} + +impl FromStr for OrderStatus { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "pending" => Ok(OrderStatus::Pending), + "processing" => Ok(OrderStatus::Processing), + "completed" => Ok(OrderStatus::Completed), + "cancelled" | "canceled" => Ok(OrderStatus::Cancelled), + _ => Err(ParseError::UnknownStatus(s.to_string())), + } + } +} + +// Parse at boundary, use types internally +fn handle_request(status_str: &str) -> Result<(), Error> { + let status: OrderStatus = status_str.parse()?; // Validate once + process_order(status); // Type-safe from here + Ok(()) +} +``` + +## With Serde + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Status { + Pending, + InProgress, + Completed, +} + +// JSON: {"status": "in_progress"} +// Deserialization validates automatically +``` + +## Error Messages + +```rust +#[derive(Debug, Clone, Copy)] +enum Color { + Red, + Green, + Blue, +} + +impl std::fmt::Display for Color { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Color::Red => write!(f, "red"), + Color::Green => write!(f, "green"), + Color::Blue => write!(f, "blue"), + } + } +} + +// Type-safe and displayable +println!("Selected color: {}", Color::Red); +``` + +## See Also + +- [api-newtype-safety](./api-newtype-safety.md) - Newtype pattern +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Parse at boundaries +- [type-newtype-ids](./type-newtype-ids.md) - Type-safe IDs diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-type-erasure.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-type-erasure.md new file mode 100644 index 00000000..b1d1535b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-type-erasure.md @@ -0,0 +1,134 @@ +# anti-type-erasure + +> Don't use Box when impl Trait works + +## Why It Matters + +`Box` (type erasure) introduces heap allocation and dynamic dispatch overhead. When you have a single concrete type or can use generics, `impl Trait` provides the same flexibility with zero overhead through monomorphization. + +## Bad + +```rust +// Unnecessary type erasure +fn get_iterator() -> Box> { + Box::new((0..10).map(|x| x * 2)) +} + +// Boxing for no reason +fn make_handler() -> Box i32> { + Box::new(|x| x + 1) +} + +// Vec of boxed trait objects when one type would do +fn get_validators() -> Vec> { + vec![ + Box::new(LengthValidator), + Box::new(RegexValidator), + ] +} +``` + +## Good + +```rust +// impl Trait - zero overhead, inlined +fn get_iterator() -> impl Iterator { + (0..10).map(|x| x * 2) +} + +// impl Fn - no boxing +fn make_handler() -> impl Fn(i32) -> i32 { + |x| x + 1 +} + +// When mixed types are genuinely needed, Box is OK +fn get_validators() -> Vec> { + // Actually different types at runtime - Box is appropriate + config.validators.iter() + .map(|v| v.create_validator()) + .collect() +} +``` + +## When to Use Box + +Type erasure IS appropriate when: + +```rust +// Heterogeneous collection of different types +let handlers: Vec> = vec![ + Box::new(LogHandler), + Box::new(MetricsHandler), + Box::new(AuthHandler), +]; + +// Type cannot be known at compile time +fn create_from_config(config: &Config) -> Box { + match config.db_type { + DbType::Postgres => Box::new(PostgresDb::new()), + DbType::Sqlite => Box::new(SqliteDb::new()), + } +} + +// Recursive types +struct Node { + value: i32, + children: Vec>, +} + +// Breaking cycles in complex ownership +struct EventLoop { + handlers: Vec>, +} +``` + +## Comparison + +| Approach | Allocation | Dispatch | Binary Size | +|----------|------------|----------|-------------| +| `impl Trait` | Stack/inline | Static | Larger (monomorphization) | +| `Box` | Heap | Dynamic | Smaller | +| Generics `` | Stack/inline | Static | Larger | + +## impl Trait Positions + +```rust +// Return position - caller doesn't need to know concrete type +fn process() -> impl Future { } + +// Argument position - like generics but simpler +fn handle(handler: impl Handler) { } + +// Can't use in trait definitions (use associated types instead) +trait Processor { + type Output: Display; // Not impl Display + fn process(&self) -> Self::Output; +} +``` + +## Pattern: Enum Instead of dyn + +```rust +// Instead of Box +enum Shape { + Circle { radius: f64 }, + Rectangle { width: f64, height: f64 }, + Triangle { base: f64, height: f64 }, +} + +impl Shape { + fn area(&self) -> f64 { + match self { + Shape::Circle { radius } => PI * radius * radius, + Shape::Rectangle { width, height } => width * height, + Shape::Triangle { base, height } => 0.5 * base * height, + } + } +} +``` + +## See Also + +- [anti-over-abstraction](./anti-over-abstraction.md) - Excessive generics +- [type-generic-bounds](./type-generic-bounds.md) - Generic constraints +- [mem-box-large-variant](./mem-box-large-variant.md) - Boxing enum variants diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md new file mode 100644 index 00000000..ff6273c4 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-unwrap-abuse.md @@ -0,0 +1,143 @@ +# anti-unwrap-abuse + +> Don't use `.unwrap()` in production code + +## Why It Matters + +`.unwrap()` panics on `None` or `Err`, crashing your program. In production, this means lost data, failed requests, and unhappy users. It also makes debugging harder since panic messages often lack context. + +## Bad + +```rust +// Crashes if file doesn't exist +let content = std::fs::read_to_string("config.toml").unwrap(); + +// Crashes on invalid input +let num: i32 = user_input.parse().unwrap(); + +// Crashes if key missing +let value = map.get("key").unwrap(); + +// Crashes if channel closed +let msg = receiver.recv().unwrap(); +``` + +## Good + +```rust +// Propagate with ? +fn load_config() -> Result { + let content = std::fs::read_to_string("config.toml")?; + Ok(toml::from_str(&content)?) +} + +// Provide default +let num: i32 = user_input.parse().unwrap_or(0); + +// Handle missing key +let value = map.get("key").ok_or(Error::MissingKey)?; + +// Or use if-let +if let Some(value) = map.get("key") { + process(value); +} + +// Channel with proper handling +match receiver.recv() { + Ok(msg) => handle(msg), + Err(_) => break, // Channel closed +} +``` + +## When unwrap() Is Acceptable + +```rust +// 1. Tests - panics are expected failures +#[test] +fn test_parse() { + let result = parse("valid").unwrap(); // OK in tests + assert_eq!(result, expected); +} + +// 2. Const/static initialization (compile-time guaranteed) +static REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^\d+$").unwrap() // Known-valid pattern +}); + +// 3. After a check that guarantees success +if map.contains_key("key") { + let value = map.get("key").unwrap(); // Just checked +} +// Better: use if-let or entry API instead + +// 4. Truly impossible cases with proof comment +let last = vec.pop().unwrap(); +// OK only if you just checked !vec.is_empty() +// Better: use last() or pattern match +``` + +## Alternatives to unwrap() + +```rust +// unwrap_or - provide default +let x = opt.unwrap_or(default); + +// unwrap_or_default - use Default trait +let x = opt.unwrap_or_default(); + +// unwrap_or_else - compute default lazily +let x = opt.unwrap_or_else(|| expensive_default()); + +// ? operator - propagate errors +let x = opt.ok_or(Error::Missing)?; + +// if let - handle Some/Ok case +if let Some(x) = opt { + use_x(x); +} + +// match - handle all cases +match opt { + Some(x) => use_x(x), + None => handle_none(), +} + +// map - transform if present +let y = opt.map(|x| x + 1); + +// and_then - chain fallible operations +let z = opt.and_then(|x| x.checked_add(1)); +``` + +## expect() Is Slightly Better + +```rust +// unwrap() - no context +let file = File::open(path).unwrap(); +// Panics with: "called `Result::unwrap()` on an `Err` value: Os { code: 2, ... }" + +// expect() - adds context +let file = File::open(path) + .expect("config file should exist at startup"); +// Panics with: "config file should exist at startup: Os { code: 2, ... }" + +// But still use only for invariants, not error handling +``` + +## Clippy Lint + +```rust +// Enable these lints to catch unwrap usage: +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] // Stricter + +// Or per-function: +#[allow(clippy::unwrap_used)] +fn tests_only() { } +``` + +## See Also + +- [err-question-mark](err-question-mark.md) - Use ? for propagation +- [err-result-over-panic](err-result-over-panic.md) - Return Result instead of panicking +- [anti-expect-lazy](anti-expect-lazy.md) - Don't use expect for recoverable errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-vec-for-slice.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-vec-for-slice.md new file mode 100644 index 00000000..ed50a038 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/anti-vec-for-slice.md @@ -0,0 +1,121 @@ +# anti-vec-for-slice + +> Don't accept &Vec when &[T] works + +## Why It Matters + +`&Vec` is strictly less flexible than `&[T]`. A slice can be created from `Vec`, arrays, and other slice-like types. Accepting `&Vec` forces callers to have exactly a `Vec`, preventing them from using arrays, slices, or other collections. + +## Bad + +```rust +// Forces callers to have a Vec +fn sum(numbers: &Vec) -> i32 { + numbers.iter().sum() +} + +// Caller must allocate +let arr = [1, 2, 3, 4, 5]; +sum(&arr.to_vec()); // Unnecessary allocation + +// Slice won't work +let slice: &[i32] = &[1, 2, 3]; +// sum(slice); // Error: expected &Vec +``` + +## Good + +```rust +// Accept slice - works with Vec, arrays, slices +fn sum(numbers: &[i32]) -> i32 { + numbers.iter().sum() +} + +// All these work +sum(&[1, 2, 3, 4, 5]); // Array +sum(&vec![1, 2, 3]); // Vec +sum(&numbers[1..3]); // Slice of slice +sum(numbers.as_slice()); // Explicit slice +``` + +## Deref Coercion + +`Vec` implements `Deref`, so `&Vec` automatically coerces to `&[T]`: + +```rust +fn takes_slice(s: &[i32]) { } + +let vec = vec![1, 2, 3]; +takes_slice(&vec); // &Vec -> &[i32] via Deref +``` + +## Mutable Slices + +Same applies to `&mut`: + +```rust +// Bad +fn double(numbers: &mut Vec) { + for n in numbers.iter_mut() { + *n *= 2; + } +} + +// Good +fn double(numbers: &mut [i32]) { + for n in numbers.iter_mut() { + *n *= 2; + } +} +``` + +## When to Accept &Vec + +Rarely. Only when you need Vec-specific operations: + +```rust +fn needs_capacity(v: &Vec) -> usize { + v.capacity() // Only Vec has capacity +} + +fn might_grow(v: &mut Vec) { + v.push(42); // Slice can't push +} +``` + +## Pattern: Accepting Multiple Types + +```rust +// Accept anything that can be viewed as a slice +fn process>(data: T) { + let bytes: &[u8] = data.as_ref(); + // ... +} + +process(&[1u8, 2, 3]); // Array +process(vec![1u8, 2, 3]); // Vec +process(&some_vec); // &Vec +process(b"bytes"); // Byte string +``` + +## Similar Anti-patterns + +| Anti-pattern | Better | +|--------------|--------| +| `&Vec` | `&[T]` | +| `&String` | `&str` | +| `&PathBuf` | `&Path` | +| `&Box` | `&T` | + +## Clippy Detection + +```toml +[lints.clippy] +ptr_arg = "warn" # Catches &Vec, &String, &PathBuf +``` + +## See Also + +- [anti-string-for-str](./anti-string-for-str.md) - Similar for String +- [own-slice-over-vec](./own-slice-over-vec.md) - Slice patterns +- [api-impl-asref](./api-impl-asref.md) - AsRef pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-must-use.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-must-use.md new file mode 100644 index 00000000..52f57f17 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-must-use.md @@ -0,0 +1,143 @@ +# api-builder-must-use + +> Mark builder methods with `#[must_use]` to prevent silent drops + +## Why It Matters + +Builder pattern methods return a modified builder. Without `#[must_use]`, calling a builder method and ignoring the return value silently does nothing—the builder is dropped, and the configuration is lost. This creates confusing bugs where code appears correct but has no effect. + +## Bad + +```rust +struct RequestBuilder { + url: String, + timeout: Option, + headers: Vec<(String, String)>, +} + +impl RequestBuilder { + fn timeout(mut self, duration: Duration) -> Self { + self.timeout = Some(duration); + self + } + + fn header(mut self, key: &str, value: &str) -> Self { + self.headers.push((key.to_string(), value.to_string())); + self + } +} + +// Bug: builder methods are ignored - no warning! +let request = RequestBuilder::new("https://api.example.com"); +request.timeout(Duration::from_secs(30)); // Dropped silently! +request.header("Authorization", "Bearer token"); // Dropped silently! +let response = request.send(); // Sends with no timeout or headers +``` + +## Good + +```rust +struct RequestBuilder { + url: String, + timeout: Option, + headers: Vec<(String, String)>, +} + +impl RequestBuilder { + #[must_use = "builder methods return modified builder - chain or assign"] + fn timeout(mut self, duration: Duration) -> Self { + self.timeout = Some(duration); + self + } + + #[must_use = "builder methods return modified builder - chain or assign"] + fn header(mut self, key: &str, value: &str) -> Self { + self.headers.push((key.to_string(), value.to_string())); + self + } +} + +// Now warns: unused return value that must be used +let request = RequestBuilder::new("https://api.example.com"); +request.timeout(Duration::from_secs(30)); // Warning! + +// Correct: chain methods +let response = RequestBuilder::new("https://api.example.com") + .timeout(Duration::from_secs(30)) + .header("Authorization", "Bearer token") + .send(); +``` + +## Apply to Entire Type + +```rust +#[must_use = "builders do nothing unless consumed"] +struct ConfigBuilder { + log_level: Level, + max_connections: usize, +} + +// Now all methods returning Self warn if ignored +impl ConfigBuilder { + fn log_level(mut self, level: Level) -> Self { + self.log_level = level; + self + } + + fn max_connections(mut self, n: usize) -> Self { + self.max_connections = n; + self + } + + fn build(self) -> Config { + Config { + log_level: self.log_level, + max_connections: self.max_connections, + } + } +} +``` + +## Message Guidelines + +```rust +// Descriptive message helps users understand +#[must_use = "builder methods return modified builder"] +fn with_foo(self, foo: Foo) -> Self { ... } + +#[must_use = "this creates a new String and does not modify the original"] +fn to_uppercase(&self) -> String { ... } + +#[must_use = "iterator adaptors are lazy - use .collect() to consume"] +fn map(self, f: F) -> Map { ... } +``` + +## Clippy Lint + +```toml +[lints.clippy] +must_use_candidate = "warn" # Suggests where #[must_use] would help +return_self_not_must_use = "warn" # Specifically for -> Self methods +``` + +## Standard Library Examples + +```rust +// std::Option - must_use on map, and, or +let x: Option = Some(5); +x.map(|v| v * 2); // Warning: unused return value + +// std::Result - must_use on the type itself +#[must_use = "this `Result` may be an `Err` variant, which should be handled"] +pub enum Result { ... } + +// Iterator adaptors +let v = vec![1, 2, 3]; +v.iter().map(|x| x * 2); // Warning: iterators are lazy +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Builder pattern best practices +- [api-must-use](./api-must-use.md) - General must_use guidelines +- [err-result-over-panic](./err-result-over-panic.md) - Result types are must_use diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-pattern.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-pattern.md new file mode 100644 index 00000000..147e4b1f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-builder-pattern.md @@ -0,0 +1,187 @@ +# api-builder-pattern + +> Use Builder pattern for complex construction + +## Why It Matters + +When a type has many optional parameters or complex initialization, the Builder pattern provides a clear, flexible API. It avoids constructors with many parameters (which are error-prone) and makes the code self-documenting. + +## Bad + +```rust +// Constructor with many parameters - hard to read, easy to get wrong +let client = Client::new( + "https://api.example.com", // Which is which? + 30, // Timeout? Retries? + true, // What does this mean? + None, + Some("auth_token"), + false, +); + +// Or many Option fields +struct Client { + url: String, + timeout: Option, + retries: Option, + // ... 10 more optional fields +} +``` + +## Good + +```rust +#[derive(Default)] +#[must_use = "builders do nothing unless you call build()"] +pub struct ClientBuilder { + base_url: Option, + timeout: Option, + max_retries: u32, + auth_token: Option, +} + +impl ClientBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Sets the base URL for all requests. + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = Some(url.into()); + self + } + + /// Sets the request timeout. Default is 30 seconds. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Sets the maximum number of retries. Default is 3. + pub fn max_retries(mut self, n: u32) -> Self { + self.max_retries = n; + self + } + + /// Sets the authentication token. + pub fn auth_token(mut self, token: impl Into) -> Self { + self.auth_token = Some(token.into()); + self + } + + /// Builds the client with the configured options. + pub fn build(self) -> Result { + let base_url = self.base_url + .ok_or(BuilderError::MissingBaseUrl)?; + + Ok(Client { + base_url, + timeout: self.timeout.unwrap_or(Duration::from_secs(30)), + max_retries: self.max_retries, + auth_token: self.auth_token, + }) + } +} + +// Usage - clear and self-documenting +let client = ClientBuilder::new() + .base_url("https://api.example.com") + .timeout(Duration::from_secs(10)) + .max_retries(5) + .auth_token("secret") + .build()?; +``` + +## Builder Variations + +```rust +// 1. Infallible builder (build() returns T, not Result) +impl WidgetBuilder { + pub fn build(self) -> Widget { + Widget { + color: self.color.unwrap_or(Color::Black), + size: self.size.unwrap_or(Size::Medium), + } + } +} + +// 2. Typestate builder (compile-time required field checking) +pub struct ClientBuilder { + url: Url, + timeout: Option, +} + +pub struct NoUrl; +pub struct HasUrl(String); + +impl ClientBuilder { + pub fn new() -> Self { + Self { url: NoUrl, timeout: None } + } + + pub fn url(self, url: String) -> ClientBuilder { + ClientBuilder { url: HasUrl(url), timeout: self.timeout } + } +} + +impl ClientBuilder { + pub fn build(self) -> Client { + // url is guaranteed to be set + Client { url: self.url.0, timeout: self.timeout } + } +} + +// 3. Consuming vs borrowing (consuming is more common) +// Consuming (takes self) +pub fn timeout(mut self, t: Duration) -> Self { ... } + +// Borrowing (takes &mut self, allows reuse) +pub fn timeout(&mut self, t: Duration) -> &mut Self { ... } +``` + +## Evidence from reqwest + +```rust +// https://github.com/seanmonstar/reqwest/blob/master/src/async_impl/client.rs + +#[must_use] +pub struct ClientBuilder { + config: Config, +} + +impl ClientBuilder { + pub fn new() -> ClientBuilder { + ClientBuilder { + config: Config::default(), + } + } + + pub fn timeout(mut self, timeout: Duration) -> ClientBuilder { + self.config.timeout = Some(timeout); + self + } + + pub fn build(self) -> Result { + // Validation and construction + } +} +``` + +## Key Attributes + +```rust +#[derive(Default)] // Enables MyBuilder::default() +#[must_use = "builders do nothing unless you call build()"] +pub struct MyBuilder { ... } + +impl MyBuilder { + #[must_use] // Each method should have this + pub fn option(mut self, value: T) -> Self { ... } +} +``` + +## See Also + +- [api-builder-must-use](api-builder-must-use.md) - Add #[must_use] to builders +- [api-typestate](api-typestate.md) - Compile-time state machines +- [api-impl-into](api-impl-into.md) - Accept impl Into for flexibility diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-common-traits.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-common-traits.md new file mode 100644 index 00000000..63a8938b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-common-traits.md @@ -0,0 +1,165 @@ +# api-common-traits + +> Implement standard traits (Debug, Clone, PartialEq, etc.) for public types + +## Why It Matters + +Standard traits make your types interoperable with the Rust ecosystem. `Debug` enables `println!("{:?}")` and error messages. `Clone` allows explicit duplication. `PartialEq` enables `==`. Without these, users can't use your types in common patterns like testing, collections, or debugging. + +## Bad + +```rust +// Bare struct - severely limited usability +pub struct Point { + pub x: f64, + pub y: f64, +} + +// Can't debug +println!("{:?}", point); // Error: Debug not implemented + +// Can't compare +if point1 == point2 { } // Error: PartialEq not implemented + +// Can't use in HashMap +let mut map: HashMap = HashMap::new(); // Error: Hash not implemented + +// Can't clone +let copy = point.clone(); // Error: Clone not implemented +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +// Now everything works +println!("{:?}", point); +assert_eq!(point1, point2); +let copy = point; // Copy, not just Clone + +// For hashable types +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct UserId(u64); + +let mut map: HashMap = HashMap::new(); +``` + +## Trait Derivation Guide + +| Trait | Derive When | Requirements | +|-------|-------------|--------------| +| `Debug` | Always for public types | All fields implement Debug | +| `Clone` | Type can be duplicated | All fields implement Clone | +| `Copy` | Small, simple types | All fields implement Copy, no Drop | +| `PartialEq` | Comparison makes sense | All fields implement PartialEq | +| `Eq` | Total equality | PartialEq, no floating-point fields | +| `Hash` | Used as HashMap/HashSet key | Eq, consistent with PartialEq | +| `Default` | Sensible default exists | All fields implement Default | +| `PartialOrd` | Ordering makes sense | PartialEq, all fields implement PartialOrd | +| `Ord` | Total ordering | Eq + PartialOrd, no floating-point | + +## Common Trait Bundles + +```rust +// ID types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EntityId(u64); + +// Value types +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Vector2 { x: f32, y: f32 } + +// Configuration +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Config { + name: String, + options: HashMap, +} + +// Error types +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + InvalidSyntax(String), + UnexpectedToken(Token), +} +``` + +## Manual Implementations + +```rust +// When derive doesn't do what you want +struct CaseInsensitiveString(String); + +impl PartialEq for CaseInsensitiveString { + fn eq(&self, other: &Self) -> bool { + self.0.to_lowercase() == other.0.to_lowercase() + } +} + +impl Eq for CaseInsensitiveString {} + +impl Hash for CaseInsensitiveString { + fn hash(&self, state: &mut H) { + // Must be consistent with PartialEq + self.0.to_lowercase().hash(state); + } +} + +// Custom Debug for sensitive data +struct Password(String); + +impl Debug for Password { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Password([REDACTED])") + } +} +``` + +## Serde Traits + +```rust +use serde::{Serialize, Deserialize}; + +// For serializable types +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiResponse { + pub status: String, + pub data: Vec, +} + +// With custom serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub verbose: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} +``` + +## Minimum Recommended + +```rust +// At minimum, public types should have: +#[derive(Debug, Clone, PartialEq)] +pub struct MyType { ... } + +// Add based on use case: +// + Eq, Hash → for HashMap keys +// + Ord, PartialOrd → for BTreeMap, sorting +// + Default → for Option::unwrap_or_default() +// + Copy → for small value types +// + Serialize → for serialization +``` + +## See Also + +- [own-copy-small](./own-copy-small.md) - When to implement Copy +- [api-default-impl](./api-default-impl.md) - Implementing Default +- [doc-examples-section](./doc-examples-section.md) - Documenting trait implementations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-default-impl.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-default-impl.md new file mode 100644 index 00000000..8ac2ae88 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-default-impl.md @@ -0,0 +1,177 @@ +# api-default-impl + +> Implement `Default` for types with sensible default values + +## Why It Matters + +`Default` is a standard trait that provides a canonical way to create a default instance. It integrates with many ecosystem patterns: `Option::unwrap_or_default()`, `#[derive(Default)]`, struct update syntax `..Default::default()`, and generic code that requires `T: Default`. Implementing it makes your types more ergonomic. + +## Bad + +```rust +struct Config { + timeout: Duration, + retries: u32, + verbose: bool, +} + +impl Config { + // Custom constructor - works but non-standard + fn new() -> Self { + Config { + timeout: Duration::from_secs(30), + retries: 3, + verbose: false, + } + } +} + +// Can't use with standard patterns +let config: Config = Default::default(); // Error: Default not implemented +let timeout = settings.get("timeout").unwrap_or_default(); // Won't work +``` + +## Good + +```rust +#[derive(Default)] +struct Config { + #[default = Duration::from_secs(30)] // Nightly, or implement manually + timeout: Duration, + retries: u32, // Defaults to 0 with derive + verbose: bool, // Defaults to false with derive +} + +// Or implement manually for custom defaults +impl Default for Config { + fn default() -> Self { + Config { + timeout: Duration::from_secs(30), + retries: 3, + verbose: false, + } + } +} + +// Now works with all standard patterns +let config = Config::default(); +let config = Config { retries: 5, ..Default::default() }; +let value = map.get("key").cloned().unwrap_or_default(); +``` + +## Derive vs Manual + +```rust +// Derive: all fields use their own Default +#[derive(Default)] +struct Simple { + count: u32, // 0 + name: String, // "" + items: Vec, // [] +} + +// Manual: when you need custom defaults +struct Connection { + host: String, + port: u16, + timeout: Duration, +} + +impl Default for Connection { + fn default() -> Self { + Connection { + host: "localhost".to_string(), + port: 8080, + timeout: Duration::from_secs(30), + } + } +} +``` + +## Builder with Default + +```rust +#[derive(Default)] +struct ServerBuilder { + host: String, + port: u16, + workers: usize, +} + +impl ServerBuilder { + fn host(mut self, host: impl Into) -> Self { + self.host = host.into(); + self + } + + fn port(mut self, port: u16) -> Self { + self.port = port; + self + } +} + +// Clean initialization +let server = ServerBuilder::default() + .host("0.0.0.0") + .port(3000) + .build(); +``` + +## Default with Required Fields + +```rust +// When some fields have no sensible default, don't implement Default +struct User { + id: UserId, // No sensible default + name: String, // Could default to "" +} + +// Instead, provide a constructor +impl User { + fn new(id: UserId, name: impl Into) -> Self { + User { id, name: name.into() } + } +} + +// Or use builder with required fields +struct UserBuilder { + id: Option, + name: String, +} + +impl Default for UserBuilder { + fn default() -> Self { + UserBuilder { + id: None, + name: String::new(), + } + } +} +``` + +## Generic Default + +```rust +// Require Default in generic bounds when needed +fn create_or_default(opt: Option) -> T { + opt.unwrap_or_default() +} + +// PhantomData is Default regardless of T +use std::marker::PhantomData; +struct Wrapper { + _marker: PhantomData, +} + +impl Default for Wrapper { + fn default() -> Self { + Wrapper { _marker: PhantomData } + } +} +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Building complex types +- [api-common-traits](./api-common-traits.md) - Other common traits to implement +- [api-from-not-into](./api-from-not-into.md) - Conversion traits diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-extension-trait.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-extension-trait.md new file mode 100644 index 00000000..d363909f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-extension-trait.md @@ -0,0 +1,163 @@ +# api-extension-trait + +> Use extension traits to add methods to external types + +## Why It Matters + +Rust's orphan rules prevent implementing external traits on external types. Extension traits provide a workaround: define a new trait with your methods, then implement it for the external type. This pattern is used extensively in the ecosystem (e.g., `itertools::Itertools`, `tokio::AsyncReadExt`). + +## Bad + +```rust +// Can't add methods directly to external types +impl Vec { + fn as_hex(&self) -> String { + // Error: cannot define inherent impl for a type outside this crate + } +} + +// Can't implement external trait for external type +impl SomeExternalTrait for Vec { + // Error: orphan rules violation +} +``` + +## Good + +```rust +// Define an extension trait +pub trait ByteSliceExt { + fn as_hex(&self) -> String; + fn is_ascii_printable(&self) -> bool; +} + +// Implement for the external type +impl ByteSliceExt for [u8] { + fn as_hex(&self) -> String { + self.iter() + .map(|b| format!("{:02x}", b)) + .collect() + } + + fn is_ascii_printable(&self) -> bool { + self.iter().all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace()) + } +} + +// Usage: import the trait to use the methods +use my_crate::ByteSliceExt; + +let data: &[u8] = b"hello"; +println!("{}", data.as_hex()); // "68656c6c6f" +``` + +## Convention: Ext Suffix + +```rust +// Standard naming: TypeExt for extending Type +pub trait OptionExt { + fn unwrap_or_log(self, msg: &str) -> Option; +} + +impl OptionExt for Option { + fn unwrap_or_log(self, msg: &str) -> Option { + if self.is_none() { + log::warn!("{}", msg); + } + self + } +} + +// For generic extensions +pub trait ResultExt { + fn log_err(self) -> Self; +} + +impl ResultExt for Result { + fn log_err(self) -> Self { + if let Err(ref e) = self { + log::error!("{}", e); + } + self + } +} +``` + +## Ecosystem Examples + +```rust +// itertools::Itertools +use itertools::Itertools; +let groups = vec![1, 1, 2, 2, 3].into_iter().group_by(|x| *x); + +// futures::StreamExt +use futures::StreamExt; +let next = stream.next().await; + +// tokio::io::AsyncReadExt +use tokio::io::AsyncReadExt; +let mut buf = [0u8; 1024]; +reader.read(&mut buf).await?; + +// anyhow::Context +use anyhow::Context; +let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path))?; +``` + +## Scoped Extensions + +```rust +// Extension only visible where imported +mod string_utils { + pub trait StringExt { + fn truncate_ellipsis(&self, max_len: usize) -> String; + } + + impl StringExt for str { + fn truncate_ellipsis(&self, max_len: usize) -> String { + if self.len() <= max_len { + self.to_string() + } else { + format!("{}...", &self[..max_len.saturating_sub(3)]) + } + } + } +} + +// Only available when explicitly imported +use string_utils::StringExt; +let short = "very long string".truncate_ellipsis(10); +``` + +## Generic Extensions with Bounds + +```rust +pub trait VecExt { + fn push_if_unique(&mut self, item: T) + where + T: PartialEq; +} + +impl VecExt for Vec { + fn push_if_unique(&mut self, item: T) + where + T: PartialEq, + { + if !self.contains(&item) { + self.push(item); + } + } +} + +// Works with any T: PartialEq +let mut v = vec![1, 2, 3]; +v.push_if_unique(2); // No-op +v.push_if_unique(4); // Adds 4 +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Controlling trait implementations +- [api-impl-into](./api-impl-into.md) - Using standard conversion traits +- [name-as-free](./name-as-free.md) - Naming conventions for conversions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-from-not-into.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-from-not-into.md new file mode 100644 index 00000000..11b7f04b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-from-not-into.md @@ -0,0 +1,146 @@ +# api-from-not-into + +> Implement `From`, not `Into` - From gives you Into for free + +## Why It Matters + +The standard library has a blanket implementation: `impl Into for T where U: From`. This means implementing `From for U` automatically gives you `Into for T`. Implementing `Into` directly bypasses this and is considered non-idiomatic. Always implement `From`. + +## Bad + +```rust +struct UserId(u64); + +// Non-idiomatic: implementing Into directly +impl Into for u64 { + fn into(self) -> UserId { + UserId(self) + } +} + +// Works, but now you can't use From syntax +let id = UserId::from(42); // Error: From not implemented +let id: UserId = 42.into(); // Works, but limited +``` + +## Good + +```rust +struct UserId(u64); + +// Idiomatic: implement From +impl From for UserId { + fn from(id: u64) -> Self { + UserId(id) + } +} + +// Now both work automatically +let id = UserId::from(42); // From syntax +let id: UserId = 42.into(); // Into syntax (via blanket impl) + +// And Into bound works in generics +fn process(id: impl Into) { + let id: UserId = id.into(); +} +process(42u64); // Works! +``` + +## Blanket Implementation + +```rust +// This is in std, you don't write it +impl Into for T +where + U: From, +{ + fn into(self) -> U { + U::from(self) + } +} + +// So when you implement From: +impl From for MyType { ... } + +// You automatically get: +// impl Into for String { ... } +``` + +## Multiple From Implementations + +```rust +struct Email(String); + +impl From for Email { + fn from(s: String) -> Self { + Email(s) + } +} + +impl From<&str> for Email { + fn from(s: &str) -> Self { + Email(s.to_string()) + } +} + +// All of these work +let e1 = Email::from("test@example.com"); +let e2 = Email::from(String::from("test@example.com")); +let e3: Email = "test@example.com".into(); +let e4: Email = String::from("test@example.com").into(); +``` + +## TryFrom for Fallible Conversions + +```rust +use std::convert::TryFrom; + +struct PositiveInt(u32); + +// Fallible conversion +impl TryFrom for PositiveInt { + type Error = &'static str; + + fn try_from(value: i32) -> Result { + if value > 0 { + Ok(PositiveInt(value as u32)) + } else { + Err("value must be positive") + } + } +} + +// Usage +let pos = PositiveInt::try_from(42)?; // From-style +let pos: PositiveInt = 42.try_into()?; // Into-style (via blanket) +``` + +## Clippy Lint + +```toml +[lints.clippy] +from_over_into = "warn" # Warns when implementing Into instead of From +``` + +```rust +// Clippy will warn: +impl Into for Foo { // Warning: prefer From + fn into(self) -> Bar { ... } +} +``` + +## When Into IS Needed (Rare) + +```rust +// Only when implementing for external types in specific trait bounds +// This is very rare and usually indicates a design issue + +// Example: you can't implement From for ExternalB +// because of orphan rules. But you usually shouldn't need to. +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - Using Into in function parameters +- [err-from-impl](./err-from-impl.md) - From for error types +- [api-newtype-safety](./api-newtype-safety.md) - Newtype conversions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-asref.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-asref.md new file mode 100644 index 00000000..626f4c2b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-asref.md @@ -0,0 +1,142 @@ +# api-impl-asref + +> Use `AsRef` when you only need to borrow the inner data + +## Why It Matters + +`AsRef` provides a cheap borrowed view of data without taking ownership or copying. Functions accepting `impl AsRef` can work with multiple types that contain or represent `T`, making APIs flexible while avoiding unnecessary allocations. Use `AsRef` when you only need to read, `Into` when you need to own. + +## Bad + +```rust +// Forces callers to provide exact types +fn process_text(text: &str) { ... } +fn read_file(path: &Path) { ... } + +// Can't call directly with owned types +let s = String::from("hello"); +process_text(&s); // Works but verbose + +let p = PathBuf::from("/file"); +read_file(&p); // Works but verbose +read_file("/file"); // Error! &str != &Path +``` + +## Good + +```rust +// Accept anything that can be viewed as the target type +fn process_text(text: impl AsRef) { + let s: &str = text.as_ref(); + println!("{}", s); +} + +fn read_file(path: impl AsRef) -> io::Result> { + std::fs::read(path.as_ref()) +} + +// All of these work: +process_text("literal"); // &str +process_text(String::from("owned")); // String +process_text(Cow::from("cow")); // Cow + +read_file("/path/to/file"); // &str +read_file(Path::new("/path")); // &Path +read_file(PathBuf::from("/path")); // PathBuf +read_file(OsStr::new("/path")); // &OsStr +``` + +## AsRef vs Into vs Borrow + +```rust +// AsRef: cheap borrow, no ownership transfer +fn read(p: impl AsRef) { + let path: &Path = p.as_ref(); +} + +// Into: ownership transfer, may allocate +fn store(p: impl Into) { + let owned: PathBuf = p.into(); +} + +// Borrow: like AsRef but with Eq/Hash consistency guarantee +use std::borrow::Borrow; +fn lookup(map: &HashMap, key: &Q) -> Option<&V> +where + String: Borrow, + Q: Hash + Eq, +{ + map.get(key) +} +``` + +## Implement AsRef for Custom Types + +```rust +struct Name(String); + +impl AsRef for Name { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef<[u8]> for Name { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } +} + +// Now Name works with functions expecting AsRef +fn greet(name: impl AsRef) { + println!("Hello, {}!", name.as_ref()); +} + +greet(Name("Alice".into())); +``` + +## Common AsRef Implementations + +```rust +// Standard library provides many +impl AsRef for String { ... } +impl AsRef for str { ... } +impl AsRef<[u8]> for str { ... } +impl AsRef<[u8]> for String { ... } +impl AsRef<[u8]> for Vec { ... } +impl AsRef for str { ... } +impl AsRef for String { ... } +impl AsRef for PathBuf { ... } +impl AsRef for OsStr { ... } +impl AsRef for str { ... } +``` + +## When to Use Which + +| Trait | Use When | +|-------|----------| +| `&T` | Single type, simple API | +| `AsRef` | Read-only access, multiple input types | +| `Into` | Need to store/own the value | +| `Borrow` | HashMap/HashSet keys, Eq/Hash needed | +| `Deref` | Smart pointer semantics | + +## Pattern: Optional AsRef Bound + +```rust +// When T itself might be passed +fn process, U>(value: T) { + let inner: &U = value.as_ref(); +} + +// More flexible: accept T or &T +fn process + ?Sized, U: ?Sized>(value: &T) { + let inner: &U = value.as_ref(); +} +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - When to use Into instead +- [own-slice-over-vec](./own-slice-over-vec.md) - Using slices for flexibility +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Preferring borrows diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-into.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-into.md new file mode 100644 index 00000000..1d461c75 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-impl-into.md @@ -0,0 +1,160 @@ +# api-impl-into + +> Accept `impl Into` for flexible APIs, implement `From` for conversions + +## Why It Matters + +APIs that accept `impl Into` are ergonomic—callers can pass the target type directly or any type that converts to it. This reduces boilerplate `.into()` calls at call sites. Implement `From` rather than `Into` because `From` implies `Into` through a blanket implementation. + +## Bad + +```rust +// Requires exact type - forces callers to convert +fn process_path(path: PathBuf) { ... } +fn set_name(name: String) { ... } + +// Caller must convert explicitly +process_path(PathBuf::from("/path/to/file")); +process_path("/path/to/file".to_path_buf()); // Verbose +process_path("/path/to/file".into()); // Explicit + +set_name(String::from("Alice")); +set_name("Alice".to_string()); // Verbose +``` + +## Good + +```rust +// Accept anything that converts to the target type +fn process_path(path: impl Into) { + let path = path.into(); // Convert once inside + // ... +} + +fn set_name(name: impl Into) { + let name = name.into(); + // ... +} + +// Callers are ergonomic +process_path("/path/to/file"); // &str converts automatically +process_path(PathBuf::from(".")); // PathBuf works too + +set_name("Alice"); // &str +set_name(String::from("Alice")); // String +set_name(format!("User-{}", id)); // String from format! +``` + +## Implement From, Not Into + +```rust +struct UserId(u64); + +// ✅ Implement From +impl From for UserId { + fn from(id: u64) -> Self { + UserId(id) + } +} + +// Into is automatically provided by blanket impl +let id: UserId = 42u64.into(); // Works! + +// ❌ Don't implement Into directly +impl Into for u64 { + fn into(self) -> UserId { + UserId(self) // This works but is non-idiomatic + } +} +``` + +## Common Conversions + +```rust +// String-like types +fn log_message(msg: impl Into) { ... } +log_message("literal"); // &str +log_message(String::from("own")); // String +log_message(Cow::from("cow")); // Cow + +// Path-like types +fn read_file(path: impl AsRef) { ... } // AsRef for borrowed access +fn write_file(path: impl Into) { ... } // Into when storing + +// Duration +fn set_timeout(duration: impl Into) { ... } +set_timeout(Duration::from_secs(5)); +// Note: no blanket impl for integers, would need custom wrapper +``` + +## AsRef vs Into + +```rust +// AsRef: borrow as &T, no conversion cost +fn count_bytes(data: impl AsRef<[u8]>) -> usize { + data.as_ref().len() // Just borrows, no allocation +} +count_bytes("hello"); // &str -> &[u8] +count_bytes(b"hello"); // &[u8] -> &[u8] +count_bytes(vec![1, 2, 3]); // &Vec -> &[u8] + +// Into: convert to owned T, may allocate +fn store_data(data: impl Into>) { + let owned: Vec = data.into(); // Takes ownership + // ... +} +``` + +## When NOT to Use impl Into + +```rust +// ❌ Trait objects need Sized +fn process(handler: impl Into>) { } +// Better: just take Box directly + +// ❌ Recursive types +struct Node { + children: Vec>, // Error: impl Trait not allowed here +} + +// ❌ Performance-critical hot paths (minor overhead of trait dispatch) +fn hot_path(value: impl Into) { + // Consider taking u64 directly if called billions of times +} + +// ❌ When you need to name the type +fn returns_impl() -> impl Into { } // Opaque, hard to use +``` + +## Builder Pattern with Into + +```rust +struct Config { + name: String, + path: PathBuf, +} + +impl Config { + fn new(name: impl Into) -> Self { + Config { + name: name.into(), + path: PathBuf::new(), + } + } + + fn path(mut self, path: impl Into) -> Self { + self.path = path.into(); + self + } +} + +// Clean builder calls +let config = Config::new("myapp") + .path("/etc/myapp"); +``` + +## See Also + +- [api-impl-asref](./api-impl-asref.md) - When to use AsRef instead +- [api-from-not-into](./api-from-not-into.md) - Why From is preferred +- [err-from-impl](./err-from-impl.md) - From for error conversion diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-must-use.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-must-use.md new file mode 100644 index 00000000..1894ae91 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-must-use.md @@ -0,0 +1,125 @@ +# api-must-use + +> Mark types and functions with `#[must_use]` when ignoring results is likely a bug + +## Why It Matters + +Some return values should never be ignored—`Result`, locks, RAII guards, computed values that have no side effects. Without `#[must_use]`, silently discarding these values can introduce subtle bugs that are hard to detect. The attribute generates compiler warnings when the value is unused. + +## Bad + +```rust +// Result ignored - error silently dropped +fn send_email(to: &str, body: &str) -> Result<(), EmailError> { ... } + +send_email("user@example.com", "Hello!"); // No warning if Result ignored! +// Email may have failed, but we don't know + +// Computed value ignored - likely a bug +fn compute_checksum(data: &[u8]) -> u32 { ... } + +let data = vec![1, 2, 3, 4]; +compute_checksum(&data); // Result discarded - pointless call +``` + +## Good + +```rust +#[must_use = "this `Result` may be an `Err` that should be handled"] +fn send_email(to: &str, body: &str) -> Result<(), EmailError> { ... } + +send_email("user@example.com", "Hello!"); +// Warning: unused `Result` that must be used + +// Mark pure functions +#[must_use = "this returns a new value and does not modify the input"] +fn compute_checksum(data: &[u8]) -> u32 { ... } + +compute_checksum(&data); +// Warning: unused return value of `compute_checksum` that must be used +``` + +## Apply to Types + +```rust +// Mark the type itself when it should always be used +#[must_use = "futures do nothing unless polled"] +struct MyFuture { ... } + +// Mark RAII guards +#[must_use = "if unused, the lock will be immediately released"] +struct MutexGuard<'a, T> { ... } + +// Mark results/errors +#[must_use = "errors should be handled"] +enum AppError { ... } +``` + +## Standard Library Examples + +```rust +// Result and Option are #[must_use] +let v: Vec = vec![1, 2, 3]; +v.first(); // Warning: unused Option + +// Iterator adapters are #[must_use] +v.iter().map(|x| x * 2); // Warning: iterators are lazy + +// String methods that return new values +let s = "hello"; +s.to_uppercase(); // Warning: unused String +``` + +## When to Apply + +```rust +// ✅ Pure functions (no side effects) +#[must_use] +fn add(a: i32, b: i32) -> i32 { a + b } + +// ✅ Builder methods returning Self +#[must_use = "builder methods return a new builder"] +fn with_timeout(self, t: Duration) -> Self { ... } + +// ✅ Fallible operations +#[must_use] +fn try_parse(s: &str) -> Result { ... } + +// ✅ Iterators and futures (lazy) +#[must_use = "iterators are lazy and do nothing unless consumed"] +struct Map { ... } + +// ❌ Side-effecting functions where result is optional +fn log(msg: &str) -> Result<(), io::Error> { ... } // Might be ok to ignore + +// ❌ Methods with useful side effects +fn vec.push(item); // Mutates vec, no return to use +``` + +## Custom Messages + +```rust +#[must_use = "creating a guard does nothing without assignment"] +struct ScopeGuard { ... } + +#[must_use = "this returns the old value"] +fn replace(&mut self, new: T) -> T { ... } + +#[must_use = "use `.await` to execute the future"] +async fn fetch() -> Data { ... } +``` + +## Clippy Lints + +```toml +[lints.clippy] +must_use_candidate = "warn" # Suggests where to add #[must_use] +unused_must_use = "deny" # Built-in, treat warnings as errors +double_must_use = "warn" # Redundant #[must_use] +``` + +## See Also + +- [api-builder-must-use](./api-builder-must-use.md) - Builder pattern must_use +- [err-result-over-panic](./err-result-over-panic.md) - Result types require handling +- [lint-deny-correctness](./lint-deny-correctness.md) - Enabling useful lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-newtype-safety.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-newtype-safety.md new file mode 100644 index 00000000..9049c2a8 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-newtype-safety.md @@ -0,0 +1,162 @@ +# api-newtype-safety + +> Use newtypes to prevent mixing semantically different values + +## Why It Matters + +Raw primitives like `u64` or `String` carry no semantic meaning. A function taking `(u64, u64)` can easily be called with arguments swapped. Newtypes wrap primitives in distinct types, making the compiler catch mistakes at compile time rather than runtime. + +## Bad + +```rust +struct User { + id: u64, + group_id: u64, + created_at: u64, // Unix timestamp +} + +fn add_user_to_group(user_id: u64, group_id: u64) { ... } + +// Bug: arguments swapped - compiles fine, fails at runtime +let user = User { id: 100, group_id: 5, created_at: 1234567890 }; +add_user_to_group(user.group_id, user.id); // Silent bug! + +// Bug: wrong field used - timestamp passed as ID +add_user_to_group(user.created_at, user.group_id); // Compiles fine! +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct UserId(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct GroupId(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct Timestamp(u64); + +struct User { + id: UserId, + group_id: GroupId, + created_at: Timestamp, +} + +fn add_user_to_group(user_id: UserId, group_id: GroupId) { ... } + +// Compile error: expected UserId, found GroupId +let user = User { ... }; +add_user_to_group(user.group_id, user.id); // Error! + +// Compile error: expected UserId, found Timestamp +add_user_to_group(user.created_at, user.group_id); // Error! +``` + +## Derive Common Traits + +```rust +// Minimal: just enough for your use case +#[derive(Debug, Clone, Copy)] +struct MeterId(u32); + +// Full ID type: hashable, comparable, displayable +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct OrderId(u64); + +impl std::fmt::Display for OrderId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ORD-{:08}", self.0) + } +} + +// With serde for serialization +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] // Serializes as raw u64 +struct ProductId(u64); +``` + +## Constructor Patterns + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct Email(String); + +impl Email { + /// Creates a new Email, validating the format. + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(EmailError::InvalidFormat) + } + } + + /// Returns the email as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// Usage enforces validation +let email = Email::new("user@example.com")?; // Must go through validation +``` + +## Zero-Cost Abstraction + +```rust +use std::mem::size_of; + +#[derive(Clone, Copy)] +struct Miles(f64); + +#[derive(Clone, Copy)] +struct Kilometers(f64); + +// Same size as raw f64 +assert_eq!(size_of::(), size_of::()); +assert_eq!(size_of::(), size_of::()); + +// But can't accidentally mix them +fn drive(distance: Miles) { ... } + +let km = Kilometers(100.0); +drive(km); // Error: expected Miles, found Kilometers + +// Explicit conversion +impl From for Miles { + fn from(km: Kilometers) -> Self { + Miles(km.0 * 0.621371) + } +} + +drive(km.into()); // Explicit, visible conversion +``` + +## When Newtypes Help Most + +```rust +// ✅ IDs that could be confused +fn transfer(from: AccountId, to: AccountId, amount: Money) { ... } + +// ✅ Units that shouldn't mix +struct Celsius(f64); +struct Fahrenheit(f64); + +// ✅ Validated strings +struct Username(String); // Validated alphanumeric +struct Password(String); // Never logged + +// ✅ Different meanings of same type +struct Milliseconds(u64); +struct Seconds(u64); + +// ❌ Overkill: single use, no confusion possible +struct X(i32); // Just use i32 +``` + +## See Also + +- [type-newtype-ids](./type-newtype-ids.md) - Newtype pattern for IDs +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven validation +- [own-copy-small](./own-copy-small.md) - Making newtypes Copy diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-non-exhaustive.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-non-exhaustive.md new file mode 100644 index 00000000..ad6d71c0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-non-exhaustive.md @@ -0,0 +1,177 @@ +# api-non-exhaustive + +> Use `#[non_exhaustive]` on public enums and structs for forward compatibility + +## Why It Matters + +Adding a variant to a public enum or a field to a public struct is normally a breaking change—downstream code may match exhaustively or use struct literal syntax. `#[non_exhaustive]` forces external code to use wildcards in matches and constructors, allowing you to add variants/fields in minor versions without breaking callers. + +## Bad + +```rust +// Public enum - adding variant breaks downstream matches +pub enum ErrorKind { + NotFound, + PermissionDenied, + TimedOut, +} + +// Downstream code +match error.kind() { + ErrorKind::NotFound => ..., + ErrorKind::PermissionDenied => ..., + ErrorKind::TimedOut => ..., + // No wildcard - will break when you add ErrorKind::Interrupted +} + +// Public struct - adding field breaks downstream construction +pub struct Config { + pub name: String, + pub value: i32, +} + +// Downstream code +let config = Config { name: "test".into(), value: 42 }; +// Will break when you add `pub enabled: bool` +``` + +## Good + +```rust +// Can add variants in minor versions +#[non_exhaustive] +pub enum ErrorKind { + NotFound, + PermissionDenied, + TimedOut, + // Future: can add Interrupted here without breaking changes +} + +// Downstream code MUST have wildcard +match error.kind() { + ErrorKind::NotFound => ..., + ErrorKind::PermissionDenied => ..., + ErrorKind::TimedOut => ..., + _ => ..., // Required by non_exhaustive +} + +// Can add fields in minor versions +#[non_exhaustive] +pub struct Config { + pub name: String, + pub value: i32, +} + +// Downstream CANNOT use struct literal syntax +// let config = Config { name: "test".into(), value: 42 }; // Error! + +// Must use constructor +impl Config { + pub fn new(name: impl Into, value: i32) -> Self { + Config { name: name.into(), value } + } +} +``` + +## How It Works + +```rust +#[non_exhaustive] +pub enum Status { + Active, + Inactive, +} + +// Inside your crate: exhaustive match is allowed +fn internal(s: Status) { + match s { + Status::Active => {}, + Status::Inactive => {}, + // No wildcard needed inside defining crate + } +} + +// Outside your crate: wildcard required +fn external(s: my_crate::Status) { + match s { + my_crate::Status::Active => {}, + my_crate::Status::Inactive => {}, + _ => {}, // REQUIRED + } +} +``` + +## Struct Usage + +```rust +#[non_exhaustive] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + // Provide constructor + pub fn new(x: f64, y: f64) -> Self { + Point { x, y } + } +} + +// External code can read fields but not construct with literals +fn external(p: Point) { + println!("x: {}, y: {}", p.x, p.y); // Reading is fine + + // let p2 = Point { x: 1.0, y: 2.0 }; // Error! + let p2 = Point::new(1.0, 2.0); // Must use constructor +} +``` + +## Non-Exhaustive Variants + +```rust +pub enum Message { + // Specific variant is non-exhaustive + #[non_exhaustive] + Error { code: u32, message: String }, + + Ok(Data), +} + +// Can destructure Ok normally +// But Error requires `..` to handle future fields +match msg { + Message::Ok(data) => {}, + Message::Error { code, message, .. } => {}, // `..` required +} +``` + +## When to Use + +```rust +// ✅ Use for public API types that may evolve +#[non_exhaustive] +pub enum ApiError { ... } + +#[non_exhaustive] +pub struct Options { ... } + +// ✅ Use for error types +#[non_exhaustive] +pub enum MyError { ... } + +// ❌ Don't use for internal types +enum InternalState { ... } // Not public, no concern + +// ❌ Don't use for stable, complete types +pub enum Ordering { // Less, Equal, Greater is complete + Less, + Equal, + Greater, +} +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Controlling trait implementations +- [err-custom-type](./err-custom-type.md) - Error type design +- [api-builder-pattern](./api-builder-pattern.md) - Alternative to struct literals diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-parse-dont-validate.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-parse-dont-validate.md new file mode 100644 index 00000000..82426a60 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-parse-dont-validate.md @@ -0,0 +1,184 @@ +# api-parse-dont-validate + +> Parse into validated types at boundaries + +## Why It Matters + +Instead of validating data and hoping you remember to check everywhere, parse it into a type that can only be constructed from valid data. The type system then guarantees validity - you can't forget to validate because invalid states are unrepresentable. + +## Bad + +```rust +// Validation scattered throughout codebase +fn send_email(email: &str) -> Result<(), Error> { + // Did someone validate this already? Who knows! + if !is_valid_email(email) { + return Err(Error::InvalidEmail); + } + // Send email... +} + +fn add_to_mailing_list(email: &str) -> Result<(), Error> { + // Duplicate validation, or did we forget? + if !is_valid_email(email) { + return Err(Error::InvalidEmail); + } + // Add to list... +} + +// Easy to forget validation +fn process_user_email(email: &str) { + // Oops, no validation! + database.store_email(email); +} +``` + +## Good + +```rust +/// A validated email address. +/// Can only be constructed via `Email::parse()`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Email(String); + +impl Email { + /// Parses and validates an email address. + pub fn parse(s: impl Into) -> Result { + let s = s.into(); + if Self::is_valid(&s) { + Ok(Email(s)) + } else { + Err(EmailError::Invalid) + } + } + + fn is_valid(s: &str) -> bool { + s.contains('@') && s.len() > 3 // Simplified + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// Now functions can accept Email - guaranteed valid! +fn send_email(email: &Email) -> Result<(), Error> { + // No validation needed - Email is always valid + smtp_send(email.as_str()) +} + +fn add_to_mailing_list(email: Email) { + // No validation needed + list.push(email); +} +``` + +## More Examples + +```rust +// Port number (1-65535) +pub struct Port(u16); + +impl Port { + pub fn new(n: u16) -> Option { + if n > 0 { Some(Port(n)) } else { None } + } + + pub fn get(&self) -> u16 { + self.0 + } +} + +// Non-empty string +pub struct NonEmptyString(String); + +impl NonEmptyString { + pub fn new(s: impl Into) -> Option { + let s = s.into(); + if s.is_empty() { None } else { Some(Self(s)) } + } +} + +// Positive integer +pub struct PositiveI32(i32); + +impl PositiveI32 { + pub fn new(n: i32) -> Option { + if n > 0 { Some(Self(n)) } else { None } + } +} + +// Bounded value +pub struct Percentage(u8); + +impl Percentage { + pub fn new(n: u8) -> Option { + if n <= 100 { Some(Self(n)) } else { None } + } +} +``` + +## Parsing at Boundaries + +```rust +// Parse at the system boundary (API, CLI, config file) +fn handle_request(raw: RawRequest) -> Result { + // Parse ALL inputs upfront + let email = Email::parse(&raw.email)?; + let age = Age::parse(raw.age)?; + let username = Username::parse(&raw.username)?; + + // Now work with validated types + process_user(email, age, username) +} + +fn process_user(email: Email, age: Age, username: Username) { + // All inputs guaranteed valid - no checks needed +} +``` + +## Evidence from sqlx + +```rust +// sqlx parses SQL at compile time, ensuring query validity +// https://github.com/launchbadge/sqlx/blob/master/src/macros/mod.rs + +// The query! macro parses and validates SQL +let user = sqlx::query!("SELECT * FROM users WHERE id = ?", id) + .fetch_one(&pool) + .await?; + +// If SQL is invalid, compilation fails - invalid state unrepresentable +``` + +## Combining with Display + +```rust +use std::fmt; + +pub struct Email(String); + +impl Email { + pub fn parse(s: &str) -> Result { ... } +} + +// Implement Display for easy printing +impl fmt::Display for Email { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// Implement AsRef for easy borrowing +impl AsRef for Email { + fn as_ref(&self) -> &str { + &self.0 + } +} +``` + +## See Also + +- [api-newtype-safety](api-newtype-safety.md) - Use newtypes for type safety +- [type-newtype-validated](type-newtype-validated.md) - Newtypes for validated data +- [api-typestate](api-typestate.md) - Compile-time state machines diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-sealed-trait.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-sealed-trait.md new file mode 100644 index 00000000..198a62b6 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-sealed-trait.md @@ -0,0 +1,168 @@ +# api-sealed-trait + +> Use sealed traits to prevent external implementations while allowing use + +## Why It Matters + +Public traits can be implemented by anyone, which may be undesirable when you need to guarantee behavior or add methods in future versions. A sealed trait can be used by external code but not implemented by it, giving you control over implementations while maintaining a usable API. + +## Bad + +```rust +// Anyone can implement this trait +pub trait DatabaseDriver { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; +} + +// External crate implements it incorrectly +impl DatabaseDriver for MyBadDriver { + fn connect(&self, url: &str) -> Connection { + // Buggy implementation that doesn't handle errors + unsafe { force_connect(url) } + } +} + +// Later, you want to add a required method - BREAKING CHANGE +pub trait DatabaseDriver { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; + fn transaction(&self) -> Transaction; // External impls now broken! +} +``` + +## Good + +```rust +// Create a private module with a private trait +mod private { + pub trait Sealed {} +} + +// Public trait requires the private trait +pub trait DatabaseDriver: private::Sealed { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; +} + +// Only your crate can implement Sealed, thus DatabaseDriver +pub struct PostgresDriver; +impl private::Sealed for PostgresDriver {} +impl DatabaseDriver for PostgresDriver { + fn connect(&self, url: &str) -> Connection { ... } + fn execute(&self, query: &str) -> Result { ... } +} + +pub struct MySqlDriver; +impl private::Sealed for MySqlDriver {} +impl DatabaseDriver for MySqlDriver { + fn connect(&self, url: &str) -> Connection { ... } + fn execute(&self, query: &str) -> Result { ... } +} + +// External crate cannot implement - private::Sealed is not accessible +// impl DatabaseDriver for ExternalDriver { } // Error! + +// But external code CAN use the trait +fn use_driver(driver: &impl DatabaseDriver) { + let conn = driver.connect("postgres://localhost"); +} +``` + +## Full Pattern + +```rust +pub mod db { + mod private { + pub trait Sealed {} + } + + /// Database driver trait. + /// + /// This trait is sealed and cannot be implemented outside this crate. + pub trait Driver: private::Sealed { + /// Connects to the database. + fn connect(&self, url: &str) -> Result; + + /// Executes a query. + fn execute(&self, sql: &str) -> Result; + } + + pub struct Postgres; + impl private::Sealed for Postgres {} + impl Driver for Postgres { ... } + + pub struct Sqlite; + impl private::Sealed for Sqlite {} + impl Driver for Sqlite { ... } +} + +// Usage works fine +use db::{Driver, Postgres}; + +fn query(driver: &impl Driver) { + driver.execute("SELECT 1")?; +} + +query(&Postgres); +``` + +## Benefits of Sealing + +```rust +// 1. Add methods without breaking changes +pub trait Format: private::Sealed { + fn format(&self) -> String; + + // Added later - not breaking because no external impls exist + fn format_pretty(&self) -> String { + self.format() // Default implementation + } +} + +// 2. Guarantee invariants +pub trait SafeBuffer: private::Sealed { + // You control all implementations, so you know they're all correct + fn get(&self, index: usize) -> Option<&u8>; +} + +// 3. Use as marker traits +pub trait ValidConfig: private::Sealed {} +// Only validated configs implement this +``` + +## Partially Sealed + +```rust +// Allow implementing some methods but not all +mod private { + pub trait SealedCore {} +} + +pub trait Plugin: private::SealedCore { + // Sealed - only we implement + fn initialize(&self); + fn shutdown(&self); + + // Open - users can override + fn name(&self) -> &str { "unnamed" } +} + +// Only we can add new required sealed methods +// Users can customize open methods +``` + +## When to Seal + +| Seal When | Don't Seal When | +|-----------|-----------------| +| API stability is critical | You want extension points | +| Implementation correctness is hard | Users need custom implementations | +| You'll add methods later | Trait is simple and stable | +| Safety invariants required | Standard patterns (Iterator, etc.) | + +## See Also + +- [api-non-exhaustive](./api-non-exhaustive.md) - Related pattern for enums/structs +- [api-extension-trait](./api-extension-trait.md) - Adding methods to external types +- [api-typestate](./api-typestate.md) - Compile-time state guarantees diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-serde-optional.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-serde-optional.md new file mode 100644 index 00000000..f9927d95 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-serde-optional.md @@ -0,0 +1,182 @@ +# api-serde-optional + +> Make serde a feature flag, not a hard dependency for library crates + +## Why It Matters + +Not all users of your library need serialization. Making serde a required dependency adds compile time and binary size for everyone. Feature flags let users opt-in to serde support only when needed, following Rust's philosophy of zero-cost abstractions and minimal dependencies. + +## Bad + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"] } + +// lib.rs +use serde::{Serialize, Deserialize}; + +// Every user pays for serde, even if they don't need it +#[derive(Serialize, Deserialize)] +pub struct Config { + pub name: String, + pub value: i32, +} +``` + +## Good + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"], optional = true } + +[features] +default = [] +serde = ["dep:serde"] + +// lib.rs +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Config { + pub name: String, + pub value: i32, +} + +// Users opt-in: +// my_crate = { version = "1.0", features = ["serde"] } +``` + +## Macro Pattern + +```rust +// Reusable macro for serde derives +#[cfg(feature = "serde")] +macro_rules! impl_serde { + ($($t:ty),*) => { + $( + impl serde::Serialize for $t { + // ... + } + impl<'de> serde::Deserialize<'de> for $t { + // ... + } + )* + }; +} + +#[cfg(not(feature = "serde"))] +macro_rules! impl_serde { + ($($t:ty),*) => {}; +} + +// Or use cfg_attr for derived impls +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Point { + pub x: f64, + pub y: f64, +} +``` + +## Feature Documentation + +```rust +// lib.rs + +//! # Features +//! +//! - `serde`: Enables `Serialize` and `Deserialize` implementations for all types. +//! +//! # Example with serde +//! +//! ```toml +//! [dependencies] +//! my_crate = { version = "1.0", features = ["serde"] } +//! ``` + +#![cfg_attr(docsrs, feature(doc_cfg))] + +/// A configuration type. +/// +/// When the `serde` feature is enabled, this type implements +/// `Serialize` and `Deserialize`. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(docsrs, doc(cfg(feature = "serde")))] +pub struct Config { + pub name: String, +} +``` + +## Multiple Optional Dependencies + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"], optional = true } +rkyv = { version = "0.7", optional = true } +borsh = { version = "0.10", optional = true } + +[features] +default = [] +serde = ["dep:serde"] +rkyv = ["dep:rkyv"] +borsh = ["dep:borsh"] + +// lib.rs +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "rkyv", derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize))] +#[cfg_attr(feature = "borsh", derive(borsh::BorshSerialize, borsh::BorshDeserialize))] +pub struct Message { + pub id: u64, + pub content: String, +} +``` + +## Testing with Features + +```bash +# Test without serde +cargo test + +# Test with serde +cargo test --features serde + +# Test all feature combinations +cargo test --all-features +``` + +```rust +// Test serde round-trip when feature enabled +#[cfg(feature = "serde")] +#[test] +fn test_serde_roundtrip() { + let config = Config { name: "test".into() }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(config, parsed); +} +``` + +## When to Make Serde Required + +```rust +// ✅ Required: Library is about serialization +// (e.g., json-schema, config-file parser) +[dependencies] +serde = "1.0" + +// ✅ Required: Domain heavily uses serde +// (e.g., API client, data format library) + +// ❌ Optional: General-purpose utility library +// ❌ Optional: Math/algorithm library +// ❌ Optional: Most libraries! +``` + +## See Also + +- [proj-lib-main-split](./proj-lib-main-split.md) - Library structure +- [api-common-traits](./api-common-traits.md) - Core trait implementations +- [lint-deny-correctness](./lint-deny-correctness.md) - Feature testing diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-typestate.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-typestate.md new file mode 100644 index 00000000..80e6130f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/api-typestate.md @@ -0,0 +1,199 @@ +# api-typestate + +> Use typestate pattern to encode state machine invariants in the type system + +## Why It Matters + +State machines with runtime state checks ("are we connected?", "is the transaction started?") can have invalid transitions. The typestate pattern uses different types for each state, making invalid state transitions compile errors. The compiler enforces your state machine. + +## Bad + +```rust +struct Connection { + state: ConnectionState, + socket: Option, +} + +enum ConnectionState { + Disconnected, + Connected, + Authenticated, +} + +impl Connection { + fn send(&mut self, data: &[u8]) -> Result<(), Error> { + // Runtime check - can fail if called in wrong state + if self.state != ConnectionState::Authenticated { + return Err(Error::NotAuthenticated); + } + self.socket.as_mut().unwrap().write_all(data)?; + Ok(()) + } + + fn authenticate(&mut self, password: &str) -> Result<(), Error> { + // Runtime check - can fail + if self.state != ConnectionState::Connected { + return Err(Error::NotConnected); + } + // ... + } +} + +// Bug: forgot to authenticate +let mut conn = Connection::new(); +conn.connect()?; +conn.send(b"data")?; // Runtime error: NotAuthenticated +``` + +## Good + +```rust +// Different types for each state +struct Disconnected; +struct Connected { socket: TcpStream } +struct Authenticated { socket: TcpStream, session: Session } + +struct Connection { + state: State, +} + +impl Connection { + fn new() -> Self { + Connection { state: Disconnected } + } + + fn connect(self, addr: &str) -> Result, Error> { + let socket = TcpStream::connect(addr)?; + Ok(Connection { state: Connected { socket } }) + } +} + +impl Connection { + fn authenticate(self, password: &str) -> Result, Error> { + let session = do_auth(&self.state.socket, password)?; + Ok(Connection { + state: Authenticated { socket: self.state.socket, session } + }) + } +} + +impl Connection { + fn send(&mut self, data: &[u8]) -> Result<(), Error> { + // No runtime check needed - type guarantees we're authenticated + self.state.socket.write_all(data)?; + Ok(()) + } +} + +// Bug: forgot to authenticate +let conn = Connection::new(); +let conn = conn.connect("server:8080")?; +conn.send(b"data"); // Compile error! send() not available on Connection + +// Correct usage +let conn = Connection::new(); +let conn = conn.connect("server:8080")?; +let mut conn = conn.authenticate("secret")?; +conn.send(b"data")?; // Works - type is Connection +``` + +## Builder Typestate + +```rust +// Enforce required fields via typestate +struct BuilderNoUrl; +struct BuilderWithUrl { url: String } + +struct RequestBuilder { + state: State, + timeout: Option, +} + +impl RequestBuilder { + fn new() -> Self { + RequestBuilder { + state: BuilderNoUrl, + timeout: None, + } + } + + fn url(self, url: &str) -> RequestBuilder { + RequestBuilder { + state: BuilderWithUrl { url: url.to_string() }, + timeout: self.timeout, + } + } +} + +impl RequestBuilder { + fn timeout(mut self, t: Duration) -> Self { + self.timeout = Some(t); + self + } + + // Only available once URL is set + fn build(self) -> Request { + Request { + url: self.state.url, + timeout: self.timeout, + } + } +} + +// Compile error: build() not available +let bad = RequestBuilder::new().build(); + +// Correct: must set URL first +let good = RequestBuilder::new() + .url("https://example.com") + .timeout(Duration::from_secs(30)) + .build(); +``` + +## Transaction Example + +```rust +struct NotStarted; +struct InProgress { tx_id: u64 } +struct Committed; + +struct Transaction { + conn: Connection, + state: State, +} + +impl Transaction { + fn begin(conn: Connection) -> Result, Error> { + let tx_id = conn.execute("BEGIN")?; + Ok(Transaction { + conn, + state: InProgress { tx_id }, + }) + } +} + +impl Transaction { + fn execute(&mut self, sql: &str) -> Result<(), Error> { + self.conn.execute(sql) + } + + fn commit(self) -> Result, Error> { + self.conn.execute("COMMIT")?; + Ok(Transaction { + conn: self.conn, + state: Committed, + }) + } + + fn rollback(self) -> Connection { + let _ = self.conn.execute("ROLLBACK"); + self.conn + } +} +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Basic builder pattern +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven invariants +- [api-sealed-trait](./api-sealed-trait.md) - Restricting trait implementations diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-bounded-channel.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-bounded-channel.md new file mode 100644 index 00000000..c367036e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-bounded-channel.md @@ -0,0 +1,175 @@ +# async-bounded-channel + +> Use bounded channels to apply backpressure and prevent unbounded memory growth + +## Why It Matters + +Unbounded channels grow without limit when producers outpace consumers. In production, this leads to memory exhaustion. Bounded channels apply backpressure—producers wait when the channel is full, naturally throttling the system. This prevents OOM and makes resource usage predictable. + +## Bad + +```rust +use tokio::sync::mpsc; + +// Unbounded channel - can grow forever +let (tx, mut rx) = mpsc::unbounded_channel::(); + +// Fast producer, slow consumer = unbounded memory growth +tokio::spawn(async move { + loop { + let msg = generate_message(); + tx.send(msg).unwrap(); // Never blocks, never fails (until OOM) + } +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + slow_process(msg).await; // Can't keep up + } +}); +// Memory grows unboundedly until crash +``` + +## Good + +```rust +use tokio::sync::mpsc; + +// Bounded channel - backpressure when full +let (tx, mut rx) = mpsc::channel::(100); // Max 100 items + +// Producer waits when channel full +tokio::spawn(async move { + loop { + let msg = generate_message(); + // Blocks if channel is full - natural backpressure + tx.send(msg).await.unwrap(); + } +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + slow_process(msg).await; + } +}); +// Memory usage capped at ~100 messages +``` + +## Choosing Buffer Size + +```rust +// Too small: frequent blocking, reduced throughput +let (tx, rx) = mpsc::channel::(1); + +// Too large: delayed backpressure, memory waste +let (tx, rx) = mpsc::channel::(1_000_000); + +// Guidelines: +// - Start with expected burst size +// - Measure actual usage in production +// - Err on the smaller side initially + +// Small items, high throughput +let (tx, rx) = mpsc::channel::(1000); + +// Large items, moderate throughput +let (tx, rx) = mpsc::channel::(100); + +// Low latency requirement +let (tx, rx) = mpsc::channel::(10); +``` + +## Handling Full Channel + +```rust +use tokio::sync::mpsc; +use tokio::time::{timeout, Duration}; + +let (tx, mut rx) = mpsc::channel::(100); + +// Option 1: Wait indefinitely (default) +tx.send(msg).await?; + +// Option 2: Try send, fail if full +match tx.try_send(msg) { + Ok(()) => println!("Sent"), + Err(TrySendError::Full(msg)) => { + println!("Channel full, dropping message"); + } + Err(TrySendError::Closed(msg)) => { + println!("Receiver dropped"); + } +} + +// Option 3: Timeout +match timeout(Duration::from_secs(1), tx.send(msg)).await { + Ok(Ok(())) => println!("Sent"), + Ok(Err(_)) => println!("Channel closed"), + Err(_) => println!("Timeout - channel full for too long"), +} + +// Option 4: send with permit reservation +let permit = tx.reserve().await?; +permit.send(msg); // Guaranteed to succeed +``` + +## Channel Types + +```rust +// mpsc: many producers, single consumer +let (tx, rx) = mpsc::channel::(100); +let tx2 = tx.clone(); // Can clone sender + +// oneshot: single value, one producer, one consumer +let (tx, rx) = oneshot::channel::(); +tx.send(response); // Can only send once + +// broadcast: multiple consumers, each gets all messages +let (tx, _) = broadcast::channel::(100); +let mut rx1 = tx.subscribe(); +let mut rx2 = tx.subscribe(); + +// watch: single latest value, multiple consumers +let (tx, rx) = watch::channel::(initial); +// Receivers see latest value, not all values +``` + +## Worker Pool Pattern + +```rust +async fn process_with_workers(items: Vec) -> Vec { + let (tx, rx) = mpsc::channel(100); + let rx = Arc::new(Mutex::new(rx)); + + // Spawn worker pool + let workers: Vec<_> = (0..4).map(|_| { + let rx = rx.clone(); + tokio::spawn(async move { + loop { + let item = { + let mut rx = rx.lock().await; + rx.recv().await + }; + match item { + Some(item) => process(item).await, + None => break, + } + } + }) + }).collect(); + + // Send items + for item in items { + tx.send(item).await.unwrap(); + } + drop(tx); // Signal workers to stop + + futures::future::join_all(workers).await; +} +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Multi-producer patterns +- [async-oneshot-response](./async-oneshot-response.md) - Request-response pattern +- [async-watch-latest](./async-watch-latest.md) - Latest-value broadcasting diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md new file mode 100644 index 00000000..c4efad26 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-broadcast-pubsub.md @@ -0,0 +1,185 @@ +# async-broadcast-pubsub + +> Use `broadcast` channel for pub/sub where all subscribers receive all messages + +## Why It Matters + +Unlike `mpsc` where one consumer receives each message, `broadcast` delivers each message to all subscribers. This is ideal for event broadcasting, real-time notifications, or when multiple components need to react to the same events independently. + +## Bad + +```rust +use tokio::sync::mpsc; + +// mpsc only delivers to ONE consumer +let (tx, mut rx) = mpsc::channel::(100); + +// Only one of these receives each message! +let mut rx2 = ???; // Can't clone receiver +``` + +## Good + +```rust +use tokio::sync::broadcast; + +// broadcast delivers to ALL subscribers +let (tx, _) = broadcast::channel::(100); + +// Each subscriber gets ALL messages +let mut rx1 = tx.subscribe(); +let mut rx2 = tx.subscribe(); + +tokio::spawn(async move { + while let Ok(event) = rx1.recv().await { + handle_in_logger(event); + } +}); + +tokio::spawn(async move { + while let Ok(event) = rx2.recv().await { + handle_in_metrics(event); + } +}); + +// Both subscribers receive this +tx.send(Event::UserLogin { user_id: 42 })?; +``` + +## Broadcast Semantics + +```rust +use tokio::sync::broadcast; + +let (tx, mut rx1) = broadcast::channel::(16); +let mut rx2 = tx.subscribe(); + +tx.send(1)?; +tx.send(2)?; + +// Both receive all messages +assert_eq!(rx1.recv().await?, 1); +assert_eq!(rx1.recv().await?, 2); +assert_eq!(rx2.recv().await?, 1); +assert_eq!(rx2.recv().await?, 2); +``` + +## Handling Lagging Receivers + +```rust +use tokio::sync::broadcast::{self, error::RecvError}; + +let (tx, mut rx) = broadcast::channel::(16); + +loop { + match rx.recv().await { + Ok(event) => { + process(event); + } + Err(RecvError::Lagged(count)) => { + // Receiver couldn't keep up, missed `count` messages + log::warn!("Missed {} events", count); + // Continue receiving new messages + } + Err(RecvError::Closed) => { + break; // All senders dropped + } + } +} +``` + +## Event Bus Pattern + +```rust +use tokio::sync::broadcast; + +#[derive(Clone, Debug)] +enum AppEvent { + UserLoggedIn { user_id: u64 }, + OrderCreated { order_id: u64 }, + SystemShutdown, +} + +struct EventBus { + tx: broadcast::Sender, +} + +impl EventBus { + fn new() -> Self { + let (tx, _) = broadcast::channel(1000); + EventBus { tx } + } + + fn publish(&self, event: AppEvent) { + // Ignore error if no subscribers + let _ = self.tx.send(event); + } + + fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +// Usage +let bus = EventBus::new(); + +// Logger subscribes +let mut log_rx = bus.subscribe(); +tokio::spawn(async move { + while let Ok(event) = log_rx.recv().await { + log::info!("Event: {:?}", event); + } +}); + +// Metrics subscribes +let mut metrics_rx = bus.subscribe(); +tokio::spawn(async move { + while let Ok(event) = metrics_rx.recv().await { + record_metric(&event); + } +}); + +// Publish events +bus.publish(AppEvent::UserLoggedIn { user_id: 42 }); +``` + +## Broadcast vs Watch + +```rust +// broadcast: subscribers get ALL messages +// Good for: events, logs, notifications +let (tx, _) = broadcast::channel::(100); + +// watch: subscribers get LATEST value only +// Good for: config changes, state updates +let (tx, _) = watch::channel(initial_state); + +// If subscriber is slow: +// - broadcast: they receive old messages (or lag) +// - watch: they skip to latest (no history) +``` + +## Clone Requirement + +```rust +// broadcast requires Clone because message is cloned to each receiver +use tokio::sync::broadcast; + +#[derive(Clone)] // Required for broadcast +struct Event { + data: String, +} + +let (tx, _) = broadcast::channel::(100); + +// For non-Clone types, wrap in Arc +use std::sync::Arc; + +let (tx, _) = broadcast::channel::>(100); +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Single-consumer channels +- [async-watch-latest](./async-watch-latest.md) - Latest-value only +- [async-bounded-channel](./async-bounded-channel.md) - Buffer sizing diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-cancellation-token.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-cancellation-token.md new file mode 100644 index 00000000..a244b605 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-cancellation-token.md @@ -0,0 +1,203 @@ +# async-cancellation-token + +> Use `CancellationToken` for graceful shutdown and task cancellation + +## Why It Matters + +Dropping a `JoinHandle` doesn't cancel the task—it just detaches it. For graceful shutdown, you need explicit cancellation. `tokio_util::sync::CancellationToken` provides a cooperative cancellation mechanism that tasks can check and respond to, enabling clean resource cleanup. + +## Bad + +```rust +// Dropping handle doesn't stop the task +let handle = tokio::spawn(async { + loop { + do_work().await; + } +}); + +drop(handle); // Task continues running in background! + +// Using bool flag - not async-aware +let running = Arc::new(AtomicBool::new(true)); + +tokio::spawn({ + let running = running.clone(); + async move { + while running.load(Ordering::Relaxed) { + do_work().await; // Can't wake up if blocked here + } + } +}); + +running.store(false, Ordering::Relaxed); +// Task won't stop until current do_work() completes +``` + +## Good + +```rust +use tokio_util::sync::CancellationToken; + +let token = CancellationToken::new(); + +let handle = tokio::spawn({ + let token = token.clone(); + async move { + loop { + tokio::select! { + _ = token.cancelled() => { + println!("Shutting down gracefully"); + cleanup().await; + break; + } + _ = do_work() => { + // Work completed + } + } + } + } +}); + +// Later: trigger cancellation +token.cancel(); +handle.await?; // Task completes cleanly +``` + +## CancellationToken API + +```rust +use tokio_util::sync::CancellationToken; + +// Create token +let token = CancellationToken::new(); + +// Clone for sharing (cheap Arc-based clone) +let token2 = token.clone(); + +// Check if cancelled (non-blocking) +if token.is_cancelled() { + return; +} + +// Wait for cancellation (async) +token.cancelled().await; + +// Trigger cancellation +token.cancel(); + +// Child tokens - cancelled when parent is cancelled +let child = token.child_token(); +``` + +## Hierarchical Cancellation + +```rust +async fn run_server(shutdown: CancellationToken) { + let listener = TcpListener::bind("0.0.0.0:8080").await?; + + loop { + tokio::select! { + _ = shutdown.cancelled() => { + println!("Server shutting down"); + break; + } + result = listener.accept() => { + let (socket, _) = result?; + // Each connection gets child token + let conn_token = shutdown.child_token(); + tokio::spawn(handle_connection(socket, conn_token)); + } + } + } + + // Child tokens auto-cancelled when we exit +} + +async fn handle_connection(socket: TcpStream, token: CancellationToken) { + loop { + tokio::select! { + _ = token.cancelled() => { + // Connection cleanup + break; + } + data = socket.read() => { + // Handle data + } + } + } +} +``` + +## Graceful Shutdown Pattern + +```rust +use tokio::signal; + +async fn main() -> Result<()> { + let shutdown = CancellationToken::new(); + + // Spawn signal handler + let shutdown_trigger = shutdown.clone(); + tokio::spawn(async move { + signal::ctrl_c().await.expect("failed to listen for Ctrl+C"); + println!("Received Ctrl+C, initiating shutdown..."); + shutdown_trigger.cancel(); + }); + + // Run application with shutdown token + run_app(shutdown).await +} + +async fn run_app(shutdown: CancellationToken) -> Result<()> { + let mut tasks = JoinSet::new(); + + tasks.spawn(worker_task(shutdown.child_token())); + tasks.spawn(server_task(shutdown.child_token())); + + // Wait for shutdown or task completion + tokio::select! { + _ = shutdown.cancelled() => { + println!("Shutdown requested, waiting for tasks..."); + } + Some(result) = tasks.join_next() => { + // A task completed/failed + result??; + } + } + + // Wait for remaining tasks with timeout + tokio::time::timeout( + Duration::from_secs(30), + async { while tasks.join_next().await.is_some() {} } + ).await.ok(); + + Ok(()) +} +``` + +## DropGuard Pattern + +```rust +use tokio_util::sync::CancellationToken; + +// Auto-cancel on drop +let token = CancellationToken::new(); +let guard = token.clone().drop_guard(); + +tokio::spawn({ + let token = token.clone(); + async move { + token.cancelled().await; + println!("Cancelled!"); + } +}); + +drop(guard); // Automatically calls token.cancel() +``` + +## See Also + +- [async-joinset-structured](./async-joinset-structured.md) - Managing multiple tasks +- [async-select-racing](./async-select-racing.md) - select! for cancellation +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime shutdown diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-clone-before-await.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-clone-before-await.md new file mode 100644 index 00000000..c9f8e26e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-clone-before-await.md @@ -0,0 +1,171 @@ +# async-clone-before-await + +> Clone Arc/Rc data before await points to avoid holding references across suspension + +## Why It Matters + +References held across `.await` points extend the future's lifetime and can cause borrow checker issues or prevent `Send` bounds. Cloning `Arc`/`Rc` before the await ensures the future only holds owned data, making it `Send` and avoiding lifetime complications. + +## Bad + +```rust +use std::sync::Arc; + +async fn process(data: Arc) { + // Borrow extends across await - future is not Send + let slice = &data.items[..]; // Borrow of Arc contents + + expensive_async_operation().await; // Await with active borrow + + use_slice(slice); // Still using the borrow +} + +// Error: future cannot be sent between threads safely +// because `&[Item]` cannot be sent between threads safely +tokio::spawn(process(data)); +``` + +## Good + +```rust +use std::sync::Arc; + +async fn process(data: Arc) { + // Clone what you need before await + let items = data.items.clone(); // Owned Vec + + expensive_async_operation().await; + + use_items(&items); // Using owned data +} + +// Or clone the Arc itself +async fn share_data(data: Arc) { + let data = data.clone(); // Another Arc handle + + some_async_work().await; + + process(&data); // Safe - we own the Arc +} +``` + +## The Send Problem + +```rust +// Futures must be Send to spawn on multi-threaded runtime +async fn not_send() { + let rc = Rc::new(42); // Rc is !Send + + tokio::time::sleep(Duration::from_secs(1)).await; + + println!("{}", rc); // rc held across await +} + +tokio::spawn(not_send()); // ERROR: future is not Send + +// Fix: use Arc or don't hold across await +async fn is_send() { + let arc = Arc::new(42); // Arc is Send + + tokio::time::sleep(Duration::from_secs(1)).await; + + println!("{}", arc); +} + +tokio::spawn(is_send()); // OK +``` + +## Minimizing Clones + +```rust +// Bad: clone everything eagerly +async fn wasteful(data: Arc) { + let data = (*data).clone(); // Clones entire LargeData + async_work().await; + use_one_field(&data.small_field); +} + +// Good: clone only what you need +async fn efficient(data: Arc) { + let small = data.small_field.clone(); // Clone only needed field + async_work().await; + use_one_field(&small); +} + +// Good: if you need the whole thing, keep the Arc +async fn arc_efficient(data: Arc) { + let data = data.clone(); // Cheap Arc clone + async_work().await; + use_data(&data); // Access through Arc +} +``` + +## Spawn Pattern + +```rust +// Common pattern: clone for spawned task +let shared = Arc::new(SharedState::new()); + +for i in 0..10 { + let shared = shared.clone(); // Clone before moving into spawn + tokio::spawn(async move { + // Task owns its Arc clone + shared.do_something(i).await; + }); +} +``` + +## Scope-Based Approach + +```rust +// Limit borrow scope to before await +async fn scoped(data: Arc) { + // Scope 1: borrow, compute, drop borrow + let computed = { + let slice = &data.items[..]; // Borrow + compute_something(slice) // Use + }; // Borrow ends here + + // Now safe to await + expensive_async_operation().await; + + use_computed(computed); +} +``` + +## MutexGuard Across Await + +```rust +use tokio::sync::Mutex; + +// BAD: holding guard across await +async fn bad(mutex: Arc>) { + let mut guard = mutex.lock().await; + guard.value += 1; + + slow_operation().await; // Guard held during await! + + guard.value += 1; +} + +// GOOD: release before await +async fn good(mutex: Arc>) { + { + let mut guard = mutex.lock().await; + guard.value += 1; + } // Guard released + + slow_operation().await; + + { + let mut guard = mutex.lock().await; + guard.value += 1; + } +} +``` + +## See Also + +- [async-no-lock-await](./async-no-lock-await.md) - Lock guards across await +- [own-arc-shared](./own-arc-shared.md) - Arc usage patterns +- [async-spawn-blocking](./async-spawn-blocking.md) - Blocking in async diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-join-parallel.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-join-parallel.md new file mode 100644 index 00000000..8d2c5433 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-join-parallel.md @@ -0,0 +1,158 @@ +# async-join-parallel + +> Use `join!` or `try_join!` for concurrent independent futures + +## Why It Matters + +Awaiting futures sequentially takes the sum of their durations. `join!` runs futures concurrently, taking only as long as the slowest one. For independent operations like multiple API calls or parallel file reads, this can dramatically reduce latency. + +## Bad + +```rust +async fn fetch_data() -> (User, Posts, Comments) { + // Sequential: 300ms total (100 + 100 + 100) + let user = fetch_user().await; // 100ms + let posts = fetch_posts().await; // 100ms + let comments = fetch_comments().await; // 100ms + + (user, posts, comments) +} + +async fn read_configs() -> Result<(Config, Settings)> { + // Sequential: 20ms + 20ms = 40ms + let config = fs::read_to_string("config.toml").await?; + let settings = fs::read_to_string("settings.json").await?; + + Ok((parse_config(&config)?, parse_settings(&settings)?)) +} +``` + +## Good + +```rust +use tokio::join; + +async fn fetch_data() -> (User, Posts, Comments) { + // Concurrent: ~100ms total (max of all three) + let (user, posts, comments) = join!( + fetch_user(), + fetch_posts(), + fetch_comments(), + ); + + (user, posts, comments) +} + +use tokio::try_join; + +async fn read_configs() -> Result<(Config, Settings)> { + // Concurrent: ~20ms total + let (config_str, settings_str) = try_join!( + fs::read_to_string("config.toml"), + fs::read_to_string("settings.json"), + )?; + + Ok((parse_config(&config_str)?, parse_settings(&settings_str)?)) +} +``` + +## join! vs try_join! + +```rust +// join! - all futures run to completion, returns tuple +let (a, b, c) = join!(future_a, future_b, future_c); + +// try_join! - short-circuits on first error +let (a, b, c) = try_join!(fallible_a, fallible_b, fallible_c)?; +// If fallible_b fails, returns Err immediately +// Other futures may still be running (cancellation is async) +``` + +## futures::join_all for Dynamic Collections + +```rust +use futures::future::join_all; + +async fn fetch_all_users(ids: &[u64]) -> Vec { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + join_all(futures).await +} + +// With fallible futures +use futures::future::try_join_all; + +async fn fetch_all_users(ids: &[u64]) -> Result> { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + try_join_all(futures).await +} +``` + +## Limiting Concurrency + +```rust +use futures::stream::{self, StreamExt}; + +async fn fetch_with_limit(ids: &[u64]) -> Vec> { + stream::iter(ids) + .map(|id| fetch_user(*id)) + .buffer_unordered(10) // Max 10 concurrent requests + .collect() + .await +} + +// Or with tokio::sync::Semaphore +use tokio::sync::Semaphore; + +async fn fetch_with_semaphore(ids: &[u64]) -> Vec { + let semaphore = Arc::new(Semaphore::new(10)); + + let futures: Vec<_> = ids.iter().map(|id| { + let semaphore = semaphore.clone(); + async move { + let _permit = semaphore.acquire().await.unwrap(); + fetch_user(*id).await + } + }).collect(); + + join_all(futures).await +} +``` + +## When NOT to Use join! + +```rust +// ❌ Dependent futures - must be sequential +async fn create_and_populate() -> Result<()> { + let db = create_database().await?; // Must complete first + populate_tables(&db).await?; // Depends on db + Ok(()) +} + +// ❌ Short-circuiting logic +async fn find_first() -> Option { + // Want to stop when one succeeds + // Use select! instead +} + +// ❌ Shared mutable state +async fn bad_shared_state() { + let counter = Arc::new(Mutex::new(0)); + // This might work but can cause contention + join!( + increment(counter.clone()), + increment(counter.clone()), + ); +} +``` + +## See Also + +- [async-try-join](./async-try-join.md) - Error handling in concurrent futures +- [async-select-racing](./async-select-racing.md) - Racing futures +- [async-joinset-structured](./async-joinset-structured.md) - Dynamic task sets diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-joinset-structured.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-joinset-structured.md new file mode 100644 index 00000000..f0076594 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-joinset-structured.md @@ -0,0 +1,195 @@ +# async-joinset-structured + +> Use `JoinSet` for managing dynamic collections of spawned tasks + +## Why It Matters + +When spawning a variable number of tasks, collecting `JoinHandle`s in a `Vec` and using `join_all` works but lacks flexibility. `JoinSet` provides a better abstraction: add/remove tasks dynamically, get results as they complete, and abort all on drop. It's the idiomatic way to manage task collections. + +## Bad + +```rust +// Manual handle management +let mut handles: Vec>> = Vec::new(); + +for url in urls { + handles.push(tokio::spawn(fetch(url))); +} + +// Wait for all, in order (not as they complete) +let results = futures::future::join_all(handles).await; + +// No easy way to cancel all, handle errors progressively, or add more tasks +``` + +## Good + +```rust +use tokio::task::JoinSet; + +let mut set = JoinSet::new(); + +for url in urls { + set.spawn(fetch(url.clone())); +} + +// Process results as they complete +while let Some(result) = set.join_next().await { + match result { + Ok(Ok(data)) => process(data), + Ok(Err(e)) => log::error!("Task failed: {}", e), + Err(e) => log::error!("Task panicked: {}", e), + } +} + +// All tasks done, set is empty +``` + +## Dynamic Task Addition + +```rust +use tokio::task::JoinSet; + +async fn worker_pool(mut rx: mpsc::Receiver) { + let mut set = JoinSet::new(); + let max_concurrent = 10; + + loop { + tokio::select! { + // Accept new tasks if under limit + Some(task) = rx.recv(), if set.len() < max_concurrent => { + set.spawn(process_task(task)); + } + + // Process completed tasks + Some(result) = set.join_next() => { + handle_result(result); + } + + // Exit when no tasks and channel closed + else => break, + } + } +} +``` + +## Abort on Drop + +```rust +use tokio::task::JoinSet; + +{ + let mut set = JoinSet::new(); + set.spawn(long_running_task()); + set.spawn(another_task()); + + // Early exit + return; +} // JoinSet dropped here - all tasks are aborted! + +// Explicit abort +let mut set = JoinSet::new(); +set.spawn(task()); +set.abort_all(); // Cancel all tasks +``` + +## Error Handling Pattern + +```rust +use tokio::task::JoinSet; + +async fn fetch_all(urls: &[String]) -> Vec> { + let mut set = JoinSet::new(); + let mut results = Vec::new(); + + for url in urls { + set.spawn(fetch(url.clone())); + } + + while let Some(join_result) = set.join_next().await { + let result = match join_result { + Ok(task_result) => task_result, + Err(join_error) => { + if join_error.is_panic() { + Err(Error::TaskPanicked) + } else { + Err(Error::TaskCancelled) + } + } + }; + results.push(result); + } + + results +} +``` + +## With Cancellation + +```rust +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +async fn run_workers(shutdown: CancellationToken) { + let mut set = JoinSet::new(); + + for i in 0..4 { + let token = shutdown.child_token(); + set.spawn(async move { + loop { + tokio::select! { + _ = token.cancelled() => break, + _ = do_work(i) => {} + } + } + }); + } + + // Wait for shutdown + shutdown.cancelled().await; + + // Abort remaining tasks + set.abort_all(); + + // Wait for all to finish (drain aborted tasks) + while set.join_next().await.is_some() {} +} +``` + +## Spawning with Context + +```rust +use tokio::task::JoinSet; + +let mut set: JoinSet<(usize, Result)> = JoinSet::new(); + +for (index, url) in urls.iter().enumerate() { + let url = url.clone(); + set.spawn(async move { + (index, fetch(&url).await) + }); +} + +// Results include their index +while let Some(result) = set.join_next().await { + if let Ok((index, data)) = result { + results[index] = Some(data); + } +} +``` + +## JoinSet vs join_all + +| Feature | JoinSet | join_all | +|---------|---------|----------| +| Add tasks dynamically | Yes | No | +| Results as-completed | Yes | No (all at once) | +| Abort all on drop | Yes | No | +| Cancel individual | Yes | No | +| Memory efficient | Yes | Pre-allocates | + +## See Also + +- [async-join-parallel](./async-join-parallel.md) - Static concurrent futures +- [async-cancellation-token](./async-cancellation-token.md) - Cancellation patterns +- [async-try-join](./async-try-join.md) - Error handling in joins diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-mpsc-queue.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-mpsc-queue.md new file mode 100644 index 00000000..bd411470 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-mpsc-queue.md @@ -0,0 +1,171 @@ +# async-mpsc-queue + +> Use `mpsc` channels for async message queues between tasks + +## Why It Matters + +`tokio::sync::mpsc` (multi-producer, single-consumer) is the workhorse channel for async Rust. It provides async send/receive, backpressure via bounded capacity, and efficient cloning of senders. It's the default choice for task-to-task communication. + +## Bad + +```rust +use std::sync::mpsc; // Wrong! Blocks the async runtime + +let (tx, rx) = std::sync::mpsc::channel(); + +tokio::spawn(async move { + tx.send("hello").unwrap(); // Might block +}); + +tokio::spawn(async move { + let msg = rx.recv().unwrap(); // BLOCKS the executor thread! +}); +``` + +## Good + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); + +tokio::spawn(async move { + tx.send("hello".to_string()).await.unwrap(); +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + println!("Received: {}", msg); + } +}); +``` + +## Sender Cloning + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); + +// Multiple producers +for i in 0..10 { + let tx = tx.clone(); // Cheap clone + tokio::spawn(async move { + tx.send(Event { source: i }).await.unwrap(); + }); +} + +// Drop original sender so channel closes when all clones dropped +drop(tx); + +// Consumer +while let Some(event) = rx.recv().await { + process(event); +} +// Loop exits when all senders dropped +``` + +## Message Handler Pattern + +```rust +use tokio::sync::mpsc; + +enum Command { + Get { key: String, reply: oneshot::Sender> }, + Set { key: String, value: Value }, + Delete { key: String }, +} + +async fn run_store(mut commands: mpsc::Receiver) { + let mut store = HashMap::new(); + + while let Some(cmd) = commands.recv().await { + match cmd { + Command::Get { key, reply } => { + let _ = reply.send(store.get(&key).cloned()); + } + Command::Set { key, value } => { + store.insert(key, value); + } + Command::Delete { key } => { + store.remove(&key); + } + } + } +} + +// Usage +async fn client(tx: mpsc::Sender) -> Option { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Command::Get { + key: "foo".to_string(), + reply: reply_tx + }).await.unwrap(); + + reply_rx.await.unwrap() +} +``` + +## Graceful Shutdown + +```rust +async fn worker(mut rx: mpsc::Receiver, shutdown: CancellationToken) { + loop { + tokio::select! { + _ = shutdown.cancelled() => { + // Drain remaining messages + while let Ok(task) = rx.try_recv() { + process(task).await; + } + break; + } + Some(task) = rx.recv() => { + process(task).await; + } + else => break, // Channel closed + } + } +} +``` + +## WeakSender for Optional Producers + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); +let weak = tx.downgrade(); // Doesn't keep channel alive + +tokio::spawn(async move { + // Strong sender - keeps channel alive + tx.send("from strong".into()).await.unwrap(); +}); + +tokio::spawn(async move { + // Weak sender - may fail if strong senders dropped + if let Some(tx) = weak.upgrade() { + tx.send("from weak".into()).await.unwrap(); + } +}); +``` + +## Permit Pattern + +```rust +// Reserve slot before preparing message +let permit = tx.reserve().await?; + +// Now we have guaranteed capacity +let message = expensive_to_create_message(); +permit.send(message); // Never fails + +// Useful when message creation is expensive +// and you don't want to create it if channel is full +``` + +## See Also + +- [async-bounded-channel](./async-bounded-channel.md) - Why bounded channels +- [async-oneshot-response](./async-oneshot-response.md) - Request-response with oneshot +- [async-broadcast-pubsub](./async-broadcast-pubsub.md) - Multiple consumers diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-no-lock-await.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-no-lock-await.md new file mode 100644 index 00000000..387de742 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-no-lock-await.md @@ -0,0 +1,156 @@ +# async-no-lock-await + +> Never hold `Mutex`/`RwLock` across `.await` + +## Why It Matters + +Holding a lock across an `.await` point can cause deadlocks and severely hurt performance. The task may be suspended while holding the lock, blocking all other tasks waiting for it - potentially indefinitely. + +## Bad + +```rust +use tokio::sync::Mutex; + +async fn bad_update(state: &Mutex) { + let mut guard = state.lock().await; + + // BAD: Lock held across await! + let data = fetch_from_network().await; + + guard.value = data; +} // Lock finally released + +// This can deadlock or starve other tasks +``` + +## Good + +```rust +use tokio::sync::Mutex; + +async fn good_update(state: &Mutex) { + // Fetch data BEFORE taking the lock + let data = fetch_from_network().await; + + // Lock only for the quick update + let mut guard = state.lock().await; + guard.value = data; +} // Lock released immediately + +// Alternative: Clone data out, process, then update +async fn good_update_v2(state: &Mutex) { + // Extract what we need + let id = { + let guard = state.lock().await; + guard.id.clone() + }; // Lock released! + + // Do async work without lock + let data = fetch_by_id(id).await; + + // Quick update + state.lock().await.value = data; +} +``` + +## The Problem Visualized + +```rust +// Task A: +let guard = mutex.lock().await; // Acquires lock +expensive_io().await; // Suspended, still holding lock! +// ... many milliseconds pass ... +drop(guard); // Finally releases + +// Task B, C, D: +let guard = mutex.lock().await; // All blocked waiting for A! +``` + +## Patterns for Extraction + +```rust +use tokio::sync::Mutex; + +// Pattern 1: Clone out, process, update +async fn pattern_clone(state: &Mutex) { + let config = state.lock().await.config.clone(); + let result = process_with_io(&config).await; + state.lock().await.result = result; +} + +// Pattern 2: Compute closure, apply +async fn pattern_closure(state: &Mutex) { + let update = compute_update().await; + + state.lock().await.apply(update); +} + +// Pattern 3: Message passing +async fn pattern_message( + state: &Mutex, + tx: mpsc::Sender, +) { + let update = compute_update().await; + tx.send(update).await.unwrap(); +} + +// Separate task handles updates +async fn state_manager( + state: Arc>, + mut rx: mpsc::Receiver, +) { + while let Some(update) = rx.recv().await { + state.lock().await.apply(update); + } +} +``` + +## Using RwLock + +```rust +use tokio::sync::RwLock; + +async fn read_heavy(state: &RwLock) { + // Multiple readers OK, but still don't hold across await + let value = { + let guard = state.read().await; + guard.value.clone() + }; + + // Process without lock + let result = process(value).await; + + // Write lock for update + state.write().await.result = result; +} +``` + +## std::sync::Mutex vs tokio::sync::Mutex + +```rust +// std::sync::Mutex: Blocks the entire thread +// - Use for quick, CPU-only operations +// - NEVER use in async code with await inside + +// tokio::sync::Mutex: Async-aware, yields to runtime +// - Use in async code +// - Still don't hold across await points! + +// std::sync::Mutex in async (quick operation, OK): +async fn quick_update(state: &std::sync::Mutex) { + state.lock().unwrap().counter += 1; // No await, OK +} + +// tokio::sync::Mutex (must use if lock scope has await): +async fn must_await_inside(state: &tokio::sync::Mutex) { + let mut guard = state.lock().await; + // Only if you REALLY need the lock during async op + // (usually you don't - redesign instead) +} +``` + +## See Also + +- [async-spawn-blocking](async-spawn-blocking.md) - Use spawn_blocking for CPU work +- [async-clone-before-await](async-clone-before-await.md) - Clone data before await +- [anti-lock-across-await](anti-lock-across-await.md) - Anti-pattern reference diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-oneshot-response.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-oneshot-response.md new file mode 100644 index 00000000..13a51fe2 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-oneshot-response.md @@ -0,0 +1,191 @@ +# async-oneshot-response + +> Use `oneshot` channel for request-response patterns + +## Why It Matters + +When one task needs to send a request and wait for exactly one response, `oneshot` is the perfect fit. It's a single-use channel optimized for this pattern—no buffering, no clone overhead. Combined with `mpsc`, it enables clean actor-style message passing. + +## Bad + +```rust +// Using mpsc for single response - wasteful +let (tx, mut rx) = mpsc::channel::(1); +send_request().await; +let response = rx.recv().await.unwrap(); +// Channel persists, could accidentally receive more + +// Using shared state - complex +let result = Arc::new(Mutex::new(None)); +send_request(result.clone()).await; +while result.lock().await.is_none() { + tokio::time::sleep(Duration::from_millis(10)).await; // Polling! +} +``` + +## Good + +```rust +use tokio::sync::oneshot; + +let (tx, rx) = oneshot::channel::(); + +// Send request with reply channel +send_request(Request { data, reply: tx }).await; + +// Wait for response +let response = rx.await?; + +// Channel is consumed - can't accidentally reuse +``` + +## Request-Response Pattern + +```rust +use tokio::sync::{mpsc, oneshot}; + +enum Request { + Get { + key: String, + reply: oneshot::Sender>, + }, + Set { + key: String, + value: Value, + reply: oneshot::Sender, + }, +} + +// Service handler +async fn service(mut rx: mpsc::Receiver) { + let mut store = HashMap::new(); + + while let Some(req) = rx.recv().await { + match req { + Request::Get { key, reply } => { + let value = store.get(&key).cloned(); + let _ = reply.send(value); // Ignore if receiver dropped + } + Request::Set { key, value, reply } => { + store.insert(key, value); + let _ = reply.send(true); + } + } + } +} + +// Client +async fn get_value(tx: &mpsc::Sender, key: &str) -> Option { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Request::Get { + key: key.to_string(), + reply: reply_tx, + }).await.ok()?; + + reply_rx.await.ok()? +} +``` + +## With Timeout + +```rust +use tokio::time::{timeout, Duration}; + +async fn request_with_timeout( + tx: &mpsc::Sender, + key: &str, +) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Request::Get { + key: key.to_string(), + reply: reply_tx, + }).await.map_err(|_| Error::ServiceDown)?; + + timeout(Duration::from_secs(5), reply_rx) + .await + .map_err(|_| Error::Timeout)? + .map_err(|_| Error::ServiceDown)? + .ok_or(Error::NotFound) +} +``` + +## Error Handling + +```rust +use tokio::sync::oneshot; + +let (tx, rx) = oneshot::channel::(); + +// Sender dropped without sending +drop(tx); +match rx.await { + Ok(value) => println!("Got: {}", value), + Err(oneshot::error::RecvError { .. }) => { + println!("Sender dropped"); + } +} + +// Receiver dropped before send +let (tx, rx) = oneshot::channel::(); +drop(rx); +match tx.send("hello".to_string()) { + Ok(()) => println!("Sent"), + Err(value) => println!("Receiver dropped, value: {}", value), +} +``` + +## Closed Detection + +```rust +// Check if receiver is still waiting +let (tx, rx) = oneshot::channel::(); + +// In producer +if tx.is_closed() { + println!("Receiver already gone, skip expensive computation"); +} else { + let result = expensive_computation(); + tx.send(result).ok(); +} + +// Async wait for close +let tx_clone = tx.clone(); // Note: can't actually clone, just showing concept +tokio::select! { + _ = tx.closed() => println!("Receiver dropped"), + result = compute() => { tx.send(result).ok(); } +} +``` + +## Response Type Wrapper + +```rust +// Standardize request-response pattern +struct RpcRequest { + request: Req, + reply: oneshot::Sender, +} + +impl RpcRequest { + fn new(request: Req) -> (Self, oneshot::Receiver) { + let (tx, rx) = oneshot::channel(); + (RpcRequest { request, reply: tx }, rx) + } + + fn respond(self, response: Res) { + let _ = self.reply.send(response); + } +} + +// Usage +let (req, rx) = RpcRequest::new(GetUser { id: 42 }); +tx.send(req).await?; +let user = rx.await?; +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Pair with oneshot for request-response +- [async-bounded-channel](./async-bounded-channel.md) - Channel sizing +- [async-select-racing](./async-select-racing.md) - Timeout patterns diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-select-racing.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-select-racing.md new file mode 100644 index 00000000..19b5e3c4 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-select-racing.md @@ -0,0 +1,198 @@ +# async-select-racing + +> Use `select!` to race futures and handle the first to complete + +## Why It Matters + +Sometimes you need the first result from multiple futures—timeout vs operation, cancellation vs work, or competing alternatives. `tokio::select!` lets you race futures and handle whichever completes first, while properly cancelling the others. + +## Bad + +```rust +// Can't express "whichever finishes first" +async fn fetch_with_fallback() -> Data { + match fetch_primary().await { + Ok(data) => data, + Err(_) => fetch_fallback().await.unwrap(), // Sequential, not racing + } +} + +// Manual timeout is error-prone +async fn fetch_with_timeout() -> Option { + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(5) { + return None; + } + // How do we check timeout while awaiting? + } +} +``` + +## Good + +```rust +use tokio::select; + +async fn fetch_with_timeout() -> Result { + select! { + result = fetch_data() => result, + _ = tokio::time::sleep(Duration::from_secs(5)) => { + Err(Error::Timeout) + } + } +} + +async fn fetch_with_fallback() -> Data { + select! { + result = fetch_primary() => { + match result { + Ok(data) => data, + Err(_) => fetch_fallback().await.unwrap() + } + } + _ = tokio::time::sleep(Duration::from_secs(1)) => { + // Primary too slow, use fallback + fetch_fallback().await.unwrap() + } + } +} +``` + +## select! Syntax + +```rust +select! { + // Pattern = future => handler + result = async_operation() => { + // Handle result + println!("Got: {:?}", result); + } + + // Can bind with pattern matching + Ok(data) = fallible_operation() => { + process(data); + } + + // Conditional branches with if guards + msg = channel.recv(), if should_receive => { + handle_message(msg); + } + + // else branch for when all futures are disabled + else => { + println!("All branches disabled"); + } +} +``` + +## Cancellation Behavior + +```rust +async fn select_example() { + select! { + _ = operation_a() => { + println!("A completed first"); + // operation_b() is dropped/cancelled + } + _ = operation_b() => { + println!("B completed first"); + // operation_a() is dropped/cancelled + } + } +} + +// Futures are cancelled at their next .await point +// For immediate cancellation, futures must be cancel-safe +``` + +## Biased Selection + +```rust +// By default, select! randomly picks when multiple are ready +// Use biased mode for deterministic priority +select! { + biased; // Check branches in order + + msg = high_priority.recv() => handle_high(msg), + msg = low_priority.recv() => handle_low(msg), +} + +// Without biased, both channels have equal chance +// when both have messages ready +``` + +## Loop with select! + +```rust +async fn event_loop( + mut commands: mpsc::Receiver, + shutdown: CancellationToken, +) { + loop { + select! { + _ = shutdown.cancelled() => { + println!("Shutting down"); + break; + } + Some(cmd) = commands.recv() => { + process_command(cmd).await; + } + else => { + // commands channel closed + break; + } + } + } +} +``` + +## Racing Multiple of Same Type + +```rust +// Race multiple servers for fastest response +async fn fastest_response(servers: &[String]) -> Result { + let futures = servers.iter() + .map(|s| fetch_from(s)) + .collect::>(); + + // select! requires static branches, use select_all for dynamic + let (result, _index, _remaining) = + futures::future::select_all(futures).await; + + result +} +``` + +## Common Patterns + +```rust +// Timeout +select! { + result = operation() => result, + _ = sleep(Duration::from_secs(5)) => Err(Timeout), +} + +// Cancellation +select! { + result = operation() => result, + _ = cancel_token.cancelled() => Err(Cancelled), +} + +// Interval with cancellation +let mut interval = tokio::time::interval(Duration::from_secs(1)); +loop { + select! { + _ = shutdown.cancelled() => break, + _ = interval.tick() => { + do_periodic_work().await; + } + } +} +``` + +## See Also + +- [async-cancellation-token](./async-cancellation-token.md) - Cancellation patterns +- [async-join-parallel](./async-join-parallel.md) - All futures, not racing +- [async-bounded-channel](./async-bounded-channel.md) - Channel operations in select diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-spawn-blocking.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-spawn-blocking.md new file mode 100644 index 00000000..8312d339 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-spawn-blocking.md @@ -0,0 +1,154 @@ +# async-spawn-blocking + +> Use `spawn_blocking` for CPU-intensive work + +## Why It Matters + +Async runtimes like Tokio use a small number of threads to handle many tasks. CPU-intensive or blocking operations on these threads starve other tasks. `spawn_blocking` moves such work to a dedicated thread pool. + +## Bad + +```rust +// BAD: Blocks the async runtime thread +async fn process_image(data: &[u8]) -> ProcessedImage { + // CPU-intensive work on async thread! + let resized = resize_image(data); // Blocks! + let compressed = compress(resized); // Blocks! + compressed +} + +// BAD: Synchronous file I/O in async context +async fn read_large_file(path: &Path) -> Vec { + std::fs::read(path).unwrap() // Blocks the runtime! +} +``` + +## Good + +```rust +use tokio::task; + +// GOOD: Offload CPU work to blocking pool +async fn process_image(data: Vec) -> ProcessedImage { + task::spawn_blocking(move || { + let resized = resize_image(&data); + compress(resized) + }) + .await + .expect("spawn_blocking failed") +} + +// GOOD: Use async file I/O +async fn read_large_file(path: &Path) -> tokio::io::Result> { + tokio::fs::read(path).await +} + +// GOOD: Or spawn_blocking for unavoidable sync I/O +async fn read_with_sync_lib(path: PathBuf) -> Vec { + task::spawn_blocking(move || { + sync_library::read_file(&path) + }) + .await + .unwrap() +} +``` + +## What Counts as Blocking + +```rust +// CPU-intensive operations +- Cryptographic operations (hashing, encryption) +- Image/video processing +- Compression/decompression +- Complex parsing +- Mathematical computations + +// Blocking I/O +- std::fs operations +- Synchronous database drivers +- Synchronous HTTP clients +- Thread::sleep + +// Example thresholds (rough guidelines): +// < 10µs: OK on async thread +// 10µs - 1ms: Consider spawn_blocking +// > 1ms: Definitely spawn_blocking +``` + +## Practical Examples + +```rust +// Password hashing (CPU-intensive) +async fn hash_password(password: String) -> String { + task::spawn_blocking(move || { + bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap() +} + +// JSON parsing of large documents +async fn parse_large_json(data: String) -> serde_json::Value { + task::spawn_blocking(move || { + serde_json::from_str(&data).unwrap() + }) + .await + .unwrap() +} + +// Compression +async fn compress_data(data: Vec) -> Vec { + task::spawn_blocking(move || { + let mut encoder = flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + ); + encoder.write_all(&data).unwrap(); + encoder.finish().unwrap() + }) + .await + .unwrap() +} +``` + +## spawn_blocking vs spawn + +```rust +// spawn: Runs async code on runtime threads +tokio::spawn(async { + // Async code here + some_async_operation().await; +}); + +// spawn_blocking: Runs sync code on blocking thread pool +tokio::task::spawn_blocking(|| { + // Synchronous, possibly CPU-intensive code + heavy_computation(); +}); + +// spawn_blocking returns JoinHandle that can be awaited +let result = tokio::task::spawn_blocking(|| { + expensive_sync_operation() +}).await?; +``` + +## Rayon for Parallel CPU Work + +```rust +// For parallel CPU work, consider Rayon inside spawn_blocking +async fn parallel_process(items: Vec) -> Vec { + task::spawn_blocking(move || { + use rayon::prelude::*; + items.par_iter() + .map(|item| cpu_intensive_transform(item)) + .collect() + }) + .await + .unwrap() +} +``` + +## See Also + +- [async-tokio-fs](async-tokio-fs.md) - Use tokio::fs for async file I/O +- [async-no-lock-await](async-no-lock-await.md) - Don't hold locks across await diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-fs.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-fs.md new file mode 100644 index 00000000..bf2abff5 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-fs.md @@ -0,0 +1,167 @@ +# async-tokio-fs + +> Use `tokio::fs` instead of `std::fs` in async code + +## Why It Matters + +`std::fs` operations are blocking—they stop the current thread until the syscall completes. In async code, this blocks the executor thread, preventing it from running other tasks. `tokio::fs` wraps filesystem operations in `spawn_blocking`, keeping the executor responsive. + +## Bad + +```rust +async fn process_files(paths: &[PathBuf]) -> Result> { + let mut contents = Vec::new(); + + for path in paths { + // BLOCKS the entire executor thread! + let data = std::fs::read_to_string(path)?; + contents.push(data); + } + + Ok(contents) +} + +// While reading a file, NO other tasks can run on this thread +``` + +## Good + +```rust +use tokio::fs; + +async fn process_files(paths: &[PathBuf]) -> Result> { + let mut contents = Vec::new(); + + for path in paths { + // Non-blocking: allows other tasks to run + let data = fs::read_to_string(path).await?; + contents.push(data); + } + + Ok(contents) +} + +// Even better: concurrent reads +async fn process_files_concurrent(paths: &[PathBuf]) -> Result> { + let futures: Vec<_> = paths.iter() + .map(|path| fs::read_to_string(path)) + .collect(); + + futures::future::try_join_all(futures).await +} +``` + +## tokio::fs API + +```rust +use tokio::fs; + +// Reading +let contents = fs::read_to_string("file.txt").await?; +let bytes = fs::read("file.bin").await?; + +// Writing +fs::write("output.txt", "contents").await?; + +// File operations +let file = fs::File::open("file.txt").await?; +let file = fs::File::create("new.txt").await?; + +// Directory operations +fs::create_dir("new_dir").await?; +fs::create_dir_all("nested/dir/path").await?; +fs::remove_dir("empty_dir").await?; +fs::remove_dir_all("dir_with_contents").await?; + +// Metadata +let metadata = fs::metadata("file.txt").await?; +let canonical = fs::canonicalize("./relative").await?; + +// Rename/remove +fs::rename("old.txt", "new.txt").await?; +fs::remove_file("file.txt").await?; + +// Read directory +let mut entries = fs::read_dir("some_dir").await?; +while let Some(entry) = entries.next_entry().await? { + println!("{}", entry.path().display()); +} +``` + +## Async File I/O + +```rust +use tokio::fs::File; +use tokio::io::{AsyncReadExt, AsyncWriteExt, AsyncBufReadExt, BufReader}; + +// Read with buffer +let mut file = File::open("large.bin").await?; +let mut buffer = vec![0u8; 4096]; +let bytes_read = file.read(&mut buffer).await?; + +// Read all +let mut contents = Vec::new(); +file.read_to_end(&mut contents).await?; + +// Write +let mut file = File::create("output.bin").await?; +file.write_all(b"data").await?; +file.flush().await?; + +// Buffered line reading +let file = File::open("lines.txt").await?; +let reader = BufReader::new(file); +let mut lines = reader.lines(); + +while let Some(line) = lines.next_line().await? { + println!("{}", line); +} +``` + +## When std::fs is Acceptable + +```rust +// Startup/initialization (before async runtime) +fn main() { + let config = std::fs::read_to_string("config.toml") + .expect("config file required"); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(run_with_config(config)); +} + +// Single-threaded current_thread runtime (less impact) +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Still prefer tokio::fs, but impact is lower +} + +// When file operations are rare and quick +// (e.g., reading small config once per hour) +``` + +## Performance Considerations + +```rust +// tokio::fs uses spawn_blocking internally +// For many small files, the overhead adds up + +// Batch operations when possible +let paths: Vec<_> = entries.iter() + .map(|e| e.path()) + .collect(); + +let contents = futures::future::try_join_all( + paths.iter().map(|p| fs::read_to_string(p)) +).await?; + +// For heavy I/O, consider memory-mapped files +// (requires unsafe or mmap crate) +``` + +## See Also + +- [async-spawn-blocking](./async-spawn-blocking.md) - How tokio::fs works internally +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime configuration +- [err-context-chain](./err-context-chain.md) - Adding path context to IO errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-runtime.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-runtime.md new file mode 100644 index 00000000..53692268 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-tokio-runtime.md @@ -0,0 +1,169 @@ +# async-tokio-runtime + +> Configure Tokio runtime appropriately for your workload + +## Why It Matters + +Tokio's default multi-threaded runtime isn't always optimal. CPU-bound work needs different configuration than IO-bound work. Incorrect configuration leads to poor performance, blocked workers, or resource exhaustion. Understanding runtime options lets you tune for your specific use case. + +## Bad + +```rust +// Default runtime for everything - not optimal +#[tokio::main] +async fn main() { + // CPU-heavy work on async executor starves IO tasks + for data in datasets { + let result = heavy_computation(data).await; + } +} + +// Single-threaded when multi-threaded is needed +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Can't utilize multiple cores for concurrent tasks + for _ in 0..1000 { + tokio::spawn(async { /* IO work */ }); + } +} +``` + +## Good + +```rust +// Multi-threaded for concurrent IO (default) +#[tokio::main] +async fn main() { + // Good for many concurrent network connections + let handles: Vec<_> = urls.iter() + .map(|url| tokio::spawn(fetch(url.clone()))) + .collect(); + + futures::future::join_all(handles).await; +} + +// Current-thread for single-threaded scenarios +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Good for single-connection clients, simpler debugging + let client = Client::new(); + client.run().await; +} + +// Custom configuration +#[tokio::main(worker_threads = 4)] +async fn main() { + // Limit to 4 worker threads +} + +// Or manual setup for more control +fn main() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .thread_name("my-worker") + .build() + .unwrap(); + + runtime.block_on(async_main()); +} +``` + +## Runtime Types + +| Runtime | Use Case | Configuration | +|---------|----------|---------------| +| Multi-thread | IO-bound, many connections | `#[tokio::main]` (default) | +| Current-thread | CLI tools, tests, single connection | `flavor = "current_thread"` | +| Custom | Fine-tuned performance | `Builder::new_*()` | + +## Worker Thread Tuning + +```rust +use tokio::runtime::Builder; + +// IO-bound: more threads than cores can help +let io_runtime = Builder::new_multi_thread() + .worker_threads(num_cpus::get() * 2) // IO can benefit from oversubscription + .max_blocking_threads(32) // For spawn_blocking calls + .enable_io() + .enable_time() + .build()?; + +// CPU-bound: match core count +let cpu_runtime = Builder::new_multi_thread() + .worker_threads(num_cpus::get()) // No benefit from more than cores + .build()?; +``` + +## Multiple Runtimes + +```rust +// Separate runtimes for different workloads +struct App { + io_runtime: Runtime, + cpu_runtime: Runtime, +} + +impl App { + fn new() -> Self { + Self { + io_runtime: Builder::new_multi_thread() + .worker_threads(8) + .thread_name("io-worker") + .build() + .unwrap(), + cpu_runtime: Builder::new_multi_thread() + .worker_threads(4) + .thread_name("cpu-worker") + .build() + .unwrap(), + } + } + + fn spawn_io(&self, future: F) + where F: Future + Send + 'static, F::Output: Send + 'static + { + self.io_runtime.spawn(future); + } + + fn spawn_cpu(&self, task: F) + where F: FnOnce() + Send + 'static + { + self.cpu_runtime.spawn_blocking(task); + } +} +``` + +## Runtime in Tests + +```rust +// Single test runtime +#[tokio::test] +async fn test_single() { + assert!(true); +} + +// Multi-threaded test +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_concurrent() { + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { tx.send(42).unwrap() }); + assert_eq!(rx.await.unwrap(), 42); +} + +// Custom runtime in test +#[test] +fn test_with_custom_runtime() { + let rt = Builder::new_current_thread().build().unwrap(); + rt.block_on(async { + // test code + }); +} +``` + +## See Also + +- [async-spawn-blocking](./async-spawn-blocking.md) - Handling blocking code +- [async-no-lock-await](./async-no-lock-await.md) - Avoiding lock issues +- [async-joinset-structured](./async-joinset-structured.md) - Managing spawned tasks diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-try-join.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-try-join.md new file mode 100644 index 00000000..c9e0cc49 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-try-join.md @@ -0,0 +1,172 @@ +# async-try-join + +> Use `try_join!` for concurrent fallible operations with early return on error + +## Why It Matters + +When running multiple fallible operations concurrently, `try_join!` returns `Err` as soon as any future fails, without waiting for the others. This provides fail-fast behavior while still running operations in parallel. For many operations, use `futures::future::try_join_all`. + +## Bad + +```rust +// Sequential - slow and no early return benefit +async fn fetch_all() -> Result<(A, B, C)> { + let a = fetch_a().await?; // If this fails, we wait for nothing + let b = fetch_b().await?; // But if this fails, we waited for A + let c = fetch_c().await?; + Ok((a, b, c)) +} + +// join! ignores errors +async fn fetch_all() -> (Result
, Result, Result) { + let (a, b, c) = join!(fetch_a(), fetch_b(), fetch_c()); + // All complete even if first one failed + (a, b, c) // Now we have to handle three Results +} +``` + +## Good + +```rust +use tokio::try_join; + +async fn fetch_all() -> Result<(A, B, C)> { + // Concurrent AND fail-fast + let (a, b, c) = try_join!( + fetch_a(), + fetch_b(), + fetch_c(), + )?; + + Ok((a, b, c)) +} + +// For dynamic collections +use futures::future::try_join_all; + +async fn fetch_users(ids: &[u64]) -> Result> { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + try_join_all(futures).await +} +``` + +## Error Handling Patterns + +```rust +// Different error types - need common error type +async fn mixed_operations() -> Result<(A, B), Error> { + let (a, b) = try_join!( + fetch_a().map_err(Error::from), // Convert errors + fetch_b().map_err(Error::from), + )?; + Ok((a, b)) +} + +// Collect all results, then handle errors +async fn all_or_nothing(ids: &[u64]) -> Result> { + try_join_all(ids.iter().map(|id| fetch_user(*id))).await +} + +// Collect successes, log failures +async fn best_effort(ids: &[u64]) -> Vec { + let results = futures::future::join_all( + ids.iter().map(|id| fetch_user(*id)) + ).await; + + results.into_iter() + .filter_map(|r| match r { + Ok(user) => Some(user), + Err(e) => { + log::warn!("Failed to fetch user: {}", e); + None + } + }) + .collect() +} +``` + +## Cancellation Behavior + +```rust +// try_join! cancels remaining futures on error +async fn with_cancellation() -> Result<()> { + // If fetch_a() fails, fetch_b() and fetch_c() are dropped + // But "dropped" != "immediately stopped" + // They stop at their next .await point + + try_join!( + async { + fetch_a().await?; + cleanup_a().await; // May not run if other future fails + Ok::<_, Error>(()) + }, + async { + fetch_b().await?; + cleanup_b().await; // May not run if other future fails + Ok::<_, Error>(()) + }, + )?; + + Ok(()) +} + +// For guaranteed cleanup, use Drop guards or explicit handling +``` + +## With Timeout + +```rust +use tokio::time::{timeout, Duration}; + +async fn fetch_with_timeout() -> Result<(A, B)> { + timeout( + Duration::from_secs(10), + try_join!(fetch_a(), fetch_b()) + ) + .await + .map_err(|_| Error::Timeout)? +} + +// Per-operation timeout +async fn individual_timeouts() -> Result<(A, B)> { + try_join!( + timeout(Duration::from_secs(5), fetch_a()) + .map_err(|_| Error::Timeout) + .and_then(|r| async { r }), + timeout(Duration::from_secs(5), fetch_b()) + .map_err(|_| Error::Timeout) + .and_then(|r| async { r }), + ) +} +``` + +## try_join! vs FuturesUnordered + +```rust +use futures::stream::{FuturesUnordered, StreamExt}; + +// try_join!: wait for all, fail fast +let (a, b, c) = try_join!(fa, fb, fc)?; + +// FuturesUnordered: process as they complete +let mut futures = FuturesUnordered::new(); +futures.push(fetch_a()); +futures.push(fetch_b()); +futures.push(fetch_c()); + +while let Some(result) = futures.next().await { + match result { + Ok(data) => process(data), + Err(e) => return Err(e), // Can fail fast manually + } +} +``` + +## See Also + +- [async-join-parallel](./async-join-parallel.md) - Non-fallible concurrent futures +- [async-select-racing](./async-select-racing.md) - First-to-complete semantics +- [err-question-mark](./err-question-mark.md) - Error propagation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-watch-latest.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-watch-latest.md new file mode 100644 index 00000000..9dd485d7 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/async-watch-latest.md @@ -0,0 +1,189 @@ +# async-watch-latest + +> Use `watch` channel for sharing the latest value with multiple observers + +## Why It Matters + +`watch` is optimized for scenarios where receivers only care about the most recent value, not the history of changes. Unlike `broadcast`, slow receivers don't lag—they simply skip intermediate values. This is perfect for configuration, state, or status that should always reflect the current situation. + +## Bad + +```rust +// Using broadcast when only latest value matters +let (tx, _) = broadcast::channel::(100); + +// Receivers might process stale configs if they're slow +// And they waste time processing intermediate values + +// Using mpsc with buffered stale values +let (tx, mut rx) = mpsc::channel::(100); +// Receiver might process outdated statuses +``` + +## Good + +```rust +use tokio::sync::watch; + +let (tx, rx) = watch::channel(Config::default()); + +// Multiple observers +let rx1 = rx.clone(); +let rx2 = rx.clone(); + +// Observer 1: waits for changes +tokio::spawn(async move { + let mut rx = rx1; + while rx.changed().await.is_ok() { + let config = rx.borrow(); + apply_config(&*config); + } +}); + +// Observer 2: also sees all changes +tokio::spawn(async move { + let mut rx = rx2; + while rx.changed().await.is_ok() { + let config = rx.borrow(); + log_config_change(&*config); + } +}); + +// Update the value +tx.send(Config::new())?; +``` + +## watch Semantics + +```rust +use tokio::sync::watch; + +let (tx, mut rx) = watch::channel("initial"); + +// Immediate read - no waiting +assert_eq!(*rx.borrow(), "initial"); + +// Wait for change +tx.send("updated")?; +rx.changed().await?; +assert_eq!(*rx.borrow(), "updated"); + +// Multiple rapid updates - receiver sees latest +tx.send("v1")?; +tx.send("v2")?; +tx.send("v3")?; +rx.changed().await?; +assert_eq!(*rx.borrow(), "v3"); // Skipped v1, v2 +``` + +## Configuration Reload Pattern + +```rust +use tokio::sync::watch; +use std::sync::Arc; + +struct AppConfig { + log_level: Level, + max_connections: usize, +} + +async fn config_watcher(tx: watch::Sender>) { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + + if let Ok(new_config) = reload_config_from_disk() { + // Only notifies if value actually changed + tx.send_if_modified(|current| { + if *current != new_config { + *current = Arc::new(new_config); + true + } else { + false + } + }); + } + } +} + +async fn worker(mut config_rx: watch::Receiver>) { + loop { + tokio::select! { + _ = config_rx.changed() => { + let config = config_rx.borrow().clone(); + reconfigure(&config); + } + _ = do_work() => {} + } + } +} +``` + +## State Machine Updates + +```rust +#[derive(Clone, PartialEq)] +enum ConnectionState { + Disconnected, + Connecting, + Connected, + Error(String), +} + +struct Connection { + state_tx: watch::Sender, + state_rx: watch::Receiver, +} + +impl Connection { + async fn wait_connected(&mut self) -> Result<(), Error> { + loop { + let state = self.state_rx.borrow().clone(); + match state { + ConnectionState::Connected => return Ok(()), + ConnectionState::Error(e) => return Err(Error::Connection(e)), + _ => { + self.state_rx.changed().await?; + } + } + } + } +} +``` + +## Borrow vs Clone + +```rust +use tokio::sync::watch; + +let (tx, rx) = watch::channel(vec![1, 2, 3]); + +// borrow() returns Ref - must not hold across await +{ + let data = rx.borrow(); + println!("{:?}", *data); +} // Ref dropped here + +// For use across await, clone the data +let data = rx.borrow().clone(); +some_async_operation().await; +use_data(&data); // Safe + +// Or use borrow_and_update() to mark as seen +let data = rx.borrow_and_update().clone(); +``` + +## watch vs broadcast vs mpsc + +| Feature | watch | broadcast | mpsc | +|---------|-------|-----------|------| +| Receivers | Multiple | Multiple | Single | +| Message delivery | Latest only | All messages | All messages | +| Slow receiver | Skips to latest | Lags/misses | Backpressure | +| Clone required | No | Yes | No | +| Best for | Config, status | Events | Work queues | + +## See Also + +- [async-broadcast-pubsub](./async-broadcast-pubsub.md) - When history matters +- [async-mpsc-queue](./async-mpsc-queue.md) - Work queue patterns +- [async-cancellation-token](./async-cancellation-token.md) - Related pattern diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-all-public.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-all-public.md new file mode 100644 index 00000000..42c28b1f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-all-public.md @@ -0,0 +1,113 @@ +# doc-all-public + +> Document all public items with `///` doc comments + +## Why It Matters + +Public items define your crate's API contract. Without documentation, users must read source code to understand how to use your library. Well-documented APIs reduce support burden, improve adoption, and serve as the primary reference for users. + +Rust's `cargo doc` generates beautiful HTML documentation from doc comments, but only if you write them. + +## Bad + +```rust +pub struct Config { + pub timeout: Duration, + pub retries: u32, + pub base_url: String, +} + +pub fn connect(config: Config) -> Result { + // ... +} + +pub enum Status { + Pending, + Active, + Failed, +} +``` + +## Good + +```rust +/// Configuration for establishing a connection to the service. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Config; +/// use std::time::Duration; +/// +/// let config = Config { +/// timeout: Duration::from_secs(30), +/// retries: 3, +/// base_url: "https://api.example.com".to_string(), +/// }; +/// ``` +pub struct Config { + /// Maximum time to wait for a response before timing out. + pub timeout: Duration, + + /// Number of retry attempts for failed requests. + pub retries: u32, + + /// Base URL for all API requests. + pub base_url: String, +} + +/// Establishes a connection using the provided configuration. +/// +/// # Errors +/// +/// Returns an error if the connection cannot be established +/// or if the configuration is invalid. +pub fn connect(config: Config) -> Result { + // ... +} + +/// Represents the current status of a job. +pub enum Status { + /// Job is waiting to be processed. + Pending, + /// Job is currently being processed. + Active, + /// Job has failed and will not be retried. + Failed, +} +``` + +## What to Document + +| Item Type | Required Content | +|-----------|------------------| +| Structs | Purpose, usage example | +| Struct fields | What the field represents | +| Enums | When to use each variant | +| Enum variants | What state it represents | +| Functions | What it does, parameters, return value | +| Traits | Contract and expected behavior | +| Trait methods | Default implementation behavior | +| Type aliases | Why the alias exists | +| Constants | What the value represents | + +## Enforcement + +Enable the `missing_docs` lint to catch undocumented public items: + +```rust +#![warn(missing_docs)] +``` + +Or in `Cargo.toml` for workspace-wide enforcement: + +```toml +[workspace.lints.rust] +missing_docs = "warn" +``` + +## See Also + +- [doc-module-inner](./doc-module-inner.md) - Module-level documentation +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [lint-missing-docs](./lint-missing-docs.md) - Enforcing documentation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-cargo-metadata.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-cargo-metadata.md new file mode 100644 index 00000000..482a8d97 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-cargo-metadata.md @@ -0,0 +1,147 @@ +# doc-cargo-metadata + +> Fill `Cargo.toml` metadata for published crates + +## Why It Matters + +Cargo.toml metadata appears on crates.io, in search results, and helps users evaluate your crate. Missing metadata makes your crate look unprofessional, harder to find, and harder to trust. Complete metadata improves discoverability and adoption. + +## Bad + +```toml +[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" + +[dependencies] +# ... +``` + +## Good + +```toml +[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +# Required for crates.io +description = "A fast, ergonomic HTTP client for Rust" +license = "MIT OR Apache-2.0" +repository = "https://github.com/username/my-awesome-crate" + +# Highly recommended +documentation = "https://docs.rs/my-awesome-crate" +readme = "README.md" +keywords = ["http", "client", "async", "networking"] +categories = ["network-programming", "web-programming::http-client"] +authors = ["Your Name "] +homepage = "https://my-awesome-crate.dev" + +# Optional but helpful +include = ["src/**/*", "Cargo.toml", "LICENSE*", "README.md"] +exclude = ["tests/fixtures/*", ".github/*"] + +[badges] +maintenance = { status = "actively-developed" } + +[dependencies] +# ... +``` + +## Required Fields for Publishing + +| Field | Purpose | +|-------|---------| +| `name` | Crate name on crates.io | +| `version` | Semver version | +| `license` or `license-file` | SPDX license identifier | +| `description` | One-line summary (≤256 chars) | + +## Recommended Fields + +| Field | Purpose | Example | +|-------|---------|---------| +| `repository` | Link to source code | `https://github.com/user/repo` | +| `documentation` | Link to docs | `https://docs.rs/crate` | +| `readme` | Path to README | `README.md` | +| `keywords` | Search terms (max 5) | `["http", "async"]` | +| `categories` | crates.io categories | `["network-programming"]` | +| `rust-version` | MSRV | `"1.70"` | + +## Keywords Best Practices + +```toml +# Good: specific, searchable terms +keywords = ["json", "serialization", "serde", "parsing"] + +# Bad: too generic or redundant +keywords = ["rust", "library", "awesome", "fast", "best"] +``` + +## Categories + +Choose from [crates.io categories](https://crates.io/category_slugs): + +```toml +categories = [ + "network-programming", + "web-programming::http-client", + "asynchronous", +] +``` + +## License Patterns + +```toml +# Single license +license = "MIT" + +# Dual license (common in Rust ecosystem) +license = "MIT OR Apache-2.0" + +# Custom license file +license-file = "LICENSE" +``` + +## Include/Exclude + +Control what gets published: + +```toml +# Explicit include (whitelist) +include = [ + "src/**/*", + "Cargo.toml", + "LICENSE*", + "README.md", + "CHANGELOG.md", +] + +# Or exclude (blacklist) +exclude = [ + "tests/fixtures/large-file.bin", + ".github/*", + "benches/*", +] +``` + +## Verification + +Check your package before publishing: + +```bash +# See what will be included +cargo package --list + +# Check metadata +cargo publish --dry-run +``` + +## See Also + +- [doc-module-inner](./doc-module-inner.md) - Crate-level documentation +- [lint-cargo-metadata](./lint-cargo-metadata.md) - Linting Cargo.toml +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace management diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-errors-section.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-errors-section.md new file mode 100644 index 00000000..2c861ec9 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-errors-section.md @@ -0,0 +1,122 @@ +# doc-errors-section + +> Include `# Errors` section for fallible functions + +## Why It Matters + +Functions returning `Result` can fail in specific, documented ways. The `# Errors` section tells users exactly when and why a function might return an error, enabling them to handle failures appropriately without reading source code. + +This is especially critical for library code where users cannot easily inspect implementation details. + +## Bad + +```rust +/// Opens a file and reads its contents. +pub fn read_file(path: &Path) -> Result { + // Users have no idea what errors to expect +} + +/// Connects to the database. +pub async fn connect(url: &str) -> Result { + // Multiple failure modes, none documented +} +``` + +## Good + +```rust +/// Opens a file and reads its contents as a UTF-8 string. +/// +/// # Errors +/// +/// Returns an error if: +/// - The file does not exist ([`Error::NotFound`]) +/// - The process lacks permission to read the file ([`Error::PermissionDenied`]) +/// - The file contains invalid UTF-8 ([`Error::InvalidUtf8`]) +pub fn read_file(path: &Path) -> Result { + // ... +} + +/// Establishes a connection to the database. +/// +/// # Errors +/// +/// This function will return an error if: +/// - The URL is malformed ([`DbError::InvalidUrl`]) +/// - The database server is unreachable ([`DbError::ConnectionFailed`]) +/// - Authentication fails ([`DbError::AuthenticationFailed`]) +/// - The connection pool is exhausted ([`DbError::PoolExhausted`]) +pub async fn connect(url: &str) -> Result { + // ... +} +``` + +## Error Documentation Patterns + +### Simple Single Error + +```rust +/// Parses a string as an integer. +/// +/// # Errors +/// +/// Returns [`ParseIntError`] if the string is not a valid integer. +pub fn parse_int(s: &str) -> Result { + s.parse() +} +``` + +### Multiple Error Variants + +```rust +/// Sends an HTTP request and returns the response. +/// +/// # Errors +/// +/// | Error | Condition | +/// |-------|-----------| +/// | [`HttpError::Timeout`] | Request exceeded timeout duration | +/// | [`HttpError::InvalidUrl`] | URL could not be parsed | +/// | [`HttpError::ConnectionRefused`] | Server refused connection | +/// | [`HttpError::TlsError`] | TLS handshake failed | +pub fn send(request: Request) -> Result { + // ... +} +``` + +### Propagated Errors + +```rust +/// Loads configuration from a file. +/// +/// # Errors +/// +/// Returns an error if: +/// - The configuration file cannot be read (IO error) +/// - The file contains invalid TOML syntax +/// - Required fields are missing from the configuration +/// +/// The underlying error is wrapped with context about which +/// configuration file failed to load. +pub fn load_config(path: &Path) -> Result { + // ... +} +``` + +## Linking to Error Types + +Use intra-doc links to connect error variants to their definitions: + +```rust +/// # Errors +/// +/// Returns [`ValidationError::TooShort`] if the input is less than +/// the minimum length, or [`ValidationError::InvalidChars`] if it +/// contains forbidden characters. +``` + +## See Also + +- [doc-panics-section](./doc-panics-section.md) - Documenting panics +- [err-doc-errors](./err-doc-errors.md) - Error documentation patterns +- [doc-intra-links](./doc-intra-links.md) - Linking to types diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-examples-section.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-examples-section.md new file mode 100644 index 00000000..63c86c14 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-examples-section.md @@ -0,0 +1,161 @@ +# doc-examples-section + +> Include `# Examples` with runnable code + +## Why It Matters + +Examples are the most valuable part of documentation. They show users exactly how to use your API. Rust's doc tests ensure examples stay correct as code evolves. + +## Bad + +```rust +/// Parses a string into a Foo. +pub fn parse(s: &str) -> Result { + // No examples - users have to guess usage +} + +/// A widget for doing things. +/// +/// This widget is very useful. +pub struct Widget { + // Still no examples +} +``` + +## Good + +```rust +/// Parses a string into a Foo. +/// +/// # Examples +/// +/// ``` +/// use my_crate::parse; +/// +/// let foo = parse("hello").unwrap(); +/// assert_eq!(foo.name(), "hello"); +/// ``` +/// +/// Handles empty strings: +/// +/// ``` +/// use my_crate::parse; +/// +/// let foo = parse("").unwrap(); +/// assert!(foo.is_empty()); +/// ``` +pub fn parse(s: &str) -> Result { + // ... +} +``` + +## Use ? Not unwrap() + +```rust +/// Loads configuration from a file. +/// +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// use my_crate::Config; +/// +/// let config = Config::load("config.toml")?; +/// println!("Port: {}", config.port); +/// # Ok(()) +/// # } +/// ``` +pub fn load(path: &str) -> Result { + // ... +} +``` + +## Hide Setup Code + +```rust +/// Processes items from a database. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Database, Item}; +/// # fn get_db() -> Database { Database::mock() } +/// let db = get_db(); +/// let items = db.process_items()?; +/// assert!(!items.is_empty()); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +pub fn process_items(&self) -> Result, Error> { + // ... +} +``` + +## Multiple Examples + +```rust +/// Creates a new buffer with the specified capacity. +/// +/// # Examples +/// +/// Basic usage: +/// +/// ``` +/// use my_crate::Buffer; +/// +/// let buf = Buffer::with_capacity(1024); +/// assert_eq!(buf.capacity(), 1024); +/// ``` +/// +/// Zero capacity creates an empty buffer: +/// +/// ``` +/// use my_crate::Buffer; +/// +/// let buf = Buffer::with_capacity(0); +/// assert!(buf.is_empty()); +/// ``` +pub fn with_capacity(cap: usize) -> Self { + // ... +} +``` + +## Show Error Cases + +```rust +/// Divides two numbers. +/// +/// # Examples +/// +/// ``` +/// use my_crate::divide; +/// +/// assert_eq!(divide(10, 2), Ok(5)); +/// ``` +/// +/// Division by zero returns an error: +/// +/// ``` +/// use my_crate::{divide, MathError}; +/// +/// assert_eq!(divide(10, 0), Err(MathError::DivisionByZero)); +/// ``` +pub fn divide(a: i32, b: i32) -> Result { + // ... +} +``` + +## Running Doc Tests + +```bash +# Run all doc tests +cargo test --doc + +# Run doc tests for specific item +cargo test --doc my_function +``` + +## See Also + +- [doc-question-mark](doc-question-mark.md) - Use ? in examples +- [doc-hidden-setup](doc-hidden-setup.md) - Hide setup code with # +- [doc-errors-section](doc-errors-section.md) - Document error conditions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-hidden-setup.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-hidden-setup.md new file mode 100644 index 00000000..fa7442e7 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-hidden-setup.md @@ -0,0 +1,149 @@ +# doc-hidden-setup + +> Use `# ` prefix to hide example setup code + +## Why It Matters + +Doc examples often require setup code (imports, struct initialization, mock data) that distracts from the main point. The `# ` prefix hides lines from rendered documentation while keeping them in the compiled test, showing users only the relevant code. + +This keeps examples focused and readable while ensuring they still compile and run. + +## Bad + +```rust +/// Processes a batch of items. +/// +/// # Examples +/// +/// ``` +/// use my_crate::{Processor, Config, Item}; +/// use std::sync::Arc; +/// +/// let config = Config { +/// batch_size: 100, +/// timeout_ms: 5000, +/// retry_count: 3, +/// }; +/// let processor = Processor::new(Arc::new(config)); +/// let items = vec![ +/// Item::new("a"), +/// Item::new("b"), +/// Item::new("c"), +/// ]; +/// +/// // This is the actual example - buried after 15 lines of setup +/// let results = processor.process_batch(&items)?; +/// assert!(results.all_succeeded()); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +pub fn process_batch(&self, items: &[Item]) -> Result { + // ... +} +``` + +## Good + +```rust +/// Processes a batch of items. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Processor, Config, Item, Error}; +/// # use std::sync::Arc; +/// # let config = Config { batch_size: 100, timeout_ms: 5000, retry_count: 3 }; +/// # let processor = Processor::new(Arc::new(config)); +/// # let items = vec![Item::new("a"), Item::new("b"), Item::new("c")]; +/// let results = processor.process_batch(&items)?; +/// assert!(results.all_succeeded()); +/// # Ok::<(), Error>(()) +/// ``` +pub fn process_batch(&self, items: &[Item]) -> Result { + // ... +} +``` + +Users see only: + +```rust +let results = processor.process_batch(&items)?; +assert!(results.all_succeeded()); +``` + +## What to Hide + +| Hide | Show | +|------|------| +| `use` statements | Core API usage | +| Type definitions | Method calls | +| Mock/test data setup | Key parameters | +| Error handling boilerplate | Return value handling | +| `Ok(())` return | Assertions (sometimes) | + +## Pattern: Hiding Multi-Line Setup + +```rust +/// # Examples +/// +/// ``` +/// # use my_crate::{Client, Request}; +/// # fn main() -> Result<(), Box> { +/// # let client = Client::builder() +/// # .timeout(30) +/// # .retry(3) +/// # .build()?; +/// let response = client.send(Request::get("/users"))?; +/// println!("Status: {}", response.status()); +/// # Ok(()) +/// # } +/// ``` +``` + +## Pattern: Showing Setup When Relevant + +Sometimes setup IS the point—don't hide it: + +```rust +/// Creates a new client with custom configuration. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Client; +/// +/// // Configuration IS the example - show it +/// let client = Client::builder() +/// .base_url("https://api.example.com") +/// .timeout_secs(30) +/// .max_retries(3) +/// .build()?; +/// # Ok::<(), my_crate::Error>(()) +/// ``` +``` + +## Pattern: `ignore` and `no_run` + +For examples that shouldn't run in tests: + +```rust +/// # Examples +/// +/// ```no_run +/// # use my_crate::Server; +/// // This would actually start a server - don't run in tests +/// let server = Server::bind("0.0.0.0:8080").await?; +/// server.run().await?; +/// # Ok::<(), my_crate::Error>(()) +/// ``` + +/// ```ignore +/// // Pseudocode or incomplete example +/// let magic = do_something_undefined(); +/// ``` +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Writing examples +- [doc-question-mark](./doc-question-mark.md) - Using `?` in examples +- [test-doctest-examples](./test-doctest-examples.md) - Doctests as tests diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-intra-links.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-intra-links.md new file mode 100644 index 00000000..11858f7c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-intra-links.md @@ -0,0 +1,138 @@ +# doc-intra-links + +> Use intra-doc links to reference types and items + +## Why It Matters + +Intra-doc links (`[TypeName]`, `[method](Self::method)`) create clickable references in generated documentation. They're verified at doc-build time, catching broken links early. Unlike URL links, they automatically update when items are renamed or moved. + +## Bad + +```rust +/// Returns the length of the buffer. +/// +/// See also `capacity()` for the allocated size, and the +/// `Buffer` struct for more details. +pub fn len(&self) -> usize { + self.data.len() +} + +/// Parses the input using std::str::FromStr trait. +/// Check the Error enum for possible failures. +pub fn parse(input: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Returns the length of the buffer. +/// +/// See also [`capacity()`](Self::capacity) for the allocated size, and +/// [`Buffer`] for more details. +pub fn len(&self) -> usize { + self.data.len() +} + +/// Parses the input using [`FromStr`] trait. +/// Check [`Error`] for possible failures. +/// +/// [`FromStr`]: std::str::FromStr +pub fn parse(input: &str) -> Result { + // ... +} +``` + +## Link Syntax + +| Syntax | Links To | Example | +|--------|----------|---------| +| `[Name]` | Item in scope | `[Vec]`, `[Option]` | +| `[path::Name]` | Fully qualified item | `[std::vec::Vec]` | +| `[Self::method]` | Method on current type | `[Self::new]` | +| `[Type::method]` | Method on other type | `[String::new]` | +| `[Type::CONST]` | Associated constant | `[usize::MAX]` | +| `[text](path)` | Custom text | `[see here](Self::len)` | + +## Common Patterns + +### Linking to Self Members + +```rust +impl Buffer { + /// Creates an empty buffer. + /// + /// Use [`with_capacity`](Self::with_capacity) if you know the size. + pub fn new() -> Self { /* ... */ } + + /// Creates a buffer with pre-allocated capacity. + /// + /// See [`new`](Self::new) for the default constructor. + pub fn with_capacity(cap: usize) -> Self { /* ... */ } +} +``` + +### Linking to Trait Methods + +```rust +/// Converts to a string representation. +/// +/// This is the implementation of [`Display::fmt`](std::fmt::Display::fmt). +impl Display for MyType { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + // ... + } +} +``` + +### Disambiguation + +When names conflict, use suffixes: + +```rust +/// See [`foo()`](fn@foo) for the function and [`foo`](mod@foo) for the module. + +/// Works with [`Error`](struct@Error) struct or [`Error`](trait@Error) trait. +``` + +| Suffix | Item Type | +|--------|-----------| +| `fn@` | Function | +| `mod@` | Module | +| `struct@` | Struct | +| `enum@` | Enum | +| `trait@` | Trait | +| `type@` | Type alias | +| `const@` | Constant | +| `macro@` | Macro | + +### Reference-Style Links + +For repeated links or long paths: + +```rust +/// Parses using [`serde`] with [`Deserialize`] trait. +/// Returns a [`Result`] that may contain [`Error`]. +/// +/// [`serde`]: https://serde.rs +/// [`Deserialize`]: serde::Deserialize +/// [`Result`]: std::result::Result +/// [`Error`]: crate::Error +``` + +## Verification + +Enable link checking in CI: + +```bash +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +``` + +This fails if any intra-doc links are broken. + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documenting public items +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [doc-errors-section](./doc-errors-section.md) - Documenting errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-link-types.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-link-types.md new file mode 100644 index 00000000..eec19eeb --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-link-types.md @@ -0,0 +1,169 @@ +# doc-link-types + +> Use intra-doc links to connect related types and functions + +## Why It Matters + +Intra-doc links (`[`TypeName`]`) create clickable references in generated documentation. They enable navigation between related items, verify that referenced items exist at compile time, and update automatically when items are renamed. Plain text references become stale and unclickable. + +## Bad + +```rust +/// Parses input and returns a ParseResult. +/// +/// See also: ParseError for error types. +/// Uses the Tokenizer internally. +pub fn parse(input: &str) -> ParseResult { + // "ParseResult", "ParseError", "Tokenizer" are not clickable + // No verification they exist +} +``` + +## Good + +```rust +/// Parses input and returns a [`ParseResult`]. +/// +/// # Errors +/// +/// Returns [`ParseError::InvalidSyntax`] if the input contains invalid tokens. +/// Returns [`ParseError::UnexpectedEof`] if the input ends prematurely. +/// +/// # Related +/// +/// - [`Tokenizer`] - The underlying tokenizer used by this parser +/// - [`parse_file`] - Convenience function for parsing files +/// - [`ParseOptions`] - Configuration options for parsing +pub fn parse(input: &str) -> ParseResult { + // All links are clickable and verified +} +``` + +## Link Syntax + +```rust +/// Basic link to type in same module +/// See [`MyType`] for details. + +/// Link to method +/// Use [`MyType::new`] to create instances. + +/// Link to associated type +/// Returns [`Iterator::Item`]. + +/// Link to module +/// See the [`parser`] module. + +/// Link to external crate type +/// Works with [`std::collections::HashMap`]. + +/// Link with custom text +/// See [the parser][`parse`] for details. + +/// Link to module item +/// See [`crate::utils::helper`]. + +/// Link to parent module item +/// See [`super::Parent`]. +``` + +## Common Patterns + +```rust +/// A configuration builder. +/// +/// # Example +/// +/// ``` +/// use my_crate::Config; +/// +/// let config = Config::builder() +/// .timeout(30) +/// .build()?; +/// ``` +/// +/// # Methods +/// +/// - [`Config::builder`] - Create a new builder +/// - [`Config::default`] - Create with defaults +/// +/// # Related Types +/// +/// - [`ConfigBuilder`] - The builder returned by [`Config::builder`] +/// - [`ConfigError`] - Errors that can occur when building +pub struct Config { ... } + +impl Config { + /// Creates a new [`ConfigBuilder`]. + /// + /// This is equivalent to [`ConfigBuilder::new`]. + pub fn builder() -> ConfigBuilder { ... } +} +``` + +## Linking to Trait Items + +```rust +/// Implements [`Iterator`] for lazy evaluation. +/// +/// The [`Iterator::next`] method advances the cursor. +/// +/// For parallel iteration, see [`rayon::ParallelIterator`]. +pub struct MyIterator { ... } + +impl Iterator for MyIterator { + /// Advances and returns the next value. + /// + /// See also [`Iterator::nth`] for skipping elements. + fn next(&mut self) -> Option { ... } +} +``` + +## Broken Link Detection + +```bash +# Catch broken intra-doc links +RUSTDOCFLAGS="-D warnings" cargo doc + +# Or in CI +cargo doc --no-deps 2>&1 | grep "warning: unresolved link" +``` + +```toml +# Cargo.toml - deny broken links +[lints.rustdoc] +broken_intra_doc_links = "deny" +``` + +## Module-Level Documentation + +```rust +//! # Parser Module +//! +//! This module provides parsing utilities. +//! +//! ## Main Types +//! +//! - [`Parser`] - The main parser struct +//! - [`Token`] - Tokens produced by tokenization +//! - [`Ast`] - The abstract syntax tree +//! +//! ## Functions +//! +//! - [`parse`] - Parse a string +//! - [`parse_file`] - Parse a file +//! +//! ## Errors +//! +//! All functions return [`ParseError`] on failure. + +pub struct Parser { ... } +pub enum Token { ... } +pub struct Ast { ... } +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Code examples in docs +- [err-doc-errors](./err-doc-errors.md) - Documenting errors +- [lint-deny-correctness](./lint-deny-correctness.md) - Lint settings diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-module-inner.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-module-inner.md new file mode 100644 index 00000000..9a4e028c --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-module-inner.md @@ -0,0 +1,116 @@ +# doc-module-inner + +> Use `//!` for module-level documentation + +## Why It Matters + +Inner doc comments (`//!`) document the module itself, not the next item. They appear at the top of module files and describe the module's purpose, contents, and usage patterns. This helps users understand what a module provides before diving into individual items. + +Module docs are the first thing users see in `cargo doc` when navigating to a module. + +## Bad + +```rust +// This module handles authentication +// It provides JWT and session-based auth + +mod auth; + +pub use auth::*; +``` + +```rust +// auth.rs +/// Authentication utilities // Wrong: this documents nothing useful +use std::collections::HashMap; + +pub struct Session { /* ... */ } +``` + +## Good + +```rust +//! Authentication and authorization utilities. +//! +//! This module provides multiple authentication strategies: +//! +//! - [`JwtAuth`] - JSON Web Token based authentication +//! - [`SessionAuth`] - Cookie-based session authentication +//! - [`ApiKeyAuth`] - API key authentication for services +//! +//! # Examples +//! +//! ``` +//! use my_crate::auth::{JwtAuth, Authenticator}; +//! +//! let auth = JwtAuth::new("secret-key"); +//! let token = auth.generate_token(&user)?; +//! ``` +//! +//! # Feature Flags +//! +//! - `jwt` - Enables JWT authentication (enabled by default) +//! - `sessions` - Enables session-based authentication + +use std::collections::HashMap; + +pub struct Session { /* ... */ } +``` + +## Where to Use Inner Docs + +| Location | Purpose | +|----------|---------| +| `lib.rs` | Crate-level documentation (appears on crate root) | +| `mod.rs` | Module documentation for directory modules | +| `module.rs` | Module documentation for single-file modules | + +## Crate Root Example + +```rust +//! # My Awesome Crate +//! +//! `my_crate` provides utilities for handling complex workflows. +//! +//! ## Quick Start +//! +//! ```rust +//! use my_crate::prelude::*; +//! +//! let workflow = Workflow::builder() +//! .add_step(Step::new("fetch")) +//! .add_step(Step::new("process")) +//! .build(); +//! ``` +//! +//! ## Modules +//! +//! - [`workflow`] - Core workflow engine +//! - [`steps`] - Built-in workflow steps +//! - [`prelude`] - Common imports +//! +//! ## Feature Flags +//! +//! | Feature | Description | +//! |---------|-------------| +//! | `async` | Async workflow execution | +//! | `serde` | Serialization support | + +pub mod workflow; +pub mod steps; +pub mod prelude; +``` + +## Key Sections for Module Docs + +1. **Brief description** - One-line summary +2. **Overview** - What the module provides +3. **Examples** - How to use it +4. **Feature flags** - Optional functionality +5. **See Also** - Related modules + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documenting public items +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Crate metadata diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-panics-section.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-panics-section.md new file mode 100644 index 00000000..30a8b889 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-panics-section.md @@ -0,0 +1,128 @@ +# doc-panics-section + +> Include `# Panics` section for functions that can panic + +## Why It Matters + +Panics are exceptional conditions that crash the program (or unwind the stack). Users need to know when a function might panic so they can ensure preconditions are met or avoid the function in contexts where panics are unacceptable (e.g., `no_std`, embedded, FFI). + +If a function can panic, document exactly when. + +## Bad + +```rust +/// Returns the element at the given index. +pub fn get(index: usize) -> &T { + &self.data[index] // Panics if out of bounds - not documented! +} + +/// Divides two numbers. +pub fn divide(a: i32, b: i32) -> i32 { + a / b // Panics on division by zero - not documented! +} +``` + +## Good + +```rust +/// Returns the element at the given index. +/// +/// # Panics +/// +/// Panics if `index` is out of bounds (i.e., `index >= self.len()`). +/// +/// # Examples +/// +/// ``` +/// let v = vec![1, 2, 3]; +/// assert_eq!(v.get(1), &2); +/// ``` +pub fn get(&self, index: usize) -> &T { + &self.data[index] +} + +/// Divides two numbers. +/// +/// # Panics +/// +/// Panics if `divisor` is zero. +/// +/// For a non-panicking version, use [`checked_divide`]. +pub fn divide(dividend: i32, divisor: i32) -> i32 { + dividend / divisor +} + +/// Divides two numbers, returning `None` if the divisor is zero. +pub fn checked_divide(dividend: i32, divisor: i32) -> Option { + if divisor == 0 { + None + } else { + Some(dividend / divisor) + } +} +``` + +## Common Panic Conditions + +| Operation | Panic Condition | +|-----------|-----------------| +| Index access `[i]` | Index out of bounds | +| Division `/`, `%` | Division by zero | +| `.unwrap()` | `None` or `Err` value | +| `.expect()` | `None` or `Err` value | +| `slice::split_at(mid)` | `mid > len` | +| `Vec::remove(i)` | `i >= len` | +| Overflow (debug) | Integer overflow | + +## Pattern: Panic vs Return Error + +Document why you chose to panic vs return `Result`: + +```rust +/// Creates a new buffer with the given capacity. +/// +/// # Panics +/// +/// Panics if `capacity` is zero. A buffer must have at least +/// one byte of capacity. +/// +/// This panics rather than returning an error because a zero-capacity +/// buffer represents a programming error, not a runtime condition. +pub fn new(capacity: usize) -> Self { + assert!(capacity > 0, "capacity must be non-zero"); + // ... +} +``` + +## Pattern: Debug-Only Panics + +```rust +/// Adds an item to the collection. +/// +/// # Panics +/// +/// In debug builds, panics if the collection is at capacity. +/// In release builds, this is a no-op when at capacity. +pub fn push(&mut self, item: T) { + debug_assert!(self.len < self.capacity, "collection at capacity"); + // ... +} +``` + +## Provide Non-Panicking Alternatives + +When documenting a panicking function, point to safe alternatives: + +```rust +/// # Panics +/// +/// Panics if the index is out of bounds. +/// +/// For a non-panicking version, use [`get`] which returns `Option<&T>`. +``` + +## See Also + +- [doc-errors-section](./doc-errors-section.md) - Documenting errors +- [doc-safety-section](./doc-safety-section.md) - Documenting unsafe +- [err-result-over-panic](./err-result-over-panic.md) - Preferring Result diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-question-mark.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-question-mark.md new file mode 100644 index 00000000..6c85233f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-question-mark.md @@ -0,0 +1,136 @@ +# doc-question-mark + +> Use `?` in examples, not `.unwrap()` + +## Why It Matters + +Doc examples should model best practices. Using `.unwrap()` teaches users to ignore errors, while `?` demonstrates proper error propagation. Examples with `?` also fail the doctest if an error occurs, catching bugs in documentation. + +Rust doctests wrap examples in a function that returns `Result<(), E>` by default when you use `?`, making this pattern easy to adopt. + +## Bad + +```rust +/// Reads a configuration file. +/// +/// # Examples +/// +/// ``` +/// let config = Config::from_file("config.toml").unwrap(); +/// println!("{:?}", config.database_url); +/// ``` +pub fn from_file(path: &str) -> Result { + // ... +} + +/// Fetches data from the API. +/// +/// # Examples +/// +/// ``` +/// let client = Client::new(); +/// let response = client.get("https://api.example.com").unwrap(); +/// let data: Data = response.json().unwrap(); +/// ``` +pub async fn get(&self, url: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Reads a configuration file. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Config, Error}; +/// # fn main() -> Result<(), Error> { +/// let config = Config::from_file("config.toml")?; +/// println!("{:?}", config.database_url); +/// # Ok(()) +/// # } +/// ``` +pub fn from_file(path: &str) -> Result { + // ... +} + +/// Fetches data from the API. +/// +/// # Examples +/// +/// ```no_run +/// # use my_crate::{Client, Data, Error}; +/// # async fn example() -> Result<(), Error> { +/// let client = Client::new(); +/// let response = client.get("https://api.example.com").await?; +/// let data: Data = response.json().await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn get(&self, url: &str) -> Result { + // ... +} +``` + +## Doctest Wrapper Pattern + +Rust wraps doc examples in a function. You can make this explicit: + +```rust +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// let value = parse_config("key=value")?; +/// assert_eq!(value.key, "value"); +/// # Ok(()) +/// # } +/// ``` +``` + +Or use the implicit wrapper (Rust 2021+): + +```rust +/// # Examples +/// +/// ``` +/// # use my_crate::parse_config; +/// let value = parse_config("key=value")?; +/// assert_eq!(value.key, "value"); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +``` + +## When to Use `.unwrap()` + +There are specific cases where `.unwrap()` is acceptable in examples: + +```rust +/// # Examples +/// +/// ``` +/// // Static regex that is known at compile time to be valid +/// let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); +/// +/// // Parsing a literal that cannot fail +/// let n: i32 = "42".parse().unwrap(); +/// ``` +``` + +But still prefer `?` when demonstrating error handling patterns. + +## Comparison + +| Pattern | Behavior on Error | Teaches | +|---------|-------------------|---------| +| `.unwrap()` | Panics with generic message | Bad habits | +| `.expect()` | Panics with custom message | Slightly better | +| `?` | Propagates error, test fails | Best practices | + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Writing examples +- [doc-hidden-setup](./doc-hidden-setup.md) - Hiding setup code +- [err-question-mark](./err-question-mark.md) - Error propagation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-safety-section.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-safety-section.md new file mode 100644 index 00000000..52206f10 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/doc-safety-section.md @@ -0,0 +1,131 @@ +# doc-safety-section + +> Include `# Safety` section for unsafe functions + +## Why It Matters + +Unsafe functions require callers to uphold invariants that the compiler cannot verify. The `# Safety` section documents exactly what the caller must guarantee for the function to be sound. Without this, users cannot safely call the function. + +This is not optional—it's a requirement for sound unsafe code. + +## Bad + +```rust +/// Reads a value from a raw pointer. +pub unsafe fn read_ptr(ptr: *const T) -> T { + // What guarantees must the caller provide? Unknown! + ptr.read() +} + +/// Creates a string from raw parts. +pub unsafe fn string_from_raw(ptr: *mut u8, len: usize, cap: usize) -> String { + String::from_raw_parts(ptr, len, cap) +} +``` + +## Good + +```rust +/// Reads a value from a raw pointer. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `ptr` is valid for reads of `size_of::()` bytes +/// - `ptr` is properly aligned for type `T` +/// - `ptr` points to a properly initialized value of type `T` +/// - The memory referenced by `ptr` is not mutated during this call +pub unsafe fn read_ptr(ptr: *const T) -> T { + ptr.read() +} + +/// Creates a `String` from raw parts. +/// +/// # Safety +/// +/// The caller must guarantee that: +/// - `ptr` was allocated by the same allocator that `String` uses +/// - `len` is less than or equal to `cap` +/// - The first `len` bytes at `ptr` are valid UTF-8 +/// - `cap` is the capacity that `ptr` was allocated with +/// - No other code will use `ptr` after this call (ownership is transferred) +/// +/// Violating these requirements leads to undefined behavior including +/// memory corruption, use-after-free, or invalid UTF-8 in strings. +pub unsafe fn string_from_raw(ptr: *mut u8, len: usize, cap: usize) -> String { + String::from_raw_parts(ptr, len, cap) +} +``` + +## Key Elements of Safety Documentation + +| Element | Description | +|---------|-------------| +| **Preconditions** | What must be true before calling | +| **Pointer validity** | Alignment, null-ness, lifetime | +| **Memory ownership** | Who owns what, transfer semantics | +| **Invariants** | Type invariants that must hold | +| **Consequences** | What happens if violated | + +## Pattern: Unsafe Trait Implementations + +```rust +/// A type that can be safely zeroed. +/// +/// # Safety +/// +/// Implementing this trait guarantees that: +/// - All bit patterns of zeros represent a valid value of this type +/// - The type has no padding bytes that could leak data +/// - The type contains no references or pointers +pub unsafe trait Zeroable { + fn zeroed() -> Self; +} + +// SAFETY: u32 is a primitive integer type where all zero bits +// represent a valid value (0). +unsafe impl Zeroable for u32 { + fn zeroed() -> Self { + 0 + } +} +``` + +## Pattern: Unsafe Blocks in Safe Functions + +When a safe function contains unsafe blocks, document the invariants: + +```rust +/// Returns a reference to the element at the given index. +/// +/// Returns `None` if the index is out of bounds. +pub fn get(&self, index: usize) -> Option<&T> { + if index < self.len { + // SAFETY: We just verified that index < len, so this + // access is within bounds. + Some(unsafe { self.data.get_unchecked(index) }) + } else { + None + } +} +``` + +## Common Safety Requirements + +```rust +/// # Safety +/// +/// - Pointer must be non-null +/// - Pointer must be aligned to `align_of::()` +/// - Pointer must be valid for reads/writes of `size_of::()` bytes +/// - Pointer must point to an initialized value of `T` +/// - The referenced memory must not be accessed through any other pointer +/// for the duration of the returned reference +/// - The total size must not exceed `isize::MAX` +``` + +## See Also + +- [doc-panics-section](./doc-panics-section.md) - Documenting panics +- [lint-unsafe-doc](./lint-unsafe-doc.md) - Enforcing unsafe documentation +- [doc-errors-section](./doc-errors-section.md) - Documenting errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-anyhow-app.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-anyhow-app.md new file mode 100644 index 00000000..ecf0e517 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-anyhow-app.md @@ -0,0 +1,179 @@ +# err-anyhow-app + +> Use `anyhow` for application error handling + +## Why It Matters + +Applications often don't need typed errors - they just need to report what went wrong with good context. `anyhow` provides easy error handling with context chaining, backtraces, and conversion from any error type. + +## Bad + +```rust +// Tedious type management +fn load_config() -> Result> { + let path = find_config()?; // Returns FindError + let content = std::fs::read_to_string(&path)?; // Returns io::Error + let config: Config = toml::from_str(&content)?; // Returns toml::Error + validate(&config)?; // Returns ValidationError + Ok(config) +} + +// No context - hard to debug +fn process() -> Result<(), Box> { + let data = fetch()?; // Which fetch failed? + transform(data)?; // What was being transformed? + save()?; // Where was it saving to? + Ok(()) +} +``` + +## Good + +```rust +use anyhow::{Context, Result}; + +fn load_config() -> Result { + let path = find_config() + .context("failed to locate config file")?; + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read config from {}", path.display()))?; + + let config: Config = toml::from_str(&content) + .context("failed to parse config as TOML")?; + + validate(&config) + .context("config validation failed")?; + + Ok(config) +} + +// Error message: "config validation failed: field 'port' must be > 0" +// Full chain preserved for debugging +``` + +## Key Features + +```rust +use anyhow::{anyhow, bail, ensure, Context, Result}; + +fn example() -> Result<()> { + // Create ad-hoc errors + let err = anyhow!("something went wrong"); + + // Early return with error + bail!("aborting due to {}", reason); + + // Assert with error + ensure!(condition, "condition was false"); + + // Add context to any error + risky_operation() + .context("risky operation failed")?; + + // Dynamic context + fetch(url) + .with_context(|| format!("failed to fetch {}", url))?; + + Ok(()) +} +``` + +## Main Function Pattern + +```rust +use anyhow::Result; + +fn main() -> Result<()> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} + +// Or with custom exit handling +fn main() { + if let Err(e) = run() { + eprintln!("Error: {:#}", e); // Pretty-print with causes + std::process::exit(1); + } +} + +fn run() -> Result<()> { + // Application logic + Ok(()) +} +``` + +## Error Display Formats + +```rust +use anyhow::Result; + +fn show_error(err: anyhow::Error) { + // Just the top-level message + println!("{}", err); + // "config validation failed" + + // With cause chain (# alternate format) + println!("{:#}", err); + // "config validation failed: field 'port' must be > 0" + + // Debug format with backtrace + println!("{:?}", err); + // Full backtrace if RUST_BACKTRACE=1 + + // Iterate through cause chain + for cause in err.chain() { + println!("Caused by: {}", cause); + } +} +``` + +## Combining with thiserror + +```rust +// In your library crate - typed errors +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ApiError { + #[error("rate limited")] + RateLimited, + #[error("not found: {0}")] + NotFound(String), +} + +// In your application - anyhow for handling +use anyhow::{Context, Result}; + +fn fetch_user(id: u64) -> Result { + api::get_user(id) + .with_context(|| format!("failed to fetch user {}", id)) +} + +// Can still downcast if needed +fn handle_error(err: anyhow::Error) { + if let Some(api_err) = err.downcast_ref::() { + match api_err { + ApiError::RateLimited => wait_and_retry(), + ApiError::NotFound(id) => log_missing(id), + } + } +} +``` + +## When to Use Which + +| Situation | Use | +|-----------|-----| +| Library public API | `thiserror` | +| Application code | `anyhow` | +| CLI tools | `anyhow` | +| Internal library code | Either | +| Need to match error variants | `thiserror` | +| Just need to report errors | `anyhow` | + +## See Also + +- [err-thiserror-lib](err-thiserror-lib.md) - Use thiserror for libraries +- [err-context-chain](err-context-chain.md) - Add context to errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-context-chain.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-context-chain.md new file mode 100644 index 00000000..066bea79 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-context-chain.md @@ -0,0 +1,144 @@ +# err-context-chain + +> Add context with `.context()` or `.with_context()` + +## Why It Matters + +Raw errors often lack information about what operation failed. Adding context creates an error chain that tells the full story: what you were trying to do, and why it failed. + +## Bad + +```rust +// Raw error - no context +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + let content = std::fs::read_to_string(&path)?; + Ok(serde_json::from_str(&content)?) +} + +// Error message: "No such file or directory (os error 2)" +// Which file? What were we doing? +``` + +## Good + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read user file: {}", path))?; + + let user: User = serde_json::from_str(&content) + .with_context(|| format!("failed to parse user {} JSON", id))?; + + Ok(user) +} + +// Error: "failed to parse user 42 JSON" +// Caused by: "expected ':' at line 5 column 12" +``` + +## context() vs with_context() + +```rust +// context() - static string (slight allocation) +fs::read_to_string(path) + .context("failed to read config")?; + +// with_context() - lazy evaluation (only allocates on error) +fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path))?; + +// Use with_context() when: +// - Message includes runtime data (format!) +// - Computing the message is expensive +// - Error path is cold (most of the time) +``` + +## Building Context Chains + +```rust +fn process_order(order_id: u64) -> Result<()> { + let order = fetch_order(order_id) + .with_context(|| format!("failed to fetch order {}", order_id))?; + + let user = load_user(order.user_id) + .with_context(|| format!("failed to load user for order {}", order_id))?; + + let payment = process_payment(&order, &user) + .context("payment processing failed")?; + + ship_order(&order, &payment) + .context("shipping failed")?; + + Ok(()) +} + +// Full error chain: +// "shipping failed" +// Caused by: "carrier API returned 503" +// Caused by: "connection refused" +``` + +## Displaying Error Chains + +```rust +fn main() { + if let Err(e) = run() { + // Just top-level message + eprintln!("Error: {}", e); + + // Full chain with alternate format + eprintln!("Error: {:#}", e); + + // Debug format (includes backtrace if enabled) + eprintln!("Error: {:?}", e); + + // Iterate through chain + for (i, cause) in e.chain().enumerate() { + eprintln!(" {}: {}", i, cause); + } + } +} +``` + +## With thiserror + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("failed to load config from {path}")] + ConfigLoad { + path: String, + #[source] + cause: std::io::Error, + }, + + #[error("failed to connect to database")] + Database { + #[source] + cause: sqlx::Error, + }, +} + +// Usage +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| AppError::ConfigLoad { + path: path.to_string(), + cause: e, + })?; + // ... +} +``` + +## See Also + +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications +- [err-source-chain](err-source-chain.md) - Use #[source] to chain errors +- [err-question-mark](err-question-mark.md) - Use ? for propagation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-custom-type.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-custom-type.md new file mode 100644 index 00000000..7497c92a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-custom-type.md @@ -0,0 +1,152 @@ +# err-custom-type + +> Define custom error types for domain-specific failures + +## Why It Matters + +Generic errors like `String`, `Box`, or catch-all enums obscure what can actually go wrong. Custom error types document failure modes in the type system, enable pattern matching for specific handling, and provide clear API contracts. They make your code self-documenting and help callers handle errors appropriately. + +## Bad + +```rust +// Generic string errors - no structure +fn validate_user(user: &User) -> Result<(), String> { + if user.name.is_empty() { + return Err("Name is empty".to_string()); + } + if user.age > 150 { + return Err("Age is invalid".to_string()); + } + Ok(()) +} + +// Caller can't match on specific errors +match validate_user(&user) { + Ok(()) => save(user), + Err(msg) => { + // Can only do string comparison - fragile! + if msg.contains("Name") { + prompt_for_name() + } + } +} +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ValidationError { + #[error("name cannot be empty")] + EmptyName, + + #[error("name exceeds maximum length of {max} characters")] + NameTooLong { max: usize, actual: usize }, + + #[error("invalid age {0}: must be between 0 and 150")] + InvalidAge(u8), + + #[error("email format is invalid: {0}")] + InvalidEmail(String), +} + +fn validate_user(user: &User) -> Result<(), ValidationError> { + if user.name.is_empty() { + return Err(ValidationError::EmptyName); + } + if user.name.len() > 100 { + return Err(ValidationError::NameTooLong { + max: 100, + actual: user.name.len() + }); + } + if user.age > 150 { + return Err(ValidationError::InvalidAge(user.age)); + } + Ok(()) +} + +// Caller can match specifically +match validate_user(&user) { + Ok(()) => save(user), + Err(ValidationError::EmptyName) => prompt_for_name(), + Err(ValidationError::InvalidAge(age)) => { + show_error(&format!("Please enter a valid age (you entered {})", age)) + } + Err(e) => show_error(&e.to_string()), +} +``` + +## Error Type Design Guidelines + +```rust +// 1. Group related errors in domain-specific enums +#[derive(Error, Debug)] +pub enum AuthError { + #[error("invalid credentials")] + InvalidCredentials, + #[error("account locked after {attempts} failed attempts")] + AccountLocked { attempts: u32 }, + #[error("token expired")] + TokenExpired, +} + +#[derive(Error, Debug)] +pub enum PaymentError { + #[error("insufficient funds: need {required}, have {available}")] + InsufficientFunds { required: Decimal, available: Decimal }, + #[error("card declined: {reason}")] + CardDeclined { reason: String }, +} + +// 2. Include relevant data for error handling/display +#[derive(Error, Debug)] +pub enum FileError { + #[error("file not found: {path}")] + NotFound { path: PathBuf }, + #[error("permission denied for {path}")] + PermissionDenied { path: PathBuf }, +} + +// 3. Consider #[non_exhaustive] for public APIs +#[derive(Error, Debug)] +#[non_exhaustive] // Allows adding variants without breaking changes +pub enum ApiError { + #[error("rate limited")] + RateLimited, + #[error("not found")] + NotFound, +} +``` + +## When to Use What + +| Error Pattern | Use Case | +|---------------|----------| +| Custom enum | Library with specific failure modes | +| `thiserror` | Libraries needing `std::error::Error` | +| `anyhow::Error` | Applications, prototypes | +| Struct with source | Single error type with wrapped cause | + +## Struct-Based Errors + +For single error types with rich context: + +```rust +#[derive(Error, Debug)] +#[error("query failed for table '{table}' with filter '{filter}'")] +pub struct QueryError { + pub table: String, + pub filter: String, + #[source] + pub source: DatabaseError, +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - thiserror for error definitions +- [err-anyhow-app](./err-anyhow-app.md) - When to use anyhow instead +- [api-non-exhaustive](./api-non-exhaustive.md) - Forward-compatible enums diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-doc-errors.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-doc-errors.md new file mode 100644 index 00000000..c20e616f --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-doc-errors.md @@ -0,0 +1,145 @@ +# err-doc-errors + +> Document error conditions with `# Errors` section in doc comments + +## Why It Matters + +Users of your API need to know what can go wrong and why. The `# Errors` documentation section is the standard Rust convention for describing when a function returns `Err`. Good error documentation helps callers handle errors appropriately and understand the contract of your API. + +## Bad + +```rust +/// Loads a configuration from the specified path. +pub fn load_config(path: &Path) -> Result { + // No documentation of error conditions + // Caller must read source code to understand what can fail +} + +/// Parses and validates the input string. +/// +/// Returns the parsed value. // What about errors? +pub fn parse_input(input: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Loads a configuration from the specified path. +/// +/// # Errors +/// +/// Returns an error if: +/// - The file at `path` does not exist or cannot be read +/// - The file contents are not valid TOML +/// - Required configuration keys are missing +/// - Configuration values are out of valid ranges +/// +/// # Examples +/// +/// ``` +/// # use mylib::{load_config, ConfigError}; +/// # fn main() -> Result<(), ConfigError> { +/// let config = load_config("app.toml")?; +/// # Ok(()) +/// # } +/// ``` +pub fn load_config(path: &Path) -> Result { + // ... +} + +/// Parses and validates the input string as a positive integer. +/// +/// # Errors +/// +/// Returns [`ParseError::Empty`] if the input is empty. +/// Returns [`ParseError::InvalidFormat`] if the input contains non-digit characters. +/// Returns [`ParseError::Overflow`] if the value exceeds `i64::MAX`. +/// Returns [`ParseError::NotPositive`] if the value is zero or negative. +pub fn parse_positive_int(input: &str) -> Result { + // ... +} +``` + +## Linking to Error Variants + +```rust +/// Attempts to connect to the database. +/// +/// # Errors +/// +/// This function will return an error if: +/// +/// - [`DbError::ConnectionFailed`] - The database server is unreachable +/// - [`DbError::AuthenticationFailed`] - Invalid credentials +/// - [`DbError::Timeout`] - Connection attempt exceeded timeout +/// - [`DbError::TlsError`] - TLS handshake failed +/// +/// See [`DbError`] for more details on each variant. +pub fn connect(config: &DbConfig) -> Result { + // ... +} +``` + +## Panic vs Error Documentation + +```rust +/// Divides two numbers. +/// +/// # Errors +/// +/// Returns [`MathError::DivisionByZero`] if `divisor` is zero. +/// +/// # Panics +/// +/// Panics if called from a non-main thread (debug builds only). +pub fn divide(dividend: i64, divisor: i64) -> Result { + // ... +} +``` + +## Error Section Format Options + +```rust +// Style 1: Bullet list (good for multiple conditions) +/// # Errors +/// +/// Returns an error if: +/// - The file does not exist +/// - The file cannot be read +/// - The content is invalid UTF-8 + +// Style 2: Returns statements (good for mapping to variants) +/// # Errors +/// +/// Returns [`Error::NotFound`] if the item doesn't exist. +/// Returns [`Error::PermissionDenied`] if access is forbidden. + +// Style 3: Prose (good for complex conditions) +/// # Errors +/// +/// This function returns an error when the input fails validation. +/// Validation includes checking that all required fields are present, +/// that numeric fields are within allowed ranges, and that string +/// fields match their expected formats. +``` + +## Clippy Lint + +```toml +# Cargo.toml - require error documentation +[lints.clippy] +missing_errors_doc = "warn" +``` + +```rust +// This will warn without # Errors section +pub fn might_fail() -> Result<(), Error> { Ok(()) } +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Examples in documentation +- [err-thiserror-lib](./err-thiserror-lib.md) - Defining error types +- [api-must-use](./api-must-use.md) - Marking Results as must_use diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-expect-bugs-only.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-expect-bugs-only.md new file mode 100644 index 00000000..5db4e18a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-expect-bugs-only.md @@ -0,0 +1,133 @@ +# err-expect-bugs-only + +> Use `expect()` only for invariants that indicate bugs, not user errors + +## Why It Matters + +`expect()` is better than `unwrap()` because it provides context, but it still panics. Reserve it for situations where failure indicates a bug in your code—a violated invariant, not a user error or external failure. The message should explain why the invariant should hold, helping future developers understand and fix the bug. + +## Bad + +```rust +// User input can legitimately fail - don't expect +fn parse_user_input(input: &str) -> Config { + serde_json::from_str(input) + .expect("Invalid JSON") // User error, not a bug! +} + +// Network can fail - don't expect +fn fetch_data(url: &str) -> Data { + reqwest::get(url) + .expect("Network request failed") // External failure! + .json() + .expect("Invalid response") +} + +// File might not exist - don't expect +fn load_config() -> Config { + let content = fs::read_to_string("config.json") + .expect("Config file missing"); // Environment issue! +} +``` + +## Good + +```rust +// Invariant: after insert, key exists +fn cache_and_get(&mut self, key: String, value: Value) -> &Value { + self.cache.insert(key.clone(), value); + self.cache.get(&key) + .expect("BUG: key must exist immediately after insert") +} + +// Invariant: regex is compile-time constant +fn create_parser() -> Regex { + Regex::new(r"^\d{4}-\d{2}-\d{2}$") + .expect("BUG: date regex is invalid - this is a compile-time constant") +} + +// Invariant: already validated +fn process_validated(data: ValidatedData) -> Result { + let value = data.required_field + .expect("BUG: ValidatedData guarantees required_field is Some"); + // ... +} + +// Invariant: type system guarantees +fn get_first(vec: Vec) -> T +where + Vec: NonEmpty, // Hypothetical trait +{ + vec.into_iter().next() + .expect("BUG: NonEmpty Vec cannot be empty") +} +``` + +## expect() Message Guidelines + +Messages should: +1. Start with "BUG:" or similar to indicate it's an invariant +2. Explain WHY the invariant should hold +3. Help developers fix the issue + +```rust +// ❌ Bad messages +.expect("failed") // No context +.expect("should not be None") // Doesn't explain why +.expect("Invalid state") // Vague + +// ✅ Good messages +.expect("BUG: HashMap entry exists after insert") +.expect("BUG: validated input must parse - validation is broken") +.expect("BUG: static regex compilation failed - regex syntax error in source") +``` + +## Pattern: Validate Once, expect() After + +```rust +struct ValidatedEmail(String); + +impl ValidatedEmail { + pub fn new(email: &str) -> Result { + // Validation happens here, returns Result + if !is_valid_email(email) { + return Err(EmailError::Invalid); + } + Ok(ValidatedEmail(email.to_string())) + } + + pub fn domain(&self) -> &str { + // After validation, expect() is fine + self.0.split('@').nth(1) + .expect("BUG: ValidatedEmail must contain @") + } +} +``` + +## Alternatives When expect() Is Wrong + +```rust +// Don't: expect on user data +let port: u16 = input.parse().expect("Invalid port"); + +// Do: Return Result +let port: u16 = input.parse().map_err(|_| ConfigError::InvalidPort)?; + +// Do: Provide default +let port: u16 = input.parse().unwrap_or(8080); + +// Do: Handle explicitly +let port: u16 = match input.parse() { + Ok(p) => p, + Err(_) => { + log::warn!("Invalid port '{}', using default", input); + 8080 + } +}; +``` + +## See Also + +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoiding unwrap in production +- [err-result-over-panic](./err-result-over-panic.md) - When to return Result +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven validation diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-from-impl.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-from-impl.md new file mode 100644 index 00000000..4b2b6220 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-from-impl.md @@ -0,0 +1,152 @@ +# err-from-impl + +> Implement `From` for error conversions to enable `?` operator + +## Why It Matters + +The `?` operator automatically converts errors using `From` trait. By implementing `From for YourError`, you enable seamless error propagation without explicit `.map_err()` calls. This makes error handling code cleaner and ensures consistent error wrapping throughout your codebase. + +## Bad + +```rust +#[derive(Debug)] +enum AppError { + Io(std::io::Error), + Parse(serde_json::Error), + Database(diesel::result::Error), +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| AppError::Io(e))?; // Manual conversion everywhere + + let config: Config = serde_json::from_str(&content) + .map_err(|e| AppError::Parse(e))?; // Repeated boilerplate + + save_to_db(&config) + .map_err(|e| AppError::Database(e))?; // Gets tedious + + Ok(config) +} +``` + +## Good + +```rust +#[derive(Debug)] +enum AppError { + Io(std::io::Error), + Parse(serde_json::Error), + Database(diesel::result::Error), +} + +// Implement From for each source error type +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::Io(err) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> Self { + AppError::Parse(err) + } +} + +impl From for AppError { + fn from(err: diesel::result::Error) -> Self { + AppError::Database(err) + } +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // Auto-converts + let config: Config = serde_json::from_str(&content)?; // Clean! + save_to_db(&config)?; + Ok(config) +} +``` + +## Use thiserror for Automatic From + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum AppError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), // Auto-generates From impl + + #[error("Parse error: {0}")] + Parse(#[from] serde_json::Error), // #[from] does the work + + #[error("Database error: {0}")] + Database(#[from] diesel::result::Error), +} + +// Now ? just works +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + let config: Config = serde_json::from_str(&content)?; + save_to_db(&config)?; + Ok(config) +} +``` + +## From with Context + +Sometimes you need to add context during conversion: + +```rust +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config from '{path}': {source}")] + ReadFailed { + path: String, + #[source] + source: std::io::Error, + }, +} + +// Can't use #[from] when you need extra context +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|source| ConfigError::ReadFailed { + path: path.to_string(), + source, + })?; + // ... +} + +// Or use anyhow for ad-hoc context +use anyhow::{Context, Result}; + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config from '{}'", path))?; + // ... +} +``` + +## Blanket From Implementations + +Be careful with blanket implementations: + +```rust +// ❌ Too broad - conflicts with other From impls +impl From for AppError { + fn from(err: E) -> Self { + AppError::Other(err.to_string()) + } +} + +// ✅ Specific implementations +impl From for AppError { ... } +impl From for AppError { ... } +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Using thiserror for libraries +- [err-source-chain](./err-source-chain.md) - Preserving error chains +- [err-question-mark](./err-question-mark.md) - The ? operator diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-lowercase-msg.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-lowercase-msg.md new file mode 100644 index 00000000..fc65667e --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-lowercase-msg.md @@ -0,0 +1,124 @@ +# err-lowercase-msg + +> Start error messages lowercase, no trailing punctuation + +## Why It Matters + +Error messages are often chained, logged, or displayed with additional context. Consistent formatting—lowercase start, no trailing period—allows clean composition: "failed to load config: invalid JSON: unexpected token". Mixed case and punctuation create awkward output: "Failed to load config.: Invalid JSON.: Unexpected token.". + +## Bad + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file.")] // Capital F, trailing period + ReadFailed(#[from] std::io::Error), + + #[error("Invalid JSON format!")] // Capital I, exclamation + ParseFailed(#[from] serde_json::Error), + + #[error("The requested key was not found")] // Reads like a sentence + KeyNotFound(String), +} + +// Chained output: "Config load error: Failed to read config file.: No such file" +// Awkward capitalization and punctuation +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("failed to read config file")] // lowercase, no period + ReadFailed(#[from] std::io::Error), + + #[error("invalid JSON format")] // lowercase, no period + ParseFailed(#[from] serde_json::Error), + + #[error("key not found: {0}")] // lowercase, data at end + KeyNotFound(String), +} + +// Chained output: "config load error: failed to read config file: no such file" +// Clean, consistent +``` + +## Rust Standard Library Convention + +The standard library follows this convention: + +```rust +// std::io::Error messages +"entity not found" +"permission denied" +"connection refused" + +// std::num::ParseIntError +"invalid digit found in string" + +// std::str::Utf8Error +"invalid utf-8 sequence" +``` + +## Formatting Guidelines + +| Do | Don't | +|----|-------| +| `"failed to parse config"` | `"Failed to parse config."` | +| `"invalid input: expected number"` | `"Invalid input - expected a number!"` | +| `"connection timed out after {0}s"` | `"Connection Timed Out After {0} seconds."` | +| `"key '{0}' not found"` | `"Key Not Found: {0}"` | + +## Context Addition Pattern + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let data = fetch(id) + .with_context(|| format!("failed to fetch user {}", id))?; + + parse_user(data) + .with_context(|| "failed to parse user data")? +} + +// Output: "failed to fetch user 42: connection refused" +// All lowercase, clean chain +``` + +## Display vs Debug + +```rust +#[derive(Error, Debug)] +#[error("invalid configuration")] // Display: for users/logs +pub struct ConfigError { + path: PathBuf, + source: io::Error, +} + +// Debug output (for developers) can have more detail +// Display output (for users) should be clean +``` + +## When to Use Capitals + +```rust +// Proper nouns / acronyms keep their case +#[error("invalid JSON syntax")] // JSON is an acronym +#[error("OAuth token expired")] // OAuth is a proper noun +#[error("HTTP request failed")] // HTTP is an acronym + +// Error codes can be uppercase +#[error("error code E0001: invalid input")] +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Error definition with thiserror +- [err-context-chain](./err-context-chain.md) - Adding context to errors +- [doc-examples-section](./doc-examples-section.md) - Documentation conventions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md new file mode 100644 index 00000000..ddd7f0d4 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-no-unwrap-prod.md @@ -0,0 +1,115 @@ +# err-no-unwrap-prod + +> Avoid `unwrap()` in production code; use `?`, `expect()`, or handle errors + +## Why It Matters + +`unwrap()` panics on `None` or `Err` without any context about what went wrong. In production, this creates cryptic crash messages that are hard to debug. Either propagate errors with `?`, use `expect()` with a message explaining the invariant, or handle the error explicitly. + +## Bad + +```rust +fn process_request(req: Request) -> Response { + let user_id = req.headers.get("X-User-Id").unwrap(); // Why did it fail? + let user = database.find_user(user_id).unwrap(); // Which operation? + let data = user.preferences.get("theme").unwrap(); // No context + + Response::new(data) +} + +// Crash message: "called `Option::unwrap()` on a `None` value" +// Where? Why? No idea. +``` + +## Good + +```rust +// Option 1: Propagate with ? +fn process_request(req: Request) -> Result { + let user_id = req.headers + .get("X-User-Id") + .ok_or(AppError::MissingHeader("X-User-Id"))?; + + let user = database.find_user(user_id)?; + + let data = user.preferences + .get("theme") + .ok_or(AppError::MissingPreference("theme"))?; + + Ok(Response::new(data)) +} + +// Option 2: expect() for invariants (not user input) +fn get_config_value(&self, key: &str) -> &str { + self.config + .get(key) + .expect("BUG: required config key missing after validation") +} + +// Option 3: Provide defaults +fn get_theme(user: &User) -> &str { + user.preferences + .get("theme") + .unwrap_or(&"default") +} + +// Option 4: Match for complex handling +fn process_optional(value: Option) -> ProcessedData { + match value { + Some(data) => process(data), + None => { + log::warn!("No data provided, using fallback"); + ProcessedData::default() + } + } +} +``` + +## `expect()` vs `unwrap()` + +```rust +// Bad: no context +let port = config.get("port").unwrap(); + +// Better: explains the invariant +let port = config.get("port") + .expect("config must contain 'port' after validation"); + +// Best: propagate if it's not truly an invariant +let port = config.get("port") + .ok_or_else(|| ConfigError::MissingKey("port"))?; +``` + +## Alternatives to unwrap() + +| Situation | Use Instead | +|-----------|-------------| +| Can propagate error | `?` operator | +| Has sensible default | `unwrap_or()`, `unwrap_or_default()` | +| Default requires computation | `unwrap_or_else(\|\| ...)` | +| Internal invariant | `expect("explanation")` | +| Need to handle both cases | `match` or `if let` | + +## Clippy Lints + +```toml +# Cargo.toml +[lints.clippy] +unwrap_used = "warn" # Warn on unwrap() +expect_used = "warn" # Also warn on expect() (stricter) +``` + +```rust +// Allow in specific places where it's justified +#[allow(clippy::unwrap_used)] +fn definitely_safe() { + // Unwrap is safe here because... + let x = Some(5).unwrap(); +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Return Result instead of panicking +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When expect() is appropriate +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Patterns for avoiding unwrap diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-question-mark.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-question-mark.md new file mode 100644 index 00000000..34730a8b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-question-mark.md @@ -0,0 +1,151 @@ +# err-question-mark + +> Use `?` operator for clean propagation + +## Why It Matters + +The `?` operator is Rust's idiomatic way to propagate errors. It's concise, readable, and automatically converts between compatible error types using `From`. It replaces verbose `match` or `unwrap()` calls. + +## Bad + +```rust +// Verbose match-based error handling +fn load_config() -> Result { + let content = match std::fs::read_to_string("config.toml") { + Ok(c) => c, + Err(e) => return Err(Error::Io(e)), + }; + + let config = match toml::from_str(&content) { + Ok(c) => c, + Err(e) => return Err(Error::Parse(e)), + }; + + Ok(config) +} + +// Or worse - using unwrap +fn load_config_bad() -> Config { + let content = std::fs::read_to_string("config.toml").unwrap(); + toml::from_str(&content).unwrap() +} +``` + +## Good + +```rust +fn load_config() -> Result { + let content = std::fs::read_to_string("config.toml")?; + let config = toml::from_str(&content)?; + Ok(config) +} + +// Even more concise +fn load_config() -> Result { + Ok(toml::from_str(&std::fs::read_to_string("config.toml")?)?) +} +``` + +## How ? Works + +```rust +// This: +let x = expr?; + +// Expands roughly to: +let x = match expr { + Ok(val) => val, + Err(err) => return Err(From::from(err)), +}; +``` + +## Combining with Context + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read user file: {}", path))?; + + let user: User = serde_json::from_str(&content) + .context("failed to parse user JSON")?; + + Ok(user) +} +``` + +## ? with Option + +```rust +fn get_first_word(text: &str) -> Option<&str> { + let first_line = text.lines().next()?; + let first_word = first_line.split_whitespace().next()?; + Some(first_word) +} + +// Convert Option to Result +fn get_required_config(key: &str) -> Result { + config.get(key) + .cloned() + .ok_or_else(|| Error::MissingConfig(key.to_string())) +} +``` + +## Error Type Conversion + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum MyError { + #[error("io error")] + Io(#[from] std::io::Error), // Auto From impl + + #[error("parse error")] + Parse(#[from] serde_json::Error), // Auto From impl +} + +fn process() -> Result<(), MyError> { + // ? automatically converts io::Error to MyError via From + let content = std::fs::read_to_string("file.txt")?; + + // ? automatically converts serde_json::Error to MyError + let data: Data = serde_json::from_str(&content)?; + + Ok(()) +} +``` + +## In main() + +```rust +// Option 1: Return Result from main +fn main() -> Result<(), Box> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} + +// Option 2: Handle in main, exit on error +fn main() { + if let Err(e) = run() { + eprintln!("Error: {:#}", e); + std::process::exit(1); + } +} + +fn run() -> anyhow::Result<()> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} +``` + +## See Also + +- [err-context-chain](err-context-chain.md) - Add context with .context() +- [err-from-impl](err-from-impl.md) - Use #[from] for automatic conversion +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-result-over-panic.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-result-over-panic.md new file mode 100644 index 00000000..7247417b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-result-over-panic.md @@ -0,0 +1,130 @@ +# err-result-over-panic + +> Return `Result` instead of panicking for recoverable errors + +## Why It Matters + +Panics unwind the stack and crash the thread (or program). They're unrecoverable from the caller's perspective. `Result` gives callers the ability to decide how to handle errors—retry, fallback, propagate, or log. Libraries should almost never panic; applications should minimize panics to truly unrecoverable situations. + +## Bad + +```rust +fn parse_config(path: &str) -> Config { + let content = std::fs::read_to_string(path) + .expect("Failed to read config"); // Crashes on missing file + + serde_json::from_str(&content) + .expect("Invalid config format") // Crashes on bad JSON +} + +fn divide(a: i32, b: i32) -> i32 { + if b == 0 { + panic!("Division by zero!"); // Crashes the program + } + a / b +} +``` + +Caller has no chance to recover or provide a fallback. + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file: {0}")] + Io(#[from] std::io::Error), + #[error("Invalid config format: {0}")] + Parse(#[from] serde_json::Error), +} + +fn parse_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + let config = serde_json::from_str(&content)?; + Ok(config) +} + +fn divide(a: i32, b: i32) -> Result { + if b == 0 { + return Err("Division by zero"); + } + Ok(a / b) +} + +// Caller decides how to handle +match parse_config("app.json") { + Ok(config) => run_app(config), + Err(e) => { + eprintln!("Using default config: {}", e); + run_app(Config::default()) + } +} +``` + +## When Panic IS Appropriate + +```rust +// 1. Bug in the program (invariant violation) +fn get_cached_value(&self, key: &str) -> &Value { + self.cache.get(key).expect("BUG: key was verified to exist") +} + +// 2. Setup/initialization that can't reasonably fail +fn main() { + let config = Config::load().expect("Failed to load required config"); + // Can't run without config, panic is reasonable +} + +// 3. Tests +#[test] +fn test_parse() { + let result = parse("valid input").unwrap(); // unwrap OK in tests + assert_eq!(result, expected); +} + +// 4. Examples and prototypes +fn main() { + // Quick prototype, panic is fine + let data = fetch_data().unwrap(); +} +``` + +## Panic vs Result Decision Guide + +| Situation | Use | +|-----------|-----| +| File not found | `Result` | +| Network error | `Result` | +| Invalid user input | `Result` | +| Parse error | `Result` | +| Index out of bounds (from user data) | `Result` | +| Index out of bounds (internal bug) | Panic | +| Violated internal invariant | Panic | +| Unimplemented code path | Panic (`unimplemented!()`) | +| Impossible state reached | Panic (`unreachable!()`) | + +## Library vs Application + +```rust +// Library: NEVER panic on user input +pub fn parse(input: &str) -> Result { + // Always return Result +} + +// Application: Can panic at top level for critical failures +fn main() { + if let Err(e) = run() { + eprintln!("Fatal error: {}", e); + std::process::exit(1); + } +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Define error types for libraries +- [err-anyhow-app](./err-anyhow-app.md) - Ergonomic errors for applications +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoid unwrap in production code +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - When unwrap is acceptable diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-source-chain.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-source-chain.md new file mode 100644 index 00000000..252a10a8 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-source-chain.md @@ -0,0 +1,155 @@ +# err-source-chain + +> Preserve error chains with `#[source]` or `source()` method + +## Why It Matters + +Errors often have underlying causes. Preserving the error chain (via `source()` method) allows logging frameworks and error reporters to show the full context: "config parse failed → JSON syntax error at line 5 → unexpected token". Without chaining, you lose valuable debugging information. + +## Bad + +```rust +#[derive(Debug)] +enum ConfigError { + ParseFailed(String), // Lost the original serde_json::Error +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::ParseFailed(e.to_string()))?; // Chain lost! + + serde_json::from_str(&content) + .map_err(|e| ConfigError::ParseFailed(e.to_string()))? // No source +} + +// Error output: "Parse failed: invalid type: ..." +// Missing: which file? what line? what was the parent error? +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file '{path}'")] + ReadFailed { + path: String, + #[source] // Preserves the error chain + source: std::io::Error, + }, + + #[error("Failed to parse config file '{path}'")] + ParseFailed { + path: String, + #[source] // Original parse error preserved + source: serde_json::Error, + }, +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|source| ConfigError::ReadFailed { + path: path.to_string(), + source, // Chain preserved + })?; + + serde_json::from_str(&content) + .map_err(|source| ConfigError::ParseFailed { + path: path.to_string(), + source, + }) +} +``` + +## Manual source() Implementation + +```rust +use std::error::Error; + +#[derive(Debug)] +struct MyError { + message: String, + source: Option>, +} + +impl std::fmt::Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl Error for MyError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_ref().map(|e| e.as_ref() as &(dyn Error + 'static)) + } +} +``` + +## Walking the Error Chain + +```rust +fn print_error_chain(error: &dyn std::error::Error) { + eprintln!("Error: {}", error); + + let mut source = error.source(); + while let Some(err) = source { + eprintln!("Caused by: {}", err); + source = err.source(); + } +} + +// With anyhow, use {:?} for full chain +let result: anyhow::Result<()> = do_something(); +if let Err(e) = result { + eprintln!("{:?}", e); // Prints full chain with backtraces +} +``` + +## anyhow Context + +```rust +use anyhow::{Context, Result}; + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read '{}'", path))?; + + let config: Config = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse '{}'", path))?; + + Ok(config) +} + +// Output: +// Error: Failed to parse 'config.json' +// Caused by: expected `:` at line 5 column 10 +``` + +## #[from] vs #[source] + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum MyError { + // #[from] = implements From + sets source + #[error("IO error")] + Io(#[from] std::io::Error), + + // #[source] = only sets source (no From impl) + #[error("Parse error in file '{path}'")] + Parse { + path: String, + #[source] + source: serde_json::Error, + }, +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - thiserror for error definitions +- [err-context-chain](./err-context-chain.md) - Adding context to errors +- [err-from-impl](./err-from-impl.md) - From implementations for ? diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-thiserror-lib.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-thiserror-lib.md new file mode 100644 index 00000000..8c798bfc --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/err-thiserror-lib.md @@ -0,0 +1,171 @@ +# err-thiserror-lib + +> Use `thiserror` for library error types + +## Why It Matters + +Libraries should expose typed, matchable errors so users can handle specific error conditions. `thiserror` generates `Error` trait implementations with minimal boilerplate, creating ergonomic error types that are easy to match against. + +## Bad + +```rust +// String errors - not matchable +fn parse(input: &str) -> Result { + Err("parse error".to_string()) +} + +// Box - not matchable +fn load(path: &Path) -> Result> { + Err(Box::new(std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"))) +} + +// Manual implementation - verbose +#[derive(Debug)] +enum MyError { + Io(std::io::Error), + Parse(String), +} + +impl std::fmt::Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MyError::Io(e) => write!(f, "io error: {}", e), + MyError::Parse(s) => write!(f, "parse error: {}", s), + } + } +} + +impl std::error::Error for MyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + MyError::Io(e) => Some(e), + MyError::Parse(_) => None, + } + } +} +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ParseError { + #[error("invalid syntax at line {line}: {message}")] + Syntax { line: usize, message: String }, + + #[error("unexpected end of file")] + UnexpectedEof, + + #[error("invalid utf-8 encoding")] + Utf8(#[from] std::str::Utf8Error), + + #[error("io error reading input")] + Io(#[from] std::io::Error), +} + +// Usage +fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(ParseError::UnexpectedEof); + } + // ... +} + +// Users can match specific errors +match parse(input) { + Ok(ast) => process(ast), + Err(ParseError::Syntax { line, message }) => { + eprintln!("Syntax error on line {}: {}", line, message); + } + Err(ParseError::UnexpectedEof) => { + eprintln!("File ended unexpectedly"); + } + Err(e) => eprintln!("Error: {}", e), +} +``` + +## Key Attributes + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MyError { + // Simple message + #[error("operation failed")] + Failed, + + // Interpolated fields + #[error("invalid value: {0}")] + InvalidValue(String), + + // Named fields + #[error("connection to {host}:{port} failed")] + Connection { host: String, port: u16 }, + + // Automatic From impl with #[from] + #[error("database error")] + Database(#[from] sqlx::Error), + + // Source without From (manual conversion needed) + #[error("validation failed")] + Validation { + #[source] + cause: ValidationError, + field: String, + }, + + // Transparent - delegates Display and source to inner + #[error(transparent)] + Other(#[from] anyhow::Error), +} +``` + +## Error Chaining + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("failed to read config file")] + Read(#[source] std::io::Error), + + #[error("failed to parse config")] + Parse(#[source] toml::de::Error), + + #[error("invalid config value for '{key}'")] + InvalidValue { + key: String, + #[source] + cause: ValueError, + }, +} + +// Error chain is preserved +fn load_config(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(ConfigError::Read)?; + + let config: Config = toml::from_str(&content) + .map_err(ConfigError::Parse)?; + + Ok(config) +} +``` + +## Library vs Application + +| Context | Crate | Why | +|---------|-------|-----| +| Library | `thiserror` | Typed errors users can match | +| Application | `anyhow` | Easy error handling with context | +| Both | `thiserror` for public API, `anyhow` internally | Best of both | + +## See Also + +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications +- [err-from-impl](err-from-impl.md) - Use #[from] for automatic conversion +- [err-source-chain](err-source-chain.md) - Use #[source] to chain errors diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-cargo-metadata.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-cargo-metadata.md new file mode 100644 index 00000000..8e5b16aa --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-cargo-metadata.md @@ -0,0 +1,138 @@ +# lint-cargo-metadata + +> Enable clippy::cargo for published crates + +## Why It Matters + +The `clippy::cargo` lint group checks Cargo.toml for issues that affect publishing and dependency management. For crates intended for crates.io, these checks help ensure a professional, well-configured package. + +## Configuration + +```toml +# Cargo.toml +[lints.clippy] +cargo = "warn" +``` + +Or in code: + +```rust +#![warn(clippy::cargo)] +``` + +## What It Catches + +### Missing Metadata + +```toml +# WARN: missing package.description +# WARN: missing package.license or package.license-file +# WARN: missing package.repository +[package] +name = "my-crate" +version = "0.1.0" +``` + +### Dependency Issues + +```toml +# WARN: feature used but not defined +# WARN: dependency version not specified +[dependencies] +serde = "*" # Bad: any version +tokio = { git = "..." } # WARN for published crates +``` + +### Feature Issues + +```toml +# WARN: negative_feature_names +[features] +no-std = [] # Should be: std = [] (opt-out vs opt-in) + +# WARN: redundant_feature_names +[features] +default = ["feature-a"] +feature-a = [] # Feature name matches crate name +``` + +## Notable Lints + +| Lint | Issue | +|------|-------| +| `cargo_common_metadata` | Missing description/license/repository | +| `multiple_crate_versions` | Same crate at different versions | +| `negative_feature_names` | Features like `no-std` instead of `std` | +| `redundant_feature_names` | Feature same as crate name | +| `wildcard_dependencies` | Using `*` for version | + +## Complete Cargo.toml + +```toml +[package] +name = "my-crate" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +# Required for cargo lint satisfaction +description = "A short description of what this crate does" +license = "MIT OR Apache-2.0" +repository = "https://github.com/user/my-crate" + +# Recommended +documentation = "https://docs.rs/my-crate" +readme = "README.md" +keywords = ["keyword1", "keyword2"] +categories = ["category-slug"] + +[dependencies] +# Specific versions, not wildcards +serde = "1.0" +tokio = { version = "1.0", features = ["full"] } + +[features] +default = ["std"] +std = [] # Opt-out, not no-std opt-in + +[lints.clippy] +cargo = "warn" +``` + +## Multiple Crate Versions + +``` +# WARN: multiple versions of `syn` in dependency tree +# syn v1.0.109 +# syn v2.0.48 +``` + +Fix by updating dependencies or using `[patch]`: + +```toml +[patch.crates-io] +old-dep = { git = "...", branch = "syn-2" } +``` + +## When to Disable + +For internal/unpublished crates: + +```toml +[lints.clippy] +cargo = "allow" # Not publishing, metadata not needed +``` + +Or selectively: + +```toml +[lints.clippy] +cargo = "warn" +multiple_crate_versions = "allow" # Acceptable in this project +``` + +## See Also + +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Cargo.toml metadata +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace dependencies +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-deny-correctness.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-deny-correctness.md new file mode 100644 index 00000000..bf6074d9 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-deny-correctness.md @@ -0,0 +1,107 @@ +# lint-deny-correctness + +> `#![deny(clippy::correctness)]` + +## Why It Matters + +Clippy's correctness lints catch code that is outright wrong - logic errors, undefined behavior, or code that doesn't do what you think. These should always be errors, not warnings. + +## Setup + +```rust +// At the top of lib.rs or main.rs +#![deny(clippy::correctness)] + +// Or in Cargo.toml for workspace-wide +[lints.clippy] +correctness = "deny" +``` + +## What It Catches + +```rust +// Infinite loop (iter::repeat without take) +for x in std::iter::repeat(1) { // ERROR: infinite iterator + println!("{}", x); +} + +// Comparison to NaN (always false) +if x == f64::NAN { // ERROR: NaN != NaN always + // This never executes +} + +// Use after free patterns +let r; +{ + let x = 5; + r = &x; // ERROR: x dropped here +} +println!("{}", r); + +// Wrong equality check +if x = 5 { // ERROR: assignment in condition (should be ==) +} + +// Useless comparisons +if x >= 0 && x < 0 { // ERROR: impossible condition +} +``` + +## Important Correctness Lints + +```rust +// approx_constant - using imprecise PI, E values +let pi = 3.14; // Use std::f64::consts::PI + +// invalid_regex - regex that won't compile +let re = Regex::new("["); // Invalid regex + +// iter_next_loop - using .next() in for loop incorrectly +for x in iter.next() { // Should be: for x in iter + +// never_loop - loop that never actually loops +loop { + break; // Always breaks immediately +} + +// nonsensical_open_options - impossible file options +File::options().read(false).write(false).open("f"); + +// unit_cmp - comparing unit type () +if foo() == bar() { } // Both return (), always true +``` + +## Full Recommended Lints + +```rust +#![deny(clippy::correctness)] +#![warn(clippy::suspicious)] +#![warn(clippy::style)] +#![warn(clippy::complexity)] +#![warn(clippy::perf)] + +// For published crates +#![warn(missing_docs)] +#![warn(clippy::cargo)] +``` + +## Running Clippy + +```bash +# Basic check +cargo clippy + +# With all warnings as errors +cargo clippy -- -D warnings + +# Check specific lint category +cargo clippy -- -W clippy::correctness + +# In CI (fail on warnings) +cargo clippy -- -D warnings -D clippy::correctness +``` + +## See Also + +- [lint-warn-suspicious](lint-warn-suspicious.md) - Warn on suspicious code +- [lint-warn-perf](lint-warn-perf.md) - Warn on performance issues diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-missing-docs.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-missing-docs.md new file mode 100644 index 00000000..bc068643 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-missing-docs.md @@ -0,0 +1,154 @@ +# lint-missing-docs + +> Warn on missing documentation for public items + +## Why It Matters + +The `missing_docs` lint ensures all public API items are documented. For libraries, documentation IS the user interface. Missing docs mean users can't understand your API without reading source code. + +## Configuration + +```rust +// In lib.rs +#![warn(missing_docs)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.rust] +missing_docs = "warn" +``` + +For strict enforcement: + +```rust +#![deny(missing_docs)] +``` + +## What It Catches + +```rust +#![warn(missing_docs)] + +pub struct User { // WARN: missing documentation for a struct + pub name: String, // WARN: missing documentation for a field + pub age: u32, // WARN: missing documentation for a field +} + +pub fn process() { } // WARN: missing documentation for a function + +pub trait Handler { // WARN: missing documentation for a trait + fn handle(&self); // WARN: missing documentation for a method +} +``` + +## Good + +```rust +#![warn(missing_docs)] + +//! User management module. + +/// Represents a registered user in the system. +pub struct User { + /// The user's display name. + pub name: String, + /// The user's age in years. + pub age: u32, +} + +/// Processes pending user requests. +/// +/// # Examples +/// +/// ``` +/// process(); +/// ``` +pub fn process() { } + +/// Handler trait for request processing. +pub trait Handler { + /// Handle an incoming request. + fn handle(&self); +} +``` + +## Private Items + +`missing_docs` only applies to `pub` items. Private items don't trigger warnings: + +```rust +#![warn(missing_docs)] + +struct Internal { } // No warning - private + +pub struct Public { } // WARN - public, needs docs +``` + +## Allow for Specific Items + +```rust +#![warn(missing_docs)] + +/// Documented module. +pub mod api { + /// Documented struct. + pub struct Config { } + + #[allow(missing_docs)] + pub mod internal { + // Internal API, docs not required + pub struct Helper { } + } +} +``` + +## Gradual Adoption + +For existing codebases, start with `warn` and fix incrementally: + +```rust +// Phase 1: Warn, fix critical items +#![warn(missing_docs)] + +// Phase 2: After cleanup, deny +#![deny(missing_docs)] +``` + +## Combining with doc Attributes + +```rust +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![warn(rustdoc::private_intra_doc_links)] +``` + +## Workspace Configuration + +```toml +# In workspace Cargo.toml +[workspace.lints.rust] +missing_docs = "warn" + +# Member crates inherit +[lints] +workspace = true +``` + +## What to Document + +| Item | Doc Focus | +|------|-----------| +| Structs | Purpose, usage example | +| Struct fields | What it represents | +| Enums | When to use each variant | +| Functions | What it does, params, return | +| Traits | Contract and expectations | +| Modules | What the module provides | + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documentation patterns +- [lint-unsafe-doc](./lint-unsafe-doc.md) - Unsafe documentation +- [doc-examples-section](./doc-examples-section.md) - Adding examples diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-pedantic-selective.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-pedantic-selective.md new file mode 100644 index 00000000..896e9fcf --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-pedantic-selective.md @@ -0,0 +1,118 @@ +# lint-pedantic-selective + +> Enable clippy::pedantic selectively + +## Why It Matters + +The `clippy::pedantic` group contains opinionated lints that aren't universally applicable. Enabling it wholesale produces noise; selectively enabling useful pedantic lints improves code quality without false positives. + +## Bad + +```rust +// Too noisy - will fight you constantly +#![warn(clippy::pedantic)] +``` + +## Good + +```toml +# Cargo.toml - cherry-pick useful pedantic lints +[lints.clippy] +# Enable pedantic as baseline +pedantic = "warn" + +# Disable noisy ones +missing_errors_doc = "allow" # Document errors separately +missing_panics_doc = "allow" # Document panics separately +module_name_repetitions = "allow" # Allow Foo::FooError pattern +too_many_lines = "allow" # Function length varies +must_use_candidate = "allow" # Too many suggestions +``` + +## Recommended Pedantic Lints + +| Lint | Why Enable | +|------|-----------| +| `doc_markdown` | Catch unmarked code in docs | +| `match_wildcard_for_single_variants` | Explicit variant matching | +| `semicolon_if_nothing_returned` | Consistent semicolons | +| `string_add_assign` | Use `+=` for string concatenation | +| `unnested_or_patterns` | Simplify match patterns | +| `unused_self` | Catch methods that should be functions | +| `used_underscore_binding` | Warn on using `_var` | +| `wildcard_imports` | Avoid glob imports | + +## Often Disabled + +| Lint | Why Disable | +|------|-------------| +| `missing_errors_doc` | Handle with `#[doc]` policy | +| `missing_panics_doc` | Handle with `#[doc]` policy | +| `module_name_repetitions` | Sometimes intentional | +| `must_use_candidate` | Too aggressive | +| `too_many_lines` | Arbitrary threshold | +| `struct_excessive_bools` | Valid for config structs | + +## Full Configuration + +```toml +# Cargo.toml +[lints.clippy] +# Start with pedantic +pedantic = "warn" + +# Keep these +doc_markdown = "warn" +match_wildcard_for_single_variants = "warn" +semicolon_if_nothing_returned = "warn" +unused_self = "warn" +wildcard_imports = "warn" + +# Disable these +missing_errors_doc = "allow" +missing_panics_doc = "allow" +module_name_repetitions = "allow" +must_use_candidate = "allow" +too_many_lines = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +``` + +## Alternative: Explicit Opt-in + +```toml +# Only enable specific lints, not the group +[lints.clippy] +# From pedantic, only these: +doc_markdown = "warn" +semicolon_if_nothing_returned = "warn" +unused_self = "warn" +wildcard_imports = "warn" +``` + +## Module-Level Overrides + +```rust +// Allow specific lint for a module +#![allow(clippy::module_name_repetitions)] + +// Or for specific items +#[allow(clippy::too_many_arguments)] +fn complex_function(/* many args */) { } +``` + +## Team Consensus + +Pedantic lints are style choices. Agree as a team: + +1. Enable `pedantic` as baseline +2. Run `cargo clippy` on codebase +3. Discuss each warning category +4. Disable ones that don't fit your style +5. Document decisions in `clippy.toml` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-rustfmt-check.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-rustfmt-check.md new file mode 100644 index 00000000..6c7f9e6a --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-rustfmt-check.md @@ -0,0 +1,157 @@ +# lint-rustfmt-check + +> Run cargo fmt --check in CI + +## Why It Matters + +Consistent formatting eliminates style debates and makes diffs cleaner. Running `cargo fmt --check` in CI ensures all code follows the same format. This catches formatting issues before merge, not after. + +## CI Configuration + +### GitHub Actions + +```yaml +name: CI + +on: [push, pull_request] + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check +``` + +### GitLab CI + +```yaml +fmt: + image: rust:latest + script: + - rustup component add rustfmt + - cargo fmt --all --check +``` + +### Pre-commit Hook + +```bash +#!/bin/sh +# .git/hooks/pre-commit +cargo fmt --all --check +``` + +## Configuration + +Create `rustfmt.toml` for custom settings: + +```toml +# rustfmt.toml +edition = "2021" +max_width = 100 +use_small_heuristics = "Max" +imports_granularity = "Module" +group_imports = "StdExternalCrate" +reorder_imports = true +``` + +## Common Options + +| Option | Default | Description | +|--------|---------|-------------| +| `max_width` | 100 | Maximum line width | +| `tab_spaces` | 4 | Spaces per indent | +| `edition` | "2015" | Rust edition | +| `use_small_heuristics` | "Default" | Layout heuristics | +| `imports_granularity` | "Preserve" | Import grouping | +| `group_imports` | "Preserve" | Import ordering | + +## Running Locally + +```bash +# Check formatting (doesn't modify files) +cargo fmt --all --check + +# Apply formatting +cargo fmt --all + +# Format specific file +cargo fmt -- src/main.rs + +# Check with verbose output +cargo fmt --all --check -- --verbose +``` + +## Workspace Formatting + +```bash +# Format all workspace members +cargo fmt --all + +# Format specific package +cargo fmt -p my-package +``` + +## Ignoring Files + +In `rustfmt.toml`: + +```toml +# Skip generated files +ignore = [ + "src/generated/*", + "build.rs", +] +``` + +Or in code: + +```rust +#[rustfmt::skip] +mod generated_code; + +#[rustfmt::skip] +const MATRIX: [[i32; 4]; 4] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], +]; +``` + +## Nightly Features + +Some options require nightly: + +```toml +# rustfmt.toml (nightly only) +unstable_features = true +imports_granularity = "Crate" +wrap_comments = true +format_code_in_doc_comments = true +``` + +```bash +# Use nightly rustfmt +cargo +nightly fmt +``` + +## IDE Integration + +Most IDEs format on save. Configure to use project `rustfmt.toml`: + +```json +// VS Code settings.json +{ + "rust-analyzer.rustfmt.extraArgs": ["--config-path", "./rustfmt.toml"] +} +``` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style lints +- [lint-pedantic-selective](./lint-pedantic-selective.md) - Pedantic lints +- [name-funcs-snake](./name-funcs-snake.md) - Naming conventions diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-unsafe-doc.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-unsafe-doc.md new file mode 100644 index 00000000..87112edf --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-unsafe-doc.md @@ -0,0 +1,133 @@ +# lint-unsafe-doc + +> Require documentation for unsafe blocks + +## Why It Matters + +The `undocumented_unsafe_blocks` lint ensures every unsafe block has a `// SAFETY:` comment explaining why the operation is sound. Unsafe code is the source of most memory safety bugs—documenting invariants catches mistakes and helps reviewers. + +## Configuration + +```rust +#![warn(clippy::undocumented_unsafe_blocks)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "warn" +``` + +For strict enforcement: + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "deny" +``` + +## Bad + +```rust +pub fn read_data(ptr: *const u8, len: usize) -> &[u8] { + unsafe { + std::slice::from_raw_parts(ptr, len) // WARN: undocumented + } +} + +impl Buffer { + pub fn get_unchecked(&self, index: usize) -> &u8 { + unsafe { self.data.get_unchecked(index) } // WARN + } +} +``` + +## Good + +```rust +pub fn read_data(ptr: *const u8, len: usize) -> &[u8] { + // SAFETY: Caller guarantees: + // - ptr is valid for reads of len bytes + // - ptr is properly aligned for u8 + // - the memory is initialized + // - no mutable references exist to this memory + unsafe { + std::slice::from_raw_parts(ptr, len) + } +} + +impl Buffer { + pub fn get_unchecked(&self, index: usize) -> &u8 { + debug_assert!(index < self.len(), "index out of bounds"); + // SAFETY: We verified index < len in debug builds. + // Callers must ensure index is within bounds. + unsafe { self.data.get_unchecked(index) } + } +} +``` + +## SAFETY Comment Format + +```rust +// SAFETY: +unsafe { + // ... +} +``` + +The comment should explain: +1. **What invariants are upheld** - preconditions that make this safe +2. **Why the invariants hold** - how you know they're satisfied +3. **What could go wrong** - if invariants are violated + +## Examples by Category + +### Pointer Operations + +```rust +// SAFETY: ptr was obtained from Box::into_raw, so it's valid +// and properly aligned. We're taking back ownership. +let boxed = unsafe { Box::from_raw(ptr) }; +``` + +### Unchecked Operations + +```rust +// SAFETY: We just checked that i < self.len() above. +// The bounds check cannot be elided by the optimizer +// because len() is not inlined. +unsafe { self.data.get_unchecked(i) } +``` + +### FFI Calls + +```rust +// SAFETY: libc::getenv is safe to call with a null-terminated +// string. We ensure null termination with CString::new. +// The returned pointer is valid for the lifetime of the environment. +let value = unsafe { libc::getenv(key.as_ptr()) }; +``` + +### Trait Implementations + +```rust +// SAFETY: MyType contains no pointers or interior mutability, +// and all bit patterns are valid MyType values. +unsafe impl Send for MyType {} +unsafe impl Sync for MyType {} +``` + +## Related Lints + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "warn" +# Also consider: +multiple_unsafe_ops_per_block = "warn" # One operation per block +``` + +## See Also + +- [doc-safety-section](./doc-safety-section.md) - `# Safety` in docs +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints +- [type-repr-transparent](./type-repr-transparent.md) - FFI safety diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-complexity.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-complexity.md new file mode 100644 index 00000000..dd88b9c0 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-complexity.md @@ -0,0 +1,131 @@ +# lint-warn-complexity + +> Enable clippy::complexity for simpler code + +## Why It Matters + +The `clippy::complexity` lint group identifies unnecessarily complex code that can be simplified. Complex code is harder to read, maintain, and often hides bugs. Clippy suggests cleaner alternatives. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::complexity)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +complexity = "warn" +``` + +## What It Catches + +### Unnecessary Complexity + +```rust +// WARN: Overly complex boolean expression +if !(x == 0) { } // Use: if x != 0 { } + +// WARN: Manual implementation of Option::map +match option { + Some(x) => Some(x + 1), + None => None, +} // Use: option.map(|x| x + 1) + +// WARN: Unnecessary filter before count +iter.filter(|x| predicate(x)).count() // Could simplify if only counting +``` + +### Redundant Operations + +```rust +// WARN: Redundant allocation +let s = format!("literal"); // Use: "literal".to_string() or just "literal" + +// WARN: Unnecessarily complicated match +match result { + Ok(ok) => Ok(ok), + Err(err) => Err(err), +} // Just use: result + +// WARN: Box::new in return position +fn make_error() -> Box { + Box::new(MyError) // Could use: MyError.into() +} +``` + +### Overly Verbose Code + +```rust +// WARN: bind_instead_of_map +option.and_then(|x| Some(x + 1)) // Use: option.map(|x| x + 1) + +// WARN: clone_on_copy +let y = x.clone(); // Where x is Copy type, just use: let y = x; + +// WARN: useless_let_if_seq +let result; +if condition { + result = 1; +} else { + result = 2; +} +// Use: let result = if condition { 1 } else { 2 }; +``` + +## Notable Lints in This Group + +| Lint | Simplification | +|------|---------------| +| `bind_instead_of_map` | Use `map` instead of `and_then(Some(...))` | +| `bool_comparison` | `if x == true` → `if x` | +| `clone_on_copy` | Remove `.clone()` for Copy types | +| `filter_next` | Use `.find()` instead | +| `option_map_unit_fn` | Use `if let` instead | +| `search_is_some` | Use `.any()` or `.contains()` | +| `unnecessary_cast` | Remove redundant casts | +| `useless_conversion` | Remove `.into()` when types match | + +## Examples + +```rust +// Before (complexity warnings) +fn find_positive(nums: &[i32]) -> Option { + let filtered: Vec<_> = nums.iter() + .cloned() + .filter(|x| *x > 0) + .collect(); + if filtered.len() == 0 { + None + } else { + Some(filtered[0]) + } +} + +// After (simplified) +fn find_positive(nums: &[i32]) -> Option { + nums.iter() + .copied() + .find(|&x| x > 0) +} +``` + +## Cognitive Load + +Complex code isn't just longer—it's harder to understand: + +```rust +// High cognitive load +let value = if x.is_some() { x.unwrap() } else { y.unwrap_or(z) }; + +// Lower cognitive load +let value = x.unwrap_or_else(|| y.unwrap_or(z)); +``` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-perf](./lint-warn-perf.md) - Performance warnings +- [lint-pedantic-selective](./lint-pedantic-selective.md) - Pedantic lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-perf.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-perf.md new file mode 100644 index 00000000..93ee4544 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-perf.md @@ -0,0 +1,136 @@ +# lint-warn-perf + +> Enable clippy::perf for performance improvements + +## Why It Matters + +The `clippy::perf` lint group catches performance anti-patterns—inefficient allocations, unnecessary copies, suboptimal API usage. While not all performance issues are critical, avoiding obvious inefficiencies is good practice. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::perf)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +perf = "warn" +``` + +## What It Catches + +### Unnecessary Allocations + +```rust +// WARN: Unnecessary to_string before into +fn take_string(s: impl Into) { } +take_string("hello".to_string()); // Just use: "hello" + +// WARN: Box::new in return with deref coercion +fn make_trait() -> Box { + Box::new(concrete) // Could use Into +} + +// WARN: Unnecessary vec! for iteration +for x in vec![1, 2, 3] { } // Use array: [1, 2, 3] +``` + +### Inefficient Operations + +```rust +// WARN: Single-character string patterns +s.starts_with("x") // Use char: 'x' +s.contains("a") // Use char: 'a' + +// WARN: iter().nth(0) instead of first() +iter.nth(0) // Use: iter.first() or iter.next() + +// WARN: Manual saturating arithmetic +if x > i32::MAX - y { i32::MAX } else { x + y } +// Use: x.saturating_add(y) +``` + +### Collection Inefficiencies + +```rust +// WARN: extend with a single element +vec.extend(std::iter::once(item)); // Use: vec.push(item) + +// WARN: Inefficient to_vec +slice.iter().cloned().collect::>() // Use: slice.to_vec() + +// WARN: Manual string concatenation +let s = format!("{}{}", a, b); // When both are &str, use: a.to_owned() + b +``` + +## Notable Lints in This Group + +| Lint | Improvement | +|------|-------------| +| `box_collection` | Use `Vec` not `Box>` | +| `iter_nth` | Use `.get(n)` or `.next()` | +| `large_enum_variant` | Box large variants | +| `manual_memcpy` | Use slice copy methods | +| `redundant_allocation` | Remove double boxing | +| `single_char_pattern` | Use `char` not `&str` | +| `slow_vector_initialization` | Use `vec![0; n]` | +| `unnecessary_to_owned` | Remove redundant `.to_owned()` | + +## Examples + +```rust +// Before (perf warnings) +fn process(input: &str) -> String { + let parts: Vec<_> = input.split(",").collect(); + let mut result = String::new(); + for part in parts.iter() { + if part.starts_with(" ") { + result = result + &part.trim().to_string(); + } + } + result +} + +// After (optimized) +fn process(input: &str) -> String { + input.split(',') + .filter(|part| part.starts_with(' ')) + .map(str::trim) + .collect() +} +``` + +## Allocation Patterns + +```rust +// Unnecessary allocation +let vec: Vec = vec![]; // Creates capacity +let vec: Vec = Vec::new(); // No allocation + +// Pre-allocation +let mut vec = Vec::with_capacity(100); // One allocation +for i in 0..100 { + vec.push(i); // No reallocation +} +``` + +## String Patterns + +```rust +// Slow: str pattern +s.contains("x"); +s.find("y"); + +// Fast: char pattern +s.contains('x'); +s.find('y'); +``` + +## See Also + +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation +- [perf-profile-first](./perf-profile-first.md) - Profile before optimizing diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-style.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-style.md new file mode 100644 index 00000000..4e017cd1 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-style.md @@ -0,0 +1,135 @@ +# lint-warn-style + +> Enable clippy::style for idiomatic code + +## Why It Matters + +The `clippy::style` lint group enforces idiomatic Rust patterns. While not bugs, style violations make code harder to read and maintain. Consistent style helps teams work together and makes code easier to review. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::style)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +style = "warn" +``` + +## What It Catches + +### Redundant Code + +```rust +// WARN: Redundant clone on Copy type +let x = 5; +let y = x.clone(); // Just use: let y = x; + +// WARN: Redundant closure +iter.map(|x| foo(x)) // Just use: iter.map(foo) + +// WARN: Redundant pattern matching +match result { + Ok(x) => Ok(x), + Err(e) => Err(e), +} // Just return result +``` + +### Non-Idiomatic Patterns + +```rust +// WARN: Should use if let +match option { + Some(x) => do_something(x), + None => {}, +} +// Better: if let Some(x) = option { do_something(x) } + +// WARN: Should use or_else +let value = if option.is_some() { + option.unwrap() +} else { + default() +}; +// Better: option.unwrap_or_else(default) + +// WARN: Collapsible if statements +if condition1 { + if condition2 { + do_something(); + } +} +// Better: if condition1 && condition2 { do_something() } +``` + +### Naming Issues + +```rust +// WARN: Function should not start with 'is_' returning non-bool +fn is_valid() -> i32 { 0 } // Misleading name + +// WARN: Method should not be named 'new' without returning Self +impl Foo { + fn new() -> Bar { Bar } // Confusing +} +``` + +## Notable Lints in This Group + +| Lint | Better Pattern | +|------|---------------| +| `len_zero` | Use `is_empty()` instead of `len() == 0` | +| `redundant_field_names` | Use shorthand `{ x }` not `{ x: x }` | +| `unused_unit` | Remove `-> ()` and trailing `()` | +| `collapsible_if` | Combine nested ifs with `&&` | +| `single_match` | Use `if let` instead | +| `match_like_matches_macro` | Use `matches!()` macro | +| `needless_return` | Remove explicit `return` at end | +| `question_mark` | Use `?` instead of `match` | + +## Examples + +```rust +// Before (style warnings) +fn process(data: Vec) -> Option { + if data.len() == 0 { + return None; + } + let first = match data.first() { + Some(x) => x, + None => return None, + }; + return Some(*first); +} + +// After (idiomatic) +fn process(data: Vec) -> Option { + if data.is_empty() { + return None; + } + let first = data.first()?; + Some(*first) +} +``` + +## Selective Allowance + +Some style lints may conflict with team preferences: + +```rust +// If your team prefers explicit returns +#[allow(clippy::needless_return)] +fn explicit_return() -> i32 { + return 42; +} +``` + +## See Also + +- [lint-warn-suspicious](./lint-warn-suspicious.md) - Suspicious patterns +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [lint-rustfmt-check](./lint-rustfmt-check.md) - Formatting checks diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-suspicious.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-suspicious.md new file mode 100644 index 00000000..65f44ef4 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-warn-suspicious.md @@ -0,0 +1,122 @@ +# lint-warn-suspicious + +> Enable clippy::suspicious for likely bugs + +## Why It Matters + +The `clippy::suspicious` lint group catches code patterns that are syntactically valid but almost always wrong. These are potential bugs that deserve investigation. Enabling this group as a warning helps catch mistakes early. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::suspicious)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +suspicious = "warn" +``` + +Or in `clippy.toml`: + +```toml +warn = ["clippy::suspicious"] +``` + +## What It Catches + +### Suspicious Arithmetic + +```rust +// WARN: Suspicious use of + in a << expression +let bits = 1 << 4 + 1; // Probably meant (1 << 4) + 1 or 1 << (4 + 1) + +// WARN: Suspicious use of | in a + expression +let value = x | 1 + y; // Probably meant (x | 1) + y or x | (1 + y) +``` + +### Suspicious Comparisons + +```rust +// WARN: Almost swapped operands in a comparison +if 5 < x && x < 3 { } // Impossible condition + +// WARN: Suspicious assignment in a condition +if (x = 5) { } // Probably meant x == 5 +``` + +### Suspicious Method Calls + +```rust +// WARN: Suspicious map usage +let _: Vec<_> = vec.iter().map(|x| { + println!("{}", x); // Side effect in map + x +}).collect(); // Use for_each instead + +// WARN: Suspicious string formatting +let s = format!("{}", format!("{}", x)); // Redundant nested format +``` + +### Suspicious Casts + +```rust +// WARN: Suspicious use of not on a bool +let inverted = !x as i32; // Did you mean (!x) as i32 or !(x as i32)? + +// WARN: Cast of float to int may lose precision +let n = 3.14_f64 as i32; // May want .round() first +``` + +## Notable Lints in This Group + +| Lint | Description | +|------|-------------| +| `suspicious_arithmetic_impl` | Unusual operator in arithmetic trait | +| `suspicious_assignment_formatting` | Looks like typo in assignment | +| `suspicious_else_formatting` | Else on wrong line | +| `suspicious_map` | Map with side effects | +| `suspicious_op_assign_impl` | Unusual op-assign implementation | +| `suspicious_splitn` | splitn that can't produce n parts | +| `suspicious_unary_op_formatting` | Confusing unary operator spacing | + +## Example Catches + +```rust +// Caught: Suspicious double negation +let value = --x; // In Rust, this is -(-x), not pre-decrement + +// Caught: Suspicious modulo +let remainder = x % 1; // Always 0 for integers + +// Caught: Suspicious else formatting +if condition { + do_something(); +} +else { // Weird formatting, might be a mistake + do_other(); +} +``` + +## When to Allow + +Rarely. If you need to suppress, document why: + +```rust +#[allow(clippy::suspicious_arithmetic_impl)] +impl Mul for Matrix { + // Custom matrix multiplication using + for reduction step + fn mul(self, rhs: Self) -> Self::Output { + // ... + } +} +``` + +## See Also + +- [lint-deny-correctness](./lint-deny-correctness.md) - Deny definite bugs +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-workspace-lints.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-workspace-lints.md new file mode 100644 index 00000000..62a8f33b --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/lint-workspace-lints.md @@ -0,0 +1,172 @@ +# lint-workspace-lints + +> Configure lints at workspace level for consistent enforcement + +## Why It Matters + +Without centralized lint configuration, each crate develops its own standards (or none). Workspace-level lints (Rust 1.74+) ensure consistent code quality across all crates. Denied lints catch issues in CI before they reach production. + +## Bad + +```toml +# crate-a/Cargo.toml - strict +[lints.clippy] +unwrap_used = "deny" + +# crate-b/Cargo.toml - lenient +# No lint config + +# crate-c/Cargo.toml - different +[lints.clippy] +unwrap_used = "warn" + +# Inconsistent enforcement, some issues slip through +``` + +## Good + +```toml +# Root Cargo.toml +[workspace.lints.rust] +unsafe_code = "deny" +missing_docs = "warn" + +[workspace.lints.clippy] +# Correctness +unwrap_used = "deny" +expect_used = "warn" +panic = "deny" + +# Style +needless_pass_by_value = "warn" +redundant_clone = "warn" + +# Complexity +cognitive_complexity = "warn" + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" + +# crate-a/Cargo.toml +[lints] +workspace = true + +# crate-b/Cargo.toml +[lints] +workspace = true +``` + +## Recommended Lint Configuration + +```toml +# Root Cargo.toml +[workspace.lints.rust] +# Safety +unsafe_code = "deny" +missing_debug_implementations = "warn" + +# Quality +unused_results = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +# === Correctness (deny) === +correctness = { level = "deny", priority = -1 } + +# === Suspicious (deny) === +suspicious = { level = "deny", priority = -1 } + +# === Style (warn) === +style = { level = "warn", priority = -1 } + +# === Complexity (warn) === +complexity = { level = "warn", priority = -1 } + +# === Perf (warn) === +perf = { level = "warn", priority = -1 } + +# === Pedantic (selective) === +# Not all pedantic lints are useful +doc_markdown = "warn" +needless_pass_by_value = "warn" +redundant_closure_for_method_calls = "warn" +semicolon_if_nothing_returned = "warn" + +# === Nursery (selective) === +cognitive_complexity = "warn" +useless_let_if_seq = "warn" + +# === Restriction (selective) === +unwrap_used = "deny" +expect_used = "warn" +dbg_macro = "warn" +print_stdout = "warn" # Use logging instead +todo = "warn" + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" +private_intra_doc_links = "warn" +``` + +## Per-Crate Overrides + +```toml +# crate-with-binary/Cargo.toml +[lints] +workspace = true + +# Binary entry point can use unwrap +[lints.clippy] +unwrap_used = "allow" + +# test-utils/Cargo.toml +[lints] +workspace = true + +# Test utilities can print +[lints.clippy] +print_stdout = "allow" +``` + +## CI Integration + +```yaml +# .github/workflows/ci.yml +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Rustdoc + run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps +``` + +## Lint Categories + +```toml +# Category-level configuration +[workspace.lints.clippy] +# All lints in category at once +correctness = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +style = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } + +# Then override specific lints (higher priority) +missing_errors_doc = "allow" # Override pedantic +``` + +## See Also + +- [lint-deny-correctness](./lint-deny-correctness.md) - Critical lints +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace configuration +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - unwrap lints diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arena-allocator.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arena-allocator.md new file mode 100644 index 00000000..68435cfa --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arena-allocator.md @@ -0,0 +1,168 @@ +# mem-arena-allocator + +> Use arena allocators for batch allocations + +## Why It Matters + +Arena allocators (bump allocators) allocate memory from a contiguous region, making allocation extremely fast (just bump a pointer). All allocations are freed at once when the arena is dropped. Perfect for request-scoped or parse-tree allocations. + +## Bad + +```rust +// Many small allocations during parsing +fn parse(input: &str) -> Vec { + let mut nodes = Vec::new(); + for token in tokenize(input) { + nodes.push(Box::new(Node::new(token))); // Heap alloc per node! + } + nodes +} + +// Per-request allocations add up +fn handle_request(req: Request) -> Response { + let headers = parse_headers(&req); // Allocates + let body = parse_body(&req); // Allocates + let response = generate_response(); // Allocates + // All freed individually at end + response +} +``` + +## Good + +```rust +use bumpalo::Bump; + +// All nodes allocated from same arena +fn parse<'a>(input: &str, arena: &'a Bump) -> Vec<&'a Node> { + let mut nodes = Vec::new(); + for token in tokenize(input) { + let node = arena.alloc(Node::new(token)); // Fast bump! + nodes.push(node); + } + nodes +} // Arena freed all at once + +// Per-request arena +fn handle_request(req: Request) -> Response { + let arena = Bump::new(); + + let headers = parse_headers(&req, &arena); + let body = parse_body(&req, &arena); + let response = generate_response(&arena); + + // Convert to owned response before arena drops + response.to_owned() +} // All request memory freed instantly +``` + +## Thread-Local Scratch Arena Pattern + +```rust +use bumpalo::Bump; +use std::cell::RefCell; + +thread_local! { + static SCRATCH: RefCell = RefCell::new(Bump::with_capacity(4 * 1024)); +} + +fn with_scratch(f: impl FnOnce(&Bump) -> T) -> T { + SCRATCH.with(|scratch| { + let arena = scratch.borrow(); + let result = f(&arena); + result + }) +} + +fn reset_scratch() { + SCRATCH.with(|scratch| { + scratch.borrow_mut().reset(); + }); +} + +// Usage +fn process_batch(items: &[Item]) -> Vec { + with_scratch(|arena| { + let temp_data: Vec<&TempData> = items + .iter() + .map(|item| arena.alloc(compute_temp(item))) + .collect(); + + // Use temp_data... + let result = finalize(&temp_data); + + reset_scratch(); // Reuse arena memory + result + }) +} +``` + +## Evidence from ROC Compiler + +```rust +// https://github.com/roc-lang/roc/blob/main/crates/compiler/solve/src/to_var.rs +std::thread_local! { + static SCRATCHPAD: RefCell> = + RefCell::new(Some(bumpalo::Bump::with_capacity(4 * 1024))); +} + +fn take_scratchpad() -> bumpalo::Bump { + SCRATCHPAD.with(|f| f.take().unwrap()) +} + +fn put_scratchpad(scratchpad: bumpalo::Bump) { + SCRATCHPAD.with(|f| { + f.replace(Some(scratchpad)); + }); +} +``` + +## Bumpalo Collections + +```rust +use bumpalo::Bump; +use bumpalo::collections::{Vec, String}; + +fn process<'a>(arena: &'a Bump, input: &str) -> Vec<'a, String<'a>> { + let mut results = Vec::new_in(arena); + + for word in input.split_whitespace() { + let mut s = String::new_in(arena); + s.push_str(word); + s.push_str("_processed"); + results.push(s); + } + + results // All allocated in arena +} +``` + +## When to Use Arenas + +| Situation | Use Arena? | +|-----------|-----------| +| Parsing (AST nodes) | Yes | +| Request handling | Yes | +| Batch processing | Yes | +| Long-lived data | No | +| Data escaping scope | No (or copy out) | +| Simple programs | Overkill | + +## Performance Impact + +```rust +// Benchmarks from production systems: +// - Individual allocations: ~25-50ns each +// - Arena bump: ~1-2ns each (20-50x faster) +// - Arena reset: O(1) regardless of allocation count + +// Memory overhead: +// - Arena wastes some memory (unused capacity) +// - But eliminates per-allocation metadata overhead +``` + +## See Also + +- [mem-with-capacity](mem-with-capacity.md) - Pre-allocate when size is known +- [mem-reuse-collections](mem-reuse-collections.md) - Reuse collections with clear() +- [opt-profile-first](perf-profile-first.md) - Profile to verify benefit diff --git a/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arrayvec.md b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arrayvec.md new file mode 100644 index 00000000..76cecaf5 --- /dev/null +++ b/crates/graphql-orm-ai/.agents/skills/rust-skills/rules/mem-arrayvec.md @@ -0,0 +1,142 @@ +# mem-arrayvec + +> Use `ArrayVec` for fixed-capacity collections that never heap-allocate + +## Why It Matters + +`ArrayVec` from the `arrayvec` crate provides Vec-like API with a compile-time maximum capacity, storing all elements inline on the stack. Unlike `SmallVec` which can spill to heap, `ArrayVec` guarantees no heap allocation—if you exceed capacity, it returns an error or panics. This is ideal for embedded systems, real-time code, or when you have a hard upper bound. + +## Bad + +```rust +// Vec always heap-allocates, even for small collections +fn parse_options(input: &str) -> Vec