feat: consolidate local-first mnote web runtime

This commit is contained in:
lix-2026
2026-05-28 22:01:44 +08:00
parent 7354807ee9
commit 39b9a0183a
154 changed files with 13591 additions and 12728 deletions
@@ -26,6 +26,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
"v5-sidebar-shortcut-root-uri",
include_str!("../migrations/005-sidebar-shortcut-root-uri.sql"),
),
(
"v6-navigation-recent",
include_str!("../migrations/006-navigation-recent.sql"),
),
];
/// Create the `_migrations` meta-table if it does not exist.
@@ -108,6 +112,7 @@ mod tests {
"ai_runtime_events",
"sidebar_shortcuts",
"user_ui_preferences",
"user_navigation_recent",
] {
assert!(table_exists(&conn, table), "table {table} should exist");
}
+34
View File
@@ -243,6 +243,40 @@ pub struct UpsertUserUiPreferenceInput {
pub value_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavigationRecentRecord {
pub id: EntityId,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub kind: String,
pub source_kind: String,
pub root_uri: String,
pub target_key: String,
pub relative_path: Option<String>,
pub document_id: Option<String>,
pub title: String,
pub status: String,
pub metadata_json: String,
pub visited_at: Timestamp,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub revision: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpsertNavigationRecentInput {
pub id: Option<EntityId>,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub kind: String,
pub source_kind: String,
pub root_uri: String,
pub relative_path: Option<String>,
pub document_id: Option<String>,
pub title: String,
pub metadata_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiPolicyRecord {
pub id: EntityId,
+341 -6
View File
@@ -11,10 +11,11 @@ use crate::model::{
AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord,
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
DirectoryGrantRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession,
ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord,
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -230,6 +231,56 @@ fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUi
})
}
fn row_to_navigation_recent(row: &rusqlite::Row<'_>) -> rusqlite::Result<NavigationRecentRecord> {
Ok(NavigationRecentRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: empty_string_to_option(row.get(2)?),
kind: row.get(3)?,
source_kind: row.get(4)?,
root_uri: row.get(5)?,
target_key: row.get(6)?,
relative_path: row.get(7)?,
document_id: row.get(8)?,
title: row.get(9)?,
status: row.get(10)?,
metadata_json: row.get(11)?,
visited_at: row.get(12)?,
created_at: row.get(13)?,
updated_at: row.get(14)?,
revision: row.get(15)?,
})
}
fn navigation_recent_target_key(
kind: &str,
root_uri: &str,
relative_path: Option<&str>,
document_id: Option<&str>,
) -> Result<String, ControlPlaneError> {
match kind {
"folder" => Ok(format!(
"folder:{}:{}",
root_uri.trim(),
relative_path.map(str::trim).unwrap_or_default()
)),
"page" => {
let document_id = document_id
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
ControlPlaneError::InvalidInput(
"navigation recent page 需要 document_id".to_string(),
)
})?;
Ok(format!("page:{}:{document_id}", root_uri.trim()))
}
_ => Err(ControlPlaneError::InvalidInput(
"navigation recent kind 只能是 folder 或 page".to_string(),
)),
}
}
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
Ok(AiPolicyRecord {
id: row.get(0)?,
@@ -313,6 +364,33 @@ fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
title.chars().take(80).collect()
}
fn prune_navigation_recent_for_kind(
conn: &Connection,
user_id: &str,
kind: &str,
) -> Result<(), ControlPlaneError> {
let keep = match kind {
"folder" => 10_i64,
"page" => 20_i64,
_ => 20_i64,
};
conn.execute(
"UPDATE user_navigation_recent
SET status = 'removed', updated_at = ?1, revision = revision + 1
WHERE user_id = ?2
AND kind = ?3
AND status = 'active'
AND id NOT IN (
SELECT id FROM user_navigation_recent
WHERE user_id = ?2 AND kind = ?3 AND status = 'active'
ORDER BY visited_at DESC, updated_at DESC
LIMIT ?4
)",
params![now_text(), user_id, kind, keep],
)?;
Ok(())
}
impl ControlPlaneStore for SqliteControlPlaneStore {
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
if input.username.trim().is_empty() {
@@ -864,7 +942,8 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
AND status = 'active'
AND (
?2 = root_uri
OR (recursive = 1 AND ?2 LIKE root_uri || '%')
OR (recursive = 1 AND ?2 LIKE root_uri || '/%')
OR (recursive = 1 AND root_uri LIKE '%/' AND ?2 LIKE root_uri || '%')
OR (?3 != '' AND ?3 = root_path)
OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%')
)",
@@ -1382,6 +1461,138 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(rows)
}
fn upsert_navigation_recent(
&self,
input: UpsertNavigationRecentInput,
) -> Result<NavigationRecentRecord, ControlPlaneError> {
let user_id = input.user_id.trim().to_string();
let workspace_id = option_to_stored_text(input.workspace_id);
let kind = input.kind.trim().to_string();
let source_kind = input.source_kind.trim().to_string();
let root_uri = input.root_uri.trim().to_string();
let relative_path = input
.relative_path
.map(|value| value.trim().trim_matches('/').to_string())
.filter(|value| !value.is_empty());
let document_id = input
.document_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let title = input.title.trim().to_string();
if user_id.is_empty()
|| kind.is_empty()
|| source_kind.is_empty()
|| root_uri.is_empty()
|| title.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"navigation recent user/kind/source/root/title 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
let target_key = navigation_recent_target_key(
&kind,
&root_uri,
relative_path.as_deref(),
document_id.as_deref(),
)?;
let conn = self.conn.lock().unwrap();
let now = now_text();
conn.execute(
"INSERT INTO user_navigation_recent (
id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
relative_path, document_id, title, status, metadata_json, visited_at,
created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, 1)
ON CONFLICT(user_id, kind, target_key)
DO UPDATE SET
workspace_id = excluded.workspace_id,
source_kind = excluded.source_kind,
root_uri = excluded.root_uri,
relative_path = excluded.relative_path,
document_id = excluded.document_id,
title = excluded.title,
status = 'active',
metadata_json = excluded.metadata_json,
visited_at = excluded.visited_at,
updated_at = excluded.updated_at,
revision = user_navigation_recent.revision + 1",
params![
input.id.unwrap_or_else(|| new_id("nav_recent")),
user_id,
workspace_id,
kind,
source_kind,
root_uri,
target_key,
relative_path,
document_id,
title,
input.metadata_json,
now,
now,
now,
],
)?;
prune_navigation_recent_for_kind(&conn, &user_id, &kind)?;
let record = conn.query_row(
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
relative_path, document_id, title, status, metadata_json, visited_at,
created_at, updated_at, revision
FROM user_navigation_recent
WHERE user_id = ?1 AND kind = ?2 AND target_key = ?3
LIMIT 1",
params![user_id, kind, target_key],
row_to_navigation_recent,
)?;
Ok(record)
}
fn list_navigation_recent(
&self,
user_id: &str,
kind: Option<&str>,
limit: usize,
) -> Result<Vec<NavigationRecentRecord>, ControlPlaneError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Ok(Vec::new());
}
let limit = limit.clamp(1, 100) as i64;
let conn = self.conn.lock().unwrap();
let rows = if let Some(kind) = kind.map(str::trim).filter(|value| !value.is_empty()) {
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
relative_path, document_id, title, status, metadata_json, visited_at,
created_at, updated_at, revision
FROM user_navigation_recent
WHERE user_id = ?1 AND kind = ?2 AND status = 'active'
ORDER BY visited_at DESC, updated_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(params![user_id, kind, limit], row_to_navigation_recent)?
.collect::<Result<Vec<_>, _>>()?;
rows
} else {
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
relative_path, document_id, title, status, metadata_json, visited_at,
created_at, updated_at, revision
FROM user_navigation_recent
WHERE user_id = ?1 AND status = 'active'
ORDER BY visited_at DESC, updated_at DESC
LIMIT ?2",
)?;
let rows = stmt
.query_map(params![user_id, limit], row_to_navigation_recent)?
.collect::<Result<Vec<_>, _>>()?;
rows
};
Ok(rows)
}
fn upsert_ai_policy(
&self,
input: UpsertAiPolicyInput,
@@ -1985,7 +2196,8 @@ mod tests {
use crate::model::{
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput,
UpsertUserUiPreferenceInput,
};
fn store() -> SqliteControlPlaneStore {
@@ -2158,6 +2370,36 @@ mod tests {
assert_eq!(access.grant_ids.len(), 1);
}
#[test]
fn resolve_access_does_not_match_sibling_uri_prefix() {
let store = store();
create_user(&store, "reader");
store
.grant_directory_access(DirectoryGrantInput {
user_id: "reader".to_string(),
workspace_id: None,
root_uri: "file:///tmp/mnote-ai-root".to_string(),
root_path: "/tmp/mnote-ai-root".to_string(),
permission: "write".to_string(),
recursive: true,
capabilities: vec!["ai".to_string()],
source: "admin".to_string(),
created_by: None,
})
.expect("grant root");
let inside = store
.resolve_access("reader", "file:///tmp/mnote-ai-root/page.md")
.expect("resolve inside");
assert_eq!(inside.permission, "write");
let sibling = store
.resolve_access("reader", "file:///tmp/mnote-ai-root-sibling/page.md")
.expect("resolve sibling");
assert_eq!(sibling.permission, "none");
assert!(sibling.grant_ids.is_empty());
}
#[test]
fn share_link_token_is_hashed_and_revocable() {
let store = store();
@@ -2474,6 +2716,99 @@ mod tests {
assert!(bob_sidebar_preferences.is_empty());
}
#[test]
fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
let first = store
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_tmp_mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
root_uri: "file:///tmp/mnote".to_string(),
relative_path: Some("design".to_string()),
document_id: None,
title: "design".to_string(),
metadata_json: "{}".to_string(),
})
.expect("insert folder recent");
let updated = store
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_tmp_mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
root_uri: "file:///tmp/mnote".to_string(),
relative_path: Some("design".to_string()),
document_id: None,
title: "Design".to_string(),
metadata_json: "{\"from\":\"test\"}".to_string(),
})
.expect("upsert folder recent");
assert_eq!(updated.id, first.id);
assert_eq!(updated.title, "Design");
assert_eq!(updated.revision, first.revision + 1);
store
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_tmp_mnote".to_string()),
kind: "page".to_string(),
source_kind: "local_folder".to_string(),
root_uri: "file:///tmp/mnote".to_string(),
relative_path: Some("design/Plan.md".to_string()),
document_id: Some("local-md:design~2FPlan.md".to_string()),
title: "Plan".to_string(),
metadata_json: "{}".to_string(),
})
.expect("insert page recent");
for index in 0..12 {
store
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_tmp_mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
root_uri: "file:///tmp/mnote".to_string(),
relative_path: Some(format!("folder-{index}")),
document_id: None,
title: format!("folder-{index}"),
metadata_json: "{}".to_string(),
})
.expect("insert bounded folder recent");
}
let alice_folders = store
.list_navigation_recent("alice", Some("folder"), 50)
.expect("list alice folders");
assert_eq!(alice_folders.len(), 10);
assert_eq!(alice_folders[0].kind, "folder");
assert_eq!(alice_folders[0].title, "folder-11");
assert!(alice_folders.iter().all(|record| record.user_id == "alice"));
assert!(!alice_folders.iter().any(|record| record.title == "Design"));
let alice_pages = store
.list_navigation_recent("alice", Some("page"), 20)
.expect("list alice pages");
assert_eq!(alice_pages.len(), 1);
assert_eq!(
alice_pages[0].document_id.as_deref(),
Some("local-md:design~2FPlan.md")
);
let bob_recent = store
.list_navigation_recent("bob", None, 20)
.expect("list bob recent");
assert!(bob_recent.is_empty());
}
#[test]
fn ai_policy_upsert_returns_workspace_policy_before_user_policy() {
let store = store();
+17 -5
View File
@@ -5,11 +5,11 @@ use crate::model::{
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput,
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput,
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
UserUiPreferenceRecord, WorkspaceRecord,
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord,
OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
};
pub trait ControlPlaneStore: Send + Sync {
@@ -136,6 +136,18 @@ pub trait ControlPlaneStore: Send + Sync {
source_kind: Option<&str>,
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
fn upsert_navigation_recent(
&self,
input: UpsertNavigationRecentInput,
) -> Result<NavigationRecentRecord, ControlPlaneError>;
fn list_navigation_recent(
&self,
user_id: &str,
kind: Option<&str>,
limit: usize,
) -> Result<Vec<NavigationRecentRecord>, ControlPlaneError>;
fn upsert_ai_policy(
&self,
input: UpsertAiPolicyInput,