Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .decapod/managed/specs/INTERFACES.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ pub enum ApiError {
- Parameters are always adapter-bound, never interpolated into SQL. `Parameter` enumerates `Null` / `Bool` / `Integer` / `Real` / `Text`.
- `execute` is the caller-owned schema surface: dactyl never silently creates tables.
- `Row` provides strict `get` / `try_get<T: DeserializeOwned>`, lenient `get_bool` / `get_int` / `get_real` / `get_str` / `get_json`, borrowed `get_str_ref` / `get_json_ref`, and `is_null`, with explicit `ColumnNotFound` / `Conversion` errors. Named lookup is left-to-right first-match for duplicate aliases. SQL NULL maps to `Option<T>` or a `Conversion` that mentions NULL for non-Option targets. Rows own their cells; borrowed accessors are tied to `&Row` only (dactyl #25 / #2; DecapodLabs/decapod#1111).
- `transaction` is atomic: any per-statement failure rolls back the whole unit on SQLite and is rejected by the Neon `/batch` endpoint.
- `transaction` is atomic: any per-statement failure rolls back the whole unit on SQLite and is rejected by the Neon `/batch` endpoint (dactyl #24). Nesting is not supported (no SAVEPOINT). dactyl does not retry and exposes no deadline parameter; callers own retry/idempotency after ambiguous transport failures. Empty batch → `Ok([])`. Conformance proves failure-injection on SQLite and Neon mock plus an event-plus-state fixture.
- `query!("sql")` lexically analyzes the literal at compile time and returns the rewritten SQL as a `String` for the caller to pass to `query`.

### Multi-backend vision
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- *(dactyl)* complete typed/NULL-safe named row projections for [#25](https://github.com/DecapodLabs/dactyl/issues/25): `try_get`, `is_null`, borrowed `get_str_ref` / `get_json_ref`, explicit first-match duplicate-alias semantics, NULL conversion messages, unit + SQLite/Neon matrix conformance (also DecapodLabs/decapod#1111, dactyl #2).
- *(dactyl)* complete atomic batch contract for [#24](https://github.com/DecapodLabs/dactyl/issues/24): Neon-mock failure-injection, event-plus-state fixture on both adapters, nesting/retry/timeout/idempotency docs; neon adapter surfaces non-2xx batch bodies without requiring a success-shaped decode.

### Documentation

- *(dactyl)* document the full `Row` projection contract (scalars, NULL, missing columns, aliases, ownership/lifetime) in the README and crate docs.
- *(dactyl)* document `transaction` atomicity, nesting, retry, timeout, and idempotency semantics in README and crate docs.

## [0.2.3](https://github.com/DecapodLabs/dactyl/compare/dactyl-db-macros-v0.2.1...dactyl-db-macros-v0.2.3) - 2026-08-01

Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The same SQL string produces the same logical rows regardless of the active back
- **One import, many backends** — SQLite and Neon ship today; Redis, MySQL, and Cassandra are planned behind the same `query` surface.
- **Ambient selection** — `DATASTORE` env var picks the active backend at runtime. No `init()`, no per-call datastore argument, no global connection cache.
- **Safe parameter binding** — `query(sql, &[params])` binds typed values; SQL injection via parameter values is structurally impossible.
- **Atomic batches** — `transaction(&[Statement])` commits all-or-nothing on every backend.
- **Atomic batches** — `transaction(&[Statement])` commits all-or-nothing on every backend (no nesting; caller-owned retry/idempotency; see contract below).
- **Caller-owned schema** — dactyl never silently creates tables. `execute("create table ...")` is the only way dactyl touches schema.

## Quick Start
Expand Down Expand Up @@ -107,6 +107,19 @@ The active backend is chosen by ambient environment variables — no `init()` ca

`Row` provides `get` / `try_get`, lenient scalar getters, `is_null`, and borrowed `get_str_ref` / `get_json_ref` under the projection contract above.

## Atomic batches (`transaction`)

Stable contract for multi-statement units of work ([#24](https://github.com/DecapodLabs/dactyl/issues/24); prerequisite for DecapodLabs/decapod#1111 / #1120):

| Concern | Semantics |
|---|---|
| Atomicity | Any per-statement failure aborts the whole unit. SQLite uses a real transaction; Neon uses one `POST /batch` that the server accepts or rejects as a unit. Empty slice → `Ok([])`. |
| Nesting | **Not supported.** No SAVEPOINTs. Each call uses a fresh adapter; put every statement in one slice. |
| Retry | **dactyl does not retry.** Callers own retry policy. |
| Timeout | **No public deadline.** Neon uses reqwest defaults; SQLite is local. |
| Idempotency | **Not idempotent.** Replays may conflict or double-write. Design deterministic keys / upserts if retrying after ambiguous transport failures. |
| Proof | Conformance covers SQLite + Neon-mock failure injection and an event-plus-state fixture (state row + event row in one batch; mid-batch failure leaves neither). |

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
17 changes: 13 additions & 4 deletions src/adapter/neon/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
//! }
//! ```
//!
//! `/batch` is the Neon half of [`crate::transaction`]: the server must apply
//! the statement list as one atomic unit (all commit or all reject). Non-2xx
//! responses are surfaced as [`DactylError::Adapter`] with the response body;
//! dactyl does not partially apply a failed batch client-side.
//!
//! Propodus owns auth; dactyl only forwards the opaque `bearer` token.

use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -151,15 +156,19 @@ impl Adapter for NeonAdapter {
.send()
.map_err(|e| DactylError::Adapter(format!("neon batch send: {e}")))?;
let status = resp.status();
let body: BatchResponse = resp
.json()
.map_err(|e| DactylError::Adapter(format!("neon batch decode: {e}")))?;
// Read bytes first so non-2xx error bodies (often not BatchResponse)
// still surface as Adapter errors with the server payload.
let bytes = resp
.bytes()
.map_err(|e| DactylError::Adapter(format!("neon batch body: {e}")))?;
if !status.is_success() {
return Err(DactylError::Adapter(format!(
"neon batch status {status}: {}",
serde_json::to_string(&body).unwrap_or_default()
String::from_utf8_lossy(&bytes)
)));
}
let body: BatchResponse = serde_json::from_slice(&bytes)
.map_err(|e| DactylError::Adapter(format!("neon batch decode: {e}")))?;
let mut results = Vec::with_capacity(body.results.len());
for res in body.results {
results.push(rows_from_response(res)?);
Expand Down
50 changes: 48 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,55 @@ pub fn execute(sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {

/// Execute an atomic batch of parameterized statements.
///
/// # Atomicity (dactyl #24)
///
/// On any per-statement error the whole unit rolls back and the function
/// returns the error; no partial state is committed. Equivalent semantics
/// are provided for SQLite (transaction) and Neon (`/batch` endpoint).
/// returns [`DactylError`]; **no partial state is committed**. Semantics:
///
/// | Backend | Mechanism |
/// |---|---|
/// | SQLite | Single rusqlite transaction: begin → statements → commit, or drop = rollback |
/// | Neon | One `POST {endpoint}/batch` request; the server must accept/reject the batch as a unit |
///
/// An empty `statements` slice is a successful no-op and returns `Ok(vec![])`.
///
/// # Nesting
///
/// **Not supported.** Each call builds a fresh short-lived adapter. There is no
/// SAVEPOINT API and no nesting of `transaction` inside another open unit.
/// Independent concurrent `transaction` calls are separate atomic units, not
/// nested subtransactions. Callers that need multi-step atomicity must put
/// every statement in a **single** `transaction(&[...])` slice.
///
/// # Retry
///
/// **dactyl does not retry.** A failed batch leaves no committed partial state
/// on either adapter (when the Neon transport returns a definitive error).
/// Callers own retry policy. After a **transport timeout or dropped connection**,
/// the client cannot distinguish “never applied” from “applied but response
/// lost”; retries must use **idempotent** statement design (deterministic keys,
/// upserts) if re-execution is possible.
///
/// # Timeout
///
/// **No public deadline parameter.** SQLite is process-local. Neon uses the
/// reqwest client’s default timeouts. Callers that need tighter bounds should
/// enforce them outside dactyl (process supervisor, HTTP proxy, or a future
/// env-based client config — not part of this surface).
///
/// # Idempotency
///
/// `transaction` itself is **not** idempotent. Replaying a previously
/// successful batch may insert duplicates or hit primary-key conflicts.
/// Design statements for safe replay when the caller’s retry policy may
/// re-submit after ambiguous failures.
///
/// # Returns
///
/// On success, one [`Rows`] per input statement (writes often yield empty
/// row sets; `SELECT` statements yield projections). On failure, an
/// [`DactylError::Adapter`] (or conversion error while decoding Neon rows)
/// and no committed partial state.
pub fn transaction(statements: &[Statement]) -> Result<Vec<Rows>, DactylError> {
if statements.is_empty() {
return Ok(Vec::new());
Expand Down
Loading
Loading