diff --git a/Cargo.lock b/Cargo.lock index e29c1ce..cf6001d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ dependencies = [ "fastedge-derive", "http", "mime", + "serde", "serde_json", "thiserror", "wit-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 34ecc7c..e1d5bcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ name = "fastedge" [features] default = ["proxywasm"] proxywasm = [] -json = ["serde_json"] +json = ["serde_json", "serde"] [dependencies] fastedge-derive = { path = "derive", version = "0.4" } @@ -45,6 +45,7 @@ wit-bindgen = "0.46" thiserror = "2.0" mime = "^0.3" serde_json = { version = "^1.0", optional = true } +serde = { version = "1", optional = true } [dev-dependencies] diff --git a/context/CHANGELOG.md b/context/CHANGELOG.md index a094ac1..0f97877 100644 --- a/context/CHANGELOG.md +++ b/context/CHANGELOG.md @@ -4,6 +4,107 @@ This file tracks agent decisions, architectural changes, and context for future --- +## [2026-11-05] — db: `fastedge::db::postgres` (PostgreSQL module over `gcore:db/sql`) + +### Overview +A PostgreSQL-named module over the neutral SQL interface: same `Database`, `params!`, `ToSql`, +`QueryAs`, plus SQLSTATE constants and an `Error::sqlstate()` helper. No WIT change. + +### Decisions +- Re-export, not a wrapper type: the host cannot check a binding's dialect, so a distinct + `Database` type would add code without adding checking. The module names the dialect for + readers, agents and tooling; it does not verify it +- `sqlstate` constants are taken from PostgreSQL's `errcodes.txt` (REL_17_STABLE); any other code + still arrives in `DbError::code` +- `fastedge::db::sql` stays for code that is not PostgreSQL-specific + +### Changes +- `src/db.rs` — `pub mod postgres` (re-exports, `sqlstate`, `SqlState` for `Error` and + `QueryAsError`, unit test); `sql` docs point PostgreSQL users at it +- `examples/http/wasi/db_sql_postgres` — imports `fastedge::db::postgres` (responses unchanged) +- `src/db.rs` — `postgres` docs: extension and user-defined types (pgvector, PostGIS, enums) work as text + (`$1::vector` casts) with an empty `type_name` +- `fastedge-plugin-source/.generation-config.md` — `HOST_SERVICES.md` covers `fastedge::db` (`sql`, `postgres`, + `kv`; sources `src/db.rs`, the `gcore:db` WIT and the db examples); `docs/HOST_SERVICES.md` regenerated + +## [2026-11-05] — db: dialect-neutral SQL; `Column.type_name`; example `db_sql_postgres` + +### Overview +`gcore:db/sql` is documented as dialect-neutral: SQL text, placeholders and error codes are the bound +database's, and the host picks the backend from the binding's configuration. Still `0.1.0` +(unreleased). + +### Decisions +- No `dialect()` call: binding a database of the wrong dialect is the developer's mistake +- The example is named after its dialect (PostgreSQL schema, `$1` placeholders), so a SQLite example + can sit beside it later + +### Changes +- `wit` submodule → FastEdge-wit `feat/db-interface-all` a23194c +- `Column.type_name` (the database's type name, e.g. `int4`) replaces the PostgreSQL `type_oid` +- `src/db.rs` — module docs describe the dialect as the bound database's +- `examples/http/wasi/db_sql/` → `examples/http/wasi/db_sql_postgres/` + +## [2026-10-27] — POC: `fastedge::db::kv` (gcore:db/kv-sync@0.1.0) + +### Overview +Added `fastedge::db::kv`: Database KV (regional, durable, writable; Upstash Redis in the POC) bound +to the app by name; the host holds the credentials. + +### Decisions +- Sync interface `kv-sync`, like `sql-sync`; the plain `kv` name is reserved for an async variant + (JS SDK). +- Re-export the generated types as is (`Database`, `Op`, `OpResult`, `SetOptions`, ...), plus + `Default for SetOptions` (always write, no expiry). No other wrapper. +- Example mirrors the JS SDK `examples/db-kv` (same keys, limits, responses). + +### Changes +- `wit` submodule → FastEdge-wit `feat/db-interface-kv` ca89dc6 (`kv-types`, `kv-sync`) +- `src/lib.rs` — `with:` generates `gcore:db/kv-types@0.1.0` and `gcore:db/kv-sync@0.1.0` +- `src/db.rs` — `db::kv` module +- `examples/http/wasi/db_kv/` — new wstd example; `examples/README.md` entry + +## [2026-10-27] — db: bind `gcore:db/sql-sync` (WIT split) + +### Overview +FastEdge-wit split `gcore:db/sql` into `sql-types` + `sql-sync`, mirroring `cache-types` / `cache-sync`. + +### Decisions +- Rust stays on the sync interface by default; the plain `sql` name is reserved for an async (pollable) + variant intended for the JS SDK. Public API `fastedge::db::sql` is unchanged. + +### Changes +- `wit` submodule → FastEdge-wit `feat/db-interface` 83a7d08 +- `src/lib.rs` — `with:` generates `gcore:db/sql-types@0.1.0` and `gcore:db/sql-sync@0.1.0` +- `src/db.rs` — re-exports `Database` from `sql_sync`, data types from `sql_types` + +--- + +## [2026-10-27] — POC: `fastedge::db::sql` (gcore:db/sql@0.1.0) + +### Overview +Added `fastedge::db::sql`: SQL database (PostgreSQL, Neon in the POC) bound to the app by name; the host holds the credentials. + +### Decisions +- Module path `fastedge::db::sql` mirrors the WIT package `gcore:db/sql`, and keeps it apart from `fastedge::key_value` (Edge Storage). Upstash KV will follow as `fastedge::db::kv`. +- Re-export the generated types (`Database`, `Value`, `QueryResult`, `Error`, `DbError`, ...) as is, plus a thin layer: `params!` / `ToSql` for parameters, and `QueryAs::query_as::` / `rows_as` decoding rows by column name via serde_json. +- `query_as` is behind the existing `json` feature (now also enables optional `serde`), so the default build gains no deps. Alternatives: sqlx/diesel (own the connection, don't fit), a custom serde Deserializer (more code, same result). +- Calls are blocking (sync WIT). Async (futures / WASI p3) is an open decision; see coordinator `db-integration/context/async-calls.md`. + +### Changes +- `wit` submodule → FastEdge-wit `feat/db-interface` (`deps/db/sql.wit`, world imports `gcore:db/sql@0.1.0`) +- `src/lib.rs` — `with: { "gcore:db/sql@0.1.0": generate }` in `generate!`; `pub mod db` +- `src/db.rs` — new: `db::sql` re-exports, `ToSql`, `params!`, `QueryAs`, `rows_as`, `QueryAsError` +- `Cargo.toml` — optional `serde`, `json = ["serde_json", "serde"]` +- `examples/http/wasi/db_sql/` — new wstd example + `schema.sql`; `examples/README.md` entry + +### Follow-up +- `examples/http/wasi/db_sql/Cargo.toml` uses a path dependency until `fastedge::db` is published (same as `examples/cdn/cache`). +- The `wit` submodule points at a FastEdge-wit commit that is not pushed yet (`6ddb92e`). +- Not yet: fixtures, SDK_ARCHITECTURE/PROJECT_OVERVIEW updates, plugin manifest entries, docs generation. + +--- + ## [2026-09-14] — ProxyWasm cache module ### Overview diff --git a/docs/HOST_SERVICES.md b/docs/HOST_SERVICES.md index 5ce7587..0704f4a 100644 --- a/docs/HOST_SERVICES.md +++ b/docs/HOST_SERVICES.md @@ -1,8 +1,8 @@ # FastEdge Rust SDK — Host Services -Host-provided service modules for key-value storage, secret management, configuration dictionaries, and diagnostic utilities. +Host-provided service modules for key-value storage, secret management, configuration dictionaries, diagnostic utilities, and customer-owned databases. -The host-service modules documented here (`fastedge::key_value`, `fastedge::secret`, `fastedge::dictionary`, `fastedge::utils`) are part of the standard `fastedge` crate. No additional Cargo.toml changes are needed beyond the standard `fastedge` dependency to use these services. +The host-service modules documented here (`fastedge::key_value`, `fastedge::secret`, `fastedge::dictionary`, `fastedge::utils`, `fastedge::db`) are part of the standard `fastedge` crate. No additional Cargo.toml changes are needed beyond the standard `fastedge` dependency to use these services. The only optional addition is the `json` feature, which enables the typed query helpers in `fastedge::db` (see [Databases](#databases)). --- @@ -354,7 +354,7 @@ async fn main(_request: Request) -> anyhow::Result> { | **Typical use cases** | Feature flags, routing config, tuning | Caching, counters, state, rate-limit data | API keys, tokens, certificates, credentials | | **Versioning / rotation** | No | No | Yes, via `get_effective_at` | -Use `dictionary` for simple, non-sensitive string configuration that is known at deployment time. Use `key_value` for larger datasets, binary values, or data that requires advanced query patterns. Use `secret` for any value that must be kept confidential. +Use `dictionary` for simple, non-sensitive string configuration that is known at deployment time. Use `key_value` for larger datasets, binary values, or data that requires advanced query patterns. Use `secret` for any value that must be kept confidential. Data the application itself must write belongs in a database, see [Databases](#databases). CDN apps use `fastedge::proxywasm::dictionary` instead — see [CDN_APPS.md](CDN_APPS.md) for the ProxyWasm API surface and usage examples. @@ -409,6 +409,472 @@ CDN apps use `fastedge::proxywasm::utils` instead — see [CDN_APPS.md](CDN_APPS --- +## Databases + +The `fastedge::db` module gives an HTTP app access to a database the customer owns. The database is bound to the app by name (for example `"main"`): the host holds the connection details, and the app only knows the name. There are two kinds: + +- SQL: `fastedge::db::sql` (dialect-neutral) and `fastedge::db::postgres` (the same API named for PostgreSQL). +- Database KV: `fastedge::db::kv`, a regional, durable, writable key-value database. + +All calls are blocking. Each `query` auto-commits; each `batch` runs in one transaction. Calls are subject to host limits (timeouts, response and value sizes, statements or operations per batch); exceeding one returns `Error::Timeout` or `Error::Limit`. + +`db::sql`, `db::postgres` and `db::kv` are HTTP-app APIs only. CDN apps have no `gcore:db` access, and `fastedge::db::kv` is not `fastedge::key_value`. + +To use the typed query helpers (`QueryAs`, `rows_as`), enable the `json` feature: + +```toml +[dependencies] +fastedge = { version = "0.4.5", features = ["json"] } +wstd = "0.6" +anyhow = "1" +serde = { version = "1", features = ["derive"] } +``` + +Build for the `wasm32-wasip2` target: + +```bash +cargo build --release --target wasm32-wasip2 +``` + +### Choosing Storage + +| Storage | Module | Writable from the app | Location | Use for | +| ------------ | -------------------------------------------------------------------------- | --------------------- | ------------------------ | ------------------------------ | +| Edge Storage | `fastedge::key_value` | No | Replicated to every edge | Read-mostly data at every edge | +| Database KV | `fastedge::db::kv` | Yes | Regional, durable | Counters, rate limits, state | +| SQL database | `fastedge::db::sql`, or `fastedge::db::postgres` for a PostgreSQL database | Yes | Regional, durable | Relational data, transactions | + +Database KV is also not the PoP-local cache. + +#### Binding a database + +The app opens a database by name with `Database::open("main")`. The name is bound to a database in the app's configuration, outside the code; the app never sees the connection details. + +Schema changes (migrations) are run from your machine or CI with your usual tool, not by the app. + +The host does not check that a binding's database matches the module used to open it. `db::postgres` names the dialect; it does not verify it. + +### SQL (`fastedge::db::sql`) + +The dialect-neutral SQL module. The SQL text, the placeholder syntax and the error codes are those of the bound database (`$1`, `$2`, ... for PostgreSQL). A binding can move to another provider of the same dialect without code changes; moving to a database of another dialect means changing the SQL. + +```rust +impl Database { + pub fn open(name: &str) -> Result + pub fn query(&self, sql: &str, params: &[Value]) -> Result + pub fn batch(&self, statements: &[Statement]) -> Result, Error> +} +``` + +- `open` returns `Error::NoSuchDatabase` if the name is not bound. +- `query` runs one statement with positional parameters and auto-commits. `sql` must contain exactly one statement. +- `batch` runs several statements in one transaction: either all commit or none do. It returns one `QueryResult` per statement, in order. On failure nothing is committed and the error of the failing statement is returned. + +#### Result and record types + +| Type | Field | Type | Description | +| ----------- | --------- | --------------- | ---------------------------------------- | +| QueryResult | columns | Vec | Result columns, in order | +| QueryResult | rows | Vec> | Rows in column order; empty when no rows | +| QueryResult | row_count | u64 | Rows returned or affected | +| Column | name | String | Column name or alias | +| Column | type_name | String | Lower-case type name; empty if unnamed | +| Statement | sql | String | One SQL statement | +| Statement | params | Vec | Positional parameters | + +`row_count` is the number of rows returned (`SELECT`) or affected (`INSERT`, `UPDATE`, `DELETE`). + +#### `Value` + +Parameters and result cells are `Value`. Results use the narrowest variant that holds the column's type without loss. + +| Variant | Holds | Meaning | +| ------- | ------- | -------------------------------------- | +| Null | none | SQL `NULL` | +| Boolean | bool | Boolean | +| Int32 | i32 | 32-bit integer | +| Int64 | i64 | 64-bit integer | +| Float64 | f64 | Floating point | +| Numeric | String | Exact decimal, as text | +| Text | String | Any other type, as the database's text | +| Bytes | Vec | Binary data | +| Json | String | JSON, as text | + +Parameters are converted to the target column type by the database, as its dialect does. + +#### `params!` and `ToSql` + +```rust +pub trait ToSql { + fn to_sql(self) -> Value; +} +``` + +`params![a, b, ...]` builds a `&[Value]` from plain Rust values by calling `ToSql::to_sql` on each; `params![]` is an empty list. `ToSql` is implemented for: + +| Rust type | Value | +| ----------- | ------------------------------------------ | +| `bool` | `Value::Boolean` | +| `i32` | `Value::Int32` | +| `i64` | `Value::Int64` | +| `f64` | `Value::Float64` | +| `&str` | `Value::Text` | +| `String` | `Value::Text` | +| `Vec` | `Value::Bytes` | +| `Option` | `Value::Null` for `None`; otherwise as `T` | + +Integer literals default to `i32`; write `42i64` for an `int8` column. + +```rust,no_run +use fastedge::db::sql::{params, Database, Statement, Value}; +use wstd::http::body::Body; +use wstd::http::{Request, Response}; + +#[wstd::http_server] +async fn main(_request: Request) -> anyhow::Result> { + // "main" is the binding name; the host resolves it to the app's database. + let db = Database::open("main")?; + + let result = db.query("select id, email from users where id = $1", params![42i64])?; + println!("{} rows, {} columns", result.row_count, result.columns.len()); + for row in &result.rows { + if let [Value::Int64(id), Value::Text(email)] = row.as_slice() { + println!("{id}: {email}"); + } + } + + // One transaction: both statements commit, or neither does. + let results = db.batch(&[ + Statement { + sql: "update counters set n = n + 1 where name = $1".to_string(), + params: params!["hits"].to_vec(), + }, + Statement { + sql: "select n from counters where name = $1".to_string(), + params: params!["hits"].to_vec(), + }, + ])?; + + Ok(Response::builder() + .status(200) + .body(Body::from(format!("{} results", results.len())))?) +} +``` + +#### Typed queries (feature `json`) + +```rust +pub trait QueryAs { + fn query_as(&self, sql: &str, params: &[Value]) -> Result, QueryAsError>; +} + +pub fn rows_as(result: QueryResult) -> Result, QueryAsError> +``` + +`Database::query_as::` runs one statement and decodes every row into any `serde::Deserialize` type by column name. `rows_as` does the same for an existing `QueryResult`, for example one entry of a `batch` result. Decoding rules: + +- `json` / `jsonb` columns are parsed, so a field can be a nested struct. +- `int8` is a JSON number; `numeric` and text-format types (timestamps, UUIDs, ...) are strings; `bytea` is a byte array; `NULL` is `None`. +- Non-finite floats (`NaN`, infinities) are kept as strings, so an `f64` field fails to decode instead of reading `NULL`. + +| Variant | Description | +| ----------------------------- | -------------------------------------- | +| `QueryAsError::Db(Error)` | The database or host reported an error | +| `QueryAsError::Decode(String)` | A row did not match the target type | + +`Decode` is returned for a missing column or a wrong type. + +```rust,ignore +use fastedge::db::sql::{params, Database, QueryAs}; + +#[derive(serde::Deserialize)] +struct User { + id: i64, + email: String, + nickname: Option, +} + +let users: Vec = Database::open("main")? + .query_as("select id, email, nickname from users where id = $1", params![42i64])?; +``` + +### PostgreSQL (`fastedge::db::postgres`) + +The `sql` API named for PostgreSQL, plus SQLSTATE helpers. Use this module with a PostgreSQL database (for example Neon). It re-exports `Database`, `Column`, `DbError`, `Error`, `QueryResult`, `Statement`, `ToSql`, `Value`, `params!`, and, with the `json` feature, `QueryAs`, `QueryAsError` and `rows_as`; their definitions are in [SQL](#sql-fastedgedbsql). + +Write PostgreSQL: `$1`, `$2`, ... placeholders, `RETURNING`, `jsonb`. The host does not check the dialect: bind a PostgreSQL database to the name. + +#### Value mapping + +| PostgreSQL type | Value | Notes | +| ------------------ | --------- | -------------------------------------------- | +| `int2`, `int4` | `Int32` | | +| `int8` | `Int64` | | +| `float4`, `float8` | `Float64` | | +| `numeric` | `Numeric` | Decimal text | +| `bool` | `Boolean` | | +| `json`, `jsonb` | `Json` | JSON text | +| `bytea` | `Bytes` | | +| Anything else | `Text` | PostgreSQL text format; `timestamptz` in UTC | +| `NULL` | `Null` | | + +#### Extension and user-defined types + +Extension and user-defined types (pgvector `vector`, PostGIS `geometry`, enums, domains) work as text: + +- Send them as text parameters, casting in SQL where PostgreSQL cannot infer the type, for example `order by embedding <-> $1::vector`. +- They come back as `Text` in PostgreSQL's text format (`"[0.1,0.2]"` for a vector). +- Their `Column::type_name` is empty: the host names only PostgreSQL's built-in types. + +#### SQLSTATE + +```rust +pub trait SqlState { + fn sqlstate(&self) -> Option<&str>; +} +``` + +`SqlState` is implemented for `Error` and, with the `json` feature, for `QueryAsError`. `sqlstate()` returns the SQLSTATE code for `Error::Query` and `Error::Config`, and `None` for errors that did not come from the database (no binding, timeout, connection, limit) and for `QueryAsError::Decode`. Compare it with the constants in `fastedge::db::postgres::sqlstate`. Any other code is still reported in `DbError::code`. + +| Constant | Code | Meaning | +| ------------------------------ | ------- | -------------------------------------------- | +| `UNIQUE_VIOLATION` | `23505` | Duplicate in a unique constraint or index | +| `FOREIGN_KEY_VIOLATION` | `23503` | Foreign key points at a missing row | +| `NOT_NULL_VIOLATION` | `23502` | `NULL` in a `NOT NULL` column | +| `CHECK_VIOLATION` | `23514` | A `CHECK` constraint failed | +| `EXCLUSION_VIOLATION` | `23P01` | An exclusion constraint failed | +| `INVALID_TEXT_REPRESENTATION` | `22P02` | Text not parseable as the column type | +| `NUMERIC_VALUE_OUT_OF_RANGE` | `22003` | Number out of the column type's range | +| `STRING_DATA_RIGHT_TRUNCATION` | `22001` | String longer than the column allows | +| `DIVISION_BY_ZERO` | `22012` | Division by zero | +| `UNDEFINED_TABLE` | `42P01` | Table does not exist | +| `UNDEFINED_COLUMN` | `42703` | Column does not exist | +| `UNDEFINED_FUNCTION` | `42883` | No function matches the name and arguments | +| `SYNTAX_ERROR` | `42601` | SQL syntax error (e.g. `?` instead of `$1`) | +| `INSUFFICIENT_PRIVILEGE` | `42501` | Role lacks a privilege | +| `SERIALIZATION_FAILURE` | `40001` | Serialization failure; retry the transaction | +| `DEADLOCK_DETECTED` | `40P01` | Deadlock detected; retry the transaction | +| `QUERY_CANCELED` | `57014` | Statement cancelled (e.g. a timeout) | +| `INVALID_PASSWORD` | `28P01` | Wrong role password (`Error::Config`) | +| `INVALID_CATALOG_NAME` | `3D000` | Database does not exist (`Error::Config`) | + +All constants are `&str`. + +```rust,no_run +use fastedge::db::postgres::{params, sqlstate, Database, SqlState}; +use wstd::http::body::Body; +use wstd::http::{Request, Response}; + +#[wstd::http_server] +async fn main(_request: Request) -> anyhow::Result> { + // "main" is the binding name; the host resolves it to the app's database. + let db = Database::open("main").map_err(|e| anyhow::anyhow!("open database: {e:?}"))?; + + let status = match db.query( + "insert into users (email) values ($1)", + params!["ann@example.com"], + ) { + Ok(_) => 201, + Err(e) if e.sqlstate() == Some(sqlstate::UNIQUE_VIOLATION) => 409, + Err(e) => return Err(anyhow::anyhow!("insert failed: {e:?}")), + }; + + Ok(Response::builder().status(status).body(Body::empty())?) +} +``` + +### Database KV (`fastedge::db::kv`) + +Regional, durable key-value storage that the app can write. It is not Edge Storage (`fastedge::key_value`, read-only, replicated to every edge) and not the PoP-local cache. + +```rust +impl Database { + pub fn open(name: &str) -> Result + pub fn get(&self, key: &str) -> Result, Error> + pub fn exists(&self, key: &str) -> Result + pub fn set(&self, key: &str, value: &[u8], options: SetOptions) -> Result + pub fn delete(&self, key: &str) -> Result + pub fn incr(&self, key: &str, delta: i64) -> Result + pub fn expire(&self, key: &str, ttl_ms: u64) -> Result + pub fn batch(&self, ops: &[Op]) -> Result, Error> +} + +pub type KvValue = Vec; +``` + +| Method | Behavior | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `open` | Opens the database bound to `name`; `Error::NoSuchDatabase` if the name is not bound | +| `get` | `Ok(None)` if the key does not exist or has expired | +| `exists` | Whether the key exists and has not expired, without fetching its value | +| `set` | `Ok(true)` if written, `Ok(false)` if `options.condition` was not met. A successful `set` always replaces the key's previous expiry | +| `delete` | `Ok(true)` if the key existed, `Ok(false)` otherwise | +| `incr` | Adds `delta` (may be negative) to the integer at `key`; a missing key counts as `0`; the key's expiry is unchanged. Returns the new value | +| `expire` | Sets the expiry to `ttl_ms` milliseconds from now; `Ok(false)` if the key does not exist | +| `batch` | Runs the operations atomically; see below | + +`incr` fails with `Error::Operation` if the stored value is not a decimal 64-bit integer or the result would overflow. + +#### Set options + +```rust +pub struct SetOptions { + pub ttl_ms: Option, + pub condition: SetCondition, +} + +pub enum SetCondition { + Always, + IfNotExists, + IfExists, +} +``` + +`ttl_ms` of `None` means no expiry. `SetOptions::default()` is no expiry and `SetCondition::Always`. + +| Variant | Writes | +| ------------- | ------------------------------ | +| `Always` | Always; the default | +| `IfNotExists` | Only if the key does not exist | +| `IfExists` | Only if the key exists | + +#### Atomic batches + +`batch` applies all operations or none, and no other client observes or interleaves with a partial batch. Operations see the effects of earlier operations in the same batch. Each `Op` has the same semantics as the method of the same name, and `batch` returns one `OpResult` per operation, in order. + +```rust +pub struct SetOp { pub key: String, pub value: Vec, pub options: SetOptions } +pub struct IncrOp { pub key: String, pub delta: i64 } +pub struct ExpireOp { pub key: String, pub ttl_ms: u64 } +``` + +| Op | OpResult | +| ---------------------- | -------------------------------- | +| `Op::Get(String)` | `OpResult::Get(Option>)` | +| `Op::Exists(String)` | `OpResult::Exists(bool)` | +| `Op::Set(SetOp)` | `OpResult::Set(bool)` | +| `Op::Delete(String)` | `OpResult::Delete(bool)` | +| `Op::Incr(IncrOp)` | `OpResult::Incr(i64)` | +| `Op::Expire(ExpireOp)` | `OpResult::Expire(bool)` | + +A `set` whose condition is not met is not a failure: it yields `OpResult::Set(false)` and the batch continues. Any other failing operation aborts the whole batch with `Error::Operation`, whose `op_index` identifies the operation. + +#### Values and consistency + +- Values are bytes and must be valid UTF-8 for now. Writing a non-UTF-8 value fails with `Error::Limit`. +- Within one request, reads see that request's own writes (read-your-writes). +- Other requests, possibly on other edges, see a write once it has replicated: immediately for a database in one region, eventually for one with read replicas in other regions. `batch` is atomic either way. + +```rust,no_run +use fastedge::db::kv::{Database, Error, IncrOp, Op, OpResult, SetCondition, SetOp, SetOptions}; +use wstd::http::body::Body; +use wstd::http::{Request, Response}; + +#[wstd::http_server] +async fn main(_request: Request) -> anyhow::Result> { + // "main" is the binding name; the host resolves it to the app's database. + let db = Database::open("main").map_err(|e| anyhow::anyhow!("open database: {e:?}"))?; + + // One atomic batch: start a 60 s window counter if missing, then count this request. + let ops = [ + Op::Set(SetOp { + key: "rl:ann".into(), + value: b"0".to_vec(), + options: SetOptions { + ttl_ms: Some(60_000), + condition: SetCondition::IfNotExists, + }, + }), + Op::Incr(IncrOp { + key: "rl:ann".into(), + delta: 1, + }), + ]; + + let requests = match db.batch(&ops) { + Ok(results) => match results.as_slice() { + [_, OpResult::Incr(n)] => *n, + _ => { + return Ok(Response::builder() + .status(500) + .body(Body::from("unexpected batch result"))?) + } + }, + // e.g. code "WRONGTYPE": the key holds something that is not a counter + Err(Error::Operation(e)) => { + return Ok(Response::builder().status(500).body(Body::from(e.message))?) + } + Err(e) => return Err(anyhow::anyhow!("batch failed: {e:?}")), + }; + + if requests > 5 { + return Ok(Response::builder() + .status(429) + .body(Body::from("rate limited"))?); + } + + Ok(Response::builder() + .status(200) + .body(Body::from(format!("request {requests} in this window")))?) +} +``` + +### Error Handling + +#### SQL errors + +`fastedge::db::sql::Error` (re-exported by `fastedge::db::postgres`): + +| Variant | Description | +| -------------------------- | --------------------------------------------------- | +| `Error::NoSuchDatabase` | No database is bound to the name | +| `Error::AccessDenied` | The app has no access to the database | +| `Error::Config(DbError)` | Binding misconfigured; needs a configuration change | +| `Error::Connection(String)` | Database unreachable (network, DNS, TLS) | +| `Error::Timeout` | Time limit exceeded; outcome is unknown | +| `Error::Limit(String)` | A host or backend limit was exceeded | +| `Error::Query(DbError)` | The database rejected the statement | +| `Error::InternalError` | Unexpected internal error | + +On `Error::Timeout` the statement may or may not have been applied. `Error::Config` is not retryable without a configuration change (bad credentials, unknown database name, invalid connection settings). `Error::Query` covers syntax errors, constraint violations and permission errors. + +`DbError` carries the details the database reported: + +| Field | Type | Description | +| ---------- | -------------- | --------------------------------------------- | +| code | Option | Database error code (SQLSTATE for PostgreSQL) | +| message | String | Error message | +| detail | Option | Further detail, when reported | +| hint | Option | Hint, when reported | +| constraint | Option | Name of the violated constraint, if any | + +#### Database KV errors + +`fastedge::db::kv::Error`: + +| Variant | Description | +| --------------------------- | --------------------------------------------------- | +| `Error::NoSuchDatabase` | No database is bound to the name | +| `Error::AccessDenied` | No access to the database or operation | +| `Error::Config(String)` | Binding misconfigured; needs a configuration change | +| `Error::Connection(String)` | Database unreachable (network, DNS, TLS) | +| `Error::Timeout` | Time limit exceeded; outcome is unknown | +| `Error::Limit(String)` | A host or backend limit was exceeded | +| `Error::Operation(OpError)` | An operation failed (e.g. `incr` on a non-integer) | +| `Error::InternalError` | Unexpected internal error | + +`Error::AccessDenied` also covers an operation the binding does not permit, for example a write through a read-only binding. On `Error::Timeout` the operation may or may not have been applied. + +`OpError` carries the details the backend reported: + +| Field | Type | Description | +| -------- | -------------- | ------------------------------------------------- | +| op_index | Option | Failing operation's index; `None` outside `batch` | +| code | Option | Backend error code (e.g. `WRONGTYPE`), if any | +| message | String | Error message | + +--- + ## See Also - [SDK_API.md](SDK_API.md) — Core HTTP handler, `send_request`, `Body`, and the `#[fastedge::http]` macro diff --git a/examples/README.md b/examples/README.md index 6b07a2e..63661e8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,8 @@ Examples are organized into three categories: | [headers](./http/wasi/headers/) | Echo request headers and add a custom header from an environment variable | | [variables_and_secrets](./http/wasi/variables_and_secrets/) | Read an environment variable and a secret | | [simple_fetch](./http/wasi/simple_fetch/) | Fetch from a configurable URL and return the response | +| [db_sql_postgres](./http/wasi/db_sql_postgres/) | Query a bound PostgreSQL database and decode rows into a struct (`fastedge::db::postgres`) | +| [db_kv](./http/wasi/db_kv/) | Per-user rate limit and visit counter in a bound Database KV, one atomic batch (`fastedge::db::kv`) | ### cdn (proxy-wasm) diff --git a/examples/http/wasi/db_kv/Cargo.lock b/examples/http/wasi/db_kv/Cargo.lock new file mode 100644 index 0000000..3f9f35d --- /dev/null +++ b/examples/http/wasi/db_kv/Cargo.lock @@ -0,0 +1,640 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "db_kv" +version = "0.1.0" +dependencies = [ + "anyhow", + "fastedge", + "serde_json", + "wstd", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fastedge" +version = "0.4.5" +dependencies = [ + "bytes", + "fastedge-derive", + "http", + "mime", + "thiserror", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "fastedge-derive" +version = "0.4.5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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 = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[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 0.57.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +dependencies = [ + "bitflags", + "futures", + "once_cell", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wstd" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b52936db10a79bb724dadd1c2c3aac958e8229dcb1f1c7f2b7044ca9fc6a3a" +dependencies = [ + "anyhow", + "async-task", + "bytes", + "futures-lite", + "http", + "http-body", + "http-body-util", + "itoa", + "pin-project-lite", + "serde", + "serde_json", + "slab", + "wasip2", + "wstd-macro", +] + +[[package]] +name = "wstd-macro" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153db9b65508bf6c2efe26617169840c03671ba5719a35868db7682a5261f7d4" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/http/wasi/db_kv/Cargo.toml b/examples/http/wasi/db_kv/Cargo.toml new file mode 100644 index 0000000..34cc31d --- /dev/null +++ b/examples/http/wasi/db_kv/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] + +[package] +name = "db_kv" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wstd = "0.6" +fastedge = { path = "../../../.." } +anyhow = "1" +serde_json = "1" + diff --git a/examples/http/wasi/db_kv/README.md b/examples/http/wasi/db_kv/README.md new file mode 100644 index 0000000..140115d --- /dev/null +++ b/examples/http/wasi/db_kv/README.md @@ -0,0 +1,50 @@ +[← Back to examples](../../../README.md) + +# Database KV (WASI) + +Per-user rate limit and a visit counter in a Database KV bound to the app as `main`, updated in one +atomic `batch`. + +Database KV is regional, durable and writable from the app. It is not Edge Storage +(`fastedge::key_value`, read-only) or the PoP-local cache. + +## Setup + +Bind the database: add a secret `FASTEDGE_DB_KV_main` with the Upstash Redis REST URL and token as +`https://@` (e.g. `https://AXXX...@eu1-example-12345.upstash.io`). The app never sees +it; the host resolves `Database::open("main")`. + +## Usage + +``` +GET /?user=ann → 200 {"user":"ann","requests":1,"limit":5,"visits":17} +(6th request from ann within 60 s) +GET /?user=ann → 429 {"error":"rate limited","user":"ann","limit":5} +GET / → user "anonymous" +``` + +Each request runs one batch: `set` the user's window counter to `0` with a 60 s TTL if it doesn't +exist, `incr` it, `incr` the global `visits` counter. The batch is all-or-nothing. + +## Error responses + +| Condition | Status | Body | +|---|---|---| +| No database bound to `main` | 500 | `{"error":"no such database"}` | +| A key holds a non-counter value | 500 | `{"error":"…","code":"ERR"}` | + +## Build + +```sh +cargo build --release +# Output: target/wasm32-wasip2/release/db_kv.wasm +``` + +Local run: `fastedge-run http -w target/wasm32-wasip2/release/db_kv.wasm --wasi-http true --secret "FASTEDGE_DB_KV_main=https://@"` + +## APIs used + +- `fastedge::db::kv::Database::open(name)` — open the database bound to `name` +- `db.batch(&[Op::Set(..), Op::Incr(..), …])` — run operations atomically +- `SetOptions { ttl_ms, condition: SetCondition::IfNotExists }` — expiry and conditional writes +- `Error::Operation(OpError { code, message, op_index })` — a failing operation diff --git a/examples/http/wasi/db_kv/src/lib.rs b/examples/http/wasi/db_kv/src/lib.rs new file mode 100644 index 0000000..ee34420 --- /dev/null +++ b/examples/http/wasi/db_kv/src/lib.rs @@ -0,0 +1,74 @@ +use fastedge::db::kv::{Database, Error, IncrOp, Op, OpResult, SetCondition, SetOp, SetOptions}; +use serde_json::json; +use wstd::http::body::Body; +use wstd::http::{Request, Response}; + +/// Requests one user may make per window. +const LIMIT: i64 = 5; +const WINDOW_MS: u64 = 60_000; + +#[wstd::http_server] +async fn main(request: Request) -> anyhow::Result> { + let user = request + .uri() + .query() + .and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("user="))) + .unwrap_or("anonymous") + .to_string(); + + // "main" is the binding name; the host resolves it to the app's database. + let db = match Database::open("main") { + Ok(db) => db, + Err(Error::NoSuchDatabase) => return json(500, json!({ "error": "no such database" })), + Err(e) => return json(500, json!({ "error": format!("{e:?}") })), + }; + + // One atomic batch: start the user's window counter if missing (it expires with the + // window), count this request, count the visit. + let window = format!("rl:{user}"); + let ops = [ + Op::Set(SetOp { + key: window.clone(), + value: b"0".to_vec(), + options: SetOptions { + ttl_ms: Some(WINDOW_MS), + condition: SetCondition::IfNotExists, + }, + }), + Op::Incr(IncrOp { + key: window, + delta: 1, + }), + Op::Incr(IncrOp { + key: "visits".into(), + delta: 1, + }), + ]; + let (requests, visits) = match db.batch(&ops).as_deref() { + Ok([_, OpResult::Incr(requests), OpResult::Incr(visits)]) => (*requests, *visits), + Ok(_) => return json(500, json!({ "error": "unexpected batch result" })), + // e.g. WRONGTYPE: "visits" holds something that isn't a counter + Err(Error::Operation(e)) => { + return json(500, json!({ "error": e.message, "code": e.code })) + } + Err(e) => return json(500, json!({ "error": format!("{e:?}") })), + }; + + if requests > LIMIT { + return json( + 429, + json!({ "error": "rate limited", "user": user, "limit": LIMIT }), + ); + } + json( + 200, + json!({ "user": user, "requests": requests, "limit": LIMIT, "visits": visits }), + ) +} + +fn json(status: u16, body: serde_json::Value) -> anyhow::Result> { + Ok(Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(body.to_string()))?) +} diff --git a/examples/http/wasi/db_sql_postgres/Cargo.lock b/examples/http/wasi/db_sql_postgres/Cargo.lock new file mode 100644 index 0000000..a156c6e --- /dev/null +++ b/examples/http/wasi/db_sql_postgres/Cargo.lock @@ -0,0 +1,644 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "db_sql_postgres" +version = "0.1.0" +dependencies = [ + "anyhow", + "fastedge", + "serde", + "serde_json", + "wstd", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fastedge" +version = "0.4.5" +dependencies = [ + "bytes", + "fastedge-derive", + "http", + "mime", + "serde", + "serde_json", + "thiserror", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "fastedge-derive" +version = "0.4.5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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 = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[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 0.57.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +dependencies = [ + "bitflags", + "futures", + "once_cell", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wstd" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b52936db10a79bb724dadd1c2c3aac958e8229dcb1f1c7f2b7044ca9fc6a3a" +dependencies = [ + "anyhow", + "async-task", + "bytes", + "futures-lite", + "http", + "http-body", + "http-body-util", + "itoa", + "pin-project-lite", + "serde", + "serde_json", + "slab", + "wasip2", + "wstd-macro", +] + +[[package]] +name = "wstd-macro" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153db9b65508bf6c2efe26617169840c03671ba5719a35868db7682a5261f7d4" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/http/wasi/db_sql_postgres/Cargo.toml b/examples/http/wasi/db_sql_postgres/Cargo.toml new file mode 100644 index 0000000..d6f01b9 --- /dev/null +++ b/examples/http/wasi/db_sql_postgres/Cargo.toml @@ -0,0 +1,17 @@ +[workspace] + +[package] +name = "db_sql_postgres" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wstd = "0.6" +fastedge = { path = "../../../..", features = ["json"] } +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + diff --git a/examples/http/wasi/db_sql_postgres/README.md b/examples/http/wasi/db_sql_postgres/README.md new file mode 100644 index 0000000..ec1935b --- /dev/null +++ b/examples/http/wasi/db_sql_postgres/README.md @@ -0,0 +1,50 @@ +[← Back to examples](../../../README.md) + +# SQL database (WASI) + +Reads users from a SQL database (PostgreSQL in this example) bound to the app as `main`, decoding rows into a Rust struct +with `query_as`. + +## Setup + +1. Bind the database: add a secret `FASTEDGE_DB_SQL_main` with the Postgres connection string + (e.g. a Neon pooled URL). The app never sees it; the host resolves `Database::open("main")`. +2. Create the schema once, outside the app: + +```sh +psql "$DATABASE_URL" -f schema.sql +``` + +Schema changes (migrations) are run from your machine or CI with your usual tool, not by the app. + +## Usage + +``` +GET / → 200 [{"id":1,"email":"ann@example.com","nickname":null,"created":"…+00","meta":{"plan":"pro"}}, …] +GET /?id=2 → 200 {"id":2,"email":"bob@example.com","nickname":"bobby","created":"…+00","meta":{"plan":"free"}} +GET /?id=9 → 404 {"error":"not found"} +``` + +## Error responses + +| Condition | Status | Body | +|---|---|---| +| No database bound to `main` | 500 | `{"error":"no such database"}` | +| Schema not applied | 500 | `{"error":"relation \"example_users\" does not exist","code":"42P01"}` | +| Row doesn't match `User` | 500 | `{"error":"row decode: …"}` | + +## Build + +```sh +cargo build --release +# Output: target/wasm32-wasip2/release/db_sql_postgres.wasm +``` + +Local run: `fastedge-run http -w target/wasm32-wasip2/release/db_sql_postgres.wasm --wasi-http true --secret "FASTEDGE_DB_SQL_main=postgresql://…"` + +## APIs used + +- `fastedge::db::postgres::Database::open(name)` — open the PostgreSQL database bound to `name` +- `db.query_as::(sql, params)` — run one statement and decode rows into `T` (feature `json`) +- `params![…]` — build `$1, $2, …` parameters from plain Rust values +- `Error::Query(DbError { code, message, … })` — database error; `code` is the SQLSTATE (`e.sqlstate()` and the `sqlstate` constants, e.g. `sqlstate::UNDEFINED_TABLE`) diff --git a/examples/http/wasi/db_sql_postgres/schema.sql b/examples/http/wasi/db_sql_postgres/schema.sql new file mode 100644 index 0000000..9e656ec --- /dev/null +++ b/examples/http/wasi/db_sql_postgres/schema.sql @@ -0,0 +1,14 @@ +-- Apply once, outside the app (psql, Neon SQL editor, or your migration tool): +-- psql "$DATABASE_URL" -f schema.sql +create table if not exists example_users ( + id int8 primary key, + email text not null unique, + nickname text, + created timestamptz not null default now(), + meta jsonb not null default '{}' +); + +insert into example_users (id, email, nickname, meta) values + (1, 'ann@example.com', null, '{"plan": "pro"}'), + (2, 'bob@example.com', 'bobby', '{"plan": "free"}') +on conflict (id) do nothing; diff --git a/examples/http/wasi/db_sql_postgres/src/lib.rs b/examples/http/wasi/db_sql_postgres/src/lib.rs new file mode 100644 index 0000000..d883fc5 --- /dev/null +++ b/examples/http/wasi/db_sql_postgres/src/lib.rs @@ -0,0 +1,66 @@ +use fastedge::db::postgres::{params, Database, Error, QueryAs, QueryAsError}; +use serde::{Deserialize, Serialize}; +use wstd::http::body::Body; +use wstd::http::{Request, Response}; + +#[derive(Debug, Deserialize, Serialize)] +struct User { + id: i64, + email: String, + nickname: Option, + created: String, // timestamptz, UTC text + meta: Meta, // jsonb, decoded into a struct +} + +#[derive(Debug, Deserialize, Serialize)] +struct Meta { + // Optional: the schema's default `meta` is `{}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + plan: Option, +} + +const SELECT_USERS: &str = "select id, email, nickname, created, meta from example_users"; + +#[wstd::http_server] +async fn main(request: Request) -> anyhow::Result> { + let id = request + .uri() + .query() + .and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("id="))) + .map(str::parse::) + .transpose()?; + + // "main" is the binding name; the host resolves it to the app's database. + let db = match Database::open("main") { + Ok(db) => db, + Err(Error::NoSuchDatabase) => { + return json(500, &serde_json::json!({ "error": "no such database" })) + } + Err(e) => return json(500, &serde_json::json!({ "error": format!("{e:?}") })), + }; + + let users = match id { + Some(id) => db.query_as::(&format!("{SELECT_USERS} where id = $1"), params![id]), + None => db.query_as::(&format!("{SELECT_USERS} order by id"), params![]), + }; + + match (id, users) { + (Some(_), Ok(mut users)) => match users.pop() { + Some(user) => json(200, &user), + None => json(404, &serde_json::json!({ "error": "not found" })), + }, + (None, Ok(users)) => json(200, &users), + // e.g. 42P01 (undefined_table): schema.sql not applied yet + (_, Err(QueryAsError::Db(Error::Query(e)))) => { + json(500, &serde_json::json!({ "error": e.message, "code": e.code })) + } + (_, Err(e)) => json(500, &serde_json::json!({ "error": e.to_string() })), + } +} + +fn json(status: u16, body: &impl Serialize) -> anyhow::Result> { + Ok(Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(body)?))?) +} diff --git a/fastedge-plugin-source/.generation-config.md b/fastedge-plugin-source/.generation-config.md index 67bde53..aff1b0b 100644 --- a/fastedge-plugin-source/.generation-config.md +++ b/fastedge-plugin-source/.generation-config.md @@ -175,10 +175,17 @@ Core SDK API reference. Everything a developer needs to write a basic FastEdge h - `src/proxywasm/secret.rs` — `get`, `get_effective_at` functions - `src/proxywasm/dictionary.rs` — `get` function - `src/proxywasm/utils.rs` — `set_user_diag` function +- `src/db.rs` — `fastedge::db::{sql, postgres, kv}`: re-exports, `params!`, `ToSql`, `QueryAs` / `rows_as` / + `QueryAsError` (feature `json`), `postgres::sqlstate` constants, `postgres::SqlState`, kv `SetOptions` default +- `wit/deps/db/sql-types.wit`, `wit/deps/db/sql-sync.wit`, `wit/deps/db/kv-types.wit`, `wit/deps/db/kv-sync.wit` — + `Database` methods, records and variants behind the `fastedge::db` re-exports (generated Rust names: kebab-case → + `snake_case` functions/fields, `CamelCase` types and variants) +- `examples/http/wasi/db_sql_postgres/`, `examples/http/wasi/db_kv/` — usage (not their binding instructions) ### Scope -All host-provided service modules. Everything beyond basic HTTP handling. +All host-provided service modules, including customer-owned databases (`fastedge::db`). Everything beyond basic +HTTP handling. **This file does NOT cover**: Core HTTP handling, Body type, send_request (→ SDK_API.md). @@ -229,6 +236,48 @@ pub fn bf_exists(&self, key: &str, item: &str) -> Result - `set_user_diag(value)` — diagnostic information - Where diagnostics appear (platform logs) +**Databases (`fastedge::db`):** + +- What it is: a database the customer owns, bound to the app by name (e.g. `"main"`); the host holds the connection + details, the app only knows the name. Two kinds: SQL (`db::sql`, `db::postgres`) and Database KV (`db::kv`) +- Positioning: `db::kv` is regional, durable and writable; Edge Storage (`fastedge::key_value`) is read-only and + replicated to every edge. Say which to use when, in one short table +- How a database is bound: only that the name is bound to a database in the app's configuration, outside the + code, and the app never sees the connection details. Do NOT document the `FASTEDGE_DB_*` secrets or + `--secret` binding in the example READMEs: that is a local POC mechanism that will not ship. Do not invent + portal or API steps +- `fastedge::db::sql` — the dialect-neutral SQL module: `Database::open(name)`, `query(sql, params)`, + `batch(statements)`; `QueryResult` (`columns`, `rows`, `row_count`), `Column` (`name`, `type_name`), every `Value` + variant, `Statement`, `params!` / `ToSql` (the implemented types), `QueryAs::query_as::` and `rows_as` + (feature `json`), every `Error` variant and `DbError` field. The SQL text, placeholders and error codes are the bound + database's +- `fastedge::db::postgres` — the same API named for PostgreSQL; the module to use with a PostgreSQL database. Include: + the value mapping table from its module docs, the paragraph on extension and user-defined types (pgvector, + PostGIS, enums: text in and out, `$1::vector` casts, empty `type_name`), every `sqlstate` constant (name, code, + meaning) in one table, and `SqlState::sqlstate()` on `Error` and `QueryAsError` +- `fastedge::db::kv` — every `Database` method with its exact signature, `SetOptions` / `SetCondition` and its + `Default`, the `Op` / `OpResult` / `OpError` batch types (one atomic batch), every `Error` variant, the UTF-8 value + rule, and the read-your-writes-within-a-request consistency rule +- Examples: the SQL example uses `fastedge::db::postgres` with a `sqlstate` check; the KV example uses an atomic batch. + All examples use `#[wstd::http_server]` + +CRITICAL accuracy for databases: + +- SQL is ONE database type with two modules: `db::sql` (dialect-neutral) and `db::postgres` (the same `Database`, + re-exported under a PostgreSQL name with PostgreSQL docs and SQLSTATE helpers: a dialect-specific wrapper, not a + separate database). In every table and list, SQL gets one row (e.g. module cell "`fastedge::db::sql`, or + `fastedge::db::postgres` for a PostgreSQL database"); never give `db::postgres` its own row as if it were another + storage or database type +- The "Choosing Storage" table has exactly three rows: Edge Storage (`fastedge::key_value`), Database KV + (`fastedge::db::kv`) and SQL database (module cell: "`fastedge::db::sql`, or `fastedge::db::postgres` for a + PostgreSQL database"). There is no "PostgreSQL database" row +- Calls are blocking. Each `query` auto-commits; each `batch` runs in one transaction +- The host does NOT check that a binding's database matches the module: `db::postgres` names the dialect, it does + not verify it. Never claim otherwise +- Do not state numeric host limits (timeouts, sizes, call counts): they are not in these sources +- `db::kv` is not `fastedge::key_value`, and `db::sql` / `db::postgres` / `db::kv` are HTTP-app APIs only (CDN apps + have no `gcore:db`) + **Scope clarification — CRITICAL:** - The host-service modules documented in this file (`fastedge::key_value`, `fastedge::secret`, `fastedge::dictionary`, `fastedge::utils`) are the **Component Model** versions used by **HTTP apps**. @@ -261,6 +310,12 @@ pub fn bf_exists(&self, key: &str, item: &str) -> Result ### When to Use Dictionary vs Key-Value vs Secrets ## Utilities ### Diagnostics +## Databases +### Choosing Storage +### SQL (`fastedge::db::sql`) +### PostgreSQL (`fastedge::db::postgres`) +### Database KV (`fastedge::db::kv`) +### Error Handling ## See Also ``` diff --git a/fastedge-plugin-source/manifest.json b/fastedge-plugin-source/manifest.json index 2285096..75226ea 100644 --- a/fastedge-plugin-source/manifest.json +++ b/fastedge-plugin-source/manifest.json @@ -11,7 +11,7 @@ "host-services": { "files": ["docs/HOST_SERVICES.md"], "required": true, - "description": "Host service modules (Component Model) — key_value::Store, secret, dictionary, utils" + "description": "Host service modules (Component Model) — key_value::Store, secret, dictionary, utils, db (sql, postgres, kv)" }, "cdn-apps": { "files": ["docs/CDN_APPS.md"], diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..38665f7 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,338 @@ +//! Customer-owned databases bound to the app by name (`gcore:db`). +//! +//! The connection details stay with the host; the app only knows the binding name. + +/// SQL database, `gcore:db/sql-sync`. The SQL dialect (placeholders, types, error codes) is that of +/// the database bound to the name. For a PostgreSQL database, use [`postgres`]: the same API, with +/// PostgreSQL docs and SQLSTATE helpers. +/// +/// Calls are blocking. Parameters are positional, in the database's placeholder syntax (`$1`, `$2`, +/// ... for PostgreSQL). +/// +/// # Examples +/// +/// ```no_run +/// use fastedge::db::sql::{params, Database}; +/// +/// let db = Database::open("main")?; +/// let result = db.query("select id, email from users where id = $1", params![42i64])?; +/// println!("{} rows", result.row_count); +/// # Ok::<(), fastedge::db::sql::Error>(()) +/// ``` +/// +/// With the `json` feature, rows decode into any `serde::Deserialize` type by column name: +/// +/// ```ignore +/// use fastedge::db::sql::{params, Database, QueryAs}; +/// +/// #[derive(serde::Deserialize)] +/// struct User { id: i64, email: String, nickname: Option } +/// +/// let users: Vec = Database::open("main")? +/// .query_as("select id, email, nickname from users where id = $1", params![42i64])?; +/// ``` +pub mod sql { + #[doc(inline)] + pub use crate::gcore::db::sql_sync::Database; + #[doc(inline)] + pub use crate::gcore::db::sql_types::{Column, DbError, Error, QueryResult, Statement, Value}; + + #[doc(inline)] + pub use crate::__db_sql_params as params; + + /// Converts a Rust value into a SQL parameter [`Value`]. + /// + /// Implemented for `bool`, `i32`, `i64`, `f64`, `&str`, `String`, `Vec` (bytea) and + /// `Option` (`None` is SQL `NULL`). + pub trait ToSql { + /// The parameter value. + fn to_sql(self) -> Value; + } + + macro_rules! to_sql { + ($($t:ty => $v:ident),*) => {$( + impl ToSql for $t { + fn to_sql(self) -> Value { + Value::$v(self.into()) + } + } + )*}; + } + to_sql!(bool => Boolean, i32 => Int32, i64 => Int64, f64 => Float64, String => Text, + &str => Text, Vec => Bytes); + + impl ToSql for Option { + fn to_sql(self) -> Value { + self.map_or(Value::Null, ToSql::to_sql) + } + } + + #[cfg(feature = "json")] + pub use typed::{rows_as, QueryAs, QueryAsError}; + + #[cfg(feature = "json")] + mod typed { + use super::{Database, Error, QueryResult, Value}; + use serde::de::DeserializeOwned; + use serde_json::{Map, Value as Json}; + + /// Error from [`QueryAs::query_as`]. + #[derive(Debug, thiserror::Error)] + pub enum QueryAsError { + /// The database or host reported an error. + #[error("{0}")] + Db(#[from] Error), + /// A row didn't match the target type (missing column, wrong type). + #[error("row decode: {0}")] + Decode(String), + } + + /// Typed queries: decode rows into any `serde::Deserialize` type by column name. + pub trait QueryAs { + /// Run one statement and decode every row into `T`. + fn query_as( + &self, + sql: &str, + params: &[Value], + ) -> Result, QueryAsError>; + } + + impl QueryAs for Database { + fn query_as( + &self, + sql: &str, + params: &[Value], + ) -> Result, QueryAsError> { + rows_as(self.query(sql, params)?) + } + } + + /// Decode every row of a result (e.g. one entry of a `batch` result) into `T`. + /// + /// `json`/`jsonb` columns are parsed, so a field can be a nested struct. `int8` is a + /// JSON number, `numeric` and text-format types (timestamps, uuid, ...) are strings, + /// `bytea` is a byte array, `NULL` is `None`. + pub fn rows_as(result: QueryResult) -> Result, QueryAsError> { + result + .rows + .into_iter() + .map(|row| { + let obj: Map = result + .columns + .iter() + .zip(row) + .map(|(c, v)| (c.name.clone(), to_json(v))) + .collect(); + serde_json::from_value(Json::Object(obj)) + .map_err(|e| QueryAsError::Decode(e.to_string())) + }) + .collect() + } + + fn to_json(v: Value) -> Json { + match v { + Value::Null => Json::Null, + Value::Boolean(b) => b.into(), + Value::Int32(i) => i.into(), + Value::Int64(i) => i.into(), + // JSON has no NaN/Infinity (serde_json would make them null): keep them as + // strings so an `f64` field fails to decode instead of reading NULL. + Value::Float64(f) if !f.is_finite() => Json::String(f.to_string()), + Value::Float64(f) => f.into(), + Value::Json(s) => serde_json::from_str(&s).unwrap_or(Json::String(s)), + Value::Numeric(s) | Value::Text(s) => s.into(), + Value::Bytes(b) => b.into(), + } + } + } +} + +/// PostgreSQL database, over `gcore:db/sql-sync`: the [`sql`] API, named for PostgreSQL, plus +/// SQLSTATE helpers. +/// +/// Write PostgreSQL: `$1`, `$2`, ... placeholders, `RETURNING`, `jsonb`. The host does not check the +/// dialect: bind a PostgreSQL database (e.g. Neon) to the name. Calls are blocking; each `query` +/// auto-commits and each `batch` runs in one transaction. +/// +/// Values: `int2`/`int4` → `Int32`, `int8` → `Int64`, `float4`/`float8` → `Float64`, `numeric` → +/// `Numeric` (decimal text), `bool` → `Boolean`, `json`/`jsonb` → `Json` (text), `bytea` → `Bytes`, +/// anything else → `Text` in PostgreSQL text format (`timestamptz` in UTC). `NULL` → `Null`. +/// +/// Extension and user-defined types (pgvector `vector`, PostGIS `geometry`, enums, domains) work as +/// text: send them as text parameters, casting in SQL where PostgreSQL cannot infer the type (e.g. +/// `order by embedding <-> $1::vector`), and they come back as `Text` in PostgreSQL's text format +/// (`"[0.1,0.2]"` for a vector). Their `Column::type_name` is empty: the host names only +/// PostgreSQL's built-in types. +/// +/// # Examples +/// +/// ```no_run +/// use fastedge::db::postgres::{params, sqlstate, Database, Error, SqlState}; +/// +/// let db = Database::open("main")?; +/// match db.query("insert into users (email) values ($1)", params!["ann@example.com"]) { +/// Ok(_) => {} +/// Err(e) if e.sqlstate() == Some(sqlstate::UNIQUE_VIOLATION) => println!("already registered"), +/// Err(e) => return Err(e), +/// } +/// # Ok::<(), Error>(()) +/// ``` +pub mod postgres { + #[cfg(feature = "json")] + #[doc(inline)] + pub use super::sql::{rows_as, QueryAs, QueryAsError}; + #[doc(inline)] + pub use super::sql::{Column, Database, DbError, Error, QueryResult, Statement, ToSql, Value}; + + #[doc(inline)] + pub use crate::__db_sql_params as params; + + /// SQLSTATE codes apps commonly branch on (PostgreSQL `errcodes.txt`). Any other code is + /// still reported in [`DbError::code`]. + pub mod sqlstate { + /// `23505`: a unique constraint or index rejected a duplicate. + pub const UNIQUE_VIOLATION: &str = "23505"; + /// `23503`: a foreign key points at a missing row. + pub const FOREIGN_KEY_VIOLATION: &str = "23503"; + /// `23502`: `NULL` in a `NOT NULL` column. + pub const NOT_NULL_VIOLATION: &str = "23502"; + /// `23514`: a `CHECK` constraint failed. + pub const CHECK_VIOLATION: &str = "23514"; + /// `23P01`: an exclusion constraint failed. + pub const EXCLUSION_VIOLATION: &str = "23P01"; + /// `22P02`: text could not be parsed as the column type (e.g. `'abc'` for an integer). + pub const INVALID_TEXT_REPRESENTATION: &str = "22P02"; + /// `22003`: a number out of the column type's range. + pub const NUMERIC_VALUE_OUT_OF_RANGE: &str = "22003"; + /// `22001`: a string longer than the column allows. + pub const STRING_DATA_RIGHT_TRUNCATION: &str = "22001"; + /// `22012`: division by zero. + pub const DIVISION_BY_ZERO: &str = "22012"; + /// `42P01`: the table does not exist (e.g. migrations not applied). + pub const UNDEFINED_TABLE: &str = "42P01"; + /// `42703`: the column does not exist. + pub const UNDEFINED_COLUMN: &str = "42703"; + /// `42883`: no function matches the name and argument types. + pub const UNDEFINED_FUNCTION: &str = "42883"; + /// `42601`: SQL syntax error (e.g. `?` placeholders instead of `$1`). + pub const SYNTAX_ERROR: &str = "42601"; + /// `42501`: the database role lacks a privilege. + pub const INSUFFICIENT_PRIVILEGE: &str = "42501"; + /// `40001`: serialization failure; the transaction may be retried. + pub const SERIALIZATION_FAILURE: &str = "40001"; + /// `40P01`: deadlock detected; the transaction may be retried. + pub const DEADLOCK_DETECTED: &str = "40P01"; + /// `57014`: the statement was cancelled (e.g. `statement_timeout`). + pub const QUERY_CANCELED: &str = "57014"; + /// `28P01`: wrong password for the database role (reported as `Error::Config`). + pub const INVALID_PASSWORD: &str = "28P01"; + /// `3D000`: the database does not exist (reported as `Error::Config`). + pub const INVALID_CATALOG_NAME: &str = "3D000"; + } + + /// The SQLSTATE of an error, when the database reported one. + pub trait SqlState { + /// The SQLSTATE code (compare with the [`sqlstate`] constants), or `None` for errors that + /// did not come from the database (no binding, timeout, connection, limit). + fn sqlstate(&self) -> Option<&str>; + } + + impl SqlState for Error { + fn sqlstate(&self) -> Option<&str> { + match self { + Error::Query(e) | Error::Config(e) => e.code.as_deref(), + _ => None, + } + } + } + + #[cfg(feature = "json")] + impl SqlState for QueryAsError { + fn sqlstate(&self) -> Option<&str> { + match self { + QueryAsError::Db(e) => e.sqlstate(), + QueryAsError::Decode(_) => None, + } + } + } + + #[cfg(test)] + mod tests { + use super::{sqlstate, DbError, Error, SqlState}; + + fn db_error(code: Option<&str>) -> DbError { + DbError { + code: code.map(String::from), + message: "m".into(), + detail: None, + hint: None, + constraint: None, + } + } + + #[test] + fn sqlstate_comes_from_database_errors_only() { + let unique = Error::Query(db_error(Some(sqlstate::UNIQUE_VIOLATION))); + assert_eq!(unique.sqlstate(), Some("23505")); + let auth = Error::Config(db_error(Some(sqlstate::INVALID_PASSWORD))); + assert_eq!(auth.sqlstate(), Some("28P01")); + assert_eq!(Error::Query(db_error(None)).sqlstate(), None); + assert_eq!(Error::NoSuchDatabase.sqlstate(), None); + assert_eq!(Error::Timeout.sqlstate(), None); + } + } +} + +/// Database KV (regional, durable, writable key-value), `gcore:db/kv-sync`. +/// +/// Not Edge Storage ([`crate::key_value`], read-only, replicated to every edge) and not the +/// PoP-local cache. Calls are blocking. Values are bytes and must be valid UTF-8 for now. +/// +/// Within one request, reads see the request's own writes. Other requests may see a write only +/// after it has replicated (eventually, for a database with read replicas in other regions). +/// +/// # Examples +/// +/// ```no_run +/// use fastedge::db::kv::{Database, Op, SetCondition, SetOp, SetOptions, IncrOp}; +/// +/// let db = Database::open("main")?; +/// db.set("greeting", b"hello", SetOptions::default())?; +/// let visits = db.incr("visits", 1)?; +/// +/// // Atomic: create a 60 s window counter if missing, then count this request. +/// let results = db.batch(&[ +/// Op::Set(SetOp { +/// key: "rl:ann".into(), +/// value: b"0".to_vec(), +/// options: SetOptions { ttl_ms: Some(60_000), condition: SetCondition::IfNotExists }, +/// }), +/// Op::Incr(IncrOp { key: "rl:ann".into(), delta: 1 }), +/// ])?; +/// # Ok::<(), fastedge::db::kv::Error>(()) +/// ``` +pub mod kv { + #[doc(inline)] + pub use crate::gcore::db::kv_sync::Database; + #[doc(inline)] + pub use crate::gcore::db::kv_types::{ + Error, ExpireOp, IncrOp, KvValue, Op, OpError, OpResult, SetCondition, SetOp, SetOptions, + }; + + /// Always write, no expiry. + impl Default for SetOptions { + fn default() -> Self { + Self { + ttl_ms: None, + condition: SetCondition::Always, + } + } + } +} + +/// Builds a `&[Value]` parameter list from plain Rust values; see [`sql::ToSql`]. +#[doc(hidden)] +#[macro_export] +macro_rules! __db_sql_params { + ($($p:expr),* $(,)?) => { &[$($crate::db::sql::ToSql::to_sql($p)),*] }; +} diff --git a/src/lib.rs b/src/lib.rs index 896c836..d88426d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -261,6 +261,12 @@ wit_bindgen::generate!({ world: "reactor", path: "wit", pub_export_macro: true, + with: { + "gcore:db/sql-types@0.1.0": generate, + "gcore:db/sql-sync@0.1.0": generate, + "gcore:db/kv-types@0.1.0": generate, + "gcore:db/kv-sync@0.1.0": generate, + }, }); /// Fast read-only key-value dictionary for configuration. @@ -411,6 +417,9 @@ pub mod key_value { pub use crate::gcore::fastedge::key_value::Error; } +/// Customer-owned databases bound to the app by name (`gcore:db`). +pub mod db; + /// Synchronous cache API for FastEdge applications. /// /// This module exposes the cache interface generated from the Component Model diff --git a/wit b/wit index 68c713f..f2f350c 160000 --- a/wit +++ b/wit @@ -1 +1 @@ -Subproject commit 68c713f2f8aaa14c8964978a6e98d54571281065 +Subproject commit f2f350c23219c0564a974650f07139cb5402fbbe