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..a3958b8 100644 --- a/context/CHANGELOG.md +++ b/context/CHANGELOG.md @@ -4,6 +4,84 @@ This file tracks agent decisions, architectural changes, and context for future --- +## [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/examples/README.md b/examples/README.md index 6b07a2e..aa31b8a 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 SQL database (PostgreSQL here) and decode rows into a struct (`fastedge::db::sql`) | +| [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..4925897 --- /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::sql::Database::open(name)` — open the 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; for PostgreSQL `code` is the SQLSTATE 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..c511a84 --- /dev/null +++ b/examples/http/wasi/db_sql_postgres/src/lib.rs @@ -0,0 +1,66 @@ +use fastedge::db::sql::{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/src/db.rs b/src/db.rs new file mode 100644 index 0000000..3a8f3fc --- /dev/null +++ b/src/db.rs @@ -0,0 +1,200 @@ +//! 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; PostgreSQL on Neon in the POC. +/// +/// Calls are blocking. Parameters use positional placeholders (`$1`, `$2`, ...). +/// +/// # 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(), + } + } + } +} + +/// 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..29f389b 160000 --- a/wit +++ b/wit @@ -1 +1 @@ -Subproject commit 68c713f2f8aaa14c8964978a6e98d54571281065 +Subproject commit 29f389b40c8bcfc25b15cb942307b579267583e9