fslite-sqlite 0.1.0

A transport-independent, async virtual filesystem with a SQLite-backed persistent backend, HTTP adapter, and CLI.
Documentation
use fslite_core::{ErrorCode, PageRequest, RequestContext, VirtualPath};
use fslite_sqlite::SqliteFileSystem;

fn path(input: &str) -> VirtualPath {
    VirtualPath::parse(input).unwrap()
}

#[tokio::test]
async fn change_sequences_strictly_increase() {
    let fs = SqliteFileSystem::open_in_memory(Default::default())
        .await
        .unwrap();
    let workspace = fs.create_workspace(Default::default()).await.unwrap();
    let ctx = RequestContext::trusted(workspace.id);

    for name in ["a", "b", "c"] {
        fs.mkdir(&ctx, &path(&format!("/{name}")), Default::default())
            .await
            .unwrap();
    }

    let page = fs.changes(&ctx, None, Default::default()).await.unwrap();
    assert_eq!(page.items.len(), 3);
    let sequences: Vec<u64> = page.items.iter().map(|c| c.sequence).collect();
    let mut sorted = sequences.clone();
    sorted.sort_unstable();
    assert_eq!(sequences, sorted, "sequences must already be increasing");
    assert!(sequences.windows(2).all(|w| w[1] > w[0]));
}

#[tokio::test]
async fn changes_paginate_without_duplicates_or_gaps() {
    let fs = SqliteFileSystem::open_in_memory(Default::default())
        .await
        .unwrap();
    let workspace = fs.create_workspace(Default::default()).await.unwrap();
    let ctx = RequestContext::trusted(workspace.id);

    for i in 0..7 {
        fs.mkdir(&ctx, &path(&format!("/n{i}")), Default::default())
            .await
            .unwrap();
    }

    let mut seen = Vec::new();
    let mut cursor = None;
    loop {
        let page = fs
            .changes(&ctx, cursor.clone(), PageRequest::default().limit(2))
            .await
            .unwrap();
        seen.extend(page.items.iter().map(|c| c.sequence));
        cursor = page.next_cursor.map(fslite_core::ChangeCursor::new);
        if cursor.is_none() {
            break;
        }
    }

    assert_eq!(seen.len(), 7);
    let mut sorted = seen.clone();
    sorted.sort_unstable();
    sorted.dedup();
    assert_eq!(sorted.len(), 7, "no duplicate sequences");
}

#[tokio::test]
async fn a_cursor_from_one_workspace_is_rejected_in_another() {
    let fs = SqliteFileSystem::open_in_memory(Default::default())
        .await
        .unwrap();
    let first = fs.create_workspace(Default::default()).await.unwrap();
    let second = fs.create_workspace(Default::default()).await.unwrap();
    let first_ctx = RequestContext::trusted(first.id);
    let second_ctx = RequestContext::trusted(second.id);

    for i in 0..3 {
        fs.mkdir(&first_ctx, &path(&format!("/n{i}")), Default::default())
            .await
            .unwrap();
        fs.mkdir(&second_ctx, &path(&format!("/n{i}")), Default::default())
            .await
            .unwrap();
    }

    let first_page = fs
        .changes(&first_ctx, None, PageRequest::default().limit(1))
        .await
        .unwrap();
    let cursor = first_page.next_cursor.expect("more pages remain");

    let error = fs
        .changes(
            &second_ctx,
            Some(fslite_core::ChangeCursor::new(cursor)),
            Default::default(),
        )
        .await
        .unwrap_err();
    assert_eq!(error.code(), ErrorCode::InvalidCursor);
}

#[tokio::test]
async fn malformed_change_cursor_is_rejected() {
    let fs = SqliteFileSystem::open_in_memory(Default::default())
        .await
        .unwrap();
    let workspace = fs.create_workspace(Default::default()).await.unwrap();
    let ctx = RequestContext::trusted(workspace.id);

    let error = fs
        .changes(
            &ctx,
            Some(fslite_core::ChangeCursor::new("not-a-real-cursor")),
            Default::default(),
        )
        .await
        .unwrap_err();
    assert_eq!(error.code(), ErrorCode::InvalidCursor);
}