dactyl-db 0.6.0

Interchangeably read and write to a lightweight Rust local store or cloud-hosted Vercel Neon instances behind one facade.
Documentation

dactyl

🦀 Decapod

dactyl-db is a lightweight application-layer datastore for read/write-heavy apps that need the same small Rust surface over a local store and Vercel Neon. It binds values, normalizes returned rows, and exposes explicit physical atomicity so the application does not need separate driver code for each backend.

Dactyl is deliberately not database-administration tooling. Callers own schema design, migration ids and ordering, grouping policy, retries, analytics, and business intelligence. Dactyl owns only physical execution, local durability, atomic batch boundaries, access mode, and result/error normalization.

What is shared

SQLite and Neon use the same application contract:

  • read(sql, params) returns owned Rows.
  • write(sql, params) returns the affected-row count for compatibility; write_result also returns explicit generated keys.
  • atomic(&[Operation]) executes an opaque all-or-nothing batch and preserves result order. Empty batches are no-ops, and a failed batch persists nothing. It does not implement retry, nesting, or idempotency policy.
  • OpenOptions { access_mode: ReadOnly, .. } opens a non-mutating handle.
  • Values are bound as Null, Bool, Integer, Real, Text, or Blob.
  • The Neon adapter forwards SQL to /query and atomic batches to /batch.
  • Adapter failures use typed categories including busy/locked/timeout, constraint/conflict/version-conflict/transaction-aborted, read-only, capability, value, storage, transport, authentication/authorization, quota, rate-limit, and protocol failures. Remote stable error codes are available through DactylError::adapter_code() without parsing provider messages.

The local implementation is Dactyl-owned Rust. It has no sqlite, rusqlite, libsqlite3-sys, or SQLite subprocess dependency. The sqlite feature and route constructor remain as compatibility names for existing callers, but the local file is a versioned Dactyl snapshot, not a SQLite file. A SQLite header is rejected with a typed capability error; migration/import belongs to the caller.

The local SQL surface is intentionally bounded: caller-supplied CREATE TABLE and multi-statement schema batches, ALTER TABLE ... ADD, CREATE [UNIQUE] INDEX, DROP TABLE, DROP INDEX, INSERT, UPDATE, DELETE, and SELECT with predicates and basic ordering/limits. Schema operations preserve literal defaults, NOT NULL, composite PRIMARY KEY/UNIQUE, foreign keys with restrict/cascade delete behavior, and caller-owned indexes. Indexes are structural constraints, not a query planner. Unsupported SQL fails with a typed capability/query error rather than silently changing the request. This is a storage primitive, not a planner or schema owner.

Schema versioning, migration ordering, backups, import from legacy stores, retry/backoff, idempotency keys, and domain-level version/CAS policy remain with Decapod or Propodus. The Neon adapter maps the stable Propodus v1 error codes it receives, but it does not invent the resource-route translation or claim live cloud parity when that service contract is unavailable.

Quick start

[dependencies]
dactyl-db = { version = "0.4.0", features = ["sqlite", "neon"] }

Select the backend with environment variables:

DATASTORE=sqlite DATASTORE_ROUTE=/path/to/app.db
# or
DATASTORE=neon DATASTORE_ROUTE=https://propodus.example DATASTORE_TOKEN=...

Use the same calls for either backend:

use dactyl_db::{read, write, Parameter};

fn load_app_rows() -> Result<(), dactyl_db::DactylError> {
    write(
        "insert into app_events (name) values ($1)",
        &[Parameter::Text("opened".into())],
    )?;

    let rows = read("select name from app_events order by id", &[])?;
    for row in rows.iter() {
        println!("{}", row.get_str("name")?);
    }
    Ok(())
}

For an explicit route, use Connection::open:

use dactyl_db::{Connection, DatastoreRoute, Parameter};

let db = Connection::open(DatastoreRoute::sqlite("/tmp/app.db"))?;
db.write(
    "update accounts set last_seen = $1 where id = $2",
    &[Parameter::Integer(1_725_000_000), Parameter::Integer(7)],
)?;

Use an explicit physical batch and generated-key result when those semantics matter:

use dactyl_db::{Connection, DatastoreRoute, Operation, Parameter};

let db = Connection::open(DatastoreRoute::sqlite("/tmp/app.db"))?;
let result = db.atomic(&[
    Operation::schema("create table if not exists events (id integer primary key, name text)", Vec::new()),
    Operation::write("insert into events (name) values ($1)", vec![Parameter::Text("opened".into())]),
])?;

Environment

Variable Meaning
DATASTORE sqlite or neon
DATASTORE_ROUTE Dactyl local-store path or Neon service endpoint
DATASTORE_TOKEN Optional opaque bearer token for Neon

The database schema and backend endpoint contract are application-owned. Dactyl executes caller-supplied schema statements but does not assign migration ids, order migrations, create hidden tables, or administer recovery policy.

License

MIT.