Examples
Rust: also in Python · Node · Go · Swift · Dart · Java and Kotlin · .NET · C++ · Objective-C
Every program here is executed against the published package on each release, so an example that stopped working would fail the release rather than mislead you.
Conflict resolution
Section titled “Conflict resolution”//! Conflict is data, not an exception: detect it, resolve it, lose nothing.//!//! A losing sync is a `SyncResult::Conflict` value, not an `Err`. Errors//! are for things that went wrong; losing a race is an ordinary outcome//! with a defined next step.
use potluckdb::{OpenOptions, Remote, SqlValue, SyncPolicy, SyncResult};
fn main() -> Result<(), Box<dyn std::error::Error>> { let scratch = scratch_dir()?; let remote = Remote::from_directory(scratch.join("remote"))?; let manual = OpenOptions { sync_policy: SyncPolicy::manual(), ..Default::default() };
let mut writer = potluckdb::open_with_options(scratch.join("writer"), Some(&remote), manual.clone())?; writer.execute( "CREATE TABLE item(id INTEGER PRIMARY KEY, value TEXT)", (), )?; writer.sync()?;
// No id is named: the database assigns one. That matters more than it // looks. Resolving a conflict replays local statements on top of the // winner, and a statement whose key the winner already used cannot be // replayed -- it is quarantined instead, and its write does not land. // Two devices that both pick their own ids collide the moment they // pick the same one. Left to the database, they cannot. let mut rival = potluckdb::open_with_options(scratch.join("rival"), Some(&remote), manual)?; rival.execute( "INSERT INTO item(value) VALUES('from rival')", (), )?; match rival.sync()? { SyncResult::Pushed { .. } => {} other => panic!("expected the rival to publish, got {other:?}"), }
// The writer races the same head with its own local work. writer.execute( "INSERT INTO item(value) VALUES('from writer')", (), )?; match writer.sync()? { SyncResult::Conflict { .. } => {} other => panic!("expected the writer to be told it lost, got {other:?}"), }
let resolution = writer.resolve_conflict()?; // `revision` is a plain [u8; 32] rather than an Option: a resolution // always adopts one, and the type says so, where Go and Node hand back // something nullable that a caller has to check. assert_eq!(resolution.revision.len(), 32); // Pushed, not "Pushed or UpToDate": resolving stages the replayed // statement, so there is always something left to publish. match writer.sync()? { SyncResult::Pushed { .. } => {} other => panic!("expected the writer to publish what it resolved, got {other:?}"), }
// Both sides' rows survive: the winner was adopted and the safe local // statement replayed on top of it. let rows = writer.query( "SELECT id, value FROM item ORDER BY id", (), )?; assert_eq!(rows.len(), 2); assert_eq!(rows[0][1], SqlValue::Text("from rival".into())); assert_eq!(rows[1][1], SqlValue::Text("from writer".into()));
writer.close()?; rival.close()?; drop(remote); std::fs::remove_dir_all(&scratch).ok(); println!("conflict_resolution: ok"); Ok(())}
fn scratch_dir() -> std::io::Result<std::path::PathBuf> { let mut path = std::env::temp_dir(); path.push(format!("potluckdb-conflict-{}", std::process::id())); std::fs::create_dir_all(&path)?; Ok(path)}Quickstart local
Section titled “Quickstart local”//! Local use needs no configuration: open, write, read, close.//!//! Values are a `SqlValue` enum in both directions. That is more typing//! than a dynamically typed binding, and in exchange a column that is not//! the type you expected is a compile-time `match` arm rather than a//! surprise at run time.
use potluckdb::SqlValue;
fn main() -> Result<(), Box<dyn std::error::Error>> { let scratch = tempdir()?; let mut database = potluckdb::open(scratch.join("app.potluckdb"))?;
database.execute( "CREATE TABLE note(id INTEGER PRIMARY KEY, body TEXT NOT NULL)", (), )?;
let inserted = database.execute( "INSERT INTO note(body) VALUES(?)", ("hello",), )?; assert_eq!(inserted.rows_affected, 1); // `last_insert_id` is an Option: a statement that inserted nothing has // no identifier to report, and None says so without inventing a zero. assert_eq!(inserted.last_insert_id, Some(1));
let rows = database.query( "SELECT id, body FROM note ORDER BY id", (), )?; assert_eq!(rows.columns(), ["id", "body"]); assert_eq!(rows.len(), 1); assert_eq!(rows[0].get::<i64>(0)?, 1); assert_eq!(rows[0].get::<String>("body")?, "hello"); assert_eq!(rows[0].get::<Option<String>>("body")?.as_deref(), Some("hello")); assert_eq!(rows[0][0], SqlValue::Integer(1)); assert_eq!(rows[0]["body"], SqlValue::Text("hello".into()));
database.close()?; database.close()?; // close is idempotent
std::fs::remove_dir_all(&scratch).ok(); println!("quickstart_local: ok"); Ok(())}
fn tempdir() -> std::io::Result<std::path::PathBuf> { let mut path = std::env::temp_dir(); path.push(format!("potluckdb-{}", std::process::id())); std::fs::create_dir_all(&path)?; Ok(path)}Remote sync
Section titled “Remote sync”//! Two devices share one remote: write on one, sync, read on the other.//!//! `SyncResult` is an enum, so every outcome is a `match` arm and the//! compiler objects when a new one is added and left unhandled.
use potluckdb::{OpenOptions, Remote, SqlValue, SyncPolicy, SyncResult};
fn main() -> Result<(), Box<dyn std::error::Error>> { let scratch = scratch_dir()?; let remote = Remote::from_directory(scratch.join("remote"))?; let manual = OpenOptions { sync_policy: SyncPolicy::manual(), ..Default::default() };
let mut laptop = potluckdb::open_with_options(scratch.join("laptop"), Some(&remote), manual.clone())?; laptop.execute( "CREATE TABLE task(id INTEGER PRIMARY KEY, title TEXT)", (), )?; laptop.execute( "INSERT INTO task(title) VALUES(?)", ("ship the release",), )?; match laptop.sync()? { SyncResult::Pushed { .. } => {} other => panic!("expected the first sync to publish, got {other:?}"), }
let mut phone = potluckdb::open_with_options(scratch.join("phone"), Some(&remote), manual)?; let rows = phone.query("SELECT title FROM task", ())?; assert_eq!(rows[0][0], SqlValue::Text("ship the release".into()));
// An unchanged head is reported as exactly that. match phone.sync()? { SyncResult::UpToDate { .. } => {} other => panic!("expected an unchanged head, got {other:?}"), }
laptop.close()?; phone.close()?; drop(remote); std::fs::remove_dir_all(&scratch).ok(); println!("remote_sync: ok"); Ok(())}
fn scratch_dir() -> std::io::Result<std::path::PathBuf> { let mut path = std::env::temp_dir(); path.push(format!("potluckdb-sync-{}", std::process::id())); std::fs::create_dir_all(&path)?; Ok(path)}Second device
Section titled “Second device”//! Adding a second device, including one that opens before anything is published.
use potluckdb::{OpenOptions, Remote, SqlValue, SyncPolicy, SyncResult};
fn main() -> Result<(), Box<dyn std::error::Error>> { let scratch = scratch_dir()?; let remote = Remote::from_directory(scratch.join("remote"))?; let manual = OpenOptions { sync_policy: SyncPolicy::manual(), ..Default::default() };
// The first device publishes before any other device opens. let mut laptop = potluckdb::open_with_options(scratch.join("laptop"), Some(&remote), manual.clone())?; laptop.execute( "CREATE TABLE note(id INTEGER PRIMARY KEY, body TEXT)", (), )?; laptop.execute( "INSERT INTO note VALUES(1, 'from the laptop')", (), )?; match laptop.sync()? { SyncResult::Pushed { .. } => {} other => panic!("expected the first sync to publish, got {other:?}"), }
// Opening now bootstraps from that published head, so the phone // already has the table. let mut phone = potluckdb::open_with_options(scratch.join("phone"), Some(&remote), manual.clone())?; let rows = phone.query("SELECT body FROM note", ())?; assert_eq!(rows[0][0], SqlValue::Text("from the laptop".into())); phone.close()?;
// Devices may be opened in any order. One that opens against an // empty remote has nothing to copy and nothing of its own to // publish, so its first sync is an ordinary pull. let early_remote = Remote::from_directory(scratch.join("early-remote"))?; let mut first = potluckdb::open_with_options(scratch.join("first"), Some(&early_remote), manual.clone())?; let mut tablet = potluckdb::open_with_options(scratch.join("tablet"), Some(&early_remote), manual)?; assert_eq!( tablet.status()?.pending_writes, 0, "a device that has written nothing has nothing to publish" ); first.execute( "CREATE TABLE note(id INTEGER PRIMARY KEY, body TEXT)", (), )?; first.execute( "INSERT INTO note VALUES(1, 'from the first device')", (), )?; match first.sync()? { SyncResult::Pushed { .. } => {} other => panic!("expected the first device to publish, got {other:?}"), }
match tablet.sync()? { SyncResult::Pulled { .. } => {} other => panic!("expected a pristine device to pull, got {other:?}"), } let visible = tablet.query("SELECT body FROM note", ())?; assert_eq!(visible[0][0], SqlValue::Text("from the first device".into()));
first.close()?; tablet.close()?; drop(early_remote); laptop.close()?; drop(remote); std::fs::remove_dir_all(&scratch).ok(); println!("second_device: ok"); Ok(())}
fn scratch_dir() -> std::io::Result<std::path::PathBuf> { let mut path = std::env::temp_dir(); path.push(format!("potluckdb-second-device-{}", std::process::id())); std::fs::create_dir_all(&path)?; Ok(path)}Transactions
Section titled “Transactions”//! Managed transactions: commit on success, roll back on failure.//!//! The scope is a closure returning `Result`. Returning `Ok` commits;//! returning `Err` rolls back and the error reaches the caller unchanged,//! so a failed batch cannot be mistaken for a successful one.
use potluckdb::{PotluckDBError, SqlValue};
fn main() -> Result<(), Box<dyn std::error::Error>> { let scratch = scratch_dir()?; let mut database = potluckdb::open(scratch.join("app.potluckdb"))?; database.execute( "CREATE TABLE ledger(id INTEGER PRIMARY KEY, amount INTEGER)", (), )?;
database.transaction(|transaction| { transaction.execute( "INSERT INTO ledger(amount) VALUES(?)", (100,), )?; transaction.execute( "INSERT INTO ledger(amount) VALUES(?)", (-40,), )?; let rows = transaction.query("SELECT SUM(amount) FROM ledger", ())?; assert_eq!(rows[0][0], SqlValue::Integer(60)); Ok(()) })?;
let committed = database.query("SELECT COUNT(*) FROM ledger", ())?; assert_eq!(committed[0][0], SqlValue::Integer(2));
// A scope returning Err rolls back. The error type is the SDK's, so a // caller-defined failure travels as a message rather than a new type. let aborted: Result<(), PotluckDBError> = database.transaction(|transaction| { transaction.execute( "INSERT INTO ledger(amount) VALUES(?)", (999,), )?; transaction.execute("THIS IS NOT SQL", ())?; Ok(()) }); assert!(aborted.is_err(), "the failing scope must report failure");
let after = database.query("SELECT COUNT(*) FROM ledger", ())?; assert_eq!( after[0][0], SqlValue::Integer(2), "the failed batch left nothing" );
database.close()?; std::fs::remove_dir_all(&scratch).ok(); println!("transactions: ok"); Ok(())}
fn scratch_dir() -> std::io::Result<std::path::PathBuf> { let mut path = std::env::temp_dir(); path.push(format!("potluckdb-tx-{}", std::process::id())); std::fs::create_dir_all(&path)?; Ok(path)}Two writers
Section titled “Two writers”//! Two devices that both write, and end up agreeing.//!//! This is what a local-first database is for, and it is the one sequence//! worth learning: write on each device, publish, and let the second one//! resolve. Concurrent writes are the ordinary case, not the exceptional//! one -- whichever device publishes second finds the head has moved and//! says so.
use potluckdb::{OpenOptions, Remote, SqlValue, SyncPolicy, SyncResult};
fn main() -> Result<(), Box<dyn std::error::Error>> { let scratch = scratch_dir()?; let remote = Remote::from_directory(scratch.join("remote"))?; let manual = OpenOptions { sync_policy: SyncPolicy::manual(), ..Default::default() };
let mut laptop = potluckdb::open_with_options(scratch.join("laptop"), Some(&remote), manual.clone())?; laptop.execute( "CREATE TABLE note(id INTEGER PRIMARY KEY, body TEXT)", (), )?; laptop.sync()?;
// A second device bootstraps from the published head, schema and all. // Creating the table again here is not a no-op -- it fails with // table already exists, because it does. let mut phone = potluckdb::open_with_options(scratch.join("phone"), Some(&remote), manual)?;
// Both write before either publishes. Neither names an id, so each // side assigns its own -- and both pick 1, because both are looking at // the same head. That is fine precisely because the statement does not // name it: replaying it on top of the winner assigns a fresh one. laptop.execute( "INSERT INTO note(body) VALUES('buy milk')", (), )?; phone.execute( "INSERT INTO note(body) VALUES('call the dentist')", (), )?;
match laptop.sync()? { SyncResult::Pushed { .. } => {} other => panic!("expected the laptop to publish, got {other:?}"), }
// The phone published second, so it finds the head has moved. This is // the expected result of concurrent writing, not a failure. match phone.sync()? { SyncResult::Conflict { .. } => {} other => panic!("expected the phone to be told the head moved, got {other:?}"), } let resolution = phone.resolve_conflict()?; assert!( resolution.quarantined.is_empty(), "nothing was given up to resolve this" ); match phone.sync()? { SyncResult::Pushed { .. } => {} other => panic!("expected the phone to publish what it resolved, got {other:?}"), }
// The laptop pulls what the phone resolved and published. match laptop.sync()? { SyncResult::Pulled { .. } => {} other => panic!("expected the laptop to pull, got {other:?}"), }
for device in [&mut laptop, &mut phone] { let rows = device.query("SELECT body FROM note ORDER BY body", ())?; assert_eq!(rows.len(), 2); assert_eq!(rows[0][0], SqlValue::Text("buy milk".into())); assert_eq!(rows[1][0], SqlValue::Text("call the dentist".into())); }
laptop.close()?; phone.close()?; drop(remote); std::fs::remove_dir_all(&scratch).ok(); println!("two_writers: ok"); Ok(())}
fn scratch_dir() -> std::io::Result<std::path::PathBuf> { let mut path = std::env::temp_dir(); path.push(format!("potluckdb-two-writers-{}", std::process::id())); // Cleared first: a process id comes round again, and opening a // database on top of one a crashed run left behind is not the thing // this example is demonstrating. let _ = std::fs::remove_dir_all(&path); std::fs::create_dir_all(&path)?; Ok(path)}