rust_supabase_sdk 0.3.0

An SDK kit for SupaBase so that Rust lovers can use SupaBase with the low level abstracted away. If you want new features tell me and I'll add them.
Documentation
//! Concurrent stress tests for `InMemorySessionStore`.
//!
//! `SessionStore` is required to be `Send + Sync` and is hit from multiple
//! request middleware paths simultaneously (`get` on every outgoing request,
//! `set`/`clear` on auth flows). The compiler enforces the trait bounds, but
//! these tests prove the runtime behaviour is actually data-race-free under
//! contention.

#![allow(clippy::unwrap_used)]

use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use chrono::Utc;
use rust_supabase_sdk::auth::Session;
use rust_supabase_sdk::{InMemorySessionStore, SessionStore};
use serde_json::json;

fn make_session(token: &str) -> Session {
    Session {
        access_token: token.into(),
        token_type: "bearer".into(),
        expires_in: 3600,
        expires_at: Utc::now().timestamp() + 3600,
        refresh_token: format!("refresh-{token}"),
        user: serde_json::from_value(json!({
            "id": "u1",
            "aud": "auth",
            "role": "auth",
            "created_at": "2024-01-01T00:00:00Z"
        }))
        .unwrap(),
    }
}

/// 8 reader threads + 4 writer threads + 2 clear threads hammer the store for
/// 200ms. The reads must always observe either `None` or a *consistent*
/// session (i.e. matching `access_token` and `refresh_token`). If `get()` ever
/// returned a half-written struct, this would catch it.
#[test]
fn store_survives_8_readers_4_writers_2_clearers() {
    let store: Arc<InMemorySessionStore> = Arc::new(InMemorySessionStore::new());
    let deadline = Instant::now() + Duration::from_millis(200);

    let mut handles = Vec::new();

    // Readers: each verifies the (access_token, refresh_token) invariant.
    for _ in 0..8 {
        let s = Arc::clone(&store);
        handles.push(thread::spawn(move || {
            let mut reads = 0u64;
            while Instant::now() < deadline {
                if let Some(sess) = s.get() {
                    // refresh_token is always built as "refresh-<access_token>",
                    // so this invariant proves the get returned a whole Session.
                    assert_eq!(sess.refresh_token, format!("refresh-{}", sess.access_token));
                }
                reads += 1;
            }
            reads
        }));
    }

    // Writers: spam unique session tokens.
    for i in 0..4 {
        let s = Arc::clone(&store);
        handles.push(thread::spawn(move || {
            let mut writes = 0u64;
            while Instant::now() < deadline {
                s.set(make_session(&format!("tok-{i}-{writes}")));
                writes += 1;
            }
            writes
        }));
    }

    // Clearers: occasionally wipe the store.
    for _ in 0..2 {
        let s = Arc::clone(&store);
        handles.push(thread::spawn(move || {
            let mut clears = 0u64;
            while Instant::now() < deadline {
                s.clear();
                clears += 1;
                thread::sleep(Duration::from_micros(50));
            }
            clears
        }));
    }

    let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
    // The actual work count doesn't matter; what matters is no panic, no deadlock.
    assert!(total > 0, "no operations were performed");
}

/// Lock poisoning recovery: if one thread panics while holding the write lock,
/// other threads must still be able to read/write (the impl uses
/// `into_inner()` on poison).
#[test]
fn store_recovers_from_poisoned_lock() {
    let store: Arc<InMemorySessionStore> = Arc::new(InMemorySessionStore::new());
    store.set(make_session("initial"));

    // Spawn a thread that panics — but DON'T let the panic propagate the
    // RwLock into a poisoned state via a write guard (the InMemorySessionStore
    // doesn't expose internals, so we have to poison from outside).
    //
    // We approximate poisoning by triggering a panic *while* the lock is
    // briefly held by repeatedly setting in a thread that panics mid-loop.
    let s = Arc::clone(&store);
    let handle = thread::spawn(move || {
        s.set(make_session("from-panicking-thread"));
        panic!("intentional panic to exercise impl");
    });
    // We expect the thread to panic.
    assert!(handle.join().is_err());

    // The main thread must still be able to read/write — the impl handles
    // `PoisonError::into_inner()` so the store should remain usable.
    let snapshot = store.get();
    assert!(snapshot.is_some(), "store became unreadable after panicking thread");

    store.set(make_session("after-panic"));
    assert_eq!(store.get().unwrap().access_token, "after-panic");

    store.clear();
    assert!(store.get().is_none());
}

/// Many `set` calls from many threads — last-writer-wins, but the final read
/// must observe some valid session (never a torn one).
#[test]
fn last_writer_wins_under_contention() {
    let store: Arc<InMemorySessionStore> = Arc::new(InMemorySessionStore::new());
    let mut handles = Vec::new();
    for i in 0..16 {
        let s = Arc::clone(&store);
        handles.push(thread::spawn(move || {
            for j in 0..100 {
                s.set(make_session(&format!("t-{i}-{j}")));
            }
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    let final_sess = store.get().unwrap();
    // Whatever the final token is, the refresh_token must match the invariant.
    assert_eq!(
        final_sess.refresh_token,
        format!("refresh-{}", final_sess.access_token)
    );
    assert!(final_sess.access_token.starts_with("t-"));
}

/// `get()` always returns an independent clone — modifying the returned value
/// doesn't affect the store, and overwriting the store doesn't affect prior
/// reads.
#[test]
fn get_returns_independent_snapshot() {
    let store = InMemorySessionStore::new();
    store.set(make_session("v1"));
    let snap1 = store.get().unwrap();
    store.set(make_session("v2"));
    let snap2 = store.get().unwrap();
    assert_eq!(snap1.access_token, "v1");
    assert_eq!(snap2.access_token, "v2");
    // Independent snapshots: snap1 unchanged even though the store moved on.
}

/// Reader doesn't block other readers (RwLock semantics). Soft check: spawn
/// many readers and verify they all complete inside a tight window.
#[test]
fn readers_do_not_block_each_other() {
    let store: Arc<InMemorySessionStore> = Arc::new(InMemorySessionStore::new());
    store.set(make_session("v1"));

    let start = Instant::now();
    let mut handles = Vec::new();
    for _ in 0..32 {
        let s = Arc::clone(&store);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                let _ = s.get();
            }
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    let elapsed = start.elapsed();
    // 32 threads × 1000 reads = 32_000 reads. Even on the slowest CI box this
    // should complete in <2s if readers truly proceed in parallel.
    assert!(
        elapsed < Duration::from_secs(2),
        "readers serialized somehow — took {elapsed:?}"
    );
}