feat: replace revision counters with domain state contracts
Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts. Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
This commit is contained in:
@@ -2,9 +2,11 @@
|
||||
//! blocking thread, never on an async executor. SQL IMMEDIATE transactions are
|
||||
//! the cross-process authority for uploads, tree changes, reads, and collection.
|
||||
mod blob;
|
||||
mod migrations;
|
||||
use migrations::MIGRATIONS;
|
||||
|
||||
use feature_storage::{
|
||||
FeatureDatabase, FeatureMigration, FeatureRegistration, FeatureStorageError, RegisteredFeature,
|
||||
FeatureDatabase, FeatureRegistration, FeatureStorageError, RegisteredFeature,
|
||||
ScopedFeatureStorage,
|
||||
};
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||
@@ -25,9 +27,9 @@ pub const MAX_NAME_BYTES: usize = 255;
|
||||
pub enum Error {
|
||||
#[error("invalid Drive request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("Drive node or revision no longer exists")]
|
||||
#[error("Drive node no longer exists")]
|
||||
NotFound,
|
||||
#[error("Drive revision, name, or request identity conflict")]
|
||||
#[error("Drive node changed, name is taken, or request identity conflicts")]
|
||||
Conflict,
|
||||
#[error("Drive access denied")]
|
||||
Denied,
|
||||
@@ -72,7 +74,6 @@ macro_rules! decimal_id {
|
||||
};
|
||||
}
|
||||
decimal_id!(NodeId);
|
||||
decimal_id!(Revision);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -88,7 +89,8 @@ pub struct Node {
|
||||
pub parent_id: Option<NodeId>,
|
||||
pub name: String,
|
||||
pub kind: Kind,
|
||||
pub revision: Revision,
|
||||
/// Request ID of the last committed mutation; empty only for the immutable root.
|
||||
pub last_mutation_id: String,
|
||||
pub size: u64,
|
||||
pub content_type: Option<String>,
|
||||
pub updated_by: String,
|
||||
@@ -110,19 +112,19 @@ pub enum Mutation {
|
||||
},
|
||||
Update {
|
||||
id: NodeId,
|
||||
expected_revision: Revision,
|
||||
expected_mutation_id: String,
|
||||
content_type: String,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
Relocate {
|
||||
id: NodeId,
|
||||
expected_revision: Revision,
|
||||
expected_mutation_id: String,
|
||||
parent: NodeId,
|
||||
name: String,
|
||||
},
|
||||
Delete {
|
||||
id: NodeId,
|
||||
expected_revision: Revision,
|
||||
expected_mutation_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -151,7 +153,7 @@ pub struct Page {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ReadChunk {
|
||||
pub id: NodeId,
|
||||
pub revision: Revision,
|
||||
pub last_mutation_id: String,
|
||||
pub offset: u64,
|
||||
pub bytes: Vec<u8>,
|
||||
pub eof: bool,
|
||||
@@ -159,7 +161,7 @@ pub struct ReadChunk {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TextChunk {
|
||||
pub id: NodeId,
|
||||
pub revision: Revision,
|
||||
pub last_mutation_id: String,
|
||||
pub offset: u64,
|
||||
pub text: String,
|
||||
pub eof: bool,
|
||||
@@ -187,35 +189,6 @@ pub struct Drive {
|
||||
_storage: ScopedFeatureStorage,
|
||||
}
|
||||
|
||||
static MIGRATIONS: &[FeatureMigration] =
|
||||
&[FeatureMigration::new(1, "drive_tree_and_receipts", migrate)];
|
||||
fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
tx.execute_batch("\
|
||||
CREATE TABLE drive_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
||||
workspace_id TEXT NOT NULL UNIQUE, deleting INTEGER NOT NULL DEFAULT 0 CHECK(deleting IN (0,1)));
|
||||
CREATE TABLE drive_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id TEXT NOT NULL REFERENCES drive_state(workspace_id),
|
||||
parent_id INTEGER,
|
||||
name TEXT NOT NULL CHECK(length(CAST(name AS BLOB)) <= 255),
|
||||
kind TEXT NOT NULL CHECK(kind IN ('file','directory')),
|
||||
revision INTEGER NOT NULL CHECK(revision > 0),
|
||||
blob_key TEXT, size INTEGER NOT NULL CHECK(size >= 0 AND size <= 16777216),
|
||||
content_type TEXT, updated_by TEXT NOT NULL, updated_at_ms INTEGER NOT NULL,
|
||||
UNIQUE(workspace_id,id),
|
||||
FOREIGN KEY(workspace_id,parent_id) REFERENCES drive_nodes(workspace_id,id),
|
||||
CHECK((kind='directory' AND blob_key IS NULL AND size=0 AND content_type IS NULL)
|
||||
OR (kind='file' AND blob_key IS NOT NULL AND content_type IS NOT NULL)));
|
||||
CREATE UNIQUE INDEX drive_sibling_name ON drive_nodes(workspace_id,ifnull(parent_id,0),name);
|
||||
CREATE INDEX drive_children ON drive_nodes(parent_id,id);
|
||||
CREATE INDEX drive_blob_reference ON drive_nodes(blob_key) WHERE blob_key IS NOT NULL;
|
||||
CREATE UNIQUE INDEX drive_single_root ON drive_nodes(workspace_id) WHERE parent_id IS NULL;
|
||||
CREATE TABLE drive_receipts (request_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL, committed_at_ms INTEGER NOT NULL);
|
||||
")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Drive {
|
||||
pub fn register(storage: &ScopedFeatureStorage) -> feature_storage::Result<RegisteredFeature> {
|
||||
storage.register(FeatureRegistration::new("workspace-drive", MIGRATIONS))
|
||||
@@ -232,8 +205,8 @@ impl Drive {
|
||||
tx.execute("INSERT OR IGNORE INTO drive_state(singleton,workspace_id) VALUES (1,?1)", [&workspace_id])?;
|
||||
let stored: String = tx.query_row("SELECT workspace_id FROM drive_state WHERE singleton=1", [], |r| r.get(0))?;
|
||||
if stored != workspace_id { return Err(Error::Invalid("metadata belongs to another Workspace".into())); }
|
||||
tx.execute("INSERT OR IGNORE INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
|
||||
VALUES (?1,NULL,'','directory',1,0,'server',?2)", params![workspace_id, now_ms()])?;
|
||||
tx.execute("INSERT OR IGNORE INTO drive_nodes(workspace_id,parent_id,name,kind,last_mutation_id,size,updated_by,updated_at_ms)
|
||||
VALUES (?1,NULL,'','directory','',0,'server',?2)", params![workspace_id, now_ms()])?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Self {
|
||||
@@ -356,6 +329,9 @@ impl Drive {
|
||||
})
|
||||
}
|
||||
|
||||
/// Recover the committed result by request identity, including pre-cutover
|
||||
/// receipts whose payload cannot be verified for replay. Committed status is
|
||||
/// not proof that a different actor/payload matches that request.
|
||||
pub fn request_status(&self, request_id: &str) -> Result<RequestStatus> {
|
||||
validate_identity(request_id, "request_id", 128)?;
|
||||
self.database.try_transaction(|c| {
|
||||
@@ -379,6 +355,9 @@ impl Drive {
|
||||
/// DB commit is publication. The blob and receipt are never independently
|
||||
/// treated as a successful mutation. Successful request IDs are retained for
|
||||
/// the Workspace lifetime; reuse with a different actor/payload is conflict.
|
||||
/// Pre-cutover replay checks the original typed-payload digest using proven
|
||||
/// receipt correspondence. If that evidence is unavailable, returns Conflict
|
||||
/// without executing; recover the committed result through request_status.
|
||||
pub fn mutate(
|
||||
&self,
|
||||
request_id: &str,
|
||||
@@ -396,7 +375,9 @@ impl Drive {
|
||||
self.authorize(tx, true)?;
|
||||
if let Some((old_fingerprint,json)) = tx.query_row("SELECT fingerprint,result_json FROM drive_receipts WHERE request_id=?1",
|
||||
[request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? {
|
||||
if old_fingerprint != fingerprint { return Err(Error::Conflict); }
|
||||
if old_fingerprint != fingerprint && !migrations::matches_legacy_replay(tx, request_id, actor, &mutation, &old_fingerprint)? {
|
||||
return Err(Error::Conflict);
|
||||
}
|
||||
return Ok(serde_json::from_str(&json)?);
|
||||
}
|
||||
accepting(tx)?;
|
||||
@@ -404,40 +385,40 @@ impl Drive {
|
||||
let result = match mutation {
|
||||
Mutation::CreateFolder { parent,name } => {
|
||||
directory(tx,parent)?;
|
||||
insert(tx,&self.workspace_id,parent,&name,Kind::Directory,None,0,None,actor,at)?
|
||||
insert(tx,&self.workspace_id,parent,&name,Kind::Directory,None,0,None,actor,at,request_id)?
|
||||
}
|
||||
Mutation::CreateFile { parent,name,content_type,bytes } => {
|
||||
directory(tx,parent)?;
|
||||
let key = Uuid::now_v7().to_string();
|
||||
self.blobs.put(&key,&bytes)?;
|
||||
insert(tx,&self.workspace_id,parent,&name,Kind::File,Some(&key),bytes.len(),Some(&content_type),actor,at)?
|
||||
insert(tx,&self.workspace_id,parent,&name,Kind::File,Some(&key),bytes.len(),Some(&content_type),actor,at,request_id)?
|
||||
}
|
||||
Mutation::Update { id,expected_revision,content_type,bytes } => {
|
||||
let old = changeable(tx,id,expected_revision)?;
|
||||
Mutation::Update { id,expected_mutation_id,content_type,bytes } => {
|
||||
let old = changeable(tx,id,&expected_mutation_id)?;
|
||||
if old.kind != Kind::File { return Err(Error::Invalid("cannot write a directory".into())); }
|
||||
let key = Uuid::now_v7().to_string();
|
||||
self.blobs.put(&key,&bytes)?;
|
||||
cas(tx.execute("UPDATE drive_nodes SET blob_key=?1,size=?2,content_type=?3,revision=revision+1,updated_by=?4,updated_at_ms=?5
|
||||
WHERE id=?6 AND revision=?7", params![key,bytes.len(),content_type,actor,at,id.0,expected_revision.0])?)?;
|
||||
cas(tx.execute("UPDATE drive_nodes SET blob_key=?1,size=?2,content_type=?3,last_mutation_id=?7,updated_by=?4,updated_at_ms=?5
|
||||
WHERE id=?6", params![key,bytes.len(),content_type,actor,at,id.0,request_id])?)?;
|
||||
MutationResult { node: node(tx,id)?, deleted:false }
|
||||
}
|
||||
Mutation::Relocate { id,expected_revision,parent,name } => {
|
||||
changeable(tx,id,expected_revision)?;
|
||||
Mutation::Relocate { id,expected_mutation_id,parent,name } => {
|
||||
changeable(tx,id,&expected_mutation_id)?;
|
||||
directory(tx,parent)?;
|
||||
let cyclic: bool = tx.query_row("WITH RECURSIVE ancestors(id,parent_id) AS (
|
||||
SELECT id,parent_id FROM drive_nodes WHERE id=?1 UNION ALL
|
||||
SELECT n.id,n.parent_id FROM drive_nodes n JOIN ancestors a ON n.id=a.parent_id)
|
||||
SELECT EXISTS(SELECT 1 FROM ancestors WHERE id=?2)",params![parent.0,id.0],|r|r.get(0))?;
|
||||
if cyclic { return Err(Error::Invalid("move would create a cycle".into())); }
|
||||
unique(tx.execute("UPDATE drive_nodes SET parent_id=?1,name=?2,revision=revision+1,updated_by=?3,updated_at_ms=?4
|
||||
WHERE id=?5 AND revision=?6",params![parent.0,name,actor,at,id.0,expected_revision.0]))?;
|
||||
unique(tx.execute("UPDATE drive_nodes SET parent_id=?1,name=?2,last_mutation_id=?6,updated_by=?3,updated_at_ms=?4
|
||||
WHERE id=?5",params![parent.0,name,actor,at,id.0,request_id]))?;
|
||||
MutationResult { node:node(tx,id)?,deleted:false }
|
||||
}
|
||||
Mutation::Delete { id,expected_revision } => {
|
||||
let old = changeable(tx,id,expected_revision)?;
|
||||
Mutation::Delete { id,expected_mutation_id } => {
|
||||
let old = changeable(tx,id,&expected_mutation_id)?;
|
||||
let children: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM drive_nodes WHERE parent_id=?1)",[id.0],|r|r.get(0))?;
|
||||
if children { return Err(Error::Conflict); }
|
||||
cas(tx.execute("DELETE FROM drive_nodes WHERE id=?1 AND revision=?2",params![id.0,expected_revision.0])?)?;
|
||||
cas(tx.execute("DELETE FROM drive_nodes WHERE id=?1",params![id.0])?)?;
|
||||
MutationResult { node:old,deleted:true }
|
||||
}
|
||||
};
|
||||
@@ -447,13 +428,13 @@ impl Drive {
|
||||
})
|
||||
}
|
||||
|
||||
/// Each chunk requires the originally selected revision. Old revisions are
|
||||
/// Each chunk requires the originally observed committed mutation. Old blobs are
|
||||
/// not retained: after update/delete return conflict/not-found, never switch
|
||||
/// silently to the latest blob. GC cannot run while this read holds SQL lock.
|
||||
pub fn read(
|
||||
&self,
|
||||
id: NodeId,
|
||||
revision: Revision,
|
||||
expected_mutation_id: &str,
|
||||
offset: u64,
|
||||
length: usize,
|
||||
) -> Result<ReadChunk> {
|
||||
@@ -462,7 +443,7 @@ impl Drive {
|
||||
self.authorize(tx, false)?;
|
||||
accepting(tx)?;
|
||||
let selected = node(tx, id)?;
|
||||
if selected.revision != revision {
|
||||
if selected.last_mutation_id != expected_mutation_id {
|
||||
return Err(Error::Conflict);
|
||||
}
|
||||
if selected.kind != Kind::File {
|
||||
@@ -486,7 +467,7 @@ impl Drive {
|
||||
}
|
||||
Ok(ReadChunk {
|
||||
id,
|
||||
revision,
|
||||
last_mutation_id: selected.last_mutation_id,
|
||||
offset,
|
||||
bytes,
|
||||
eof: end == selected.size,
|
||||
@@ -499,17 +480,17 @@ impl Drive {
|
||||
pub fn read_text(
|
||||
&self,
|
||||
id: NodeId,
|
||||
revision: Revision,
|
||||
expected_mutation_id: &str,
|
||||
offset: u64,
|
||||
length: usize,
|
||||
) -> Result<TextChunk> {
|
||||
let chunk = self.read(id, revision, offset, length)?;
|
||||
let chunk = self.read(id, expected_mutation_id, offset, length)?;
|
||||
let text = String::from_utf8(chunk.bytes).map_err(|_| {
|
||||
Error::Invalid("range is not valid UTF-8; select character-aligned offsets".into())
|
||||
})?;
|
||||
Ok(TextChunk {
|
||||
id,
|
||||
revision,
|
||||
last_mutation_id: chunk.last_mutation_id,
|
||||
offset,
|
||||
text,
|
||||
eof: chunk.eof,
|
||||
@@ -693,9 +674,9 @@ fn accepting(c: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn node(c: &Connection, id: NodeId) -> Result<Node> {
|
||||
c.query_row("SELECT id,workspace_id,parent_id,name,kind,revision,size,content_type,updated_by,updated_at_ms FROM drive_nodes WHERE id=?1",[id.0],|r| {
|
||||
c.query_row("SELECT id,workspace_id,parent_id,name,kind,last_mutation_id,size,content_type,updated_by,updated_at_ms FROM drive_nodes WHERE id=?1",[id.0],|r| {
|
||||
Ok(Node {id:NodeId(r.get(0)?),workspace_id:r.get(1)?,parent_id:r.get::<_,Option<i64>>(2)?.map(NodeId),name:r.get(3)?,
|
||||
kind:if r.get::<_,String>(4)?=="file" {Kind::File} else {Kind::Directory},revision:Revision(r.get(5)?),
|
||||
kind:if r.get::<_,String>(4)?=="file" {Kind::File} else {Kind::Directory},last_mutation_id:r.get(5)?,
|
||||
size:r.get::<_,i64>(6)? as u64,content_type:r.get(7)?,updated_by:r.get(8)?,updated_at_ms:r.get(9)?})
|
||||
}).optional()?.ok_or(Error::NotFound)
|
||||
}
|
||||
@@ -706,17 +687,18 @@ fn directory(c: &Connection, id: NodeId) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn changeable(c: &Connection, id: NodeId, revision: Revision) -> Result<Node> {
|
||||
// Called only inside the same IMMEDIATE transaction that publishes the mutation.
|
||||
// Compare the last successful request, not time: even identical bytes or a move
|
||||
// away and back cannot revive an old request. Receipts prevent request ID reuse.
|
||||
fn changeable(c: &Transaction<'_>, id: NodeId, expected_mutation_id: &str) -> Result<Node> {
|
||||
let n = node(c, id)?;
|
||||
if n.parent_id.is_none() {
|
||||
return Err(Error::Invalid("root is immutable".into()));
|
||||
}
|
||||
if n.revision != revision {
|
||||
validate_identity(expected_mutation_id, "expected_mutation_id", 128)?;
|
||||
if n.last_mutation_id != expected_mutation_id {
|
||||
return Err(Error::Conflict);
|
||||
}
|
||||
if revision.0 == i64::MAX {
|
||||
return Err(Error::Invalid("revision exhausted".into()));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
fn unique(result: rusqlite::Result<usize>) -> Result<usize> {
|
||||
@@ -748,9 +730,10 @@ fn insert(
|
||||
content_type: Option<&str>,
|
||||
actor: &str,
|
||||
at: i64,
|
||||
request_id: &str,
|
||||
) -> Result<MutationResult> {
|
||||
unique(tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,blob_key,size,content_type,updated_by,updated_at_ms)
|
||||
VALUES (?1,?2,?3,?4,1,?5,?6,?7,?8,?9)",params![workspace,parent.0,name,if kind==Kind::File {"file"} else {"directory"},key,size,content_type,actor,at]))?;
|
||||
unique(tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,last_mutation_id,blob_key,size,content_type,updated_by,updated_at_ms)
|
||||
VALUES (?1,?2,?3,?4,?10,?5,?6,?7,?8,?9)",params![workspace,parent.0,name,if kind==Kind::File {"file"} else {"directory"},key,size,content_type,actor,at,request_id]))?;
|
||||
Ok(MutationResult {
|
||||
node: node(tx, NodeId(tx.last_insert_rowid()))?,
|
||||
deleted: false,
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
//! Frozen schema upgrades. Old column/JSON names occur only to preserve existing data.
|
||||
use crate::{Mutation, NodeId};
|
||||
use feature_storage::FeatureMigration;
|
||||
use rusqlite::{OptionalExtension, Transaction};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(super) static MIGRATIONS: &[FeatureMigration] = &[
|
||||
FeatureMigration::new(1, "drive_tree_and_receipts", create_original_schema),
|
||||
FeatureMigration::new(
|
||||
2,
|
||||
"bind_nodes_to_committed_requests",
|
||||
bind_committed_requests,
|
||||
),
|
||||
];
|
||||
fn create_original_schema(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
tx.execute_batch("\
|
||||
CREATE TABLE drive_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
||||
workspace_id TEXT NOT NULL UNIQUE, deleting INTEGER NOT NULL DEFAULT 0 CHECK(deleting IN (0,1)));
|
||||
CREATE TABLE drive_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id TEXT NOT NULL REFERENCES drive_state(workspace_id),
|
||||
parent_id INTEGER,
|
||||
name TEXT NOT NULL CHECK(length(CAST(name AS BLOB)) <= 255),
|
||||
kind TEXT NOT NULL CHECK(kind IN ('file','directory')),
|
||||
revision INTEGER NOT NULL CHECK(revision > 0),
|
||||
blob_key TEXT, size INTEGER NOT NULL CHECK(size >= 0 AND size <= 16777216),
|
||||
content_type TEXT, updated_by TEXT NOT NULL, updated_at_ms INTEGER NOT NULL,
|
||||
UNIQUE(workspace_id,id),
|
||||
FOREIGN KEY(workspace_id,parent_id) REFERENCES drive_nodes(workspace_id,id),
|
||||
CHECK((kind='directory' AND blob_key IS NULL AND size=0 AND content_type IS NULL)
|
||||
OR (kind='file' AND blob_key IS NOT NULL AND content_type IS NOT NULL)));
|
||||
CREATE UNIQUE INDEX drive_sibling_name ON drive_nodes(workspace_id,ifnull(parent_id,0),name);
|
||||
CREATE INDEX drive_children ON drive_nodes(parent_id,id);
|
||||
CREATE INDEX drive_blob_reference ON drive_nodes(blob_key) WHERE blob_key IS NOT NULL;
|
||||
CREATE UNIQUE INDEX drive_single_root ON drive_nodes(workspace_id) WHERE parent_id IS NULL;
|
||||
CREATE TABLE drive_receipts (request_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL, committed_at_ms INTEGER NOT NULL);
|
||||
")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_committed_requests(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
// Materialize the original receipt correspondence once. In particular, a
|
||||
// deletion receipt contains the pre-delete node, so it refers to the earlier
|
||||
// create/update request, not to the deletion request itself. Equal timestamps
|
||||
// do not matter. Missing/ambiguous evidence fails constraints and rolls back.
|
||||
// Keep only proven predecessor correspondence for legacy digest verification.
|
||||
// No request payload is stored: fingerprints remain untouched. A missing
|
||||
// predecessor leaves replay unavailable, but the migrated result recoverable
|
||||
// through request_status. This evidence never authorizes a new mutation.
|
||||
tx.execute_batch("\
|
||||
CREATE TEMP TABLE drive_observed_requests (
|
||||
node_id INTEGER NOT NULL, previous_counter INTEGER NOT NULL,
|
||||
request_id TEXT NOT NULL, matches INTEGER NOT NULL CHECK(matches=1),
|
||||
PRIMARY KEY(node_id,previous_counter));
|
||||
INSERT INTO drive_observed_requests
|
||||
SELECT CAST(json_extract(result_json, '$.node.id') AS INTEGER),
|
||||
CAST(json_extract(result_json, '$.node.revision') AS INTEGER),
|
||||
min(request_id), count(*) FROM drive_receipts
|
||||
WHERE json_extract(result_json, '$.deleted') = 0 GROUP BY 1,2;
|
||||
CREATE TABLE drive_legacy_replays (
|
||||
request_id TEXT PRIMARY KEY REFERENCES drive_receipts(request_id) ON DELETE CASCADE,
|
||||
expected_mutation_id TEXT NOT NULL, expected_revision TEXT NOT NULL);
|
||||
INSERT INTO drive_legacy_replays
|
||||
SELECT receipt.request_id, observed.request_id, CAST(observed.previous_counter AS TEXT)
|
||||
FROM drive_receipts AS receipt JOIN drive_observed_requests AS observed
|
||||
ON observed.node_id = CAST(json_extract(receipt.result_json, '$.node.id') AS INTEGER)
|
||||
AND observed.previous_counter = CAST(json_extract(receipt.result_json, '$.node.revision') AS INTEGER)
|
||||
- CASE WHEN json_extract(receipt.result_json, '$.deleted') = 1 THEN 0 ELSE 1 END;
|
||||
ALTER TABLE drive_nodes ADD COLUMN last_mutation_id TEXT NOT NULL DEFAULT '';
|
||||
UPDATE drive_nodes SET last_mutation_id = (
|
||||
SELECT request_id FROM drive_observed_requests
|
||||
WHERE node_id = drive_nodes.id AND previous_counter = drive_nodes.revision
|
||||
) WHERE parent_id IS NOT NULL;
|
||||
UPDATE drive_receipts SET result_json = (
|
||||
SELECT json_remove(json_set(result_json, '$.node.last_mutation_id', observed.request_id), '$.node.revision')
|
||||
FROM drive_observed_requests AS observed
|
||||
WHERE observed.node_id = CAST(json_extract(result_json, '$.node.id') AS INTEGER)
|
||||
AND observed.previous_counter = CAST(json_extract(result_json, '$.node.revision') AS INTEGER)
|
||||
);
|
||||
ALTER TABLE drive_nodes DROP COLUMN revision;
|
||||
DROP TABLE drive_observed_requests;
|
||||
")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Frozen pre-cutover typed payload encoding, including field order and decimal
|
||||
// strings. Never reconstruct a fingerprint from the result: compare the caller's
|
||||
// complete actor/payload against the original digest. Historical counters below
|
||||
// are replay-only evidence and must never participate in live node CAS.
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "operation", rename_all = "snake_case")]
|
||||
enum LegacyMutation<'a> {
|
||||
Update {
|
||||
id: NodeId,
|
||||
expected_revision: &'a str,
|
||||
content_type: &'a str,
|
||||
bytes: &'a [u8],
|
||||
},
|
||||
Relocate {
|
||||
id: NodeId,
|
||||
expected_revision: &'a str,
|
||||
parent: NodeId,
|
||||
name: &'a str,
|
||||
},
|
||||
Delete {
|
||||
id: NodeId,
|
||||
expected_revision: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn matches_legacy_replay(
|
||||
tx: &Transaction<'_>,
|
||||
request_id: &str,
|
||||
actor: &str,
|
||||
mutation: &Mutation,
|
||||
fingerprint: &str,
|
||||
) -> crate::Result<bool> {
|
||||
let evidence = tx
|
||||
.query_row(
|
||||
"SELECT expected_mutation_id,expected_revision FROM drive_legacy_replays WHERE request_id=?1",
|
||||
[request_id],
|
||||
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((expected_request, counter)) = evidence else {
|
||||
// Missing prior receipt: do not guess. request_status still recovers the
|
||||
// committed result, while mutate rejects this identity without execution.
|
||||
return Ok(false);
|
||||
};
|
||||
let (expected, legacy) = match mutation {
|
||||
Mutation::Update {
|
||||
id,
|
||||
expected_mutation_id,
|
||||
content_type,
|
||||
bytes,
|
||||
} => (
|
||||
expected_mutation_id,
|
||||
LegacyMutation::Update {
|
||||
id: *id,
|
||||
expected_revision: &counter,
|
||||
content_type,
|
||||
bytes,
|
||||
},
|
||||
),
|
||||
Mutation::Relocate {
|
||||
id,
|
||||
expected_mutation_id,
|
||||
parent,
|
||||
name,
|
||||
} => (
|
||||
expected_mutation_id,
|
||||
LegacyMutation::Relocate {
|
||||
id: *id,
|
||||
expected_revision: &counter,
|
||||
parent: *parent,
|
||||
name,
|
||||
},
|
||||
),
|
||||
Mutation::Delete {
|
||||
id,
|
||||
expected_mutation_id,
|
||||
} => (
|
||||
expected_mutation_id,
|
||||
LegacyMutation::Delete {
|
||||
id: *id,
|
||||
expected_revision: &counter,
|
||||
},
|
||||
),
|
||||
Mutation::CreateFolder { .. } | Mutation::CreateFile { .. } => return Ok(false),
|
||||
};
|
||||
if expected != &expected_request {
|
||||
return Ok(false);
|
||||
}
|
||||
let candidate: String = Sha256::digest(serde_json::to_vec(&(actor, legacy))?)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
Ok(candidate == fingerprint)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Drive, Mutation, NodeId, RequestStatus};
|
||||
use feature_storage::{FeatureRegistration, FeatureStorage};
|
||||
use rusqlite::params;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
fn seed_old(path: &Path, copies_of_current_receipt: usize) {
|
||||
let storage = FeatureStorage::new(path).workspace("ws").unwrap();
|
||||
let registration = storage
|
||||
.register(FeatureRegistration::new(
|
||||
"workspace-drive",
|
||||
&MIGRATIONS[..1],
|
||||
))
|
||||
.unwrap();
|
||||
let database = storage.open(®istration).unwrap();
|
||||
let key = "00000000-0000-0000-0000-000000000004";
|
||||
crate::blob::BlobStore::open(&path.parent().unwrap().join("blobs"))
|
||||
.unwrap()
|
||||
.put(key, b"persisted-file")
|
||||
.unwrap();
|
||||
database.try_transaction::<_, feature_storage::FeatureStorageError>(|tx| {
|
||||
tx.execute_batch("INSERT INTO drive_state(singleton,workspace_id) VALUES(1,'ws');
|
||||
INSERT INTO drive_nodes(id,workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
|
||||
VALUES(1,'ws',NULL,'','directory',1,0,'server',42),
|
||||
(2,'ws',1,'after','directory',2,0,'actor',42),
|
||||
(3,'ws',1,'deleted','directory',1,0,'actor',42);
|
||||
DELETE FROM drive_nodes WHERE id=3;")?;
|
||||
let receipt = |request: &str, id: &str, name: &str, number: i64, deleted: bool| -> feature_storage::Result<()> {
|
||||
// Literal frozen typed JSON keeps this fixture independent of the
|
||||
// production legacy serializer (field order is part of the digest).
|
||||
let payload = if deleted {
|
||||
format!(r#"["actor",{{"operation":"delete","id":"{id}","expected_revision":"{number}"}}]"#)
|
||||
} else if number > 1 {
|
||||
format!(r#"["actor",{{"operation":"relocate","id":"{id}","expected_revision":"{}","parent":"1","name":"{name}"}}]"#, number - 1)
|
||||
} else {
|
||||
format!(r#"["actor",{{"operation":"create_folder","parent":"1","name":"{name}"}}]"#)
|
||||
};
|
||||
let fingerprint = digest(payload.as_bytes());
|
||||
let result = json!({"node":{"id":id,"workspace_id":"ws","parent_id":"1","name":name,
|
||||
"kind":"directory","revision":number.to_string(),"size":0,"content_type":null,
|
||||
"updated_by":"actor","updated_at_ms":42},"deleted":deleted});
|
||||
tx.execute("INSERT INTO drive_receipts VALUES(?1,?2,?3,42)", params![request,fingerprint,result.to_string()])?;
|
||||
Ok(())
|
||||
};
|
||||
receipt("create", "2", "before", 1, false)?;
|
||||
for index in 0..copies_of_current_receipt {
|
||||
receipt(&format!("move-{index}"), "2", "after", 2, false)?;
|
||||
}
|
||||
receipt("create-deleted", "3", "deleted", 1, false)?;
|
||||
receipt("delete", "3", "deleted", 1, true)?;
|
||||
tx.execute("INSERT INTO drive_nodes(id,workspace_id,parent_id,name,kind,revision,blob_key,size,content_type,updated_by,updated_at_ms)
|
||||
VALUES(4,'ws',1,'file','file',1,?1,14,'text/plain','actor',42)", [key])?;
|
||||
let file_result = json!({"node":{"id":"4","workspace_id":"ws","parent_id":"1","name":"file",
|
||||
"kind":"file","revision":"1","size":14,"content_type":"text/plain",
|
||||
"updated_by":"actor","updated_at_ms":42},"deleted":false});
|
||||
let create_file = Mutation::CreateFile { parent: NodeId(1), name: "file".into(), content_type: "text/plain".into(), bytes: b"persisted-file".to_vec() };
|
||||
let fingerprint = digest(&serde_json::to_vec(&("actor", create_file)).unwrap());
|
||||
tx.execute("INSERT INTO drive_receipts VALUES('file-create',?1,?2,42)", params![fingerprint,file_result.to_string()])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_uses_receipts_not_timestamps_and_preserves_replay_and_deleted_results() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("metadata");
|
||||
seed_old(&path, 1);
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert_eq!(drive.database.schema_version().unwrap(), 2);
|
||||
assert_eq!(drive.root().unwrap().last_mutation_id, "");
|
||||
let file = drive.metadata(NodeId(4)).unwrap();
|
||||
assert_eq!(file.last_mutation_id, "file-create");
|
||||
assert_eq!(
|
||||
drive
|
||||
.read(file.id, &file.last_mutation_id, 0, 100)
|
||||
.unwrap()
|
||||
.bytes,
|
||||
b"persisted-file"
|
||||
);
|
||||
let current = drive.metadata(NodeId(2)).unwrap();
|
||||
assert_eq!(current.name, "after");
|
||||
assert_eq!(current.last_mutation_id, "move-0");
|
||||
// Original create fingerprint is unchanged, and replay never overwrites the live node.
|
||||
let replay = drive
|
||||
.mutate(
|
||||
"create",
|
||||
"actor",
|
||||
Mutation::CreateFolder {
|
||||
parent: NodeId(1),
|
||||
name: "before".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(replay.node.name, "before");
|
||||
assert_eq!(replay.node.last_mutation_id, "create");
|
||||
assert_eq!(drive.metadata(NodeId(2)).unwrap(), current);
|
||||
let RequestStatus::Committed { result } = drive.request_status("delete").unwrap() else {
|
||||
panic!("lost deletion receipt");
|
||||
};
|
||||
assert!(result.deleted);
|
||||
assert_eq!(result.node.last_mutation_id, "create-deleted");
|
||||
assert_eq!(result.node.id, NodeId(3));
|
||||
assert!(matches!(
|
||||
drive.metadata(NodeId(3)),
|
||||
Err(crate::Error::NotFound)
|
||||
));
|
||||
let next = drive
|
||||
.mutate(
|
||||
"new",
|
||||
"actor",
|
||||
Mutation::CreateFolder {
|
||||
parent: NodeId(1),
|
||||
name: "new".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
next.node.id > NodeId(3),
|
||||
"deleted IDs must not be reused after migration"
|
||||
);
|
||||
drive
|
||||
.database
|
||||
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
|
||||
let count: i64 = c.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('drive_nodes') WHERE name='revision'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
assert_eq!(count, 0, "old counter must not remain in the live schema");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn digest(payload: &[u8]) -> String {
|
||||
Sha256::digest(payload)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn seed_update(path: &Path, missing_predecessors: bool) {
|
||||
seed_old(path, 1);
|
||||
let storage = FeatureStorage::new(path).workspace("ws").unwrap();
|
||||
let old = storage
|
||||
.register(FeatureRegistration::new(
|
||||
"workspace-drive",
|
||||
&MIGRATIONS[..1],
|
||||
))
|
||||
.unwrap();
|
||||
let database = storage.open(&old).unwrap();
|
||||
database.try_transaction::<_, feature_storage::FeatureStorageError>(|tx| {
|
||||
let result = json!({"node":{"id":"4","workspace_id":"ws","parent_id":"1","name":"file",
|
||||
"kind":"file","revision":"2","size":14,"content_type":"text/plain",
|
||||
"updated_by":"actor","updated_at_ms":42},"deleted":false});
|
||||
let payload = br#"["actor",{"operation":"update","id":"4","expected_revision":"1","content_type":"text/plain","bytes":[112,101,114,115,105,115,116,101,100,45,102,105,108,101]}]"#;
|
||||
tx.execute("INSERT INTO drive_receipts VALUES('file-update',?1,?2,42)", params![digest(payload), result.to_string()])?;
|
||||
tx.execute("UPDATE drive_nodes SET revision=2 WHERE id=4", [])?;
|
||||
if missing_predecessors {
|
||||
tx.execute("DELETE FROM drive_receipts WHERE request_id IN ('file-create','create')", [])?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
fn changed_replays() -> Vec<(&'static str, Mutation)> {
|
||||
vec![
|
||||
(
|
||||
"file-update",
|
||||
Mutation::Update {
|
||||
id: NodeId(4),
|
||||
expected_mutation_id: "file-create".into(),
|
||||
content_type: "text/plain".into(),
|
||||
bytes: b"persisted-file".to_vec(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"move-0",
|
||||
Mutation::Relocate {
|
||||
id: NodeId(2),
|
||||
expected_mutation_id: "create".into(),
|
||||
parent: NodeId(1),
|
||||
name: "after".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"delete",
|
||||
Mutation::Delete {
|
||||
id: NodeId(3),
|
||||
expected_mutation_id: "create-deleted".into(),
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn fingerprints(database: &feature_storage::FeatureDatabase) -> Vec<(String, String)> {
|
||||
database
|
||||
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT request_id,fingerprint FROM drive_receipts ORDER BY request_id",
|
||||
)?;
|
||||
Ok(stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_preserves_original_digests_and_never_uses_old_counters_for_new_mutations() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("metadata");
|
||||
seed_update(&path, false);
|
||||
let original = {
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let old = storage
|
||||
.register(FeatureRegistration::new(
|
||||
"workspace-drive",
|
||||
&MIGRATIONS[..1],
|
||||
))
|
||||
.unwrap();
|
||||
fingerprints(&storage.open(&old).unwrap())
|
||||
};
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert_eq!(fingerprints(&drive.database), original);
|
||||
assert!(matches!(
|
||||
drive.mutate(
|
||||
"new-update",
|
||||
"actor",
|
||||
Mutation::Update {
|
||||
id: NodeId(4),
|
||||
expected_mutation_id: "2".into(),
|
||||
content_type: "text/plain".into(),
|
||||
bytes: b"changed".to_vec(),
|
||||
}
|
||||
),
|
||||
Err(crate::Error::Conflict)
|
||||
));
|
||||
assert_eq!(
|
||||
drive.request_status("new-update").unwrap(),
|
||||
RequestStatus::Uncommitted
|
||||
);
|
||||
assert_eq!(
|
||||
drive.metadata(NodeId(4)).unwrap().last_mutation_id,
|
||||
"file-update"
|
||||
);
|
||||
assert_eq!(fingerprints(&drive.database), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_update_relocate_delete_replay_original_results_without_reexecution() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("metadata");
|
||||
seed_update(&path, false);
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert_eq!(drive.database.schema_version().unwrap(), 2);
|
||||
// Historical replay must not overwrite a newer committed mutation.
|
||||
for (id, expected) in [(NodeId(2), "move-0"), (NodeId(4), "file-update")] {
|
||||
drive
|
||||
.mutate(
|
||||
&format!("new-{}", id.get()),
|
||||
"actor",
|
||||
Mutation::Relocate {
|
||||
id,
|
||||
expected_mutation_id: expected.into(),
|
||||
parent: NodeId(1),
|
||||
name: format!("new-{}", id.get()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let before = drive.list(NodeId(1), None, 100).unwrap().nodes;
|
||||
for (request, mutation) in changed_replays() {
|
||||
let RequestStatus::Committed { result } = drive.request_status(request).unwrap() else {
|
||||
panic!("lost {request}")
|
||||
};
|
||||
assert_eq!(
|
||||
drive.mutate(request, "actor", mutation).unwrap(),
|
||||
result,
|
||||
"{request}"
|
||||
);
|
||||
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before);
|
||||
}
|
||||
// The unchanged CreateFile encoding remains replayable as well.
|
||||
let created = drive
|
||||
.mutate(
|
||||
"file-create",
|
||||
"actor",
|
||||
Mutation::CreateFile {
|
||||
parent: NodeId(1),
|
||||
name: "file".into(),
|
||||
content_type: "text/plain".into(),
|
||||
bytes: b"persisted-file".to_vec(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(created.node.last_mutation_id, "file-create");
|
||||
assert_eq!(
|
||||
drive.collect(None, 100).unwrap().removed,
|
||||
0,
|
||||
"replay must not write blobs"
|
||||
);
|
||||
drop(drive);
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before);
|
||||
assert!(matches!(
|
||||
drive.metadata(NodeId(3)),
|
||||
Err(crate::Error::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_receipts_reject_changed_actor_payload_or_precondition_without_side_effects() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("metadata");
|
||||
seed_update(&path, false);
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
let before = drive.list(NodeId(1), None, 100).unwrap().nodes;
|
||||
for (request, mutation) in changed_replays() {
|
||||
let status = drive.request_status(request).unwrap();
|
||||
assert!(matches!(
|
||||
drive.mutate(request, "other-actor", mutation.clone()),
|
||||
Err(crate::Error::Conflict)
|
||||
));
|
||||
let mut changed = mutation.clone();
|
||||
match &mut changed {
|
||||
Mutation::Update { bytes, .. } => *bytes = b"changed".to_vec(),
|
||||
Mutation::Relocate { name, .. } => *name = "changed".into(),
|
||||
Mutation::Delete { id, .. } => *id = NodeId(2),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(matches!(
|
||||
drive.mutate(request, "actor", changed),
|
||||
Err(crate::Error::Conflict)
|
||||
));
|
||||
let mut changed = mutation;
|
||||
match &mut changed {
|
||||
Mutation::Update {
|
||||
expected_mutation_id,
|
||||
..
|
||||
}
|
||||
| Mutation::Relocate {
|
||||
expected_mutation_id,
|
||||
..
|
||||
}
|
||||
| Mutation::Delete {
|
||||
expected_mutation_id,
|
||||
..
|
||||
} => *expected_mutation_id = "wrong-request".into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(matches!(
|
||||
drive.mutate(request, "actor", changed),
|
||||
Err(crate::Error::Conflict)
|
||||
));
|
||||
assert_eq!(drive.request_status(request).unwrap(), status);
|
||||
}
|
||||
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before);
|
||||
assert_eq!(drive.collect(None, 100).unwrap().removed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prior_receipt_requires_status_recovery_and_never_reexecutes_mutation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("metadata");
|
||||
seed_update(&path, true);
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
let before = drive.list(NodeId(1), None, 100).unwrap().nodes;
|
||||
for (request, mutation) in changed_replays().into_iter().take(2) {
|
||||
let status = drive.request_status(request).unwrap();
|
||||
assert!(matches!(status, RequestStatus::Committed { .. }));
|
||||
assert!(matches!(
|
||||
drive.mutate(request, "actor", mutation.clone()),
|
||||
Err(crate::Error::Conflict)
|
||||
));
|
||||
// Even substituting the *current* guard cannot reexecute an old identity.
|
||||
let mut changed = mutation;
|
||||
match &mut changed {
|
||||
Mutation::Update {
|
||||
expected_mutation_id,
|
||||
bytes,
|
||||
..
|
||||
} => {
|
||||
*expected_mutation_id = request.into();
|
||||
*bytes = b"changed".to_vec();
|
||||
}
|
||||
Mutation::Relocate {
|
||||
expected_mutation_id,
|
||||
name,
|
||||
..
|
||||
} => {
|
||||
*expected_mutation_id = request.into();
|
||||
*name = "changed".into();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(matches!(
|
||||
drive.mutate(request, "actor", changed),
|
||||
Err(crate::Error::Conflict)
|
||||
));
|
||||
assert_eq!(drive.request_status(request).unwrap(), status);
|
||||
}
|
||||
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before);
|
||||
assert_eq!(drive.collect(None, 100).unwrap().removed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_receipt_evidence_does_not_prevent_workspace_purge() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("metadata");
|
||||
seed_update(&path, false);
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
drive.purge().unwrap();
|
||||
drop(drive);
|
||||
let drive = Drive::open(&storage, ®istration, &temp.path().join("blobs")).unwrap();
|
||||
assert!(drive.list(NodeId(1), None, 100).unwrap().nodes.is_empty());
|
||||
for (request, _) in changed_replays() {
|
||||
assert_eq!(
|
||||
drive.request_status(request).unwrap(),
|
||||
RequestStatus::Uncommitted
|
||||
);
|
||||
}
|
||||
drive
|
||||
.database
|
||||
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
|
||||
let remaining: i64 =
|
||||
c.query_row("SELECT count(*) FROM drive_legacy_replays", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
assert_eq!(remaining, 0);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_ambiguous_receipts_abort_upgrade_without_partial_schema_or_data_changes() {
|
||||
for copies in [0, 2] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("metadata");
|
||||
seed_old(&path, copies);
|
||||
{
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
assert!(storage.open(®istration).is_err());
|
||||
}
|
||||
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
|
||||
let old = storage
|
||||
.register(FeatureRegistration::new(
|
||||
"workspace-drive",
|
||||
&MIGRATIONS[..1],
|
||||
))
|
||||
.unwrap();
|
||||
let database = storage.open(&old).unwrap();
|
||||
assert_eq!(database.schema_version().unwrap(), 1);
|
||||
database.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
|
||||
let row: (String, i64) = c.query_row("SELECT name,revision FROM drive_nodes WHERE id=2", [], |r| Ok((r.get(0)?,r.get(1)?)))?;
|
||||
assert_eq!(row, ("after".into(), 2));
|
||||
let added: i64 = c.query_row("SELECT count(*) FROM pragma_table_info('drive_nodes') WHERE name='last_mutation_id'", [], |r| r.get(0))?;
|
||||
assert_eq!(added, 0);
|
||||
let receipt: String = c.query_row("SELECT result_json FROM drive_receipts WHERE request_id='create'", [], |r| r.get(0))?;
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&receipt).unwrap()["node"]["revision"], "1");
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ fn create(parent: NodeId, name: &str) -> Mutation {
|
||||
fn migration_enforces_workspace_parent_and_unique_names() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let d = fixture(&t);
|
||||
assert_eq!(d.database.schema_version().unwrap(), 1);
|
||||
assert_eq!(d.database.schema_version().unwrap(), 2);
|
||||
let root = d.root().unwrap();
|
||||
d.mutate("a", "actor", create(root.id, "a")).unwrap();
|
||||
assert!(matches!(
|
||||
@@ -28,7 +28,7 @@ fn migration_enforces_workspace_parent_and_unique_names() {
|
||||
Err(Error::Conflict)
|
||||
));
|
||||
let result=d.database.try_transaction::<_,Error>(|tx| {
|
||||
tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
|
||||
tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,last_mutation_id,size,updated_by,updated_at_ms)
|
||||
VALUES ('other',?1,'bad','directory',1,0,'actor',0)",[root.id.0])?;
|
||||
Ok(())
|
||||
});
|
||||
@@ -66,7 +66,7 @@ fn saved_blob_before_metadata_failure_is_not_published_and_restart_collects_it()
|
||||
.mutate("failed", "actor", create(root.id, "failed"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
d.read(result.node.id, result.node.revision, 0, 100)
|
||||
d.read(result.node.id, &result.node.last_mutation_id, 0, 100)
|
||||
.unwrap()
|
||||
.bytes,
|
||||
b"persisted"
|
||||
@@ -117,10 +117,11 @@ fn collection_cannot_remove_in_flight_upload_or_selected_read() {
|
||||
.node;
|
||||
let go = Arc::new(std::sync::Barrier::new(2));
|
||||
let g = go.clone();
|
||||
let selected = first.clone();
|
||||
let reader = std::thread::spawn(move || {
|
||||
g.wait();
|
||||
for _ in 0..20 {
|
||||
match other.read(first.id, first.revision, 0, 100) {
|
||||
match other.read(selected.id, &selected.last_mutation_id, 0, 100) {
|
||||
Ok(chunk) => assert_eq!(chunk.bytes, b"persisted"),
|
||||
Err(Error::Conflict) => {}
|
||||
e => panic!("unexpected selected read outcome: {e:?}"),
|
||||
@@ -137,7 +138,7 @@ fn collection_cannot_remove_in_flight_upload_or_selected_read() {
|
||||
"actor",
|
||||
Mutation::Update {
|
||||
id: current.id,
|
||||
expected_revision: current.revision,
|
||||
expected_mutation_id: current.last_mutation_id.clone(),
|
||||
content_type: "text/plain".into(),
|
||||
bytes: vec![i; 1024],
|
||||
},
|
||||
@@ -149,7 +150,9 @@ fn collection_cannot_remove_in_flight_upload_or_selected_read() {
|
||||
d.collect(None, 200).unwrap();
|
||||
assert_eq!(d.blobs.list(None, 200).unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
d.read(current.id, current.revision, 0, 1024).unwrap().bytes,
|
||||
d.read(current.id, ¤t.last_mutation_id, 0, 1024)
|
||||
.unwrap()
|
||||
.bytes,
|
||||
vec![19; 1024]
|
||||
);
|
||||
}
|
||||
@@ -191,7 +194,7 @@ fn attached_server_authority_serializes_deletion_with_cached_drive_mutations() {
|
||||
Err(Error::Fenced)
|
||||
));
|
||||
assert!(matches!(
|
||||
d.read(child.id, child.revision, 0, 100),
|
||||
d.read(child.id, &child.last_mutation_id, 0, 100),
|
||||
Err(Error::Fenced)
|
||||
));
|
||||
assert!(matches!(d.collect(None, 10), Err(Error::Fenced)));
|
||||
@@ -228,7 +231,7 @@ fn restarted_collection_removes_crashed_staging_without_removing_referenced_hard
|
||||
let d = fixture(&t);
|
||||
assert_eq!(d.collect(None, 200).unwrap().removed, 2);
|
||||
assert_eq!(
|
||||
d.read(n.id, n.revision, 0, 100).unwrap().bytes,
|
||||
d.read(n.id, &n.last_mutation_id, 0, 100).unwrap().bytes,
|
||||
b"persisted"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(blobs).unwrap().count(), 1);
|
||||
@@ -257,7 +260,7 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o
|
||||
);
|
||||
let mutation = Mutation::Update {
|
||||
id: original.id,
|
||||
expected_revision: original.revision,
|
||||
expected_mutation_id: original.last_mutation_id.clone(),
|
||||
content_type: "text/plain".into(),
|
||||
bytes: b"failed replacement".to_vec(),
|
||||
};
|
||||
@@ -281,7 +284,7 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o
|
||||
d.blobs = Arc::new(blob::BlobStore::open(&t.path().join("blobs")).unwrap());
|
||||
d.collect(None, 200).unwrap();
|
||||
assert_eq!(
|
||||
d.read(original.id, original.revision, 0, 100)
|
||||
d.read(original.id, &original.last_mutation_id, 0, 100)
|
||||
.unwrap()
|
||||
.bytes,
|
||||
b"persisted"
|
||||
@@ -292,7 +295,7 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o
|
||||
);
|
||||
let replaced = d.mutate("failed", "actor", mutation).unwrap().node;
|
||||
assert_eq!(
|
||||
d.read(replaced.id, replaced.revision, 0, 100)
|
||||
d.read(replaced.id, &replaced.last_mutation_id, 0, 100)
|
||||
.unwrap()
|
||||
.bytes,
|
||||
b"failed replacement"
|
||||
@@ -360,12 +363,12 @@ fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching()
|
||||
assert!(bound.search("file", true, None, 10).is_ok());
|
||||
assert!(
|
||||
bound
|
||||
.read(result.node.id, result.node.revision, 0, 100)
|
||||
.read(result.node.id, &result.node.last_mutation_id, 0, 100)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
bound
|
||||
.read_text(result.node.id, result.node.revision, 0, 100)
|
||||
.read_text(result.node.id, &result.node.last_mutation_id, 0, 100)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(bound.request_status("request").is_ok());
|
||||
@@ -388,11 +391,11 @@ fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching()
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
clone.read(result.node.id, result.node.revision, 0, 100),
|
||||
clone.read(result.node.id, &result.node.last_mutation_id, 0, 100),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
clone.read_text(result.node.id, result.node.revision, 0, 100),
|
||||
clone.read_text(result.node.id, &result.node.last_mutation_id, 0, 100),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
|
||||
@@ -46,12 +46,14 @@ fn file(
|
||||
.node
|
||||
}
|
||||
fn read(d: &Drive, n: &Node) -> Vec<u8> {
|
||||
d.read(n.id, n.revision, 0, MAX_READ_BYTES).unwrap().bytes
|
||||
d.read(n.id, &n.last_mutation_id, 0, MAX_READ_BYTES)
|
||||
.unwrap()
|
||||
.bytes
|
||||
}
|
||||
fn update(n: &Node, bytes: &[u8]) -> Mutation {
|
||||
Mutation::Update {
|
||||
id: n.id,
|
||||
expected_revision: n.revision,
|
||||
expected_mutation_id: n.last_mutation_id.clone(),
|
||||
content_type: "text/markdown".into(),
|
||||
bytes: bytes.to_vec(),
|
||||
}
|
||||
@@ -117,7 +119,7 @@ fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() {
|
||||
"account",
|
||||
Mutation::Relocate {
|
||||
id: f.id,
|
||||
expected_revision: f.revision,
|
||||
expected_mutation_id: f.last_mutation_id.clone(),
|
||||
parent: b.id,
|
||||
name: "改名".into(),
|
||||
},
|
||||
@@ -133,7 +135,7 @@ fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() {
|
||||
std::fs::remove_file(blobs.join(&before[0])).unwrap();
|
||||
assert_eq!(d.list(b.id, None, 1).unwrap().nodes, vec![moved.clone()]);
|
||||
assert!(matches!(
|
||||
d.read(moved.id, moved.revision, 0, 1),
|
||||
d.read(moved.id, &moved.last_mutation_id, 0, 1),
|
||||
Err(Error::Blob(_))
|
||||
));
|
||||
let first = d.list(root.id, None, 1).unwrap();
|
||||
@@ -144,7 +146,7 @@ fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_revision_and_name_competitors_use_database_authority() {
|
||||
fn parallel_last_mutation_id_and_name_competitors_use_database_authority() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let db = t.path().join("db");
|
||||
let blobs = t.path().join("blobs");
|
||||
@@ -215,7 +217,7 @@ fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() {
|
||||
"account",
|
||||
Mutation::Relocate {
|
||||
id: b2.id,
|
||||
expected_revision: b2.revision,
|
||||
expected_mutation_id: b2.last_mutation_id.clone(),
|
||||
parent: aa.id,
|
||||
name: "b".into(),
|
||||
},
|
||||
@@ -227,7 +229,7 @@ fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() {
|
||||
"account",
|
||||
Mutation::Relocate {
|
||||
id: a.id,
|
||||
expected_revision: a.revision,
|
||||
expected_mutation_id: a.last_mutation_id.clone(),
|
||||
parent: b.id,
|
||||
name: "a".into(),
|
||||
},
|
||||
@@ -255,7 +257,7 @@ fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() {
|
||||
"account",
|
||||
Mutation::Delete {
|
||||
id: p.id,
|
||||
expected_revision: p.revision,
|
||||
expected_mutation_id: p.last_mutation_id.clone(),
|
||||
},
|
||||
);
|
||||
let created = worker.join().unwrap();
|
||||
@@ -308,7 +310,7 @@ fn receipts_survive_response_loss_and_restart_without_double_mutation() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() {
|
||||
fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_last_mutation_id() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws");
|
||||
let root = d.root().unwrap();
|
||||
@@ -318,7 +320,7 @@ fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() {
|
||||
.unwrap()
|
||||
.node;
|
||||
assert!(matches!(
|
||||
d.read(old.id, old.revision, 0, 1),
|
||||
d.read(old.id, &old.last_mutation_id, 0, 1),
|
||||
Err(Error::Conflict)
|
||||
));
|
||||
d.mutate(
|
||||
@@ -326,7 +328,7 @@ fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() {
|
||||
"account",
|
||||
Mutation::Delete {
|
||||
id: next.id,
|
||||
expected_revision: next.revision,
|
||||
expected_mutation_id: next.last_mutation_id.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -366,11 +368,11 @@ fn bounded_search_and_read_reject_paths_oversize_and_invalid_parents() {
|
||||
);
|
||||
let g = file(&d, &root, "g", "needle.png", "image/png", b"needle");
|
||||
assert!(matches!(
|
||||
d.read(f.id, f.revision, 0, MAX_READ_BYTES + 1),
|
||||
d.read(f.id, &f.last_mutation_id, 0, MAX_READ_BYTES + 1),
|
||||
Err(Error::Invalid(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
d.read(f.id, f.revision, f.size + 1, 1),
|
||||
d.read(f.id, &f.last_mutation_id, f.size + 1, 1),
|
||||
Err(Error::Invalid(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
@@ -434,7 +436,7 @@ fn fence_purge_restart_and_workspace_boundaries_are_persistent() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs() {
|
||||
fn stopped_writer_backup_restores_ids_last_mutation_ids_receipts_and_sequence_with_blobs() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let metadata = t.path().join("metadata");
|
||||
let blobs = t.path().join("blobs");
|
||||
@@ -454,7 +456,7 @@ fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs
|
||||
"account",
|
||||
Mutation::Delete {
|
||||
id: deleted.id,
|
||||
expected_revision: deleted.revision,
|
||||
expected_mutation_id: deleted.last_mutation_id.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -486,15 +488,10 @@ fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_ids_and_revisions_keep_full_integer_precision() {
|
||||
fn external_node_ids_keep_full_integer_precision() {
|
||||
let max = "9223372036854775807";
|
||||
let id: workspace_drive::NodeId = serde_json::from_str(&format!("\"{max}\"")).unwrap();
|
||||
let revision: workspace_drive::Revision = serde_json::from_str(&format!("\"{max}\"")).unwrap();
|
||||
assert_eq!(serde_json::to_string(&id).unwrap(), format!("\"{max}\""));
|
||||
assert_eq!(
|
||||
serde_json::to_string(&revision).unwrap(),
|
||||
format!("\"{max}\"")
|
||||
);
|
||||
for bad in ["0", "-1", "01", "9223372036854775808"] {
|
||||
assert!(serde_json::from_str::<workspace_drive::NodeId>(&format!("\"{bad}\"")).is_err());
|
||||
}
|
||||
@@ -508,9 +505,12 @@ fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() {
|
||||
let d = open(&t.path().join("db"), &blobs, "ws");
|
||||
let root = d.root().unwrap();
|
||||
let f = file(&d, &root, "f", "f", "text/plain", "日本語".as_bytes());
|
||||
assert_eq!(d.read_text(f.id, f.revision, 0, 3).unwrap().text, "日");
|
||||
assert_eq!(
|
||||
d.read_text(f.id, &f.last_mutation_id, 0, 3).unwrap().text,
|
||||
"日"
|
||||
);
|
||||
assert!(matches!(
|
||||
d.read_text(f.id, f.revision, 1, 2),
|
||||
d.read_text(f.id, &f.last_mutation_id, 1, 2),
|
||||
Err(Error::Invalid(_))
|
||||
));
|
||||
let path = std::fs::read_dir(&blobs)
|
||||
@@ -521,7 +521,7 @@ fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() {
|
||||
.path();
|
||||
std::fs::write(&path, b"corrupt length").unwrap();
|
||||
assert!(matches!(
|
||||
d.read(f.id, f.revision, 0, 1),
|
||||
d.read(f.id, &f.last_mutation_id, 0, 1),
|
||||
Err(Error::Invalid(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
@@ -537,7 +537,7 @@ fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() {
|
||||
}
|
||||
}
|
||||
assert!(matches!(
|
||||
d.read(empty.id, empty.revision, 0, 1),
|
||||
d.read(empty.id, &empty.last_mutation_id, 0, 1),
|
||||
Err(Error::Blob(_))
|
||||
));
|
||||
}
|
||||
@@ -573,7 +573,7 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl
|
||||
"account",
|
||||
Mutation::Delete {
|
||||
id: one.node.id,
|
||||
expected_revision: one.node.revision
|
||||
expected_mutation_id: one.node.last_mutation_id
|
||||
}
|
||||
),
|
||||
Err(Error::Conflict)
|
||||
@@ -585,7 +585,7 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl
|
||||
"account",
|
||||
Mutation::Delete {
|
||||
id: root.id,
|
||||
expected_revision: root.revision
|
||||
expected_mutation_id: root.last_mutation_id
|
||||
}
|
||||
),
|
||||
Err(Error::Invalid(_))
|
||||
@@ -596,7 +596,7 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl
|
||||
"account",
|
||||
Mutation::Relocate {
|
||||
id: child.id,
|
||||
expected_revision: child.revision,
|
||||
expected_mutation_id: child.last_mutation_id.clone(),
|
||||
parent: root.id,
|
||||
name: "child".into(),
|
||||
},
|
||||
@@ -609,10 +609,73 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl
|
||||
"account",
|
||||
Mutation::Delete {
|
||||
id: child.id,
|
||||
expected_revision: child.revision
|
||||
expected_mutation_id: child.last_mutation_id
|
||||
}
|
||||
),
|
||||
Err(Error::Conflict)
|
||||
));
|
||||
assert_eq!(d.metadata(changed.id).unwrap(), changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_writes_and_move_away_back_do_not_revive_old_observations() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws");
|
||||
let root = d.root().unwrap();
|
||||
let first = file(
|
||||
&d,
|
||||
&root,
|
||||
"create-資料",
|
||||
"original",
|
||||
"text/markdown",
|
||||
b"same",
|
||||
);
|
||||
assert_eq!(first.last_mutation_id, "create-資料");
|
||||
let identical = d
|
||||
.mutate("same-content", "account", update(&first, b"same"))
|
||||
.unwrap()
|
||||
.node;
|
||||
assert_eq!(identical.last_mutation_id, "same-content");
|
||||
assert!(matches!(
|
||||
d.read(first.id, &first.last_mutation_id, 0, 4),
|
||||
Err(Error::Conflict)
|
||||
));
|
||||
let mut current = identical;
|
||||
for (request, name) in [("away", "temporary"), ("back", "original")] {
|
||||
current = d
|
||||
.mutate(
|
||||
request,
|
||||
"account",
|
||||
Mutation::Relocate {
|
||||
id: current.id,
|
||||
expected_mutation_id: current.last_mutation_id,
|
||||
parent: root.id,
|
||||
name: name.into(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.node;
|
||||
}
|
||||
assert_eq!(current.name, first.name);
|
||||
assert_eq!(current.last_mutation_id, "back");
|
||||
assert!(matches!(
|
||||
d.mutate(
|
||||
"stale-delete",
|
||||
"account",
|
||||
Mutation::Delete {
|
||||
id: first.id,
|
||||
expected_mutation_id: first.last_mutation_id,
|
||||
}
|
||||
),
|
||||
Err(Error::Conflict)
|
||||
));
|
||||
assert!(
|
||||
matches!(
|
||||
d.mutate("same-content", "account", update(¤t, b"same")),
|
||||
Err(Error::Conflict)
|
||||
),
|
||||
"reusing a receipt ID with another observation must not publish"
|
||||
);
|
||||
assert_eq!(d.metadata(current.id).unwrap(), current);
|
||||
assert_eq!(read(&d, ¤t), b"same");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user