feat: 通过 Host 日志同步类型化任务记录
This commit is contained in:
@@ -4,6 +4,7 @@ pub mod core;
|
||||
pub mod credentials;
|
||||
mod payloads;
|
||||
pub mod recent;
|
||||
pub mod records;
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod request_lifecycle;
|
||||
mod runtime_compat;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Versioned logical records: explicit fields only, never raw application databases/config.
|
||||
use crate::workspace::{HostError, Result, Workspace};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TaskData {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub note_id: Option<String>,
|
||||
pub due_at_ms: Option<i64>,
|
||||
pub created_at_ms: i64,
|
||||
pub updated_at_ms: i64,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Record {
|
||||
pub schema: u32,
|
||||
pub kind: String,
|
||||
pub id: String,
|
||||
pub data: TaskData,
|
||||
}
|
||||
pub fn path(id: &str) -> Result<String> {
|
||||
if !id.starts_with("task_")
|
||||
|| id.len() != 37
|
||||
|| !id[5..]
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v))
|
||||
{
|
||||
return Err(HostError::new("RECORD_ID_INVALID"));
|
||||
}
|
||||
Ok(format!("opennexus-records/v1/tasks/{id}.json"))
|
||||
}
|
||||
pub fn is_record(path: &str) -> bool {
|
||||
path.starts_with("opennexus-records/")
|
||||
}
|
||||
pub fn allowed(path_value: &str) -> bool {
|
||||
path_value
|
||||
.strip_prefix("opennexus-records/v1/tasks/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.is_some_and(|id| path(id).is_ok())
|
||||
}
|
||||
pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
|
||||
if content.len() > 1024 * 1024 {
|
||||
return Err(HostError::new("RECORD_TOO_LARGE"));
|
||||
}
|
||||
let record: Record =
|
||||
serde_json::from_slice(content).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?;
|
||||
let time = |value: i64| (0..=253402300799999).contains(&value);
|
||||
if record.schema != 1 || record.kind != "task" || path(&record.id)? != path_value {
|
||||
return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED"));
|
||||
}
|
||||
let data = &record.data;
|
||||
if data.title.trim().is_empty()
|
||||
|| data.title.len() > 4096
|
||||
|| data.description.len() > 262144
|
||||
|| !matches!(
|
||||
data.status.as_str(),
|
||||
"todo" | "in_progress" | "done" | "cancelled"
|
||||
)
|
||||
|| !time(data.created_at_ms)
|
||||
|| !time(data.updated_at_ms)
|
||||
|| data.due_at_ms.is_some_and(|v| !time(v))
|
||||
|| data.note_id.as_ref().is_some_and(|v| {
|
||||
v.is_empty()
|
||||
|| v.len() > 128
|
||||
|| !v
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b))
|
||||
})
|
||||
{
|
||||
return Err(HostError::new("RECORD_DATA_INVALID"));
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
impl Workspace {
|
||||
pub(crate) fn normalize_record_links(&mut self) -> Result<()> {
|
||||
for path in self.sync_paths()?.into_iter().filter(|v| allowed(v)) {
|
||||
let bytes = std::fs::read(self.resolve(&path)?)?;
|
||||
let original = validate(&path, &bytes)?;
|
||||
if let Some(note_id) = original.data.note_id.as_ref() {
|
||||
if let Ok(note_path) = self.path_for_id(note_id) {
|
||||
if let Some(entry) = self.entry(¬e_path)? {
|
||||
if &entry.file_id != note_id {
|
||||
let mut record = original;
|
||||
record.data.note_id = Some(entry.file_id);
|
||||
self.write(
|
||||
&path,
|
||||
&crate::workspace::hash(&bytes),
|
||||
&serde_json::to_vec(&record)
|
||||
.map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?,
|
||||
"local",
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn record_get(&mut self, id: &str) -> Result<Option<Value>> {
|
||||
let path = path(id)?;
|
||||
if !self.resolve(&path)?.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
if std::fs::metadata(self.resolve(&path)?)?.len() > 1024 * 1024 {
|
||||
return Err(HostError::new("RECORD_TOO_LARGE"));
|
||||
}
|
||||
let document = self.read(&path)?;
|
||||
let mut record = validate(&path, document.content.as_bytes())?;
|
||||
if let Some(note_id) = record.data.note_id.as_ref() {
|
||||
if let Ok(path) = self.path_for_id(note_id) {
|
||||
if let Some(entry) = self.entry(&path)? {
|
||||
record.data.note_id = Some(entry.file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(
|
||||
json!({"record":record,"hash":document.entry.hash,"file_id":document.entry.file_id}),
|
||||
))
|
||||
}
|
||||
pub fn record_list(&mut self, offset: usize, limit: usize) -> Result<Value> {
|
||||
if limit == 0 || limit > 1000 {
|
||||
return Err(HostError::new("RECORD_LIMIT_INVALID"));
|
||||
}
|
||||
let paths = self
|
||||
.sync_paths()?
|
||||
.into_iter()
|
||||
.filter(|path| allowed(path))
|
||||
.collect::<Vec<_>>();
|
||||
let total = paths.len();
|
||||
let mut items = Vec::new();
|
||||
let mut bytes = 0;
|
||||
for path in paths.into_iter().skip(offset).take(limit) {
|
||||
let id = path
|
||||
.strip_prefix("opennexus-records/v1/tasks/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.ok_or_else(|| HostError::new("RECORD_ID_INVALID"))?;
|
||||
if let Some(value) = self.record_get(id)? {
|
||||
let size = serde_json::to_vec(&value)
|
||||
.map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?
|
||||
.len();
|
||||
if bytes + size > 4 * 1024 * 1024 {
|
||||
break;
|
||||
}
|
||||
bytes += size;
|
||||
items.push(value);
|
||||
}
|
||||
}
|
||||
Ok(json!({"items":items,"total":total}))
|
||||
}
|
||||
pub fn record_operation(&mut self, operation: &str) -> Result<Option<Value>> {
|
||||
self.recover()?;
|
||||
let Some(receipt) = self.operation(operation)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = receipt["result"]["path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| HostError::new("RECORD_OPERATION_PENDING"))?;
|
||||
if !allowed(path) {
|
||||
return Err(HostError::new("RECORD_OPERATION_DENIED"));
|
||||
}
|
||||
let bytes = self.payload(operation, &[])?;
|
||||
let record = validate(path, &bytes)?;
|
||||
Ok(Some(
|
||||
json!({"record":record,"deleted":receipt["result"]["deleted"],"state":receipt["state"]}),
|
||||
))
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn fixture() -> Value {
|
||||
json!({"schema":1,"kind":"task","id":"task_00000000000000000000000000000001","data":{"title":"Task","description":"","status":"todo","note_id":null,"due_at_ms":null,"created_at_ms":0,"updated_at_ms":0}})
|
||||
}
|
||||
#[test]
|
||||
fn whitelist_rejects_secret_unknown_fields_and_future_schema_before_journal() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let value = fixture();
|
||||
let path = path(value["id"].as_str().unwrap()).unwrap();
|
||||
for field in ["api_key", "token", "environment", "permissions", "extra"] {
|
||||
let mut value = value.clone();
|
||||
value["data"][field] = json!("planted-secret");
|
||||
assert_eq!(
|
||||
ws.write(&path, "", &serde_json::to_vec(&value).unwrap(), "local")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
let mut future = value.clone();
|
||||
future["schema"] = json!(2);
|
||||
assert_eq!(
|
||||
ws.write(&path, "", &serde_json::to_vec(&future).unwrap(), "remote")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"RECORD_SCHEMA_UNSUPPORTED"
|
||||
);
|
||||
assert!(!root.path().join(&path).exists());
|
||||
assert_eq!(ws.pending_count().unwrap(), 0);
|
||||
let operation = uuid::Uuid::new_v4().to_string();
|
||||
ws.write_operation(
|
||||
&path,
|
||||
"",
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
"local",
|
||||
&operation,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ws.record_operation(&operation).unwrap().unwrap()["record"],
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ use std::{collections::HashSet, fs, io::Read, path::Path};
|
||||
use uuid::Uuid;
|
||||
/// File transport only. Logical records receive their own versioned whitelist separately.
|
||||
pub fn allowed(path: &str) -> bool {
|
||||
if crate::records::is_record(path) {
|
||||
return crate::records::allowed(path);
|
||||
}
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
if parts.iter().any(|part| {
|
||||
part.starts_with('.')
|
||||
@@ -100,6 +103,9 @@ impl Workspace {
|
||||
if bytes.len() > 104857600 {
|
||||
return Err(HostError::new("FILE_TOO_LARGE"));
|
||||
}
|
||||
if crate::records::is_record(&path) {
|
||||
crate::records::validate(&path, &bytes)?;
|
||||
}
|
||||
let digest = hash(&bytes);
|
||||
let previous = self.entry(&path)?;
|
||||
let observed: Option<(String, String, bool)> = if let Some(entry) = &previous {
|
||||
|
||||
@@ -140,6 +140,7 @@ impl Workspace {
|
||||
}
|
||||
pub fn sync_capture(&mut self, binding: &str) -> Result<()> {
|
||||
self.check_binding(binding)?;
|
||||
self.normalize_record_links()?;
|
||||
loop {
|
||||
let pending = self.db.query_row("SELECT operation_id,file_id,path,hash,operation,content FROM outbox WHERE state='pending' ORDER BY rowid LIMIT 1", [], |r| {
|
||||
Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,Vec<u8>>(5)?))
|
||||
@@ -165,6 +166,9 @@ impl Workspace {
|
||||
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
||||
}
|
||||
crate::payloads::verify(&self.sync_spool(&digest)?, &digest, size as u64)?;
|
||||
if crate::records::is_record(&path) {
|
||||
crate::records::validate(&path, &self.payload(&operation_id, &content)?)?;
|
||||
}
|
||||
size
|
||||
} else {
|
||||
0
|
||||
|
||||
@@ -258,10 +258,11 @@ impl Workspace {
|
||||
.map_err(|_| HostError::new("UNSAFE_PATH"))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if relative
|
||||
.split('/')
|
||||
.any(|s| s.eq_ignore_ascii_case(".ainote") || s.eq_ignore_ascii_case(".git"))
|
||||
|| linked(&path)?
|
||||
if relative.split('/').any(|s| {
|
||||
s.eq_ignore_ascii_case(".ainote")
|
||||
|| s.eq_ignore_ascii_case(".git")
|
||||
|| s.eq_ignore_ascii_case("opennexus-records")
|
||||
}) || linked(&path)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -407,6 +408,9 @@ impl Workspace {
|
||||
if Uuid::parse_str(operation_id).is_err() {
|
||||
return Err(HostError::new("OPERATION_ID_INVALID"));
|
||||
}
|
||||
if crate::records::is_record(path) {
|
||||
crate::records::validate(path, content)?;
|
||||
}
|
||||
if content.len() > 100 * 1024 * 1024 {
|
||||
return Err(HostError::new("FILE_TOO_LARGE"));
|
||||
}
|
||||
@@ -526,6 +530,9 @@ impl Workspace {
|
||||
content: &[u8],
|
||||
origin: &str,
|
||||
) -> Result<()> {
|
||||
if crate::records::is_record(path) {
|
||||
crate::records::validate(path, content)?;
|
||||
}
|
||||
let target = self.resolve(path)?;
|
||||
let digest = hash(content);
|
||||
let current = if target.exists() {
|
||||
|
||||
@@ -42,6 +42,28 @@ struct Mutation {
|
||||
kind: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RecordRead {
|
||||
vault_id: String,
|
||||
id: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RecordDelete {
|
||||
vault_id: String,
|
||||
id: String,
|
||||
expected: String,
|
||||
operation_id: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RecordWrite {
|
||||
vault_id: String,
|
||||
record: Value,
|
||||
expected: String,
|
||||
operation_id: String,
|
||||
}
|
||||
fn bound(ws: &Workspace, vault_id: &str) -> Result<(), String> {
|
||||
if ws.vault_id != vault_id {
|
||||
return Err("VAULT_PERMISSION_CHANGED".into());
|
||||
@@ -54,6 +76,47 @@ fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T, String> {
|
||||
pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||
let params = &request["params"];
|
||||
match request["rpc"].as_str().unwrap_or_default() {
|
||||
"workspace.records.list" => {
|
||||
let p: List = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_list(p.offset, p.limit).map_err(|e| e.code)
|
||||
}
|
||||
"workspace.records.get" => {
|
||||
let p: RecordRead = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_get(&p.id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.records.operation" => {
|
||||
let p: Operation = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.records.delete" => {
|
||||
let p: RecordDelete = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
let path = crate::records::path(&p.id).map_err(|e| e.code)?;
|
||||
ws.mutate_operation("delete", &path, "", &p.expected, &p.operation_id)
|
||||
.map_err(|e| e.code)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.records.write" => {
|
||||
let p: RecordWrite = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
let path =
|
||||
crate::records::path(p.record["id"].as_str().unwrap_or("")).map_err(|e| e.code)?;
|
||||
let bytes = serde_json::to_vec(&p.record).map_err(|_| "RECORD_SCHEMA_INVALID")?;
|
||||
ws.write_operation(&path, &p.expected, &bytes, "local", &p.operation_id)
|
||||
.map_err(|e| e.code)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.list" => {
|
||||
let p: List = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
|
||||
@@ -146,6 +146,122 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits()
|
||||
.await;
|
||||
assert_eq!(status, 409, "{denied}");
|
||||
assert_eq!(denied["error"]["code"], "VAULT_PERMISSION_CHANGED");
|
||||
|
||||
let legacy_database = rusqlite::Connection::open(
|
||||
temp.path()
|
||||
.join("core/vault-state")
|
||||
.join(&vault)
|
||||
.join("core.sqlite3"),
|
||||
)
|
||||
.unwrap();
|
||||
legacy_database.execute("INSERT INTO tasks VALUES ('task_00000000000000000000000000000002','Legacy task','','todo',?1,NULL,'2026-09-08T00:00:00+00:00','2026-09-08T00:00:00+00:00')",[file_id]).unwrap();
|
||||
let task_operation = uuid::Uuid::new_v4().to_string();
|
||||
let task_body = json!({"title":"Host task","note_id":file_id});
|
||||
let (status, task) = request(
|
||||
&mut core,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
&vault,
|
||||
&task_operation,
|
||||
Some(task_body.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{task}");
|
||||
let task_id = task["task_id"].as_str().unwrap();
|
||||
for _ in 0..20 {
|
||||
let (status, replay) = request(
|
||||
&mut core,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
&vault,
|
||||
&task_operation,
|
||||
Some(task_body.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{replay}");
|
||||
assert_eq!(replay, task);
|
||||
}
|
||||
let (status, changed_request) = request(
|
||||
&mut core,
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
&vault,
|
||||
&task_operation,
|
||||
Some(json!({"title":"changed","note_id":file_id})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 409, "{changed_request}");
|
||||
let record_path = root.join(format!("opennexus-records/v1/tasks/{task_id}.json"));
|
||||
assert!(record_path.is_file());
|
||||
let (status, task_updated) = request(
|
||||
&mut core,
|
||||
"PATCH",
|
||||
&format!("/api/tasks/{task_id}"),
|
||||
&vault,
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
Some(json!({"status":"done"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{task_updated}");
|
||||
assert_eq!(task_updated["status"], "done");
|
||||
let (status, tasks) = request(
|
||||
&mut core,
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
&vault,
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{tasks}");
|
||||
assert_eq!(tasks["items"].as_array().unwrap().len(), 2);
|
||||
let (status, task_denied) = request(
|
||||
&mut core,
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 409, "{task_denied}");
|
||||
let deletion = uuid::Uuid::new_v4().to_string();
|
||||
for _ in 0..2 {
|
||||
let (status, result) = request(
|
||||
&mut core,
|
||||
"DELETE",
|
||||
&format!("/api/tasks/{task_id}"),
|
||||
&vault,
|
||||
&deletion,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{result}");
|
||||
}
|
||||
assert!(!record_path.exists());
|
||||
let source: String = legacy_database
|
||||
.query_row(
|
||||
"SELECT title FROM tasks WHERE task_id='task_00000000000000000000000000000002'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(source, "Legacy task");
|
||||
legacy_database
|
||||
.execute("DELETE FROM index_meta WHERE key='tasks_host_owned_v1'", [])
|
||||
.unwrap();
|
||||
let (status, after_migration) = request(
|
||||
&mut core,
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
&vault,
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{after_migration}");
|
||||
assert_eq!(after_migration["items"].as_array().unwrap().len(), 1);
|
||||
|
||||
let (status, deleted) = request(
|
||||
&mut core,
|
||||
"DELETE",
|
||||
@@ -168,7 +284,7 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits()
|
||||
.await;
|
||||
assert_eq!(status, 200, "{search}");
|
||||
assert!(!search.to_string().contains(file_id));
|
||||
assert_eq!(workspace.lock().unwrap().pending_count().unwrap(), 4);
|
||||
assert_eq!(workspace.lock().unwrap().pending_count().unwrap(), 8);
|
||||
assert!(!temp
|
||||
.path()
|
||||
.join("core/unbound-vault/Core fixture.md")
|
||||
|
||||
@@ -423,6 +423,67 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
||||
.content,
|
||||
"only-local"
|
||||
);
|
||||
|
||||
let task_id = "task_00000000000000000000000000000001";
|
||||
let task_path = format!("opennexus-records/v1/tasks/{task_id}.json");
|
||||
let task_record = json!({"schema":1,"kind":"task","id":task_id,"data":{"title":"Synchronized task","description":"","status":"todo","note_id":first.file_id,"due_at_ms":null,"created_at_ms":0,"updated_at_ms":0}});
|
||||
workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.write(
|
||||
&task_path,
|
||||
"",
|
||||
&serde_json::to_vec(&task_record).unwrap(),
|
||||
"local",
|
||||
)
|
||||
.unwrap();
|
||||
client.push_one(&workspace, &binding).await.unwrap();
|
||||
client_b.pull_page(&workspace_b, &binding_b).await.unwrap();
|
||||
{
|
||||
let mut ws = workspace_b.lock().unwrap();
|
||||
let task = ws.record_get(task_id).unwrap().unwrap();
|
||||
assert_eq!(task["record"], task_record);
|
||||
let mut next = task["record"].clone();
|
||||
next["data"]["status"] = json!("done");
|
||||
ws.write(
|
||||
&task_path,
|
||||
task["hash"].as_str().unwrap(),
|
||||
&serde_json::to_vec(&next).unwrap(),
|
||||
"local",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
client_b.push_one(&workspace_b, &binding_b).await.unwrap();
|
||||
client.pull_page(&workspace, &binding).await.unwrap();
|
||||
assert_eq!(
|
||||
workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.record_get(task_id)
|
||||
.unwrap()
|
||||
.unwrap()["record"]["data"]["status"],
|
||||
"done"
|
||||
);
|
||||
{
|
||||
let mut ws = workspace_b.lock().unwrap();
|
||||
let task = ws.record_get(task_id).unwrap().unwrap();
|
||||
ws.mutate_operation(
|
||||
"delete",
|
||||
&task_path,
|
||||
"",
|
||||
task["hash"].as_str().unwrap(),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
client_b.push_one(&workspace_b, &binding_b).await.unwrap();
|
||||
client.pull_page(&workspace, &binding).await.unwrap();
|
||||
assert!(workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.record_get(task_id)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
// Kill the actual client process after each durable 10 MiB server offset,
|
||||
// before its response reaches the client. The next process must query offset.
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
Reference in New Issue
Block a user