snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
58
packages/frontend/native/nbstore/Cargo.toml
Normal file
58
packages/frontend/native/nbstore/Cargo.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "affine_nbstore"
|
||||
publish = false
|
||||
version = "0.0.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
napi = ["affine_common/napi"]
|
||||
use-as-lib = ["napi-derive/noop", "napi/noop"]
|
||||
|
||||
[dependencies]
|
||||
affine_common = { workspace = true, features = ["napi"] }
|
||||
affine_doc_loader = { workspace = true }
|
||||
affine_schema = { path = "../schema" }
|
||||
anyhow = { workspace = true }
|
||||
bincode = { version = "2.0.1", features = ["serde"] }
|
||||
chrono = { workspace = true }
|
||||
memory-indexer = { workspace = true }
|
||||
napi = { workspace = true }
|
||||
napi-derive = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sqlx = { workspace = true, default-features = false, features = [
|
||||
"chrono",
|
||||
"macros",
|
||||
"migrate",
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
y-octo = { workspace = true }
|
||||
zstd = "0.13"
|
||||
|
||||
[target.'cfg(any(target_os = "ios", target_os = "android"))'.dependencies]
|
||||
uniffi = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
affine_schema = { path = "../schema" }
|
||||
dotenvy = { workspace = true }
|
||||
napi-build = { workspace = true }
|
||||
sqlx = { workspace = true, default-features = false, features = [
|
||||
"chrono",
|
||||
"macros",
|
||||
"migrate",
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
] }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
assert-json-diff = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
34
packages/frontend/native/nbstore/build.rs
Normal file
34
packages/frontend/native/nbstore/build.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use std::fs;
|
||||
|
||||
use affine_schema::get_migrator;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), std::io::Error> {
|
||||
napi_build::setup();
|
||||
|
||||
// always start with a fresh database to have latest db schema
|
||||
let cwd = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let db_path = format!("{cwd}/affine.db");
|
||||
|
||||
if fs::metadata(&db_path).is_ok() {
|
||||
fs::remove_file(&db_path)?;
|
||||
}
|
||||
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(&db_path)
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Off)
|
||||
.locking_mode(sqlx::sqlite::SqliteLockingMode::Exclusive)
|
||||
.create_if_missing(true);
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
get_migrator().run(&pool).await.unwrap();
|
||||
|
||||
println!("cargo::rustc-env=DATABASE_URL=sqlite://{db_path}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
186
packages/frontend/native/nbstore/src/blob.rs
Normal file
186
packages/frontend/native/nbstore/src/blob.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use super::{Blob, ListedBlob, SetBlob, error::Result, storage::SqliteDocStorage};
|
||||
|
||||
impl SqliteDocStorage {
|
||||
pub async fn get_blob(&self, key: String) -> Result<Option<Blob>> {
|
||||
let result = sqlx::query_as!(
|
||||
Blob,
|
||||
"SELECT key, data, size, mime, created_at FROM blobs WHERE key = ? AND deleted_at IS NULL",
|
||||
key
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn set_blob(&self, blob: SetBlob) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO blobs (key, data, mime, size)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT(key)
|
||||
DO UPDATE SET data=$2, mime=$3, size=$4, deleted_at=NULL;"#,
|
||||
)
|
||||
.bind(blob.key)
|
||||
.bind(blob.data.deref())
|
||||
.bind(blob.mime)
|
||||
.bind(blob.data.len() as i64)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_blob(&self, key: String, permanently: bool) -> Result<()> {
|
||||
if permanently {
|
||||
sqlx::query("DELETE FROM blobs WHERE key = ?")
|
||||
.bind(&key)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query("UPDATE blobs SET deleted_at = CURRENT_TIMESTAMP WHERE key = ?")
|
||||
.bind(&key)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn release_blobs(&self) -> Result<()> {
|
||||
sqlx::query("DELETE FROM blobs WHERE deleted_at IS NOT NULL;")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_blobs(&self) -> Result<Vec<ListedBlob>> {
|
||||
let result = sqlx::query_as!(
|
||||
ListedBlob,
|
||||
"SELECT key, size, mime, created_at FROM blobs WHERE deleted_at IS NULL ORDER BY created_at DESC;"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{super::Data, *};
|
||||
|
||||
async fn get_storage() -> SqliteDocStorage {
|
||||
let storage = SqliteDocStorage::new(":memory:".to_string());
|
||||
storage.connect().await.unwrap();
|
||||
|
||||
storage
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_blob() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
for i in 1..5u32 {
|
||||
storage
|
||||
.set_blob(SetBlob {
|
||||
key: format!("test_{}", i),
|
||||
data: Into::<Data>::into(vec![0, 0]),
|
||||
mime: "text/plain".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let result = storage.get_blob("test_1".to_string()).await.unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
|
||||
storage.delete_blob("test_".to_string(), false).await.unwrap();
|
||||
|
||||
let result = storage.get_blob("test".to_string()).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
|
||||
storage.delete_blob("test_2".to_string(), true).await.unwrap();
|
||||
|
||||
let result = storage.get_blob("test".to_string()).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_blobs() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
let blobs = storage.list_blobs().await.unwrap();
|
||||
|
||||
assert_eq!(blobs.len(), 0);
|
||||
|
||||
for i in 1..5u32 {
|
||||
storage
|
||||
.set_blob(SetBlob {
|
||||
key: format!("test_{}", i),
|
||||
data: Into::<Data>::into(vec![0, 0]),
|
||||
mime: "text/plain".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let blobs = storage.list_blobs().await.unwrap();
|
||||
|
||||
assert_eq!(blobs.len(), 4);
|
||||
assert_eq!(
|
||||
blobs.iter().map(|b| b.key.as_str()).collect::<Vec<_>>(),
|
||||
vec!["test_1", "test_2", "test_3", "test_4"]
|
||||
);
|
||||
|
||||
storage.delete_blob("test_2".to_string(), false).await.unwrap();
|
||||
|
||||
storage.delete_blob("test_3".to_string(), true).await.unwrap();
|
||||
|
||||
let query = sqlx::query("SELECT COUNT(*) as len FROM blobs;")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(query.get::<i64, &str>("len"), 3);
|
||||
|
||||
let blobs = storage.list_blobs().await.unwrap();
|
||||
assert_eq!(blobs.len(), 2);
|
||||
assert_eq!(
|
||||
blobs.iter().map(|b| b.key.as_str()).collect::<Vec<_>>(),
|
||||
vec!["test_1", "test_4"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_blobs() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
for i in 1..5u32 {
|
||||
storage
|
||||
.set_blob(SetBlob {
|
||||
key: format!("test_{}", i),
|
||||
data: Into::<Data>::into(vec![0, 0]),
|
||||
mime: "text/plain".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
storage.delete_blob("test_2".to_string(), false).await.unwrap();
|
||||
storage.release_blobs().await.unwrap();
|
||||
|
||||
let query = sqlx::query("SELECT COUNT(*) as len FROM blobs;")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(query.get::<i64, &str>("len"), 3);
|
||||
}
|
||||
}
|
||||
88
packages/frontend/native/nbstore/src/blob_sync.rs
Normal file
88
packages/frontend/native/nbstore/src/blob_sync.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use super::{error::Result, storage::SqliteDocStorage};
|
||||
|
||||
impl SqliteDocStorage {
|
||||
pub async fn set_blob_uploaded_at(
|
||||
&self,
|
||||
peer: String,
|
||||
blob_id: String,
|
||||
uploaded_at: Option<NaiveDateTime>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO peer_blob_sync (peer, blob_id, uploaded_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(peer, blob_id)
|
||||
DO UPDATE SET uploaded_at=$3;"#,
|
||||
)
|
||||
.bind(peer)
|
||||
.bind(blob_id)
|
||||
.bind(uploaded_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_blob_uploaded_at(&self, peer: String, blob_id: String) -> Result<Option<NaiveDateTime>> {
|
||||
let result = sqlx::query_scalar!(
|
||||
"SELECT uploaded_at FROM peer_blob_sync WHERE peer = ? AND blob_id = ?",
|
||||
peer,
|
||||
blob_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.flatten())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn get_storage() -> SqliteDocStorage {
|
||||
let storage = SqliteDocStorage::new(":memory:".to_string());
|
||||
storage.connect().await.unwrap();
|
||||
|
||||
storage
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blob_uploaded_at() {
|
||||
let storage = get_storage().await;
|
||||
let peer = String::from("peer1");
|
||||
let blob_id = String::from("blob1");
|
||||
|
||||
let uploaded_at = storage
|
||||
.get_blob_uploaded_at(peer.clone(), blob_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(uploaded_at.is_none());
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
storage
|
||||
.set_blob_uploaded_at(peer.clone(), blob_id.clone(), Some(now))
|
||||
.await
|
||||
.unwrap();
|
||||
let uploaded_at = storage
|
||||
.get_blob_uploaded_at(peer.clone(), blob_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(uploaded_at.is_some());
|
||||
assert_eq!(uploaded_at.unwrap(), now);
|
||||
|
||||
storage
|
||||
.set_blob_uploaded_at(peer.clone(), blob_id.clone(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let uploaded_at = storage
|
||||
.get_blob_uploaded_at(peer.clone(), blob_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(uploaded_at.is_none());
|
||||
}
|
||||
}
|
||||
465
packages/frontend/native/nbstore/src/doc.rs
Normal file
465
packages/frontend/native/nbstore/src/doc.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime};
|
||||
use sqlx::{QueryBuilder, Row};
|
||||
|
||||
use super::{DocClock, DocRecord, DocUpdate, error::Result, storage::SqliteDocStorage};
|
||||
|
||||
struct Meta {
|
||||
space_id: String,
|
||||
}
|
||||
|
||||
impl SqliteDocStorage {
|
||||
pub async fn set_space_id(&self, space_id: String) -> Result<()> {
|
||||
// ensure only one record exists in table
|
||||
let result = sqlx::query_as!(Meta, "SELECT * FROM meta;")
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
match result {
|
||||
Some(meta) => {
|
||||
if meta.space_id != space_id {
|
||||
sqlx::query("UPDATE meta SET space_id = $1;")
|
||||
.bind(&space_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE updates SET doc_id = $1 WHERE doc_id = $2;")
|
||||
.bind(&space_id)
|
||||
.bind(&meta.space_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE snapshots SET doc_id = $1 WHERE doc_id = $2;")
|
||||
.bind(&space_id)
|
||||
.bind(&meta.space_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE clocks SET doc_id = $1 WHERE doc_id = $2;")
|
||||
.bind(&space_id)
|
||||
.bind(&meta.space_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query("UPDATE indexer_sync SET doc_id = $1 WHERE doc_id = $2;")
|
||||
.bind(&space_id)
|
||||
.bind(&meta.space_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE peer_clocks SET doc_id = $1 WHERE doc_id = $2;")
|
||||
.bind(&space_id)
|
||||
.bind(&meta.space_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
sqlx::query("INSERT INTO meta (space_id) VALUES ($1);")
|
||||
.bind(&space_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn push_update<Update: AsRef<[u8]>>(&self, doc_id: String, update: Update) -> Result<NaiveDateTime> {
|
||||
let mut timestamp = DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis())
|
||||
.unwrap()
|
||||
.naive_utc();
|
||||
|
||||
let mut tried = 0;
|
||||
|
||||
// Keep trying with incremented timestamps until success
|
||||
loop {
|
||||
match self
|
||||
.try_insert_update_with_timestamp(&doc_id, update.as_ref(), timestamp)
|
||||
.await
|
||||
{
|
||||
Ok(()) => break,
|
||||
Err(e) => {
|
||||
if tried > 10 {
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
// Increment timestamp by 1ms and retry
|
||||
timestamp += chrono::Duration::milliseconds(1);
|
||||
tried += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(timestamp)
|
||||
}
|
||||
|
||||
async fn try_insert_update_with_timestamp(
|
||||
&self,
|
||||
doc_id: &str,
|
||||
update: &[u8],
|
||||
timestamp: NaiveDateTime,
|
||||
) -> sqlx::Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query(r#"INSERT INTO updates (doc_id, data, created_at) VALUES ($1, $2, $3);"#)
|
||||
.bind(doc_id)
|
||||
.bind(update)
|
||||
.bind(timestamp)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO clocks (doc_id, timestamp) VALUES ($1, $2)
|
||||
ON CONFLICT(doc_id)
|
||||
DO UPDATE SET timestamp=$2;"#,
|
||||
)
|
||||
.bind(doc_id)
|
||||
.bind(timestamp)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_doc_snapshot(&self, doc_id: String) -> Result<Option<DocRecord>> {
|
||||
let result = sqlx::query_as!(
|
||||
DocRecord,
|
||||
"SELECT doc_id, data as bin, updated_at as timestamp FROM snapshots WHERE doc_id = ?",
|
||||
doc_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn set_doc_snapshot(&self, snapshot: DocRecord) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO snapshots (doc_id, data, updated_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(doc_id)
|
||||
DO UPDATE SET data=$2, updated_at=$3
|
||||
WHERE updated_at <= $3;"#,
|
||||
)
|
||||
.bind(snapshot.doc_id)
|
||||
.bind(snapshot.bin.deref())
|
||||
.bind(snapshot.timestamp)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
pub async fn get_doc_updates(&self, doc_id: String) -> Result<Vec<DocUpdate>> {
|
||||
let result = sqlx::query_as!(
|
||||
DocUpdate,
|
||||
"SELECT doc_id, created_at as timestamp, data as bin FROM updates WHERE doc_id = ?",
|
||||
doc_id
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn mark_updates_merged(&self, doc_id: String, updates: Vec<NaiveDateTime>) -> Result<u32> {
|
||||
let mut qb = QueryBuilder::new("DELETE FROM updates");
|
||||
|
||||
qb.push(" WHERE doc_id = ");
|
||||
qb.push_bind(doc_id);
|
||||
qb.push(" AND created_at IN (");
|
||||
let mut separated = qb.separated(", ");
|
||||
updates.iter().for_each(|update| {
|
||||
separated.push_bind(update);
|
||||
});
|
||||
qb.push(");");
|
||||
|
||||
let query = qb.build();
|
||||
|
||||
let result = query.execute(&self.pool).await?;
|
||||
|
||||
Ok(result.rows_affected() as u32)
|
||||
}
|
||||
|
||||
pub async fn delete_doc(&self, doc_id: String) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM updates WHERE doc_id = ?;")
|
||||
.bind(&doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM snapshots WHERE doc_id = ?;")
|
||||
.bind(&doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM clocks WHERE doc_id = ?;")
|
||||
.bind(&doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM indexer_sync WHERE doc_id = ?;")
|
||||
.bind(&doc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_doc_clocks(&self, after: Option<NaiveDateTime>) -> Result<Vec<DocClock>> {
|
||||
let query = if let Some(after) = after {
|
||||
sqlx::query("SELECT doc_id, timestamp FROM clocks WHERE timestamp > $1").bind(after)
|
||||
} else {
|
||||
sqlx::query("SELECT doc_id, timestamp FROM clocks")
|
||||
};
|
||||
|
||||
let clocks = query.fetch_all(&self.pool).await?;
|
||||
|
||||
Ok(
|
||||
clocks
|
||||
.iter()
|
||||
.map(|row| DocClock {
|
||||
doc_id: row.get("doc_id"),
|
||||
timestamp: row.get("timestamp"),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_doc_clock(&self, doc_id: String) -> Result<Option<DocClock>> {
|
||||
let result = sqlx::query_as!(
|
||||
DocClock,
|
||||
"SELECT doc_id, timestamp FROM clocks WHERE doc_id = ?",
|
||||
doc_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::*;
|
||||
use crate::Data;
|
||||
|
||||
async fn get_storage() -> SqliteDocStorage {
|
||||
let storage = SqliteDocStorage::new(":memory:".to_string());
|
||||
storage.connect().await.unwrap();
|
||||
|
||||
storage
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_space_id() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
storage.set_space_id("test".to_string()).await.unwrap();
|
||||
|
||||
let result = sqlx::query!("SELECT space_id FROM meta;")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.space_id, "test");
|
||||
|
||||
storage.set_space_id("test2".to_string()).await.unwrap();
|
||||
|
||||
let result = sqlx::query!("SELECT space_id FROM meta;")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.space_id, "test2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_space_id_with_existing_doc() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
storage.set_space_id("test".to_string()).await.unwrap();
|
||||
storage.push_update("test".to_string(), vec![0, 0]).await.unwrap();
|
||||
storage
|
||||
.set_doc_snapshot(DocRecord {
|
||||
doc_id: "test".to_string(),
|
||||
bin: Into::<Data>::into(vec![0, 0]),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
storage
|
||||
.set_peer_pulled_remote_clock("remote".to_string(), "test".to_string(), Utc::now().naive_utc())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
storage.set_space_id("new_id".to_string()).await.unwrap();
|
||||
|
||||
let result = sqlx::query!("SELECT space_id FROM meta;")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.space_id, "new_id");
|
||||
|
||||
let clocks = storage.get_doc_clocks(None).await.unwrap();
|
||||
|
||||
assert_eq!(clocks[0].doc_id, "new_id");
|
||||
|
||||
let clocks = storage
|
||||
.get_peer_pulled_remote_clock("remote".to_string(), "new_id".to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(clocks.doc_id, "new_id");
|
||||
|
||||
let updates = storage.get_doc_updates("new_id".to_string()).await.unwrap();
|
||||
|
||||
assert_eq!(updates.len(), 1);
|
||||
|
||||
let snapshot = storage.get_doc_snapshot("new_id".to_string()).await.unwrap();
|
||||
|
||||
assert!(snapshot.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_updates() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
let updates = vec![vec![0, 0], vec![0, 1], vec![1, 0], vec![1, 1]];
|
||||
|
||||
for update in updates.iter() {
|
||||
storage.push_update("test".to_string(), update).await.unwrap();
|
||||
}
|
||||
|
||||
let result = storage.get_doc_updates("test".to_string()).await.unwrap();
|
||||
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result.iter().map(|u| u.bin.to_vec()).collect::<Vec<_>>(), updates);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_doc_snapshot() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
let none = storage.get_doc_snapshot("test".to_string()).await.unwrap();
|
||||
|
||||
assert!(none.is_none());
|
||||
|
||||
let snapshot = DocRecord {
|
||||
doc_id: "test".to_string(),
|
||||
bin: Into::<Data>::into(vec![0, 0]),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
storage.set_doc_snapshot(snapshot).await.unwrap();
|
||||
|
||||
let result = storage.get_doc_snapshot("test".to_string()).await.unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().bin.to_vec(), vec![0, 0]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_doc_snapshot() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
let snapshot = DocRecord {
|
||||
doc_id: "test".to_string(),
|
||||
bin: Into::<Data>::into(vec![0, 0]),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
storage.set_doc_snapshot(snapshot).await.unwrap();
|
||||
|
||||
let result = storage.get_doc_snapshot("test".to_string()).await.unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().bin.to_vec(), vec![0, 0]);
|
||||
|
||||
let snapshot = DocRecord {
|
||||
doc_id: "test".to_string(),
|
||||
bin: Into::<Data>::into(vec![0, 1]),
|
||||
timestamp: DateTime::from_timestamp_millis(Utc::now().timestamp_millis() - 1000)
|
||||
.unwrap()
|
||||
.naive_utc(),
|
||||
};
|
||||
|
||||
// can't update because it's tempstamp is older
|
||||
storage.set_doc_snapshot(snapshot).await.unwrap();
|
||||
|
||||
let result = storage.get_doc_snapshot("test".to_string()).await.unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().bin.to_vec(), vec![0, 0]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_doc_clocks() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
let clocks = storage.get_doc_clocks(None).await.unwrap();
|
||||
|
||||
assert_eq!(clocks.len(), 0);
|
||||
|
||||
for i in 1..5u32 {
|
||||
storage.push_update(format!("test_{i}"), vec![0, 0]).await.unwrap();
|
||||
}
|
||||
|
||||
let clocks = storage.get_doc_clocks(None).await.unwrap();
|
||||
|
||||
assert_eq!(clocks.len(), 4);
|
||||
assert_eq!(
|
||||
clocks.iter().map(|c| c.doc_id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["test_1", "test_2", "test_3", "test_4"]
|
||||
);
|
||||
|
||||
let clocks = storage.get_doc_clocks(Some(Utc::now().naive_utc())).await.unwrap();
|
||||
|
||||
assert_eq!(clocks.len(), 0);
|
||||
|
||||
let clock = storage.get_doc_clock("test_1".to_string()).await.unwrap();
|
||||
|
||||
assert!(clock.is_some());
|
||||
assert_eq!(clock.unwrap().doc_id, "test_1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_updates_merged() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
let updates = [vec![0, 0], vec![0, 1], vec![1, 0], vec![1, 1]];
|
||||
|
||||
for update in updates.iter() {
|
||||
storage.push_update("test".to_string(), update).await.unwrap();
|
||||
}
|
||||
|
||||
let updates = storage.get_doc_updates("test".to_string()).await.unwrap();
|
||||
|
||||
let result = storage
|
||||
.mark_updates_merged(
|
||||
"test".to_string(),
|
||||
updates.iter().skip(1).map(|u| u.timestamp).collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 3);
|
||||
|
||||
let updates = storage.get_doc_updates("test".to_string()).await.unwrap();
|
||||
|
||||
assert_eq!(updates.len(), 1);
|
||||
}
|
||||
}
|
||||
279
packages/frontend/native/nbstore/src/doc_sync.rs
Normal file
279
packages/frontend/native/nbstore/src/doc_sync.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use super::{DocClock, error::Result, storage::SqliteDocStorage};
|
||||
|
||||
impl SqliteDocStorage {
|
||||
pub async fn get_peer_remote_clocks(&self, peer: String) -> Result<Vec<DocClock>> {
|
||||
let result = sqlx::query_as!(
|
||||
DocClock,
|
||||
"SELECT doc_id, remote_clock as timestamp FROM peer_clocks WHERE peer = ?",
|
||||
peer
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_peer_remote_clock(&self, peer: String, doc_id: String) -> Result<Option<DocClock>> {
|
||||
let result = sqlx::query_as!(
|
||||
DocClock,
|
||||
"SELECT doc_id, remote_clock as timestamp FROM peer_clocks WHERE peer = ? AND doc_id = ?",
|
||||
peer,
|
||||
doc_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn set_peer_remote_clock(&self, peer: String, doc_id: String, clock: NaiveDateTime) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO peer_clocks (peer, doc_id, remote_clock)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(peer, doc_id)
|
||||
DO UPDATE SET remote_clock=$3 WHERE remote_clock < $3;"#,
|
||||
)
|
||||
.bind(peer)
|
||||
.bind(doc_id)
|
||||
.bind(clock)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_peer_pulled_remote_clocks(&self, peer: String) -> Result<Vec<DocClock>> {
|
||||
let result = sqlx::query_as!(
|
||||
DocClock,
|
||||
"SELECT doc_id, pulled_remote_clock as timestamp FROM peer_clocks WHERE peer = ?",
|
||||
peer
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_peer_pulled_remote_clock(&self, peer: String, doc_id: String) -> Result<Option<DocClock>> {
|
||||
let result = sqlx::query_as!(
|
||||
DocClock,
|
||||
r#"SELECT doc_id, pulled_remote_clock as timestamp FROM peer_clocks WHERE peer = ? AND doc_id = ?"#,
|
||||
peer,
|
||||
doc_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn set_peer_pulled_remote_clock(&self, peer: String, doc_id: String, clock: NaiveDateTime) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO peer_clocks (peer, doc_id, pulled_remote_clock)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(peer, doc_id)
|
||||
DO UPDATE SET pulled_remote_clock=$3 WHERE pulled_remote_clock < $3;"#,
|
||||
)
|
||||
.bind(peer)
|
||||
.bind(doc_id)
|
||||
.bind(clock)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_peer_pushed_clocks(&self, peer: String) -> Result<Vec<DocClock>> {
|
||||
let result = sqlx::query_as!(
|
||||
DocClock,
|
||||
"SELECT doc_id, pushed_clock as timestamp FROM peer_clocks WHERE peer = ?",
|
||||
peer
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_peer_pushed_clock(&self, peer: String, doc_id: String) -> Result<Option<DocClock>> {
|
||||
let result = sqlx::query_as!(
|
||||
DocClock,
|
||||
"SELECT doc_id, pushed_clock as timestamp FROM peer_clocks WHERE peer = ? AND doc_id = ?",
|
||||
peer,
|
||||
doc_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn set_peer_pushed_clock(&self, peer: String, doc_id: String, clock: NaiveDateTime) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO peer_clocks (peer, doc_id, pushed_clock)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(peer, doc_id)
|
||||
DO UPDATE SET pushed_clock=$3 WHERE pushed_clock < $3;"#,
|
||||
)
|
||||
.bind(peer)
|
||||
.bind(doc_id)
|
||||
.bind(clock)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_clocks(&self) -> Result<()> {
|
||||
sqlx::query("DELETE FROM peer_clocks;").execute(&self.pool).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn get_storage() -> SqliteDocStorage {
|
||||
let storage = SqliteDocStorage::new(":memory:".to_string());
|
||||
storage.connect().await.unwrap();
|
||||
|
||||
storage
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_peer_clock() {
|
||||
let storage = get_storage().await;
|
||||
let peer = String::from("peer1");
|
||||
|
||||
let clocks = storage.get_peer_remote_clocks(peer.clone()).await.unwrap();
|
||||
|
||||
assert!(clocks.is_empty());
|
||||
|
||||
let clock = Utc::now().naive_utc();
|
||||
storage
|
||||
.set_peer_remote_clock(peer.clone(), "doc1".to_string(), clock)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let clocks = storage.get_peer_remote_clocks(peer.clone()).await.unwrap();
|
||||
|
||||
assert_eq!(clocks.len(), 1);
|
||||
assert_eq!(clocks.first().unwrap().doc_id, "doc1");
|
||||
assert_eq!(clocks.first().unwrap().timestamp, clock);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_peer_pushed_clock() {
|
||||
let storage = get_storage().await;
|
||||
let peer = String::from("peer1");
|
||||
|
||||
let clocks = storage.get_peer_pushed_clocks(peer.clone()).await.unwrap();
|
||||
|
||||
assert!(clocks.is_empty());
|
||||
|
||||
let clock = Utc::now().naive_utc();
|
||||
storage
|
||||
.set_peer_pushed_clock(peer.clone(), "doc1".to_string(), clock)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let clocks = storage.get_peer_pushed_clocks(peer.clone()).await.unwrap();
|
||||
|
||||
assert_eq!(clocks.len(), 1);
|
||||
assert_eq!(clocks.first().unwrap().doc_id, "doc1");
|
||||
assert_eq!(clocks.first().unwrap().timestamp, clock);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_clocks() {
|
||||
let storage = get_storage().await;
|
||||
let peer = String::from("peer1");
|
||||
|
||||
storage
|
||||
.set_peer_remote_clock(peer.clone(), "doc1".to_string(), Utc::now().naive_utc())
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.set_peer_pushed_clock(peer.clone(), "doc2".to_string(), Utc::now().naive_utc())
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.set_peer_pulled_remote_clock(peer.clone(), "doc3".to_string(), Utc::now().naive_utc())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let record = sqlx::query("SELECT * FROM peer_clocks WHERE peer = ? AND doc_id = ?")
|
||||
.bind(peer.clone())
|
||||
.bind("doc1")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
record.get::<NaiveDateTime, &str>("pushed_clock"),
|
||||
DateTime::from_timestamp(0, 0).unwrap().naive_utc()
|
||||
);
|
||||
|
||||
let record = sqlx::query("SELECT * FROM peer_clocks WHERE peer = ? AND doc_id = ?")
|
||||
.bind(peer.clone())
|
||||
.bind("doc2")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.get::<NaiveDateTime, &str>("remote_clock"),
|
||||
DateTime::from_timestamp(0, 0).unwrap().naive_utc()
|
||||
);
|
||||
|
||||
let record = sqlx::query("SELECT * FROM peer_clocks WHERE peer = ? AND doc_id = ?")
|
||||
.bind(peer.clone())
|
||||
.bind("doc3")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
record.get::<NaiveDateTime, &str>("remote_clock"),
|
||||
DateTime::from_timestamp(0, 0).unwrap().naive_utc()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_clocks() {
|
||||
let storage = get_storage().await;
|
||||
let peer = String::from("peer1");
|
||||
|
||||
storage
|
||||
.set_peer_remote_clock(peer.clone(), "doc1".to_string(), Utc::now().naive_utc())
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.set_peer_pushed_clock(peer.clone(), "doc2".to_string(), Utc::now().naive_utc())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let clocks = storage.get_peer_remote_clocks(peer.clone()).await.unwrap();
|
||||
assert_eq!(clocks.len(), 2);
|
||||
|
||||
let clocks = storage.get_peer_pushed_clocks(peer.clone()).await.unwrap();
|
||||
assert_eq!(clocks.len(), 2);
|
||||
|
||||
storage.clear_clocks().await.unwrap();
|
||||
|
||||
let clocks = storage.get_peer_remote_clocks(peer.clone()).await.unwrap();
|
||||
assert!(clocks.is_empty());
|
||||
|
||||
let clocks = storage.get_peer_pushed_clocks(peer.clone()).await.unwrap();
|
||||
assert!(clocks.is_empty());
|
||||
}
|
||||
}
|
||||
23
packages/frontend/native/nbstore/src/error.rs
Normal file
23
packages/frontend/native/nbstore/src/error.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use affine_doc_loader::ParseError;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Sqlite Error: {0}")]
|
||||
SqlxError(#[from] sqlx::Error),
|
||||
#[error("Migrate Error: {0}")]
|
||||
MigrateError(#[from] sqlx::migrate::MigrateError),
|
||||
#[error("Connection in progress")]
|
||||
ConnectionInProgress,
|
||||
#[error("Invalid operation")]
|
||||
InvalidOperation,
|
||||
#[error("Index is rebuilding")]
|
||||
IndexNotReady,
|
||||
#[error("Serialization Error: {0}")]
|
||||
Serialization(String),
|
||||
#[error(transparent)]
|
||||
Indexer(#[from] memory_indexer::Error),
|
||||
#[error(transparent)]
|
||||
Parse(#[from] ParseError),
|
||||
}
|
||||
39
packages/frontend/native/nbstore/src/indexer/crawl.rs
Normal file
39
packages/frontend/native/nbstore/src/indexer/crawl.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use affine_doc_loader::{ParseError, parse_doc_from_binary};
|
||||
use y_octo::merge_updates_v1;
|
||||
|
||||
use super::{NativeCrawlResult, SqliteDocStorage, error::Result};
|
||||
|
||||
impl SqliteDocStorage {
|
||||
pub async fn crawl_doc_data(&self, doc_id: &str) -> Result<NativeCrawlResult> {
|
||||
let doc_bin = self.load_doc_binary(doc_id).await?.ok_or(ParseError::DocNotFound)?;
|
||||
Ok(parse_doc_from_binary(doc_bin, doc_id.to_string())?.into())
|
||||
}
|
||||
|
||||
async fn load_doc_binary(&self, doc_id: &str) -> Result<Option<Vec<u8>>> {
|
||||
let snapshot = self.get_doc_snapshot(doc_id.to_string()).await?;
|
||||
let mut updates = self.get_doc_updates(doc_id.to_string()).await?;
|
||||
if snapshot.is_none() && updates.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
updates.sort_by_key(|update| update.timestamp);
|
||||
let mut segments = Vec::with_capacity(snapshot.as_ref().map(|_| 1).unwrap_or(0) + updates.len());
|
||||
if let Some(record) = snapshot {
|
||||
segments.push(record.bin.to_vec());
|
||||
}
|
||||
segments.extend(updates.into_iter().map(|update| update.bin.to_vec()));
|
||||
merge_updates(segments).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_updates(mut segments: Vec<Vec<u8>>) -> Result<Vec<u8>> {
|
||||
if segments.is_empty() {
|
||||
return Err(ParseError::DocNotFound.into());
|
||||
}
|
||||
if segments.len() == 1 {
|
||||
return segments.pop().ok_or(ParseError::DocNotFound.into());
|
||||
}
|
||||
let update = merge_updates_v1(segments).map_err(|_| ParseError::InvalidBinary)?;
|
||||
update
|
||||
.encode_v1()
|
||||
.map_err(|error| ParseError::ParserError(error.to_string()).into())
|
||||
}
|
||||
142
packages/frontend/native/nbstore/src/indexer/dto.rs
Normal file
142
packages/frontend/native/nbstore/src/indexer/dto.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use affine_doc_loader::{BlockInfo, CrawlResult};
|
||||
use napi_derive::napi;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NativeBlockInfo {
|
||||
pub block_id: String,
|
||||
pub flavour: String,
|
||||
pub content: Option<Vec<String>>,
|
||||
pub blob: Option<Vec<String>>,
|
||||
pub ref_doc_id: Option<Vec<String>>,
|
||||
pub ref_info: Option<Vec<String>>,
|
||||
pub parent_flavour: Option<String>,
|
||||
pub parent_block_id: Option<String>,
|
||||
pub additional: Option<String>,
|
||||
}
|
||||
|
||||
impl From<BlockInfo> for NativeBlockInfo {
|
||||
fn from(value: BlockInfo) -> Self {
|
||||
Self {
|
||||
block_id: value.block_id,
|
||||
flavour: value.flavour,
|
||||
content: value.content,
|
||||
blob: value.blob,
|
||||
ref_doc_id: value.ref_doc_id,
|
||||
ref_info: value.ref_info,
|
||||
parent_flavour: value.parent_flavour,
|
||||
parent_block_id: value.parent_block_id,
|
||||
additional: value.additional,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NativeCrawlResult {
|
||||
pub blocks: Vec<NativeBlockInfo>,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
impl From<CrawlResult> for NativeCrawlResult {
|
||||
fn from(value: CrawlResult) -> Self {
|
||||
Self {
|
||||
blocks: value.blocks.into_iter().map(Into::into).collect(),
|
||||
title: value.title,
|
||||
summary: value.summary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeIndexField {
|
||||
pub field: String,
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeIndexDocument {
|
||||
pub id: String,
|
||||
pub fields: Vec<NativeIndexField>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeIndexQuery {
|
||||
pub kind: String,
|
||||
pub field: Option<String>,
|
||||
pub value: Option<String>,
|
||||
pub occur: Option<String>,
|
||||
pub clauses: Option<Vec<NativeIndexQuery>>,
|
||||
pub boost: Option<f64>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeIndexSearchOptions {
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
pub fields: Vec<String>,
|
||||
pub highlights: Vec<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NativeIndexSpan {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NativeIndexHighlightValue {
|
||||
pub value_index: u32,
|
||||
pub spans: Vec<NativeIndexSpan>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NativeIndexHighlight {
|
||||
pub field: String,
|
||||
pub values: Vec<NativeIndexHighlightValue>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NativeIndexHit {
|
||||
pub id: String,
|
||||
pub score: f64,
|
||||
pub fields: Vec<NativeIndexField>,
|
||||
pub highlights: Vec<NativeIndexHighlight>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NativeIndexSearchResult {
|
||||
pub total: u32,
|
||||
pub hits: Vec<NativeIndexHit>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NativeIndexBucket {
|
||||
pub key: String,
|
||||
pub count: u32,
|
||||
pub score: f64,
|
||||
pub hits: Vec<NativeIndexHit>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NativeIndexAggregateResult {
|
||||
pub total: u32,
|
||||
pub buckets: Vec<NativeIndexBucket>,
|
||||
}
|
||||
71
packages/frontend/native/nbstore/src/indexer/mod.rs
Normal file
71
packages/frontend/native/nbstore/src/indexer/mod.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
mod crawl;
|
||||
mod dto;
|
||||
mod persistence;
|
||||
mod table;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub use dto::{
|
||||
NativeBlockInfo, NativeCrawlResult, NativeIndexAggregateResult, NativeIndexBucket, NativeIndexDocument,
|
||||
NativeIndexField, NativeIndexHighlight, NativeIndexHighlightValue, NativeIndexHit, NativeIndexQuery,
|
||||
NativeIndexSearchOptions, NativeIndexSearchResult, NativeIndexSpan,
|
||||
};
|
||||
use error::{Error, Result};
|
||||
use memory_indexer::MemoryIndex;
|
||||
pub(super) use table::{TableIndex, string_values};
|
||||
|
||||
pub(super) use super::{DocIndexedClock, error, storage::SqliteDocStorage};
|
||||
|
||||
const NBSTORE_INDEXER_VERSION: u32 = 7;
|
||||
|
||||
pub struct IndexManager {
|
||||
pub(super) doc: TableIndex,
|
||||
pub(super) block: TableIndex,
|
||||
pub(super) ready: AtomicBool,
|
||||
}
|
||||
|
||||
impl IndexManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
doc: TableIndex::doc(),
|
||||
block: TableIndex::block(),
|
||||
ready: AtomicBool::new(true),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn table(&self, table: &str) -> Result<&TableIndex> {
|
||||
match table {
|
||||
"doc" => Ok(&self.doc),
|
||||
"block" => Ok(&self.block),
|
||||
_ => Err(Error::Serialization(format!("unknown index table {table}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tables(&self) -> [(&'static str, &TableIndex); 2] {
|
||||
[("doc", &self.doc), ("block", &self.block)]
|
||||
}
|
||||
|
||||
pub(super) fn ensure_ready(&self) -> Result<()> {
|
||||
self
|
||||
.ready
|
||||
.load(Ordering::Acquire)
|
||||
.then_some(())
|
||||
.ok_or(Error::IndexNotReady)
|
||||
}
|
||||
|
||||
pub(super) async fn reset(&self) {
|
||||
for (_, table) in self.tables() {
|
||||
*table.index.write().await = MemoryIndex::new(table.schema.clone());
|
||||
}
|
||||
self.ready.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IndexManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
193
packages/frontend/native/nbstore/src/indexer/persistence.rs
Normal file
193
packages/frontend/native/nbstore/src/indexer/persistence.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use memory_indexer::{Document, MemoryIndex, TermsAggregation, Value};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{
|
||||
DocIndexedClock, NBSTORE_INDEXER_VERSION, NativeIndexAggregateResult, NativeIndexBucket, NativeIndexDocument,
|
||||
NativeIndexQuery, NativeIndexSearchOptions, NativeIndexSearchResult, SqliteDocStorage, error::Result, string_values,
|
||||
};
|
||||
|
||||
impl SqliteDocStorage {
|
||||
pub async fn init_index(&self) -> Result<()> {
|
||||
let snapshots = sqlx::query("SELECT index_name, data FROM idx_snapshots WHERE index_name IN ('doc', 'block')")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let mut corrupted = false;
|
||||
for row in snapshots {
|
||||
let name: String = row.get("index_name");
|
||||
let data: Vec<u8> = row.get("data");
|
||||
let table = self.indexes.table(&name)?;
|
||||
if let Ok(index) = MemoryIndex::from_checkpoint(table.schema.clone(), &data) {
|
||||
*table.index.write().await = index;
|
||||
} else {
|
||||
corrupted = true;
|
||||
}
|
||||
}
|
||||
if corrupted {
|
||||
self.indexes.reset().await;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM idx_snapshots").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM indexer_sync").execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
}
|
||||
sqlx::query("DELETE FROM idx_snapshots WHERE index_name NOT IN ('doc', 'block')")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn flush_index(&self) -> Result<()> {
|
||||
let mut checkpoints = Vec::new();
|
||||
for (name, table) in self.indexes.tables() {
|
||||
let index = table.index.read().await;
|
||||
if index.has_unpersisted_changes() {
|
||||
checkpoints.push((name, index.checkpoint()?));
|
||||
}
|
||||
}
|
||||
if checkpoints.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
for (name, checkpoint) in &checkpoints {
|
||||
sqlx::query("INSERT OR REPLACE INTO idx_snapshots (index_name, data) VALUES (?, ?)")
|
||||
.bind(name)
|
||||
.bind(&checkpoint.bytes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
for (name, checkpoint) in checkpoints {
|
||||
self
|
||||
.indexes
|
||||
.table(name)?
|
||||
.index
|
||||
.write()
|
||||
.await
|
||||
.mark_checkpoint_persisted(checkpoint.sequence)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn commit_indexed_clocks(&self, clocks: &[DocIndexedClock]) -> Result<()> {
|
||||
let mut checkpoints = Vec::new();
|
||||
for (name, table) in self.indexes.tables() {
|
||||
checkpoints.push((name, table.index.read().await.checkpoint()?));
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
for (name, checkpoint) in &checkpoints {
|
||||
sqlx::query("INSERT OR REPLACE INTO idx_snapshots (index_name, data) VALUES (?, ?)")
|
||||
.bind(name)
|
||||
.bind(&checkpoint.bytes)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
for clock in clocks {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO indexer_sync (doc_id, indexed_clock, indexer_version)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(doc_id)
|
||||
DO UPDATE SET indexed_clock=$2, indexer_version=$3"#,
|
||||
)
|
||||
.bind(&clock.doc_id)
|
||||
.bind(clock.timestamp)
|
||||
.bind(clock.indexer_version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
for (name, checkpoint) in checkpoints {
|
||||
self
|
||||
.indexes
|
||||
.table(name)?
|
||||
.index
|
||||
.write()
|
||||
.await
|
||||
.mark_checkpoint_persisted(checkpoint.sequence)?;
|
||||
}
|
||||
self.indexes.ready.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn index_version() -> u32 {
|
||||
NBSTORE_INDEXER_VERSION
|
||||
}
|
||||
|
||||
pub async fn index_upsert(&self, table: &str, document: NativeIndexDocument) -> Result<()> {
|
||||
let table = self.indexes.table(table)?;
|
||||
let mut index_document = Document::new(document.id);
|
||||
for field in document.fields {
|
||||
let field_id = table.field(&field.field)?;
|
||||
index_document.add_values(field_id, field.values.into_iter().map(Value::String));
|
||||
}
|
||||
table.index.write().await.upsert(index_document)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn index_delete(&self, table: &str, id: &str) -> Result<()> {
|
||||
self.indexes.table(table)?.index.write().await.delete(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn index_search(
|
||||
&self,
|
||||
table: &str,
|
||||
query: NativeIndexQuery,
|
||||
options: NativeIndexSearchOptions,
|
||||
) -> Result<NativeIndexSearchResult> {
|
||||
self.indexes.ensure_ready()?;
|
||||
let table = self.indexes.table(table)?;
|
||||
let query = table.compile_query(query)?;
|
||||
let result = table
|
||||
.index
|
||||
.read()
|
||||
.await
|
||||
.search(&query, table.compile_options(options)?)?;
|
||||
Ok(NativeIndexSearchResult {
|
||||
total: result.total as u32,
|
||||
hits: result.hits.into_iter().map(|hit| table.hit(hit)).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn index_aggregate(
|
||||
&self,
|
||||
table: &str,
|
||||
query: NativeIndexQuery,
|
||||
field: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
hits: Option<NativeIndexSearchOptions>,
|
||||
) -> Result<NativeIndexAggregateResult> {
|
||||
self.indexes.ensure_ready()?;
|
||||
let table = self.indexes.table(table)?;
|
||||
let query = table.compile_query(query)?;
|
||||
let result = table.index.read().await.aggregate(
|
||||
&query,
|
||||
TermsAggregation {
|
||||
field: table.field(field)?,
|
||||
limit: limit as usize,
|
||||
offset: offset as usize,
|
||||
top_hits: hits.map(|options| table.compile_options(options)).transpose()?,
|
||||
},
|
||||
)?;
|
||||
Ok(NativeIndexAggregateResult {
|
||||
total: result.total as u32,
|
||||
buckets: result
|
||||
.buckets
|
||||
.into_iter()
|
||||
.map(|bucket| NativeIndexBucket {
|
||||
key: string_values(vec![bucket.key]).pop().unwrap_or_default(),
|
||||
count: bucket.count as u32,
|
||||
score: bucket.max_score as f64,
|
||||
hits: bucket.hits.into_iter().map(|hit| table.hit(hit)).collect(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn index_delete_by_query(&self, table: &str, query: NativeIndexQuery) -> Result<u32> {
|
||||
let table = self.indexes.table(table)?;
|
||||
let query = table.compile_query(query)?;
|
||||
Ok(table.index.write().await.delete_by_query(&query)? as u32)
|
||||
}
|
||||
}
|
||||
223
packages/frontend/native/nbstore/src/indexer/table.rs
Normal file
223
packages/frontend/native/nbstore/src/indexer/table.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use memory_indexer::{
|
||||
FieldId, FieldOptions, FieldType, Highlight, MemoryIndex, PositionEncoding, Query, Schema, SearchMode, SearchOptions,
|
||||
TextOptions, Value,
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::{
|
||||
NativeIndexField, NativeIndexHighlight, NativeIndexHighlightValue, NativeIndexHit, NativeIndexQuery,
|
||||
NativeIndexSearchOptions, NativeIndexSpan,
|
||||
error::{Error, Result},
|
||||
};
|
||||
|
||||
pub(crate) struct TableIndex {
|
||||
pub(super) schema: Schema,
|
||||
fields: HashMap<String, FieldId>,
|
||||
pub(super) index: RwLock<MemoryIndex>,
|
||||
}
|
||||
|
||||
impl TableIndex {
|
||||
pub(super) fn doc() -> Self {
|
||||
let mut schema = Schema::builder().position_encoding(PositionEncoding::Utf16);
|
||||
let fields = HashMap::from([
|
||||
("docId".into(), schema.keyword("docId", FieldOptions::indexed_stored())),
|
||||
(
|
||||
"title".into(),
|
||||
schema.text("title", text_options(), FieldOptions::indexed_stored()),
|
||||
),
|
||||
(
|
||||
"summary".into(),
|
||||
schema.keyword("summary", FieldOptions::new().stored()),
|
||||
),
|
||||
]);
|
||||
Self::from_schema(schema, fields)
|
||||
}
|
||||
|
||||
pub(super) fn block() -> Self {
|
||||
let mut schema = Schema::builder().position_encoding(PositionEncoding::Utf16);
|
||||
let fields = HashMap::from([
|
||||
("docId".into(), schema.keyword("docId", keyword_options())),
|
||||
("blockId".into(), schema.keyword("blockId", keyword_options())),
|
||||
(
|
||||
"content".into(),
|
||||
schema.text("content", text_options(), FieldOptions::indexed_stored().multi_value()),
|
||||
),
|
||||
("flavour".into(), schema.keyword("flavour", keyword_options())),
|
||||
("blob".into(), schema.keyword("blob", keyword_options())),
|
||||
("refDocId".into(), schema.keyword("refDocId", keyword_options())),
|
||||
(
|
||||
"ref".into(),
|
||||
schema.keyword("ref", FieldOptions::new().stored().multi_value()),
|
||||
),
|
||||
(
|
||||
"parentFlavour".into(),
|
||||
schema.keyword("parentFlavour", keyword_options()),
|
||||
),
|
||||
(
|
||||
"parentBlockId".into(),
|
||||
schema.keyword("parentBlockId", keyword_options()),
|
||||
),
|
||||
(
|
||||
"additional".into(),
|
||||
schema.keyword("additional", FieldOptions::new().stored().multi_value()),
|
||||
),
|
||||
(
|
||||
"markdownPreview".into(),
|
||||
schema.keyword("markdownPreview", FieldOptions::new().stored().multi_value()),
|
||||
),
|
||||
]);
|
||||
Self::from_schema(schema, fields)
|
||||
}
|
||||
|
||||
fn from_schema(builder: memory_indexer::SchemaBuilder, fields: HashMap<String, FieldId>) -> Self {
|
||||
let schema = builder.build().expect("static nbstore index schema must be valid");
|
||||
Self {
|
||||
index: RwLock::new(MemoryIndex::new(schema.clone())),
|
||||
schema,
|
||||
fields,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn field(&self, name: &str) -> Result<FieldId> {
|
||||
self
|
||||
.fields
|
||||
.get(name)
|
||||
.copied()
|
||||
.ok_or_else(|| Error::Serialization(format!("unknown index field {name}")))
|
||||
}
|
||||
|
||||
pub(super) fn compile_query(&self, query: NativeIndexQuery) -> Result<Query> {
|
||||
match query.kind.as_str() {
|
||||
"match" => {
|
||||
let field = self.field(query.field.as_deref().unwrap_or_default())?;
|
||||
let value = query.value.unwrap_or_default();
|
||||
match self.schema.field(field).map(|field| &field.field_type) {
|
||||
Some(FieldType::Text(_)) => Ok(Query::text(field, value, SearchMode::Auto)),
|
||||
Some(FieldType::Keyword) => Ok(Query::term(field, value)),
|
||||
_ => Err(Error::Serialization("match requires Text or Keyword field".into())),
|
||||
}
|
||||
}
|
||||
"exists" => Ok(Query::Exists(self.field(query.field.as_deref().unwrap_or_default())?)),
|
||||
"all" => Ok(Query::All),
|
||||
"boost" => {
|
||||
let clause = query
|
||||
.clauses
|
||||
.unwrap_or_default()
|
||||
.pop()
|
||||
.ok_or_else(|| Error::Serialization("boost requires one clause".into()))?;
|
||||
Ok(Query::Boost {
|
||||
query: Box::new(self.compile_query(clause)?),
|
||||
factor: query.boost.unwrap_or(1.0) as f32,
|
||||
})
|
||||
}
|
||||
"boolean" => {
|
||||
let clauses = query
|
||||
.clauses
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|query| self.compile_query(query))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let (must, should, must_not) = match query.occur.as_deref() {
|
||||
Some("must") => (clauses, vec![], vec![]),
|
||||
Some("should") => (vec![], clauses, vec![]),
|
||||
Some("must_not") => (vec![], vec![], clauses),
|
||||
_ => return Err(Error::Serialization("invalid boolean occurrence".into())),
|
||||
};
|
||||
Ok(Query::boolean(must, should, must_not))
|
||||
}
|
||||
kind => Err(Error::Serialization(format!("unknown query kind {kind}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn compile_options(&self, options: NativeIndexSearchOptions) -> Result<SearchOptions> {
|
||||
Ok(SearchOptions {
|
||||
limit: options.limit as usize,
|
||||
offset: options.offset as usize,
|
||||
after: None,
|
||||
sort: vec![],
|
||||
stored_fields: options
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field| self.field(field))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
highlight_fields: options
|
||||
.highlights
|
||||
.iter()
|
||||
.map(|field| self.field(field))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn hit(&self, hit: memory_indexer::SearchHit) -> NativeIndexHit {
|
||||
NativeIndexHit {
|
||||
id: hit.id,
|
||||
score: hit.score as f64,
|
||||
fields: hit
|
||||
.fields
|
||||
.into_iter()
|
||||
.map(|(field, values)| NativeIndexField {
|
||||
field: self.field_name(field),
|
||||
values: string_values(values),
|
||||
})
|
||||
.collect(),
|
||||
highlights: group_highlights(self, hit.highlights),
|
||||
}
|
||||
}
|
||||
|
||||
fn field_name(&self, field: FieldId) -> String {
|
||||
self
|
||||
.schema
|
||||
.field(field)
|
||||
.expect("result field belongs to schema")
|
||||
.name
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn string_values(values: Vec<Value>) -> Vec<String> {
|
||||
values
|
||||
.into_iter()
|
||||
.filter_map(|value| match value {
|
||||
Value::String(value) => Some(value),
|
||||
Value::I64(_) | Value::Bool(_) => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keyword_options() -> FieldOptions {
|
||||
FieldOptions::indexed_stored().multi_value()
|
||||
}
|
||||
|
||||
fn text_options() -> TextOptions {
|
||||
TextOptions::multilingual()
|
||||
.with_pinyin()
|
||||
.with_prefix()
|
||||
.with_fuzzy()
|
||||
.with_positions()
|
||||
}
|
||||
|
||||
fn group_highlights(table: &TableIndex, highlights: Vec<Highlight>) -> Vec<NativeIndexHighlight> {
|
||||
let mut grouped: Vec<NativeIndexHighlight> = Vec::new();
|
||||
for highlight in highlights {
|
||||
let field = table.field_name(highlight.field);
|
||||
let values = NativeIndexHighlightValue {
|
||||
value_index: highlight.value_index as u32,
|
||||
spans: highlight
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|(start, end)| NativeIndexSpan { start, end })
|
||||
.collect(),
|
||||
};
|
||||
if let Some(existing) = grouped.iter_mut().find(|item| item.field == field) {
|
||||
existing.values.push(values);
|
||||
} else {
|
||||
grouped.push(NativeIndexHighlight {
|
||||
field,
|
||||
values: vec![values],
|
||||
});
|
||||
}
|
||||
}
|
||||
grouped
|
||||
}
|
||||
318
packages/frontend/native/nbstore/src/indexer/tests.rs
Normal file
318
packages/frontend/native/nbstore/src/indexer/tests.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use affine_doc_loader::ParseError;
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
super::{DocIndexedClock, error::Error, storage::SqliteDocStorage},
|
||||
NativeIndexDocument, NativeIndexField, NativeIndexQuery, NativeIndexSearchOptions,
|
||||
};
|
||||
|
||||
const DEMO_BIN: &[u8] = include_bytes!("../../../../../common/native/fixtures/demo.ydoc");
|
||||
const DEMO_JSON: &[u8] = include_bytes!("../../../../../common/native/fixtures/demo.ydoc.json");
|
||||
|
||||
fn temp_workspace_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("affine-native-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
async fn init_db(path: &Path) -> SqliteDocStorage {
|
||||
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
||||
let storage = SqliteDocStorage::new(path.to_string_lossy().into_owned());
|
||||
storage.connect().await.unwrap();
|
||||
storage
|
||||
}
|
||||
|
||||
async fn cleanup(path: &Path) {
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap()).await;
|
||||
}
|
||||
|
||||
fn query(kind: &str, field: Option<&str>, value: Option<&str>) -> NativeIndexQuery {
|
||||
NativeIndexQuery {
|
||||
kind: kind.into(),
|
||||
field: field.map(Into::into),
|
||||
value: value.map(Into::into),
|
||||
occur: None,
|
||||
clauses: None,
|
||||
boost: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn options(fields: &[&str], highlights: &[&str]) -> NativeIndexSearchOptions {
|
||||
NativeIndexSearchOptions {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
fields: fields.iter().map(|value| (*value).into()).collect(),
|
||||
highlights: highlights.iter().map(|value| (*value).into()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn document(id: &str, fields: &[(&str, &[&str])]) -> NativeIndexDocument {
|
||||
NativeIndexDocument {
|
||||
id: id.into(),
|
||||
fields: fields
|
||||
.iter()
|
||||
.map(|(field, values)| NativeIndexField {
|
||||
field: (*field).into(),
|
||||
values: values.iter().map(|value| (*value).into()).collect(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_demo_snapshot_matches_fixture() {
|
||||
let base = temp_workspace_dir();
|
||||
fs::create_dir_all(&base).await.unwrap();
|
||||
let db_path = base.join("storage.db");
|
||||
let storage = init_db(&db_path).await;
|
||||
sqlx::query(r#"INSERT INTO snapshots (doc_id, data, updated_at) VALUES (?, ?, ?)"#)
|
||||
.bind("demo-doc")
|
||||
.bind(DEMO_BIN)
|
||||
.bind(Utc::now().naive_utc())
|
||||
.execute(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(r#"INSERT INTO updates (doc_id, data, created_at) VALUES (?, ?, ?)"#)
|
||||
.bind("demo-doc")
|
||||
.bind(&[0, 0][..])
|
||||
.bind(Utc::now().naive_utc())
|
||||
.execute(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = storage.crawl_doc_data("demo-doc").await.unwrap();
|
||||
let mut expected: Value = serde_json::from_slice(DEMO_JSON).unwrap();
|
||||
let mut actual = serde_json::to_value(&result).unwrap();
|
||||
for document in [&mut expected, &mut actual] {
|
||||
for block in document["blocks"].as_array_mut().unwrap() {
|
||||
if let Some(additional) = block["additional"].as_str() {
|
||||
block["additional"] = serde_json::from_str(additional).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_json_eq!(expected, actual);
|
||||
storage.close().await;
|
||||
cleanup(&db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_doc_returns_error() {
|
||||
let db_path = temp_workspace_dir().join("storage.db");
|
||||
let storage = init_db(&db_path).await;
|
||||
let error = storage.crawl_doc_data("absent-doc").await.unwrap_err();
|
||||
assert!(matches!(error, Error::Parse(ParseError::DocNotFound)));
|
||||
storage.close().await;
|
||||
cleanup(&db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_tables_support_terminal_queries_and_restart() {
|
||||
let db_path = temp_workspace_dir().join("storage.db");
|
||||
let storage = init_db(&db_path).await;
|
||||
storage
|
||||
.index_upsert(
|
||||
"doc",
|
||||
document(
|
||||
"doc-1",
|
||||
&[
|
||||
("docId", &["doc-1"]),
|
||||
("title", &["Rust 搜索"]),
|
||||
("summary", &["stored summary"]),
|
||||
],
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.index_upsert(
|
||||
"block",
|
||||
document(
|
||||
"block-1",
|
||||
&[
|
||||
("docId", &["doc-1"]),
|
||||
("blockId", &["block-1"]),
|
||||
("content", &["hello world", "你好搜索"]),
|
||||
("flavour", &["affine:paragraph"]),
|
||||
],
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.index_upsert(
|
||||
"block",
|
||||
document(
|
||||
"block-2",
|
||||
&[
|
||||
("docId", &["doc-10"]),
|
||||
("blockId", &["block-2"]),
|
||||
("content", &["hello unrelated"]),
|
||||
("flavour", &["affine:code"]),
|
||||
],
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let text = storage
|
||||
.index_search(
|
||||
"block",
|
||||
query("match", Some("content"), Some("搜索")),
|
||||
options(&["content"], &["content"]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text.total, 1);
|
||||
assert_eq!(text.hits[0].id, "block-1");
|
||||
assert!(!text.hits[0].highlights[0].values[0].spans.is_empty());
|
||||
|
||||
let exact = storage
|
||||
.index_search(
|
||||
"block",
|
||||
query("match", Some("docId"), Some("doc-1")),
|
||||
options(&["docId"], &[]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(exact.total, 1);
|
||||
assert_eq!(exact.hits[0].fields[0].values, ["doc-1"]);
|
||||
|
||||
let aggregate = storage
|
||||
.index_aggregate(
|
||||
"block",
|
||||
query("all", None, None),
|
||||
"flavour",
|
||||
10,
|
||||
0,
|
||||
Some(options(&["blockId"], &[])),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(aggregate.total, 2);
|
||||
assert_eq!(aggregate.buckets.len(), 2);
|
||||
|
||||
let clock = DocIndexedClock {
|
||||
doc_id: "doc-1".into(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
indexer_version: SqliteDocStorage::index_version() as i64,
|
||||
};
|
||||
storage.commit_indexed_clocks(&[clock]).await.unwrap();
|
||||
storage.close().await;
|
||||
|
||||
let restored = init_db(&db_path).await;
|
||||
let result = restored
|
||||
.index_search(
|
||||
"block",
|
||||
query("match", Some("content"), Some("hello")),
|
||||
options(&[], &[]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 2);
|
||||
assert_eq!(
|
||||
restored
|
||||
.index_delete_by_query("block", query("match", Some("docId"), Some("doc-1")))
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
restored.close().await;
|
||||
cleanup(&db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_checkpoint_requires_rebuild_and_clears_clocks() {
|
||||
let db_path = temp_workspace_dir().join("storage.db");
|
||||
let storage = init_db(&db_path).await;
|
||||
storage
|
||||
.index_upsert(
|
||||
"doc",
|
||||
document("doc-1", &[("docId", &["doc-1"]), ("title", &["title"])]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let clock = DocIndexedClock {
|
||||
doc_id: "doc-1".into(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
indexer_version: SqliteDocStorage::index_version() as i64,
|
||||
};
|
||||
storage.commit_indexed_clocks(&[clock]).await.unwrap();
|
||||
sqlx::query("UPDATE idx_snapshots SET data = x'00' WHERE index_name = 'doc'")
|
||||
.execute(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
storage.close().await;
|
||||
|
||||
let restored = init_db(&db_path).await;
|
||||
assert!(matches!(
|
||||
restored
|
||||
.index_search("doc", query("all", None, None), options(&[], &[]))
|
||||
.await,
|
||||
Err(Error::IndexNotReady)
|
||||
));
|
||||
assert!(restored.get_doc_indexed_clock("doc-1".into()).await.unwrap().is_none());
|
||||
restored
|
||||
.index_upsert(
|
||||
"doc",
|
||||
document("doc-1", &[("docId", &["doc-1"]), ("title", &["rebuilt"])]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
restored
|
||||
.commit_indexed_clocks(&[DocIndexedClock {
|
||||
doc_id: "doc-1".into(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
indexer_version: SqliteDocStorage::index_version() as i64,
|
||||
}])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored
|
||||
.index_search("doc", query("all", None, None), options(&[], &[]))
|
||||
.await
|
||||
.unwrap()
|
||||
.total,
|
||||
1
|
||||
);
|
||||
restored.close().await;
|
||||
cleanup(&db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_atomic_commit_keeps_index_dirty_and_clock_unadvanced() {
|
||||
let db_path = temp_workspace_dir().join("storage.db");
|
||||
let storage = init_db(&db_path).await;
|
||||
storage
|
||||
.index_upsert(
|
||||
"doc",
|
||||
document("doc-1", &[("docId", &["doc-1"]), ("title", &["title"])]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DROP TABLE idx_snapshots")
|
||||
.execute(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let clock = DocIndexedClock {
|
||||
doc_id: "doc-1".into(),
|
||||
timestamp: Utc::now().naive_utc(),
|
||||
indexer_version: SqliteDocStorage::index_version() as i64,
|
||||
};
|
||||
assert!(storage.commit_indexed_clocks(&[clock]).await.is_err());
|
||||
assert!(
|
||||
storage
|
||||
.indexes
|
||||
.table("doc")
|
||||
.unwrap()
|
||||
.index
|
||||
.read()
|
||||
.await
|
||||
.has_unpersisted_changes()
|
||||
);
|
||||
assert!(storage.get_doc_indexed_clock("doc-1".into()).await.unwrap().is_none());
|
||||
storage.close().await;
|
||||
cleanup(&db_path).await;
|
||||
}
|
||||
99
packages/frontend/native/nbstore/src/indexer_sync.rs
Normal file
99
packages/frontend/native/nbstore/src/indexer_sync.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use super::{DocIndexedClock, error::Result, storage::SqliteDocStorage};
|
||||
|
||||
impl SqliteDocStorage {
|
||||
pub async fn get_doc_indexed_clock(&self, doc_id: String) -> Result<Option<DocIndexedClock>> {
|
||||
let record = sqlx::query!(
|
||||
r#"SELECT doc_id, indexed_clock as "indexed_clock: NaiveDateTime", indexer_version
|
||||
FROM indexer_sync WHERE doc_id = ?"#,
|
||||
doc_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(record.map(|rec| DocIndexedClock {
|
||||
doc_id: rec.doc_id,
|
||||
timestamp: rec.indexed_clock,
|
||||
indexer_version: rec.indexer_version,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn set_doc_indexed_clock(
|
||||
&self,
|
||||
doc_id: String,
|
||||
indexed_clock: NaiveDateTime,
|
||||
indexer_version: i64,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO indexer_sync (doc_id, indexed_clock, indexer_version)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(doc_id)
|
||||
DO UPDATE SET indexed_clock=$2, indexer_version=$3;
|
||||
"#,
|
||||
)
|
||||
.bind(doc_id)
|
||||
.bind(indexed_clock)
|
||||
.bind(indexer_version)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_doc_indexed_clock(&self, doc_id: String) -> Result<()> {
|
||||
sqlx::query("DELETE FROM indexer_sync WHERE doc_id = ?;")
|
||||
.bind(doc_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn get_storage() -> SqliteDocStorage {
|
||||
let storage = SqliteDocStorage::new(":memory:".to_string());
|
||||
storage.connect().await.unwrap();
|
||||
|
||||
storage
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_and_get_indexed_clock() {
|
||||
let storage = get_storage().await;
|
||||
let ts = Utc::now().naive_utc();
|
||||
|
||||
storage.set_doc_indexed_clock("doc1".to_string(), ts, 1).await.unwrap();
|
||||
|
||||
let clock = storage
|
||||
.get_doc_indexed_clock("doc1".to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(clock.doc_id, "doc1");
|
||||
assert_eq!(clock.timestamp, ts);
|
||||
assert_eq!(clock.indexer_version, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_indexed_clock() {
|
||||
let storage = get_storage().await;
|
||||
let ts = Utc::now().naive_utc();
|
||||
|
||||
storage.set_doc_indexed_clock("doc1".to_string(), ts, 1).await.unwrap();
|
||||
|
||||
storage.clear_doc_indexed_clock("doc1".to_string()).await.unwrap();
|
||||
|
||||
let clock = storage.get_doc_indexed_clock("doc1".to_string()).await.unwrap();
|
||||
|
||||
assert!(clock.is_none());
|
||||
}
|
||||
}
|
||||
544
packages/frontend/native/nbstore/src/lib.rs
Normal file
544
packages/frontend/native/nbstore/src/lib.rs
Normal file
@@ -0,0 +1,544 @@
|
||||
pub mod blob;
|
||||
pub mod blob_sync;
|
||||
pub mod doc;
|
||||
pub mod doc_sync;
|
||||
pub mod error;
|
||||
pub mod indexer;
|
||||
pub mod indexer_sync;
|
||||
pub mod pool;
|
||||
pub mod storage;
|
||||
|
||||
#[cfg(not(feature = "use-as-lib"))]
|
||||
use affine_common::napi_utils::to_napi_error;
|
||||
use chrono::NaiveDateTime;
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
use pool::{Ref, SqliteDocStoragePool};
|
||||
use storage::SqliteDocStorage;
|
||||
|
||||
#[cfg(feature = "use-as-lib")]
|
||||
type Result<T> = anyhow::Result<T>;
|
||||
|
||||
#[cfg(not(feature = "use-as-lib"))]
|
||||
type Result<T> = napi::Result<T>;
|
||||
|
||||
#[cfg(not(feature = "use-as-lib"))]
|
||||
impl From<error::Error> for napi::Error {
|
||||
fn from(err: error::Error) -> Self {
|
||||
to_napi_error(err, napi::Status::GenericFailure)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "use-as-lib")]
|
||||
pub type Data = Vec<u8>;
|
||||
|
||||
#[cfg(not(feature = "use-as-lib"))]
|
||||
pub type Data = Uint8Array;
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DocUpdate {
|
||||
pub doc_id: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
#[napi(ts_type = "Uint8Array")]
|
||||
pub bin: Data,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DocRecord {
|
||||
pub doc_id: String,
|
||||
#[napi(ts_type = "Uint8Array")]
|
||||
pub bin: Data,
|
||||
pub timestamp: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[napi(object)]
|
||||
pub struct DocClock {
|
||||
pub doc_id: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[napi(object)]
|
||||
pub struct DocIndexedClock {
|
||||
pub doc_id: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub indexer_version: i64,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct SetBlob {
|
||||
pub key: String,
|
||||
#[napi(ts_type = "Uint8Array")]
|
||||
pub data: Data,
|
||||
pub mime: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct Blob {
|
||||
pub key: String,
|
||||
#[napi(ts_type = "Uint8Array")]
|
||||
pub data: Data,
|
||||
pub mime: String,
|
||||
pub size: i64,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ListedBlob {
|
||||
pub key: String,
|
||||
pub size: i64,
|
||||
pub mime: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct DocStoragePool {
|
||||
pool: SqliteDocStoragePool,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DocStoragePool {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
pool: SqliteDocStoragePool::default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get(&self, universal_id: String) -> Result<Ref<SqliteDocStorage>> {
|
||||
Ok(self.pool.get(universal_id).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
/// Initialize the database and run migrations.
|
||||
pub async fn connect(&self, universal_id: String, path: String) -> Result<()> {
|
||||
self.pool.connect(universal_id, path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn disconnect(&self, universal_id: String) -> Result<()> {
|
||||
self.pool.disconnect(universal_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn checkpoint(&self, universal_id: String) -> Result<()> {
|
||||
self.pool.get(universal_id).await?.checkpoint().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn vacuum_into(&self, universal_id: String, path: String) -> Result<()> {
|
||||
self.pool.get(universal_id).await?.vacuum_into(path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn crawl_doc_data(&self, universal_id: String, doc_id: String) -> Result<indexer::NativeCrawlResult> {
|
||||
let result = self.get(universal_id).await?.crawl_doc_data(&doc_id).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_space_id(&self, universal_id: String, space_id: String) -> Result<()> {
|
||||
self.get(universal_id).await?.set_space_id(space_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn push_update(&self, universal_id: String, doc_id: String, update: Uint8Array) -> Result<NaiveDateTime> {
|
||||
Ok(self.get(universal_id).await?.push_update(doc_id, update).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_doc_snapshot(&self, universal_id: String, doc_id: String) -> Result<Option<DocRecord>> {
|
||||
Ok(self.get(universal_id).await?.get_doc_snapshot(doc_id).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_doc_snapshot(&self, universal_id: String, snapshot: DocRecord) -> Result<bool> {
|
||||
Ok(self.get(universal_id).await?.set_doc_snapshot(snapshot).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_doc_updates(&self, universal_id: String, doc_id: String) -> Result<Vec<DocUpdate>> {
|
||||
Ok(self.get(universal_id).await?.get_doc_updates(doc_id).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn mark_updates_merged(
|
||||
&self,
|
||||
universal_id: String,
|
||||
doc_id: String,
|
||||
updates: Vec<NaiveDateTime>,
|
||||
) -> Result<u32> {
|
||||
Ok(
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.mark_updates_merged(doc_id, updates)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn delete_doc(&self, universal_id: String, doc_id: String) -> Result<()> {
|
||||
self.get(universal_id).await?.delete_doc(doc_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_doc_clocks(&self, universal_id: String, after: Option<NaiveDateTime>) -> Result<Vec<DocClock>> {
|
||||
Ok(self.get(universal_id).await?.get_doc_clocks(after).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_doc_clock(&self, universal_id: String, doc_id: String) -> Result<Option<DocClock>> {
|
||||
Ok(self.get(universal_id).await?.get_doc_clock(doc_id).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_doc_indexed_clock(&self, universal_id: String, doc_id: String) -> Result<Option<DocIndexedClock>> {
|
||||
Ok(self.get(universal_id).await?.get_doc_indexed_clock(doc_id).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_doc_indexed_clock(
|
||||
&self,
|
||||
universal_id: String,
|
||||
doc_id: String,
|
||||
indexed_clock: NaiveDateTime,
|
||||
indexer_version: i64,
|
||||
) -> Result<()> {
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.set_doc_indexed_clock(doc_id, indexed_clock, indexer_version)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_doc_indexed_clocks(&self, universal_id: String, clocks: Vec<DocIndexedClock>) -> Result<()> {
|
||||
self.get(universal_id).await?.commit_indexed_clocks(&clocks).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn clear_doc_indexed_clock(&self, universal_id: String, doc_id: String) -> Result<()> {
|
||||
self.get(universal_id).await?.clear_doc_indexed_clock(doc_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi(async_runtime)]
|
||||
pub async fn get_blob(&self, universal_id: String, key: String) -> Result<Option<Blob>> {
|
||||
Ok(self.get(universal_id).await?.get_blob(key).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_blob(&self, universal_id: String, blob: SetBlob) -> Result<()> {
|
||||
self.get(universal_id).await?.set_blob(blob).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn delete_blob(&self, universal_id: String, key: String, permanently: bool) -> Result<()> {
|
||||
self.get(universal_id).await?.delete_blob(key, permanently).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn release_blobs(&self, universal_id: String) -> Result<()> {
|
||||
self.get(universal_id).await?.release_blobs().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn list_blobs(&self, universal_id: String) -> Result<Vec<ListedBlob>> {
|
||||
Ok(self.get(universal_id).await?.list_blobs().await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_peer_remote_clocks(&self, universal_id: String, peer: String) -> Result<Vec<DocClock>> {
|
||||
Ok(self.get(universal_id).await?.get_peer_remote_clocks(peer).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_peer_remote_clock(
|
||||
&self,
|
||||
universal_id: String,
|
||||
peer: String,
|
||||
doc_id: String,
|
||||
) -> Result<Option<DocClock>> {
|
||||
Ok(
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.get_peer_remote_clock(peer, doc_id)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_peer_remote_clock(
|
||||
&self,
|
||||
universal_id: String,
|
||||
peer: String,
|
||||
doc_id: String,
|
||||
clock: NaiveDateTime,
|
||||
) -> Result<()> {
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.set_peer_remote_clock(peer, doc_id, clock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_peer_pulled_remote_clocks(&self, universal_id: String, peer: String) -> Result<Vec<DocClock>> {
|
||||
Ok(
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.get_peer_pulled_remote_clocks(peer)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_peer_pulled_remote_clock(
|
||||
&self,
|
||||
universal_id: String,
|
||||
peer: String,
|
||||
doc_id: String,
|
||||
) -> Result<Option<DocClock>> {
|
||||
Ok(
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.get_peer_pulled_remote_clock(peer, doc_id)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_peer_pulled_remote_clock(
|
||||
&self,
|
||||
universal_id: String,
|
||||
peer: String,
|
||||
doc_id: String,
|
||||
clock: NaiveDateTime,
|
||||
) -> Result<()> {
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.set_peer_pulled_remote_clock(peer, doc_id, clock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_peer_pushed_clocks(&self, universal_id: String, peer: String) -> Result<Vec<DocClock>> {
|
||||
Ok(self.get(universal_id).await?.get_peer_pushed_clocks(peer).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_peer_pushed_clock(
|
||||
&self,
|
||||
universal_id: String,
|
||||
peer: String,
|
||||
doc_id: String,
|
||||
) -> Result<Option<DocClock>> {
|
||||
Ok(
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.get_peer_pushed_clock(peer, doc_id)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_peer_pushed_clock(
|
||||
&self,
|
||||
universal_id: String,
|
||||
peer: String,
|
||||
doc_id: String,
|
||||
clock: NaiveDateTime,
|
||||
) -> Result<()> {
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.set_peer_pushed_clock(peer, doc_id, clock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn clear_clocks(&self, universal_id: String) -> Result<()> {
|
||||
self.get(universal_id).await?.clear_clocks().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_blob_uploaded_at(
|
||||
&self,
|
||||
universal_id: String,
|
||||
peer: String,
|
||||
blob_id: String,
|
||||
uploaded_at: Option<NaiveDateTime>,
|
||||
) -> Result<()> {
|
||||
self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.set_blob_uploaded_at(peer, blob_id, uploaded_at)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_blob_uploaded_at(
|
||||
&self,
|
||||
universal_id: String,
|
||||
peer: String,
|
||||
blob_id: String,
|
||||
) -> Result<Option<NaiveDateTime>> {
|
||||
let result = self
|
||||
.get(universal_id)
|
||||
.await?
|
||||
.get_blob_uploaded_at(peer, blob_id)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn index_upsert(&self, id: String, table: String, document: indexer::NativeIndexDocument) -> Result<()> {
|
||||
let storage = self.pool.get(id).await?;
|
||||
storage.index_upsert(&table, document).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn index_flush(&self, id: String) -> Result<()> {
|
||||
let storage = self.pool.get(id).await?;
|
||||
storage.flush_index().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn index_version(&self) -> Result<u32> {
|
||||
Ok(SqliteDocStorage::index_version())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn index_delete(&self, id: String, table: String, doc_id: String) -> Result<()> {
|
||||
let storage = self.pool.get(id).await?;
|
||||
storage.index_delete(&table, &doc_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn index_search(
|
||||
&self,
|
||||
id: String,
|
||||
table: String,
|
||||
query: indexer::NativeIndexQuery,
|
||||
options: indexer::NativeIndexSearchOptions,
|
||||
) -> Result<indexer::NativeIndexSearchResult> {
|
||||
let storage = self.pool.get(id).await?;
|
||||
Ok(storage.index_search(&table, query, options).await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn index_aggregate(
|
||||
&self,
|
||||
id: String,
|
||||
table: String,
|
||||
query: indexer::NativeIndexQuery,
|
||||
field: String,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
hits: Option<indexer::NativeIndexSearchOptions>,
|
||||
) -> Result<indexer::NativeIndexAggregateResult> {
|
||||
let storage = self.pool.get(id).await?;
|
||||
Ok(
|
||||
storage
|
||||
.index_aggregate(&table, query, &field, limit, offset, hits)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn index_delete_by_query(
|
||||
&self,
|
||||
id: String,
|
||||
table: String,
|
||||
query: indexer::NativeIndexQuery,
|
||||
) -> Result<u32> {
|
||||
let storage = self.pool.get(id).await?;
|
||||
Ok(storage.index_delete_by_query(&table, query).await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct DocStorage {
|
||||
storage: SqliteDocStorage,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DocStorage {
|
||||
#[napi(constructor, async_runtime)]
|
||||
pub fn new(path: String) -> Self {
|
||||
Self {
|
||||
storage: SqliteDocStorage::new(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn validate(&self) -> Result<bool> {
|
||||
Ok(self.storage.validate().await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn validate_import_schema(&self) -> Result<bool> {
|
||||
Ok(self.storage.validate_import_schema().await?)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn vacuum_into(&self, path: String) -> Result<()> {
|
||||
self.storage.vacuum_into(path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_space_id(&self, space_id: String) -> Result<()> {
|
||||
self.storage.connect().await?;
|
||||
self.storage.set_space_id(space_id).await?;
|
||||
self.storage.close().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(feature = "use-as-lib")))]
|
||||
mod tests {
|
||||
use super::error;
|
||||
|
||||
#[test]
|
||||
fn napi_error_mapping_preserves_reason() {
|
||||
let err: napi::Error = error::Error::InvalidOperation.into();
|
||||
assert_eq!(err.status, napi::Status::GenericFailure);
|
||||
assert!(err.reason.contains("Invalid operation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn napi_error_mapping_connection_in_progress() {
|
||||
let err: napi::Error = error::Error::ConnectionInProgress.into();
|
||||
assert_eq!(err.status, napi::Status::GenericFailure);
|
||||
assert!(err.reason.contains("Connection in progress"));
|
||||
}
|
||||
}
|
||||
130
packages/frontend/native/nbstore/src/pool.rs
Normal file
130
packages/frontend/native/nbstore/src/pool.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use core::ops::Deref;
|
||||
use std::{
|
||||
collections::hash_map::{Entry, HashMap},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::{
|
||||
error::{Error, Result},
|
||||
storage::SqliteDocStorage,
|
||||
};
|
||||
|
||||
pub struct Ref<V> {
|
||||
inner: Arc<V>,
|
||||
}
|
||||
|
||||
impl<V> Deref for Ref<V> {
|
||||
type Target = V;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.inner.deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SqliteDocStoragePool {
|
||||
inner: RwLock<HashMap<String, StorageState>>,
|
||||
}
|
||||
|
||||
enum StorageState {
|
||||
Connecting(Arc<SqliteDocStorage>),
|
||||
Connected(Arc<SqliteDocStorage>),
|
||||
}
|
||||
|
||||
impl SqliteDocStoragePool {
|
||||
pub async fn get(&self, universal_id: String) -> Result<Ref<SqliteDocStorage>> {
|
||||
let lock = self.inner.read().await;
|
||||
let Some(state) = lock.get(&universal_id) else {
|
||||
return Err(Error::InvalidOperation);
|
||||
};
|
||||
let StorageState::Connected(storage) = state else {
|
||||
return Err(Error::InvalidOperation);
|
||||
};
|
||||
Ok(Ref {
|
||||
inner: Arc::clone(storage),
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize the database and run migrations.
|
||||
pub async fn connect(&self, universal_id: String, path: String) -> Result<()> {
|
||||
let storage = {
|
||||
let mut lock = self.inner.write().await;
|
||||
match lock.entry(universal_id.clone()) {
|
||||
Entry::Occupied(entry) => match entry.get() {
|
||||
StorageState::Connected(_) => return Ok(()),
|
||||
StorageState::Connecting(_) => return Err(Error::ConnectionInProgress),
|
||||
},
|
||||
Entry::Vacant(entry) => {
|
||||
let storage = Arc::new(SqliteDocStorage::new(path));
|
||||
entry.insert(StorageState::Connecting(Arc::clone(&storage)));
|
||||
storage
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = storage.connect().await {
|
||||
let mut lock = self.inner.write().await;
|
||||
if matches!(
|
||||
lock.get(&universal_id),
|
||||
Some(StorageState::Connecting(existing)) if Arc::ptr_eq(existing, &storage)
|
||||
) {
|
||||
lock.remove(&universal_id);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let mut transitioned = false;
|
||||
{
|
||||
let mut lock = self.inner.write().await;
|
||||
if matches!(
|
||||
lock.get(&universal_id),
|
||||
Some(StorageState::Connecting(existing)) if Arc::ptr_eq(existing, &storage)
|
||||
) {
|
||||
lock.insert(universal_id.clone(), StorageState::Connected(Arc::clone(&storage)));
|
||||
transitioned = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !transitioned {
|
||||
let mut lock = self.inner.write().await;
|
||||
if matches!(
|
||||
lock.get(&universal_id),
|
||||
Some(StorageState::Connecting(existing)) if Arc::ptr_eq(existing, &storage)
|
||||
) {
|
||||
lock.remove(&universal_id);
|
||||
}
|
||||
drop(lock);
|
||||
storage.close().await;
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self, universal_id: String) -> Result<()> {
|
||||
let storage = {
|
||||
let mut lock = self.inner.write().await;
|
||||
match lock.get(&universal_id) {
|
||||
None => return Ok(()),
|
||||
Some(StorageState::Connecting(_)) => return Err(Error::ConnectionInProgress),
|
||||
Some(StorageState::Connected(storage)) => {
|
||||
// Prevent shutting down the shared storage while requests still hold
|
||||
// refs.
|
||||
if Arc::strong_count(storage) > 1 {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(StorageState::Connected(storage)) = lock.remove(&universal_id) else {
|
||||
return Err(Error::InvalidOperation);
|
||||
};
|
||||
storage
|
||||
};
|
||||
|
||||
storage.close().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
398
packages/frontend/native/nbstore/src/storage.rs
Normal file
398
packages/frontend/native/nbstore/src/storage.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use affine_schema::{
|
||||
get_migrator,
|
||||
import_validation::{V2_IMPORT_SCHEMA_RULES, validate_import_schema, validate_required_schema},
|
||||
};
|
||||
use sqlx::{
|
||||
Pool, Row,
|
||||
migrate::{MigrateDatabase, Migration, Migrator},
|
||||
sqlite::{Sqlite, SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
|
||||
use super::{error::Result, indexer::IndexManager};
|
||||
|
||||
pub struct SqliteDocStorage {
|
||||
pub pool: Pool<Sqlite>,
|
||||
path: String,
|
||||
pub indexes: Arc<IndexManager>,
|
||||
}
|
||||
|
||||
impl SqliteDocStorage {
|
||||
pub fn new(path: String) -> Self {
|
||||
let sqlite_options = SqliteConnectOptions::new().filename(&path).foreign_keys(false);
|
||||
|
||||
let mut pool_options = SqlitePoolOptions::new();
|
||||
|
||||
let indexes = Arc::new(IndexManager::new());
|
||||
|
||||
if path == ":memory:" {
|
||||
pool_options = pool_options
|
||||
.min_connections(1)
|
||||
.max_connections(1)
|
||||
.idle_timeout(None)
|
||||
.max_lifetime(None);
|
||||
|
||||
Self {
|
||||
pool: pool_options.connect_lazy_with(sqlite_options),
|
||||
path,
|
||||
indexes,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
pool: pool_options
|
||||
.max_connections(4)
|
||||
.connect_lazy_with(sqlite_options.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)),
|
||||
path,
|
||||
indexes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn validate(&self) -> Result<bool> {
|
||||
if self.path == ":memory:" {
|
||||
return Ok(validate_required_schema(&self.pool, &V2_IMPORT_SCHEMA_RULES).await?);
|
||||
}
|
||||
|
||||
let Ok(pool) = self.open_readonly_pool().await else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
Ok(validate_required_schema(&pool, &V2_IMPORT_SCHEMA_RULES).await?)
|
||||
}
|
||||
|
||||
pub async fn validate_import_schema(&self) -> Result<bool> {
|
||||
if self.path == ":memory:" {
|
||||
return Ok(validate_import_schema(&self.pool, &V2_IMPORT_SCHEMA_RULES).await?);
|
||||
}
|
||||
|
||||
let Ok(pool) = self.open_readonly_pool().await else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
Ok(validate_import_schema(&pool, &V2_IMPORT_SCHEMA_RULES).await?)
|
||||
}
|
||||
|
||||
pub async fn connect(&self) -> Result<()> {
|
||||
if !Sqlite::database_exists(&self.path).await? {
|
||||
Sqlite::create_database(&self.path).await?;
|
||||
};
|
||||
|
||||
self.migrate().await?;
|
||||
self.init_index().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate(&self) -> Result<()> {
|
||||
let migrator = get_migrator();
|
||||
if let Err(err) = migrator.run(&self.pool).await {
|
||||
// Compatibility: migration 3 (`add_idx_snapshots`) had a whitespace-only
|
||||
// SQL change (trailing space) between releases, which causes sqlx
|
||||
// to reject existing DBs with: `VersionMismatch(3)`. It's safe to
|
||||
// fix by updating the stored checksum.
|
||||
if matches!(err, sqlx::migrate::MigrateError::VersionMismatch(3))
|
||||
&& self.try_repair_migration_3_checksum(&migrator).await?
|
||||
{
|
||||
migrator.run(&self.pool).await?;
|
||||
} else {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_repair_migration_3_checksum(&self, migrator: &Migrator) -> Result<bool> {
|
||||
let Some(migration) = migrator.iter().find(|m| m.version == 3) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
// We're only prepared to repair the known `add_idx_snapshots`
|
||||
// whitespace-only mismatch.
|
||||
if migration.description.as_ref() != "add_idx_snapshots" {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let row = sqlx::query("SELECT description, checksum FROM _sqlx_migrations WHERE version = 3")
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let applied_description: String = row.try_get("description")?;
|
||||
if applied_description != migration.description.as_ref() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let applied_checksum: Vec<u8> = row.try_get("checksum")?;
|
||||
let expected_checksum = migration.checksum.as_ref();
|
||||
|
||||
// sqlx computes the checksum as SHA-384 of the raw SQL bytes. The legacy
|
||||
// variant had an extra trailing space at the end of the SQL string (after
|
||||
// the final newline).
|
||||
let legacy_sql = format!("{} ", migration.sql);
|
||||
let legacy_migration = Migration::new(
|
||||
migration.version,
|
||||
migration.description.clone(),
|
||||
migration.migration_type,
|
||||
std::borrow::Cow::Owned(legacy_sql),
|
||||
migration.no_tx,
|
||||
);
|
||||
|
||||
if applied_checksum.as_slice() != legacy_migration.checksum.as_ref() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = 3")
|
||||
.bind(expected_checksum)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
self.pool.close().await
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.pool.is_closed()
|
||||
}
|
||||
|
||||
///
|
||||
/// Flush the WAL file to the database file.
|
||||
/// See https://www.sqlite.org/pragma.html#pragma_wal_checkpoint:~:text=PRAGMA%20schema.wal_checkpoint%3B
|
||||
pub async fn checkpoint(&self) -> Result<()> {
|
||||
sqlx::query("PRAGMA wal_checkpoint(FULL);").execute(&self.pool).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn vacuum_into(&self, path: String) -> Result<()> {
|
||||
if self.path == ":memory:" {
|
||||
sqlx::query("VACUUM INTO ?;").bind(path).execute(&self.pool).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pool = self.open_readonly_pool().await?;
|
||||
sqlx::query("VACUUM INTO ?;").bind(path).execute(&pool).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn open_readonly_pool(&self) -> Result<Pool<Sqlite>> {
|
||||
let sqlite_options = SqliteConnectOptions::new()
|
||||
.filename(&self.path)
|
||||
.foreign_keys(false)
|
||||
.read_only(true);
|
||||
|
||||
Ok(
|
||||
SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(sqlite_options)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{borrow::Cow, fs, path::Path};
|
||||
|
||||
use affine_schema::get_migrator;
|
||||
use sqlx::migrate::{Migration, Migrator};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn get_storage() -> SqliteDocStorage {
|
||||
let storage = SqliteDocStorage::new(":memory:".to_string());
|
||||
storage.connect().await.unwrap();
|
||||
|
||||
storage
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn init_tables() {
|
||||
let storage = get_storage().await;
|
||||
|
||||
sqlx::query("INSERT INTO meta (space_id) VALUES ($1);")
|
||||
.bind("test")
|
||||
.execute(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let record = sqlx::query!("SELECT space_id FROM meta;")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(record.space_id, "test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_db() {
|
||||
let storage = get_storage().await;
|
||||
assert!(storage.validate().await.unwrap());
|
||||
|
||||
let storage = SqliteDocStorage::new(":memory:".to_string());
|
||||
assert!(!storage.validate().await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_repairs_whitespace_only_migration_checksum_mismatch() {
|
||||
// Simulate a DB migrated with an older `add_idx_snapshots` SQL that had a
|
||||
// trailing space.
|
||||
let storage = SqliteDocStorage::new(":memory:".to_string());
|
||||
|
||||
let new_migrator = get_migrator();
|
||||
let mut migrations = new_migrator.migrations.to_vec();
|
||||
assert!(migrations.len() >= 3);
|
||||
|
||||
let mig3 = migrations[2].clone();
|
||||
assert_eq!(mig3.version, 3);
|
||||
assert_eq!(mig3.description.as_ref(), "add_idx_snapshots");
|
||||
|
||||
let legacy_sql = format!("{} ", mig3.sql);
|
||||
migrations[2] = Migration::new(
|
||||
mig3.version,
|
||||
mig3.description.clone(),
|
||||
mig3.migration_type,
|
||||
Cow::Owned(legacy_sql),
|
||||
mig3.no_tx,
|
||||
);
|
||||
|
||||
// The legacy DB didn't have newer migrations.
|
||||
migrations.truncate(3);
|
||||
let legacy_migrator = Migrator {
|
||||
migrations: Cow::Owned(migrations),
|
||||
..Migrator::DEFAULT
|
||||
};
|
||||
|
||||
legacy_migrator.run(&storage.pool).await.unwrap();
|
||||
|
||||
// Now connecting with the current code should auto-repair the checksum and
|
||||
// succeed.
|
||||
storage.connect().await.unwrap();
|
||||
|
||||
let expected_checksum = get_migrator()
|
||||
.iter()
|
||||
.find(|m| m.version == 3)
|
||||
.unwrap()
|
||||
.checksum
|
||||
.as_ref()
|
||||
.to_vec();
|
||||
|
||||
let row = sqlx::query("SELECT checksum FROM _sqlx_migrations WHERE version = 3")
|
||||
.fetch_one(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let checksum: Vec<u8> = row.get("checksum");
|
||||
|
||||
assert_eq!(checksum, expected_checksum);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vacuum_into_exports_a_compacted_database() {
|
||||
let base = std::env::temp_dir().join(format!("nbstore-vacuum-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let source = base.join("storage.db");
|
||||
let export = base.join("backup.affine");
|
||||
|
||||
let storage = SqliteDocStorage::new(path_string(&source));
|
||||
storage.connect().await.unwrap();
|
||||
|
||||
storage
|
||||
.set_blob(crate::SetBlob {
|
||||
key: "large-blob".to_string(),
|
||||
data: Into::<crate::Data>::into(vec![7; 1024 * 1024]),
|
||||
mime: "application/octet-stream".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
storage.delete_blob("large-blob".to_string(), true).await.unwrap();
|
||||
storage.checkpoint().await.unwrap();
|
||||
|
||||
let source_len = fs::metadata(&source).unwrap().len();
|
||||
assert!(source_len > 0);
|
||||
|
||||
storage.vacuum_into(path_string(&export)).await.unwrap();
|
||||
|
||||
let export_len = fs::metadata(&export).unwrap().len();
|
||||
assert!(export_len < source_len);
|
||||
|
||||
let exported = SqliteDocStorage::new(path_string(&export));
|
||||
exported.connect().await.unwrap();
|
||||
assert!(exported.list_blobs().await.unwrap().is_empty());
|
||||
exported.close().await;
|
||||
storage.close().await;
|
||||
|
||||
fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_import_schema_rejects_unexpected_schema_objects() {
|
||||
let base = std::env::temp_dir().join(format!("nbstore-schema-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let source = base.join("storage.db");
|
||||
fs::File::create(&source).unwrap();
|
||||
let storage = SqliteDocStorage::new(path_string(&source));
|
||||
storage.connect().await.unwrap();
|
||||
|
||||
sqlx::query("CREATE VIEW rogue_view AS SELECT space_id FROM meta")
|
||||
.execute(&storage.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!storage.validate_import_schema().await.unwrap());
|
||||
|
||||
storage.close().await;
|
||||
fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_import_schema_accepts_initial_v2_schema() {
|
||||
let base = std::env::temp_dir().join(format!("nbstore-v2-schema-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let source = base.join("storage.db");
|
||||
let source_path = path_string(&source);
|
||||
let setup_pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(
|
||||
SqliteConnectOptions::new()
|
||||
.filename(&source_path)
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(false),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut migrations = get_migrator().migrations.to_vec();
|
||||
migrations.truncate(1);
|
||||
let migrator = Migrator {
|
||||
migrations: Cow::Owned(migrations),
|
||||
..Migrator::DEFAULT
|
||||
};
|
||||
|
||||
migrator.run(&setup_pool).await.unwrap();
|
||||
setup_pool.close().await;
|
||||
|
||||
let storage = SqliteDocStorage::new(source_path);
|
||||
|
||||
assert!(storage.validate().await.unwrap());
|
||||
assert!(storage.validate_import_schema().await.unwrap());
|
||||
|
||||
storage.close().await;
|
||||
fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
fn path_string(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user