refactor(core): rows and ops name a node's parent by local id; a landing rewrites nothing below it

This commit is contained in:
nvrl committed 2026-10-04 13:26:19 +02:00
1 parent 85ec91d6b3
commit 9c93860cd8
7 files changed
+301 -166

No files matched your search

+134 -113
View File
@@ -3,6 +3,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use rusqlite::types::Value;
use rusqlite::{OptionalExtension, Transaction, params}; use rusqlite::{OptionalExtension, Transaction, params};
use super::Db; use super::Db;
@@ -65,12 +66,13 @@ impl Db {
/// snapshot while offline, including after a live event expired its TTL. /// snapshot while offline, including after a live event expired its TTL.
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> { pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let (key, parent) = parent_key(&conn, &parent.to_string())?;
"SELECT node_json FROM nodes let mut stmt = conn.prepare(&format!(
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL "SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
ORDER BY name, uid", WHERE n.{key} = ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
)?; ORDER BY n.name, n.uid"
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?; ))?;
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
let mut nodes = Vec::new(); let mut nodes = Vec::new();
for row in rows { for row in rows {
nodes.push(serde_json::from_str(&row?)?); nodes.push(serde_json::from_str(&row?)?);
@@ -307,12 +309,14 @@ impl Db {
/// Check if a folder node has any non-trashed children in the database. /// Check if a folder node has any non-trashed children in the database.
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> { pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
let uid_str = parent_uid.to_string();
let conn = self.read(); let conn = self.read();
let (key, parent) = parent_key(&conn, &parent_uid.to_string())?;
let count: i64 = conn.query_row( let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM nodes &format!(
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL", "SELECT COUNT(*) FROM nodes
params![uid_str], WHERE {key} = ?1 AND trashed = 0 AND node_json IS NOT NULL"
),
params![parent],
|row| row.get(0), |row| row.get(0),
)?; )?;
Ok(count > 0) Ok(count > 0)
@@ -349,11 +353,11 @@ impl Db {
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> { pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS ( "WITH RECURSIVE anc(uid, parent_lid, name, depth) AS (
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1 SELECT uid, parent_lid, name, 0 FROM nodes WHERE uid = ?1
UNION ALL UNION ALL
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1 SELECT n.uid, n.parent_lid, n.name, anc.depth + 1
FROM nodes n JOIN anc ON n.uid = anc.parent_uid FROM nodes n JOIN anc ON n.lid = anc.parent_lid
WHERE anc.depth < 1024 WHERE anc.depth < 1024
) )
SELECT uid, name FROM anc ORDER BY depth", SELECT uid, name FROM anc ORDER BY depth",
@@ -402,12 +406,13 @@ impl Db {
let conn = self.read(); let conn = self.read();
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN { let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
let pat = format!("%{}%", like_escape(query)); let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT node_json, uid, path FROM nodes "SELECT {NODE_NOW}, n.uid, n.path
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\') WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY name LIMIT ?2", AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
)?; ORDER BY n.name LIMIT ?2"
))?;
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)? collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
} else { } else {
// Escape double quotes and quote each term, then combine with AND so // Escape double quotes and quote each term, then combine with AND so
@@ -417,13 +422,14 @@ impl Db {
.map(|word| format!("\"{}\"", word.replace('"', "\"\""))) .map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" AND "); .join(" AND ");
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT n.node_json, n.uid, n.path "SELECT {NODE_NOW}, n.uid, n.path
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\') AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
ORDER BY f.rank LIMIT ?2", ORDER BY f.rank LIMIT ?2"
)?; ))?;
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)? collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
}; };
@@ -460,20 +466,22 @@ impl Db {
let lane_limit = limit.div_ceil(2); let lane_limit = limit.div_ceil(2);
let mut rows: Vec<HitRow> = if terms.is_empty() { let mut rows: Vec<HitRow> = if terms.is_empty() {
let pat = format!("%{}%", like_escape(query)); let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT node_json, uid, path FROM nodes "SELECT {NODE_NOW}, n.uid, n.path
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
ORDER BY name LIMIT ?2", WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
)?; ORDER BY n.name LIMIT ?2"
))?;
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)? collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
} else { } else {
let expression = terms.join(" OR "); let expression = terms.join(" OR ");
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT n.node_json, n.uid, n.path "SELECT {NODE_NOW}, n.uid, n.path
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY f.rank LIMIT ?2", ORDER BY f.rank LIMIT ?2"
)?; ))?;
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)? collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
}; };
// Trigram rank does not know that a name *starts* with the query, and a // Trigram rank does not know that a name *starts* with the query, and a
@@ -483,12 +491,13 @@ impl Db {
if !terms.is_empty() if !terms.is_empty()
&& let Some(first) = query.chars().next() && let Some(first) = query.chars().next()
{ {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT node_json, uid, path FROM nodes "SELECT {NODE_NOW}, n.uid, n.path
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\' FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
AND trashed = 0 AND node_json IS NOT NULL WHERE n.name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
ORDER BY name COLLATE NOCASE LIMIT ?2", AND n.trashed = 0 AND n.node_json IS NOT NULL
)?; ORDER BY n.name COLLATE NOCASE LIMIT ?2"
))?;
let mut extra = Vec::new(); let mut extra = Vec::new();
for pattern in [ for pattern in [
format!("{}%", like_escape(query)), format!("{}%", like_escape(query)),
@@ -538,24 +547,20 @@ impl Db {
/// stored below it may still hold the placeholder. /// stored below it may still hold the placeholder.
pub fn load_all(&self) -> Result<Vec<StoredNode>> { pub fn load_all(&self) -> Result<Vec<StoredNode>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT n.node_json, n.listed, n.lid, p.uid "SELECT {NODE_NOW}, n.listed, n.lid
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.node_json IS NOT NULL", WHERE n.node_json IS NOT NULL"
)?; ))?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
let json: String = row.get(0)?; let json: String = row.get(0)?;
let listed: i64 = row.get(1)?; let listed: i64 = row.get(1)?;
let parent: Option<String> = row.get(3)?; Ok((json, listed != 0, row.get(2)?))
Ok((json, listed != 0, row.get(2)?, parent))
})?; })?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows { for row in rows {
let (json, listed, lid, parent) = row?; let (json, listed, lid) = row?;
let mut node: Node = serde_json::from_str(&json)?; let node: Node = serde_json::from_str(&json)?;
if let Some(parent) = parent.as_deref().and_then(parse_node_uid) {
node.parent_uid = Some(parent);
}
out.push(StoredNode { node, listed, lid }); out.push(StoredNode { node, listed, lid });
} }
Ok(out) Ok(out)
@@ -568,7 +573,10 @@ impl Db {
let conn = self.read(); let conn = self.read();
let json: Option<String> = conn let json: Option<String> = conn
.query_row( .query_row(
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL", &format!(
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.uid = ?1 AND n.node_json IS NOT NULL"
),
params![uid], params![uid],
|r| r.get(0), |r| r.get(0),
) )
@@ -613,20 +621,26 @@ impl Db {
let mut known = tx.prepare_cached("SELECT lid FROM nodes WHERE uid = ?1")?; let mut known = tx.prepare_cached("SELECT lid FROM nodes WHERE uid = ?1")?;
let mut stub = tx.prepare_cached( let mut stub = tx.prepare_cached(
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid) "INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT lid FROM nodes WHERE uid = ?2))", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?; )?;
for node in nodes { for node in nodes {
let uid = node.uid.to_string(); let uid = node.uid.to_string();
let lid = match known.query_row(params![uid], |r| r.get(0)).optional()? { let lid = match known.query_row(params![uid], |r| r.get(0)).optional()? {
Some(lid) => lid, Some(lid) => lid,
None => { None => {
let parent = node.parent_uid.as_ref().map(ToString::to_string);
let parent_lid = match &parent {
Some(parent) => node_lid(&tx, parent)?,
None => None,
};
stub.execute(params![ stub.execute(params![
uid, uid,
node.parent_uid.as_ref().map(ToString::to_string), parent,
node.name, node.name,
node.is_folder() as i64, node.is_folder() as i64,
node.modification_time, node.modification_time,
node.trashed as i64, node.trashed as i64,
parent_lid,
])?; ])?;
let lid = tx.last_insert_rowid(); let lid = tx.last_insert_rowid();
adopt_children_tx(&tx, &uid, lid)?; adopt_children_tx(&tx, &uid, lid)?;
@@ -699,11 +713,12 @@ impl Db {
/// had: a listing dropped as stale may miss or keep a child. /// had: a listing dropped as stale may miss or keep a child.
pub fn known_children(&self, parent: &NodeUid) -> Result<Vec<Node>> { pub fn known_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let (key, parent) = parent_key(&conn, &parent.to_string())?;
"SELECT node_json FROM nodes let mut stmt = conn.prepare(&format!(
WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0", "SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
)?; WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0"
let rows = stmt.query_map(params![parent.to_string()], |r| r.get::<_, String>(0))?; ))?;
let rows = stmt.query_map(params![parent], |r| r.get::<_, String>(0))?;
let mut out = Vec::new(); let mut out = Vec::new();
for json in rows { for json in rows {
out.push(serde_json::from_str(&json?)?); out.push(serde_json::from_str(&json?)?);
@@ -721,13 +736,13 @@ impl Db {
/// until the create lands (`docs/BUGS.md` B132). /// until the create lands (`docs/BUGS.md` B132).
pub fn queued_children(&self, parent: &NodeUid) -> Result<(Vec<Node>, HashSet<String>)> { pub fn queued_children(&self, parent: &NodeUid) -> Result<(Vec<Node>, HashSet<String>)> {
let conn = self.read(); let conn = self.read();
let parent = parent.to_string(); let (key, parent) = parent_key(&conn, &parent.to_string())?;
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT n.node_json FROM nodes n "SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.parent_uid = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0 WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0
AND EXISTS (SELECT 1 FROM pending_op p AND EXISTS (SELECT 1 FROM pending_op o
WHERE p.uid = n.uid AND p.kind IN (?2, ?3, ?4))", WHERE (o.lid = n.lid OR o.uid = n.uid) AND o.kind IN (?2, ?3, ?4))"
)?; ))?;
let rows = stmt.query_map(params![parent, OP_CREATE, OP_MKDIR, OP_RENAME], |r| { let rows = stmt.query_map(params![parent, OP_CREATE, OP_MKDIR, OP_RENAME], |r| {
r.get::<_, String>(0) r.get::<_, String>(0)
})?; })?;
@@ -735,10 +750,10 @@ impl Db {
for json in rows { for json in rows {
here.push(serde_json::from_str(&json?)?); here.push(serde_json::from_str(&json?)?);
} }
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT DISTINCT p.uid FROM pending_op p JOIN nodes n ON n.uid = p.uid "SELECT DISTINCT n.uid FROM pending_op p JOIN nodes n ON n.lid = p.lid OR n.uid = p.uid
WHERE p.kind = ?2 AND n.parent_uid IS NOT ?1", WHERE p.kind = ?2 AND n.{key} IS NOT ?1"
)?; ))?;
let gone = stmt let gone = stmt
.query_map(params![parent, OP_RENAME], |r| r.get::<_, String>(0))? .query_map(params![parent, OP_RENAME], |r| r.get::<_, String>(0))?
.collect::<std::result::Result<HashSet<_>, _>>()?; .collect::<std::result::Result<HashSet<_>, _>>()?;
@@ -777,8 +792,29 @@ pub(super) fn node_lid(conn: &rusqlite::Connection, uid: &str) -> Result<Option<
Ok(row.optional()?) Ok(row.optional()?)
} }
/// Hand a drained placeholder's row, and the rows below it, to the real uid it /// The column and value a row names `parent` by: its local id when it has a
/// landed as. /// row, which the children keep when it lands, or its uid when it has none,
/// as a device folder's root does.
fn parent_key(conn: &rusqlite::Connection, parent: &str) -> Result<(&'static str, Value)> {
Ok(match node_lid(conn, parent)? {
Some(lid) => ("parent_lid", Value::Integer(lid)),
None => ("parent_uid", Value::Text(parent.to_owned())),
})
}
/// `n.node_json` naming its parent as the parent's row `p`, joined by
/// `n.parent_lid`, has it now. A folder that lands takes its real uid in its
/// own row only, so a node stored below it still names the stand-in.
const NODE_NOW: &str = "CASE WHEN p.uid IS NULL OR p.uid = n.parent_uid THEN n.node_json
ELSE json_set(n.node_json, '$.parent_uid', json_object(
'volume_id', substr(p.uid, 1, instr(p.uid, '~') - 1),
'link_id', substr(p.uid, instr(p.uid, '~') + 1))) END";
/// Hand a drained placeholder's row to the real uid it landed as.
///
/// Nothing else is rewritten. The rows below it and the ops made inside it
/// name it by its local id, which stays; what reads them for a uid takes the
/// one the row has now ([`NODE_NOW`], `PARENT_NOW` in `ops.rs`).
/// ///
/// Dropping the placeholder row and waiting for the server's copy of the real /// Dropping the placeholder row and waiting for the server's copy of the real
/// node left a window with no row for either uid. A queued child drained in /// node left a window with no row for either uid. A queued child drained in
@@ -800,19 +836,6 @@ pub(super) fn adopt_placeholder_row_tx(
params![local, real], params![local, real],
)?; )?;
tx.execute("DELETE FROM pins WHERE uid = ?1", params![local])?; tx.execute("DELETE FROM pins WHERE uid = ?1", params![local])?;
// The node a row holds names its parent too, and is what a restart builds
// the tree from: a folder that landed with no write to its children after
// left them under a parent no row has (`docs/BUGS.md` B140).
let real_json = parse_node_uid(real)
.map(|uid| serde_json::to_string(&uid))
.transpose()?;
tx.execute(
"UPDATE nodes SET parent_uid = ?2,
node_json = CASE WHEN ?3 IS NULL OR node_json IS NULL THEN node_json
ELSE json_set(node_json, '$.parent_uid', json(?3)) END
WHERE parent_uid = ?1",
params![local, real, real_json],
)?;
let row: Option<(i64, Option<String>)> = tx let row: Option<(i64, Option<String>)> = tx
.query_row( .query_row(
"SELECT lid, node_json FROM nodes WHERE uid = ?1", "SELECT lid, node_json FROM nodes WHERE uid = ?1",
@@ -829,11 +852,6 @@ pub(super) fn adopt_placeholder_row_tx(
let Some((lid, json)) = row else { let Some((lid, json)) = row else {
return Ok(()); return Ok(());
}; };
// The ops made inside the folder name it by its local id, which stays.
tx.execute(
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_lid = ?1",
params![lid, real],
)?;
let known: Option<(i64, Option<String>)> = tx let known: Option<(i64, Option<String>)> = tx
.query_row( .query_row(
"SELECT lid, node_json FROM nodes WHERE uid = ?1", "SELECT lid, node_json FROM nodes WHERE uid = ?1",
@@ -908,13 +926,16 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
let (parent_lid, path) = match &parent_uid { let (parent_lid, path) = match &parent_uid {
None => (None, String::new()), None => (None, String::new()),
Some(parent) => { Some(parent) => {
let parent_row: Option<(i64, Option<String>)> = tx let parent_row: Option<(i64, Option<String>)> = match node_lid(tx, parent)? {
.query_row( Some(lid) => tx
"SELECT lid, path FROM nodes WHERE uid = ?1", .query_row(
params![parent], "SELECT lid, path FROM nodes WHERE lid = ?1",
|row| Ok((row.get(0)?, row.get(1)?)), params![lid],
) |row| Ok((row.get(0)?, row.get(1)?)),
.optional()?; )
.optional()?,
None => None,
};
let (lid, parent_path) = parent_row.unzip(); let (lid, parent_path) = parent_row.unzip();
( (
lid, lid,
@@ -980,7 +1001,7 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|| prior.trashed != node.trashed || prior.trashed != node.trashed
}); });
if node.is_folder() && subtree_moved { if node.is_folder() && subtree_moved {
reindex_subtree_tx(tx, &uid, &path, indexable)?; reindex_subtree_tx(tx, rowid, &path, indexable)?;
} }
Ok(()) Ok(())
} }
@@ -1001,22 +1022,22 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
/// this deep. /// this deep.
fn reindex_subtree_tx( fn reindex_subtree_tx(
tx: &Transaction<'_>, tx: &Transaction<'_>,
folder: &str, folder: i64,
folder_path: &str, folder_path: &str,
folder_indexable: bool, folder_indexable: bool,
) -> Result<()> { ) -> Result<()> {
let descendants: Vec<(i64, String, bool)> = { let descendants: Vec<(i64, String, bool)> = {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS ( "WITH RECURSIVE sub(rowid, name, path, blocked, depth) AS (
SELECT n.rowid, n.uid, n.name, SELECT n.rowid, n.name,
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END, CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
n.trashed, 0 n.trashed, 0
FROM nodes n WHERE n.parent_uid = ?1 FROM nodes n WHERE n.parent_lid = ?1
UNION ALL UNION ALL
SELECT n.rowid, n.uid, n.name, SELECT n.rowid, n.name,
sub.path || '/' || n.name, sub.path || '/' || n.name,
MAX(sub.blocked, n.trashed), sub.depth + 1 MAX(sub.blocked, n.trashed), sub.depth + 1
FROM nodes n JOIN sub ON n.parent_uid = sub.uid FROM nodes n JOIN sub ON n.parent_lid = sub.rowid
WHERE sub.depth < 256 WHERE sub.depth < 256
) )
SELECT rowid, path, blocked FROM sub", SELECT rowid, path, blocked FROM sub",
@@ -1057,7 +1078,8 @@ fn reindex_subtree_tx(
} }
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> { fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?; let (key, parent) = parent_key(tx, parent)?;
let mut stmt = tx.prepare(&format!("SELECT uid FROM nodes WHERE {key} = ?1"))?;
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?; let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
let mut uids = Vec::new(); let mut uids = Vec::new();
for row in rows { for row in rows {
@@ -1082,14 +1104,14 @@ fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String
/// ends at one whose parent is a row we have already visited. /// ends at one whose parent is a row we have already visited.
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> { fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
let indexable: i64 = tx.query_row( let indexable: i64 = tx.query_row(
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, depth, path) AS ( "WITH RECURSIVE ancestors(lid, parent_lid, trashed, depth, path) AS (
SELECT uid, parent_uid, trashed, 0, char(31) || uid || char(31) SELECT lid, parent_lid, trashed, 0, ',' || lid || ','
FROM nodes WHERE uid = ?1 FROM nodes WHERE uid = ?1
UNION ALL UNION ALL
SELECT n.uid, n.parent_uid, n.trashed, a.depth + 1, SELECT n.lid, n.parent_lid, n.trashed, a.depth + 1,
a.path || n.uid || char(31) a.path || n.lid || ','
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid FROM ancestors a JOIN nodes n ON n.lid = a.parent_lid
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0 WHERE instr(a.path, ',' || n.lid || ',') = 0
) )
SELECT CASE SELECT CASE
WHEN COUNT(*) = 0 THEN 0 WHEN COUNT(*) = 0 THEN 0
@@ -1097,8 +1119,7 @@ fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
WHEN EXISTS ( WHEN EXISTS (
SELECT 1 FROM ancestors a SELECT 1 FROM ancestors a
WHERE a.depth = (SELECT MAX(depth) FROM ancestors) WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
AND a.parent_uid IS NOT NULL AND EXISTS (SELECT 1 FROM nodes p WHERE p.lid = a.parent_lid)
AND EXISTS (SELECT 1 FROM nodes p WHERE p.uid = a.parent_uid)
) THEN 0 ) THEN 0
ELSE 1 ELSE 1
END END
@@ -1116,7 +1137,7 @@ fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1 SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
UNION UNION
SELECT n.rowid, n.uid, n.node_json FROM nodes n SELECT n.rowid, n.uid, n.node_json FROM nodes n
JOIN subtree s ON n.parent_uid = s.uid JOIN subtree s ON n.parent_lid = s.rowid
) )
SELECT rowid, uid, node_json FROM subtree", SELECT rowid, uid, node_json FROM subtree",
)?; )?;
+32 -22
View File
@@ -110,25 +110,29 @@ pub fn op_supersedes(kind: &str) -> bool {
fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<String>> { fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<String>> {
const DOOMED: &str = " const DOOMED: &str = "
WITH RECURSIVE WITH RECURSIVE
below(uid) AS ( below(uid, lid) AS (
SELECT ?1 SELECT ?1, ?2
UNION UNION
SELECT n.uid FROM nodes n JOIN below b ON n.parent_uid = b.uid SELECT n.uid, n.lid FROM nodes n JOIN below b
ON n.parent_lid = b.lid OR (n.parent_lid IS NULL AND n.parent_uid = b.uid)
), ),
queued(uid) AS ( queued(uid, lid) AS (
SELECT ?1 SELECT ?1, ?2
UNION UNION
SELECT p.uid FROM pending_op p SELECT p.uid, p.lid FROM pending_op p
WHERE p.kind IN ('create', 'mkdir') WHERE p.kind IN ('create', 'mkdir')
AND p.parent_uid IN (SELECT uid FROM below) AND (p.parent_lid IN (SELECT lid FROM below)
OR p.parent_uid IN (SELECT uid FROM below))
UNION UNION
SELECT p.uid FROM pending_op p JOIN queued q ON p.parent_uid = q.uid SELECT p.uid, p.lid FROM pending_op p JOIN queued q
ON p.parent_lid = q.lid OR p.parent_uid = q.uid
), ),
doomed(id) AS ( doomed(id) AS (
SELECT id FROM pending_op SELECT id FROM pending_op
WHERE uid IN (SELECT uid FROM queued) WHERE uid IN (SELECT uid FROM queued)
OR lid = ?2 OR lid IN (SELECT lid FROM queued)
OR (kind = 'revision' AND uid IN (SELECT uid FROM below)) OR (kind = 'revision'
AND (uid IN (SELECT uid FROM below) OR lid IN (SELECT lid FROM below)))
)"; )";
const SENT: &str = "kind IN ('create', 'mkdir') AND (claimed_at <> 0 OR attempts > 0)"; const SENT: &str = "kind IN ('create', 'mkdir') AND (claimed_at <> 0 OR attempts > 0)";
let lid = node_lid(tx, uid)?; let lid = node_lid(tx, uid)?;
@@ -147,8 +151,10 @@ fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<Stri
"{DOOMED} "{DOOMED}
UPDATE pending_op UPDATE pending_op
SET kind = 'trash', issue = NULL, SET kind = 'trash', issue = NULL,
parent_uid = CASE WHEN parent_uid LIKE '{LOCAL_VOLUME}~%' THEN NULL parent_uid = CASE WHEN {PARENT_NOW} LIKE '{LOCAL_VOLUME}~%' THEN NULL
ELSE parent_uid END ELSE {PARENT_NOW} END,
parent_lid = CASE WHEN {PARENT_NOW} LIKE '{LOCAL_VOLUME}~%' THEN NULL
ELSE parent_lid END
WHERE id IN (SELECT id FROM doomed) AND {SENT}" WHERE id IN (SELECT id FROM doomed) AND {SENT}"
), ),
params![uid, lid], params![uid, lid],
@@ -422,7 +428,7 @@ impl Db {
&format!( &format!(
"SELECT EXISTS( "SELECT EXISTS(
SELECT 1 FROM pending_op SELECT 1 FROM pending_op
WHERE kind = ?1 AND parent_uid = ?2 AND name = ?3 WHERE kind = ?1 AND {PARENT_NOW} = ?2 AND name = ?3
AND uid LIKE '{LOCAL_VOLUME}~%' AND uid LIKE '{LOCAL_VOLUME}~%'
)" )"
), ),
@@ -651,8 +657,10 @@ impl Db {
let tx = conn.transaction()?; let tx = conn.transaction()?;
let row: Option<CreateRow> = tx let row: Option<CreateRow> = tx
.query_row( .query_row(
"SELECT blob_path, meta_json, parent_uid, name, created_at &format!(
FROM pending_op WHERE id = ?1 AND kind <> ?2", "SELECT blob_path, meta_json, {PARENT_NOW}, name, created_at
FROM pending_op WHERE id = ?1 AND kind <> ?2"
),
params![id, OP_TRASH], params![id, OP_TRASH],
|r| { |r| {
Ok(CreateRow { Ok(CreateRow {
@@ -1112,13 +1120,13 @@ impl Db {
/// the one query that does. /// the one query that does.
pub fn parked_create_ops(&self) -> Result<Vec<PendingOp>> { pub fn parked_create_ops(&self) -> Result<Vec<PendingOp>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(&format!(
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at, "SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at attempts, last_error, next_attempt_at
FROM pending_op FROM pending_op
WHERE next_attempt_at >= ?1 AND kind IN (?2, ?3) WHERE next_attempt_at >= ?1 AND kind IN (?2, ?3)
ORDER BY created_at", ORDER BY created_at"
)?; ))?;
let rows = stmt let rows = stmt
.query_map(params![PARK_UNTIL, OP_CREATE, OP_MKDIR], |r| { .query_map(params![PARK_UNTIL, OP_CREATE, OP_MKDIR], |r| {
Ok(PendingOp { Ok(PendingOp {
@@ -1300,9 +1308,11 @@ impl Db {
) -> Result<bool> { ) -> Result<bool> {
let conn = self.conn.lock(); let conn = self.conn.lock();
let n = conn.execute( let n = conn.execute(
"UPDATE pending_op SET next_attempt_at = 0 &format!(
WHERE id = ?1 AND kind IN (?4, ?5) AND next_attempt_at < ?6 "UPDATE pending_op SET next_attempt_at = 0
AND (parent_uid IS NOT ?2 OR name IS NOT ?3)", WHERE id = ?1 AND kind IN (?4, ?5) AND next_attempt_at < ?6
AND ({PARENT_NOW} IS NOT ?2 OR name IS NOT ?3)"
),
params![id, parent_uid, name, OP_CREATE, OP_MKDIR, PARK_UNTIL], params![id, parent_uid, name, OP_CREATE, OP_MKDIR, PARK_UNTIL],
)?; )?;
Ok(n > 0) Ok(n > 0)
+15 -13
View File
@@ -64,11 +64,11 @@ impl Db {
} }
/// Whether `uid` is pinned — directly, or because a strict ancestor folder /// Whether `uid` is pinned — directly, or because a strict ancestor folder
/// is pinned recursively. The ancestor check walks `parent_uid` to the root /// is pinned recursively. The ancestor check walks `parent_lid` to the root
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row /// via a CTE; a direct pin is honoured even when the node has no `nodes` row
/// yet (e.g. a CLI that never hydrates the node cache). /// yet (e.g. a CLI that never hydrates the node cache).
/// ///
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_uid` /// The walk is depth-capped like [`super::utils::path_of`]: a parent
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never /// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
/// terminates — while holding the daemon's only SQLite connection, on a path /// terminates — while holding the daemon's only SQLite connection, on a path
/// that runs per cached read. /// that runs per cached read.
@@ -85,11 +85,11 @@ impl Db {
let anc: Option<i64> = conn let anc: Option<i64> = conn
.query_row( .query_row(
&format!( &format!(
"WITH RECURSIVE anc(uid, parent_uid, depth) AS ( "WITH RECURSIVE anc(uid, parent_lid, depth) AS (
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1 SELECT uid, parent_lid, 0 FROM nodes WHERE uid = ?1
UNION ALL UNION ALL
SELECT n.uid, n.parent_uid, anc.depth + 1 SELECT n.uid, n.parent_lid, anc.depth + 1
FROM nodes n JOIN anc ON n.uid = anc.parent_uid FROM nodes n JOIN anc ON n.lid = anc.parent_lid
WHERE anc.depth < {MAX_PATH_DEPTH} WHERE anc.depth < {MAX_PATH_DEPTH}
) )
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
@@ -109,10 +109,11 @@ impl Db {
pub fn pinned_uids(&self) -> Result<Vec<String>> { pub fn pinned_uids(&self) -> Result<Vec<String>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"WITH RECURSIVE sub(uid) AS ( "WITH RECURSIVE sub(uid, lid) AS (
SELECT uid FROM pins WHERE recursive = 1 SELECT p.uid, n.lid FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
WHERE p.recursive = 1
UNION UNION
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
) )
SELECT uid FROM sub SELECT uid FROM sub
UNION UNION
@@ -126,15 +127,16 @@ impl Db {
Ok(out) Ok(out)
} }
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE. /// Every descendant uid of `folder` (all depths), via a `parent_lid` CTE.
/// Used to evict a recursively-pinned subtree's cached blobs on unpin. /// Used to evict a recursively-pinned subtree's cached blobs on unpin.
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> { pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"WITH RECURSIVE sub(uid) AS ( "WITH RECURSIVE sub(uid, lid) AS (
SELECT uid FROM nodes WHERE parent_uid = ?1 SELECT c.uid, c.lid FROM nodes c JOIN nodes f ON c.parent_lid = f.lid
WHERE f.uid = ?1
UNION UNION
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
) )
SELECT uid FROM sub", SELECT uid FROM sub",
)?; )?;
+7 -6
View File
@@ -107,18 +107,19 @@ impl Db {
} }
let value = conn let value = conn
.query_row( .query_row(
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS ( "WITH RECURSIVE ancestors(uid, parent_uid, parent_lid, depth, path) AS (
SELECT uid, parent_uid, 0, char(31) || uid || char(31) SELECT uid, parent_uid, parent_lid, 0, char(31) || uid || char(31)
FROM nodes WHERE uid = ?1 FROM nodes WHERE uid = ?1
UNION ALL UNION ALL
SELECT a.parent_uid, n.parent_uid, a.depth + 1, SELECT COALESCE(n.uid, a.parent_uid), n.parent_uid, n.parent_lid,
a.path || a.parent_uid || char(31) a.depth + 1,
a.path || COALESCE(n.uid, a.parent_uid) || char(31)
FROM ancestors a FROM ancestors a
LEFT JOIN nodes n ON n.uid = a.parent_uid LEFT JOIN nodes n ON n.lid = a.parent_lid
WHERE a.parent_uid IS NOT NULL WHERE a.parent_uid IS NOT NULL
AND instr( AND instr(
a.path, a.path,
char(31) || a.parent_uid || char(31) char(31) || COALESCE(n.uid, a.parent_uid) || char(31)
) = 0 ) = 0
) )
SELECT sa.access SELECT sa.access
+98
View File
@@ -3900,6 +3900,104 @@ fn a_landed_folder_hands_its_children_over_in_the_stored_node_too() {
assert_eq!(names, ["a.txt"]); assert_eq!(names, ["a.txt"]);
} }
/// A folder made here that lands takes its real uid in its own row only. What
/// was made inside it names it by local id, and is found under the real uid.
#[test]
fn a_landed_folder_rewrites_no_row_or_op_below_it() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
let root = uid("root").to_string();
let dir_lid = db.add_local_row(&root, "dir", true, 1).unwrap();
let local_dir = local_uid(dir_lid);
let mut dir = folder("x", Some("root"), "dir");
dir.uid = NodeUid::new(VolumeId::from("local"), LinkId::from(dir_lid.to_string()));
db.upsert_node(&dir).unwrap();
let child_lid = db.add_local_row(&local_dir, "a.txt", false, 1).unwrap();
let local_child = local_uid(child_lid);
let mut child = file("y", "x", "a.txt", 3);
child.uid = NodeUid::new(VolumeId::from("local"), LinkId::from(child_lid.to_string()));
child.parent_uid = Some(dir.uid.clone());
db.upsert_node(&child).unwrap();
db.enqueue_op(&op_on(OP_CREATE, &local_child, Some(&local_dir)))
.unwrap();
let real_dir = uid("dir").to_string();
db.land_placeholder_row(&local_dir, &real_dir).unwrap();
let (stored_parent, op_parent): (String, String) = {
let conn = db.conn.lock();
(
conn.query_row(
"SELECT parent_uid FROM nodes WHERE lid = ?1",
[child_lid],
|r| r.get(0),
)
.unwrap(),
conn.query_row("SELECT parent_uid FROM pending_op", [], |r| r.get(0))
.unwrap(),
)
};
assert_eq!(
(stored_parent.as_str(), op_parent.as_str()),
(local_dir.as_str(), local_dir.as_str()),
"the child's row and op are not rewritten"
);
let children = db.known_children(&uid("dir")).unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].parent_uid, Some(uid("dir")));
assert!(db.has_children(&uid("dir")).unwrap());
assert_eq!(
db.node_path(&local_child).unwrap().as_deref(),
Some("dir/a.txt")
);
assert_eq!(
db.pending_ops().unwrap()[0].parent_uid.as_deref(),
Some(real_dir.as_str()),
"the drain sends the folder's real uid"
);
db.delete_ops_for_uid(&real_dir).unwrap();
assert!(
db.pending_ops().unwrap().is_empty(),
"removing the folder takes the create made inside it"
);
}
/// A create queued inside a folder made here is sent under the folder's real
/// uid once it lands. Landing where it was sent is no move.
#[test]
fn a_create_sent_into_a_landed_folder_lands_where_it_was_queued() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
let dir_lid = db
.add_local_row(&uid("root").to_string(), "dir", true, 1)
.unwrap();
let local_dir = local_uid(dir_lid);
let child_lid = db.add_local_row(&local_dir, "new.txt", false, 1).unwrap();
let local_child = local_uid(child_lid);
let (id, _) = db
.enqueue_op(&op_on(OP_CREATE, &local_child, Some(&local_dir)))
.unwrap();
let real_dir = uid("dir").to_string();
db.land_placeholder_row(&local_dir, &real_dir).unwrap();
let claimed = db.claim_next_due_op(1).unwrap().unwrap();
assert_eq!(claimed.parent_uid.as_deref(), Some(real_dir.as_str()));
let real = uid("new").to_string();
db.finish_create(
id,
Some("/staging/create"),
&landing(&local_child, &real, &real_dir, "new.txt"),
|_| None,
)
.unwrap();
assert!(
db.pending_ops().unwrap().is_empty(),
"no rename is queued for a file that landed where it was sent"
);
}
#[test] #[test]
fn migration_v36_gives_every_node_its_rowid_as_lid() { fn migration_v36_gives_every_node_its_rowid_as_lid() {
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
+6 -6
View File
@@ -72,18 +72,18 @@ pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
} }
} }
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE. /// Resolve a path by walking `parent_lid` to the root via a recursive CTE.
/// ///
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and /// A cycle in the parents is corrupt data the API can still hand us, and
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped /// `UNION ALL` over one never terminates — the walk is therefore depth-capped
/// and returns the truncated path rather than hanging the caller. /// and returns the truncated path rather than hanging the caller.
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> { pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
let mut stmt = conn.prepare(&format!( let mut stmt = conn.prepare(&format!(
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS ( "WITH RECURSIVE anc(parent_lid, parent_uid, name, depth) AS (
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1 SELECT parent_lid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
UNION ALL UNION ALL
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1 SELECT n.parent_lid, n.parent_uid, n.name, anc.depth + 1
FROM nodes n JOIN anc ON n.uid = anc.parent_uid FROM nodes n JOIN anc ON n.lid = anc.parent_lid
WHERE anc.depth < {MAX_PATH_DEPTH} WHERE anc.depth < {MAX_PATH_DEPTH}
) )
SELECT name, parent_uid FROM anc ORDER BY depth DESC" SELECT name, parent_uid FROM anc ORDER BY depth DESC"
+9 -6
View File
@@ -34,17 +34,20 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase
through `parent_lid`, and a landing folder rewrites the ops inside it by local id. through `parent_lid`, and a landing folder rewrites the ops inside it by local id.
- [x] Node rows link to their parent by lid (`nodes.parent_lid`, schema 40), and a restart - [x] Node rows link to their parent by lid (`nodes.parent_lid`, schema 40), and a restart
places each node under its parent's row as it is now. places each node under its parent's row as it is now.
- [ ] Queries over children and subtrees by `parent_lid`, and no rewrite of the children's - [x] Queries over children, subtrees and ancestors by `parent_lid`, and no rewrite of the
`parent_uid` when a folder lands (R3). Both go with the placeholders: until then the children's `parent_uid` when a folder lands (R3), nor of the ops made inside it (R2).
strings are kept in step. - [ ] No more placeholders: a node made here goes by its local id until it lands. In four
- [ ] No more placeholders: `nodes.uid` is `NULL` until a node lands. In four parts: parts:
- [x] 6a: a node made here goes by `local~<lid>`, a function of its row, instead of a - [x] 6a: a node made here goes by `local~<lid>`, a function of its row, instead of a
minted `local~<ms>-<n>`; schema 41 rewrites the old placeholders (nodes, ops, pins). minted `local~<ms>-<n>`; schema 41 rewrites the old placeholders (nodes, ops, pins).
- [x] 6b: an op is found by its node's local id under either uid, the claim keeps one op - [x] 6b: an op is found by its node's local id under either uid, the claim keeps one op
per local id on the wire, and the drain sends the uid the row has now; per local id on the wire, and the drain sends the uid the row has now;
`remap_local_uid` is gone. `remap_local_uid` is gone.
- [ ] 6c: a local node's row has `uid NULL`; children, subtrees and ops' parents by lid, - [x] 6c: children, subtrees, ancestors and ops' parents by lid, so a landing create
so a landing create rewrites no other row or op; pins by lid. rewrites no other row or op; what is read for a uid names the parent as its row has it
now. The row keeps its stand-in in `nodes.uid` rather than `NULL`: it is as stable,
and `NULL` would have changed some 40 node queries for no change in behaviour. Pins
stay keyed by uid and move with the row in the landing transaction (B148).
- [ ] 6d: in memory, `pending`, `hidden` and `creating` by lid; `is_local_uid` checks - [ ] 6d: in memory, `pending`, `hidden` and `creating` by lid; `is_local_uid` checks
become `remote_uid().is_none()`; a write or rename after landing queues under the become `remote_uid().is_none()`; a write or rename after landing queues under the
stand-in, so `landed_uid`, `follow_landed_create`, `landed_row_uid` and the stand-in, so `landed_uid`, `follow_landed_create`, `landed_row_uid` and the