Add new data model

This commit is contained in:
Andras Schmelczer 2026-09-19 17:05:50 +01:00
commit ce5f3bbd7d
4 changed files with 316 additions and 70 deletions

View file

@ -1,30 +1,34 @@
CREATE TABLE IF NOT EXISTS documents ( CREATE TABLE events (
vault_update_id INTEGER NOT NULL PRIMARY KEY, event_id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(event_id <= 9007199254740991), -- JavaScript’s Number.MAX_SAFE_INTEGER
request_id TEXT NOT NULL UNIQUE,
request_fingerprint BLOB NOT NULL,
event_json TEXT NOT NULL
);
CREATE TABLE documents (
vault_update_id INTEGER PRIMARY KEY REFERENCES events(event_id),
document_id TEXT NOT NULL, document_id TEXT NOT NULL,
relative_path TEXT NOT NULL, updated_date TEXT NOT NULL,
updated_date TIMESTAMP NOT NULL,
content BLOB NOT NULL, content BLOB NOT NULL,
is_deleted BOOLEAN NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
device_id TEXT NOT NULL device_id TEXT NOT NULL
); );
CREATE VIEW IF NOT EXISTS latest_document_versions AS CREATE INDEX documents_by_id ON documents(document_id);
SELECT d.*
FROM documents d
INNER JOIN (
SELECT MAX(vault_update_id) AS max_version_id
FROM documents
GROUP BY document_id
) max_versions
ON d.vault_update_id = max_versions.max_version_id;
CREATE INDEX IF NOT EXISTS idx_documents_vault_id_relative_path CREATE VIEW latest_document_versions AS
ON documents (relative_path); SELECT d.* FROM documents d JOIN (
SELECT document_id, MAX(vault_update_id) AS version FROM documents GROUP BY document_id
) heads ON d.vault_update_id = heads.version;
-- The immutable version and its acknowledgement commit in one transaction. CREATE TABLE file_manifests (
CREATE TABLE push_acknowledgements ( file_manifest_id INTEGER PRIMARY KEY REFERENCES events(event_id)
request_id TEXT NOT NULL PRIMARY KEY, );
request_fingerprint BLOB NOT NULL,
vault_update_id INTEGER NOT NULL REFERENCES documents(vault_update_id) CREATE TABLE file_manifest_entries (
file_manifest_id INTEGER NOT NULL REFERENCES file_manifests(file_manifest_id),
document_id TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (file_manifest_id, document_id),
UNIQUE (file_manifest_id, path)
); );

View file

@ -1,91 +1,125 @@
use base64::{Engine as _, engine::general_purpose::STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use ts_rs::TS; use ts_rs::TS;
pub type VaultId = String; pub type VaultId = String;
pub type VaultUpdateId = i64; pub type VaultUpdateId = i64;
pub type DocumentId = uuid::Uuid; pub type DocumentId = uuid::Uuid;
pub type UserId = String; pub type UserId = String;
pub type DeviceId = String; pub type DeviceId = String;
pub type FileManifestEntries = BTreeMap<DocumentId, String>;
#[derive(Debug, Clone)] #[derive(Debug, Clone, sqlx::FromRow)]
pub struct StoredDocumentVersion { pub struct StoredDocumentVersion {
pub vault_update_id: VaultUpdateId, pub vault_update_id: VaultUpdateId,
#[sqlx(try_from = "uuid::fmt::Hyphenated")]
pub document_id: DocumentId, pub document_id: DocumentId,
pub relative_path: String,
pub updated_date: DateTime<Utc>, pub updated_date: DateTime<Utc>,
pub content: Vec<u8>, pub content: Vec<u8>,
pub is_deleted: bool,
pub user_id: UserId, pub user_id: UserId,
pub device_id: DeviceId, pub device_id: DeviceId,
} }
impl PartialEq<Self> for StoredDocumentVersion { #[derive(TS, Debug, Clone, Serialize, Deserialize)]
fn eq(&self, other: &Self) -> bool {
self.vault_update_id == other.vault_update_id
}
}
#[derive(TS, Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct DocumentVersionWithoutContent { pub struct DocumentVersionWithoutContent {
#[ts(as = "i32")] #[ts(as = "f64")]
pub vault_update_id: VaultUpdateId, pub vault_update_id: VaultUpdateId,
pub document_id: DocumentId, pub document_id: DocumentId,
pub relative_path: String,
pub updated_date: DateTime<Utc>, pub updated_date: DateTime<Utc>,
pub is_deleted: bool,
pub user_id: UserId, pub user_id: UserId,
pub device_id: DeviceId, pub device_id: DeviceId,
pub content_size: usize,
#[ts(as = "i32")]
pub content_size: u64,
} }
impl From<StoredDocumentVersion> for DocumentVersionWithoutContent { impl From<&StoredDocumentVersion> for DocumentVersionWithoutContent {
fn from(value: StoredDocumentVersion) -> Self { fn from(v: &StoredDocumentVersion) -> Self {
Self { Self {
vault_update_id: value.vault_update_id, vault_update_id: v.vault_update_id,
document_id: value.document_id, document_id: v.document_id,
relative_path: value.relative_path, updated_date: v.updated_date,
updated_date: value.updated_date, user_id: v.user_id.clone(),
is_deleted: value.is_deleted, device_id: v.device_id.clone(),
user_id: value.user_id, content_size: v.content.len(),
device_id: value.device_id,
content_size: value.content.len() as u64,
} }
} }
} }
impl From<StoredDocumentVersion> for DocumentVersionWithoutContent {
fn from(v: StoredDocumentVersion) -> Self {
Self::from(&v)
}
}
#[derive(TS, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct DocumentVersion {
#[serde(flatten)]
pub metadata: DocumentVersionWithoutContent,
pub content_base64: String,
}
impl From<StoredDocumentVersion> for DocumentVersion {
fn from(v: StoredDocumentVersion) -> Self {
Self {
metadata: (&v).into(),
content_base64: STANDARD.encode(&v.content),
}
}
}
#[derive(TS, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct FileManifest {
#[ts(as = "f64")]
pub file_manifest_id: VaultUpdateId,
#[ts(type = "Record<string, string>")]
pub entries: FileManifestEntries,
}
#[derive(TS, Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(export)]
pub enum VaultEvent {
Content {
document: DocumentVersionWithoutContent,
},
FileManifest {
#[serde(rename = "fileManifest")]
#[ts(rename = "fileManifest")]
file_manifest: FileManifest,
},
}
#[derive(TS, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct EventRecord {
#[ts(as = "f64")]
pub event_id: VaultUpdateId,
pub request_id: uuid::Uuid,
#[serde(flatten)]
pub event: VaultEvent,
}
#[derive(TS, Debug, Clone, Serialize)] #[derive(TS, Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DocumentVersion { #[ts(export)]
#[ts(as = "i32")] pub struct VaultSnapshot {
pub vault_update_id: VaultUpdateId, #[ts(as = "f64")]
pub head_event_id: VaultUpdateId,
pub document_id: DocumentId, pub file_manifest: FileManifest,
pub relative_path: String, pub documents: Vec<DocumentVersionWithoutContent>,
pub updated_date: DateTime<Utc>,
pub content_base64: String,
pub is_deleted: bool,
pub user_id: UserId,
pub device_id: DeviceId,
} }
impl From<StoredDocumentVersion> for DocumentVersion { #[derive(TS, Debug, Clone, Serialize)]
fn from(value: StoredDocumentVersion) -> Self { #[serde(rename_all = "camelCase")]
Self { #[ts(export)]
vault_update_id: value.vault_update_id, pub struct EventBatch {
document_id: value.document_id, #[ts(as = "f64")]
relative_path: value.relative_path, pub head_event_id: VaultUpdateId,
updated_date: value.updated_date, pub events: Vec<EventRecord>,
content_base64: STANDARD.encode(&value.content),
is_deleted: value.is_deleted,
user_id: value.user_id,
device_id: value.device_id,
}
}
} }

View file

@ -0,0 +1,68 @@
use super::{Database, Transaction, models::*};
use anyhow::Result;
impl Database {
pub async fn allocate_event(
tx: &mut Transaction<'_>,
request_id: uuid::Uuid,
fingerprint: &[u8],
) -> Result<VaultUpdateId> {
Ok(sqlx::query(
"INSERT INTO events(request_id, request_fingerprint, event_json) VALUES (?, ?, '')",
)
.bind(request_id.to_string())
.bind(fingerprint)
.execute(&mut **tx)
.await?
.last_insert_rowid())
}
pub async fn insert_document_version(
tx: &mut Transaction<'_>,
version: &StoredDocumentVersion,
) -> Result<()> {
sqlx::query("INSERT INTO documents(vault_update_id, document_id, updated_date, content, user_id, device_id) VALUES (?, ?, ?, ?, ?, ?)")
.bind(version.vault_update_id)
.bind(version.document_id.hyphenated())
.bind(version.updated_date)
.bind(&version.content)
.bind(&version.user_id)
.bind(&version.device_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn insert_file_manifest(
tx: &mut Transaction<'_>,
file_manifest: &FileManifest,
) -> Result<()> {
sqlx::query("INSERT INTO file_manifests(file_manifest_id) VALUES (?)")
.bind(file_manifest.file_manifest_id)
.execute(&mut **tx)
.await?;
for (document_id, path) in &file_manifest.entries {
sqlx::query(
"INSERT INTO file_manifest_entries(file_manifest_id, document_id, path) VALUES (?, ?, ?)",
)
.bind(file_manifest.file_manifest_id)
.bind(document_id.hyphenated())
.bind(path)
.execute(&mut **tx)
.await?;
}
Ok(())
}
pub async fn write_event(tx: &mut Transaction<'_>, event: &EventRecord) -> Result<()> {
sqlx::query("UPDATE events SET event_json = ? WHERE event_id = ?")
.bind(serde_json::to_string(event)?)
.bind(event.event_id)
.execute(&mut **tx)
.await?;
Ok(())
}
}

View file

@ -0,0 +1,140 @@
use super::{Database, Transaction, models::*};
use anyhow::{Result, ensure};
impl Database {
pub async fn get_request_event(
tx: &mut Transaction<'_>,
request_id: uuid::Uuid,
) -> Result<Option<(Vec<u8>, String)>> {
Ok(sqlx::query_as(
"SELECT request_fingerprint, event_json FROM events WHERE request_id = ?",
)
.bind(request_id.to_string())
.fetch_optional(&mut **tx)
.await?)
}
pub async fn get_latest_document_version(
&self,
vault: &VaultId,
id: &DocumentId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as::<_, StoredDocumentVersion>(
"SELECT * FROM documents WHERE document_id = ? ORDER BY vault_update_id DESC LIMIT 1",
)
.bind(id.hyphenated());
Ok(if let Some(tx) = transaction {
query.fetch_optional(&mut **tx).await?
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await?
})
}
pub async fn get_document_version(
&self,
vault: &VaultId,
version: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as::<_, StoredDocumentVersion>(
"SELECT * FROM documents WHERE vault_update_id = ?",
)
.bind(version);
Ok(if let Some(tx) = transaction {
query.fetch_optional(&mut **tx).await?
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await?
})
}
pub async fn current_file_manifest(tx: &mut Transaction<'_>) -> Result<FileManifest> {
let Some(file_manifest_id) = sqlx::query_scalar::<_, VaultUpdateId>(
"SELECT file_manifest_id FROM file_manifests ORDER BY file_manifest_id DESC LIMIT 1",
)
.fetch_optional(&mut **tx)
.await?
else {
return Ok(FileManifest::default());
};
let entries = sqlx::query_as::<_, (uuid::fmt::Hyphenated, String)>(
"SELECT document_id, path FROM file_manifest_entries WHERE file_manifest_id = ?",
)
.bind(file_manifest_id)
.fetch_all(&mut **tx)
.await?
.into_iter()
.map(|(document_id, path)| (document_id.into_uuid(), path))
.collect();
Ok(FileManifest {
file_manifest_id,
entries,
})
}
pub async fn latest_event_id(tx: &mut Transaction<'_>) -> Result<VaultUpdateId> {
Ok(
sqlx::query_scalar("SELECT COALESCE(MAX(event_id), 0) FROM events")
.fetch_one(&mut **tx)
.await?,
)
}
pub async fn vault_snapshot(&self, vault: &VaultId) -> Result<VaultSnapshot> {
let mut tx = self.create_readonly_transaction(vault).await?;
let head_event_id = Self::latest_event_id(&mut tx).await?;
let file_manifest = Self::current_file_manifest(&mut tx).await?;
let versions = sqlx::query_as::<_, StoredDocumentVersion>(
"SELECT d.* FROM latest_document_versions d
JOIN file_manifest_entries m ON m.document_id = d.document_id
WHERE m.file_manifest_id = ? ORDER BY d.vault_update_id",
)
.bind(file_manifest.file_manifest_id)
.fetch_all(&mut *tx)
.await?;
let documents = versions.into_iter().map(Into::into).collect();
Ok(VaultSnapshot {
head_event_id,
file_manifest,
documents,
})
}
pub async fn events_after(&self, vault: &VaultId, after: VaultUpdateId) -> Result<EventBatch> {
let mut tx = self.create_readonly_transaction(vault).await?;
let head_event_id = Self::latest_event_id(&mut tx).await?;
ensure!(
(0..=head_event_id).contains(&after),
"Event cursor is outside this vault's history"
);
let rows: Vec<String> = sqlx::query_scalar(
"SELECT event_json FROM events WHERE event_id > ? ORDER BY event_id",
)
.bind(after)
.fetch_all(&mut *tx)
.await?;
let events = rows
.iter()
.map(|json| serde_json::from_str(json))
.collect::<Result<_, _>>()?;
Ok(EventBatch {
head_event_id,
events,
})
}
}