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

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View 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"] }

View 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(())
}

View 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);
}
}

View 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());
}
}

View 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);
}
}

View 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());
}
}

View 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),
}

View 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())
}

View 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>,
}

View 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;

View 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)
}
}

View 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
}

View 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;
}

View 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());
}
}

View 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"));
}
}

View 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(())
}
}

View 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()
}
}