refactor(core,fuse): a node made here goes by its local id until it lands (schema 41)

A file or folder made on the mount used to get a minted `local~<ms>-<n>`
placeholder. It now gets `local~<lid>`, a function of the row it is given
first (`Db::add_local_row`), so the stand-in never changes and always leads
back to the row, and through it to the real uid once the node lands.
`Db::lid_of` reads it without a lookup.

Migration V41 rewrites the placeholders still stored to their row's
stand-in: the row and its node_json, its children, the ops on it or into
it (uid, parent, sidecar uid, a rename's original parent), and its pins.

Step 6a of the plan for 3.0.0 phase 3.
This commit is contained in:
nvrl committed 2026-10-04 12:32:20 +02:00
1 parent 3f74b95b34
commit e5f3f612f8
8 files changed
+312 -58

No files matched your search

+58 -1
View File
@@ -11,7 +11,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 40;
pub(super) const SCHEMA_VERSION: i64 = 41;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -411,6 +411,19 @@ impl Db {
tx.execute_batch(MIGRATION_V40)?;
}
}
if current < 41 {
// Needs both tables; on a database that never had them there is
// nothing to rewrite.
let tables: i64 = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table' AND name IN ('nodes', 'pending_op', 'pins')",
[],
|row| row.get(0),
)?;
if tables == 3 {
tx.execute_batch(MIGRATION_V41)?;
}
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -1245,3 +1258,47 @@ ALTER TABLE nodes ADD COLUMN parent_lid INTEGER;
UPDATE nodes SET parent_lid = (SELECT p.lid FROM nodes p WHERE p.uid = nodes.parent_uid);
CREATE INDEX idx_nodes_parent_lid ON nodes(parent_lid);
";
/// Schema v41: a node made on this machine goes by `local~<lid>` until it
/// lands, a function of its row, where it used to get a `local~<ms>-<n>`
/// placeholder. Every placeholder still stored is rewritten to its row's
/// stand-in: the row itself and its `node_json`, its children, the ops on it
/// or into it, and its pins. A placeholder op with no row keeps its uid and
/// drains as before.
const MIGRATION_V41: &str = "
CREATE TEMP TABLE local_v41 AS
SELECT uid AS old, 'local~' || lid AS new, lid FROM nodes
WHERE uid LIKE 'local~%' AND uid <> 'local~' || lid;
UPDATE pending_op SET uid = (SELECT new FROM local_v41 WHERE old = pending_op.uid)
WHERE uid IN (SELECT old FROM local_v41);
UPDATE pending_op SET parent_uid = (SELECT new FROM local_v41 WHERE old = pending_op.parent_uid)
WHERE parent_uid IN (SELECT old FROM local_v41);
UPDATE pending_op
SET meta_json = json_set(meta_json, '$.uid',
(SELECT new FROM local_v41 WHERE old = json_extract(pending_op.meta_json, '$.uid')))
WHERE json_valid(meta_json)
AND json_extract(meta_json, '$.uid') IN (SELECT old FROM local_v41);
UPDATE pending_op
SET meta_json = json_set(meta_json, '$.original_parent_uid',
(SELECT new FROM local_v41
WHERE old = json_extract(pending_op.meta_json, '$.original_parent_uid')))
WHERE json_valid(meta_json)
AND json_extract(meta_json, '$.original_parent_uid') IN (SELECT old FROM local_v41);
UPDATE pins SET uid = (SELECT new FROM local_v41 WHERE old = pins.uid)
WHERE uid IN (SELECT old FROM local_v41);
UPDATE nodes
SET node_json = json_set(node_json, '$.parent_uid.link_id',
(SELECT CAST(lid AS TEXT) FROM local_v41 WHERE old = nodes.parent_uid))
WHERE parent_uid IN (SELECT old FROM local_v41)
AND json_valid(node_json)
AND json_extract(node_json, '$.parent_uid.volume_id') = 'local';
UPDATE nodes SET parent_uid = (SELECT new FROM local_v41 WHERE old = nodes.parent_uid)
WHERE parent_uid IN (SELECT old FROM local_v41);
UPDATE nodes
SET node_json = json_set(node_json, '$.uid.link_id', CAST(lid AS TEXT))
WHERE lid IN (SELECT lid FROM local_v41)
AND json_valid(node_json)
AND json_extract(node_json, '$.uid.volume_id') = 'local';
UPDATE nodes SET uid = 'local~' || lid WHERE lid IN (SELECT lid FROM local_v41);
DROP TABLE local_v41;
";
+1 -1
View File
@@ -58,7 +58,7 @@ pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
pub use ops::{
AttachedBlob, CreateLanding, CreateRetired, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE,
OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts,
PendingOp, RenameMeta, op_supersedes,
PendingOp, RenameMeta, local_lid, local_uid, op_supersedes,
};
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use pins::PinRow;
+48 -6
View File
@@ -6,7 +6,7 @@ use std::collections::HashSet;
use rusqlite::{OptionalExtension, Transaction, params};
use super::Db;
use super::ops::{OP_CREATE, OP_MKDIR, OP_RENAME, wake_ops_waiting_for_tx};
use super::ops::{OP_CREATE, OP_MKDIR, OP_RENAME, local_lid, local_uid, wake_ops_waiting_for_tx};
use crate::{Access, Result};
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use proton_drive_rs::{Node, NodeKind};
@@ -593,14 +593,21 @@ impl Db {
/// The local id of the node stored under `uid`, or `None` when it has no
/// row. It stays the same for as long as the row exists, including when a
/// create lands and the row takes the uid Drive gave it.
/// create lands and the row takes the uid Drive gave it: a node made on
/// this machine is found by its stand-in ([`local_uid`]) before and after.
pub fn lid_of(&self, uid: &str) -> Result<Option<i64>> {
let conn = self.read();
Ok(conn
.query_row("SELECT lid FROM nodes WHERE uid = ?1", params![uid], |r| {
let row = match local_lid(uid) {
Some(lid) => {
conn.query_row("SELECT lid FROM nodes WHERE lid = ?1", params![lid], |r| {
r.get(0)
})
}
None => conn.query_row("SELECT lid FROM nodes WHERE uid = ?1", params![uid], |r| {
r.get(0)
})
.optional()?)
}),
};
Ok(row.optional()?)
}
/// The local id of each node, in order, giving a row to each that has none.
@@ -644,6 +651,41 @@ impl Db {
Ok(lids)
}
/// Give a node being made on this machine its row under `parent_uid`, and
/// return its local id. The row goes by [`local_uid`] of that id until
/// the node lands.
///
/// Like [`Db::lids_for`], only a stub: the caller upserts the node.
pub fn add_local_row(
&self,
parent_uid: &str,
name: &str,
is_dir: bool,
mtime: i64,
) -> Result<i64> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
VALUES (NULL, ?1, ?2, ?3, ?4, 0,
COALESCE(?5, (SELECT lid FROM nodes WHERE uid = ?1)))",
params![
parent_uid,
name,
is_dir as i64,
mtime,
local_lid(parent_uid)
],
)?;
let lid = tx.last_insert_rowid();
tx.execute(
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
params![lid, local_uid(lid)],
)?;
tx.commit()?;
Ok(lid)
}
/// Read the persisted incremental-sync cursor (a `DriveEventId`), if any.
/// The daemon resumes from this on restart instead of reseeding to the
/// server head, so changes made while unmounted are still applied (P2).
+21 -1
View File
@@ -205,13 +205,33 @@ const PARENT_NOW: &str =
"COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)";
/// The volume id given to a node that exists only on this machine, so far. A
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<uuid>` and
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<lid>` and
/// round-trips through the same `Display`/parse path as any other uid.
///
/// Nothing bearing this volume may be handed to the API — it would 404. The
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// The uid a node made on this machine goes by until Drive gives it one: its
/// row's local id under [`LOCAL_VOLUME`].
///
/// A function of the row, so it never changes: whatever still holds it once
/// the node has landed finds the node, and its real uid, through
/// [`local_lid`].
pub fn local_uid(lid: i64) -> String {
format!("{LOCAL_VOLUME}~{lid}")
}
/// The local id a [`local_uid`] stands for, or `None` for any other uid,
/// including a placeholder minted before schema 41.
pub fn local_lid(uid: &str) -> Option<i64> {
let digits = uid.strip_prefix(LOCAL_VOLUME)?.strip_prefix('~')?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
/// A `next_attempt_at` far enough in the future that [`Db::next_due_op`] never
/// selects the op (ms since epoch, ≈ year 2223). Used to *park* a queued create
/// for a transient file — a browser's in-flight `*.crdownload`/`*.part`, an
+147 -14
View File
@@ -3897,15 +3897,25 @@ fn migration_v36_gives_every_node_its_rowid_as_lid() {
assert_eq!(rowids.len(), 3);
let db = Db::open(&path).unwrap();
// The placeholder goes by its row's stand-in from V41 on.
let stand_in = |uid: &str, rowid: i64| match uid == local.to_string() {
true => local_uid(rowid),
false => uid.to_string(),
};
for (uid, rowid) in &rowids {
assert_eq!(db.lid_of(uid).unwrap(), Some(*rowid), "{uid}");
let uid = stand_in(uid, *rowid);
assert_eq!(db.lid_of(&uid).unwrap(), Some(*rowid), "{uid}");
}
let found = db.search("report", 10).unwrap();
assert_eq!(found.len(), 1, "the search index still finds the node");
assert_eq!(found[0].node.uid, uid("kept"));
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].uid, local.to_string());
let (_, dir) = rowids
.iter()
.find(|(uid, _)| *uid == local.to_string())
.unwrap();
assert_eq!(ops[0].uid, local_uid(*dir));
assert_eq!(ops[0].attempts, 1);
assert_eq!(ops[0].last_error.as_deref(), Some("network unreachable"));
@@ -3916,6 +3926,93 @@ fn migration_v36_gives_every_node_its_rowid_as_lid() {
let _ = std::fs::remove_file(&path);
}
#[test]
fn migration_v41_gives_every_placeholder_its_rows_stand_in() {
let path = std::env::temp_dir().join(format!(
"pdfs-db-v40-fixture-{}-{}.db",
std::process::id(),
now_test_id()
));
let _ = std::fs::remove_file(&path);
let (dir, inner) = {
let db = Db::open(&path).unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
let old_dir = NodeUid::new(VolumeId::from("local"), LinkId::from("1700000000000-0"));
let old_inner = NodeUid::new(VolumeId::from("local"), LinkId::from("1700000000000-1"));
let mut dir = folder("x", Some("root"), "Offline folder");
dir.uid = old_dir.clone();
db.upsert_node(&dir).unwrap();
let mut inner = file("x", "root", "inner.txt", 0);
inner.uid = old_inner.clone();
inner.parent_uid = Some(old_dir.clone());
db.upsert_node(&inner).unwrap();
let op = |kind: &str, uid: &NodeUid, parent: &str, meta: Option<String>| PendingOp {
id: 0,
kind: kind.to_string(),
uid: uid.to_string(),
parent_uid: Some(parent.to_string()),
name: Some("n".to_string()),
blob_path: None,
meta_json: meta,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
db.enqueue_op(&op(OP_MKDIR, &old_dir, &uid("root").to_string(), None))
.unwrap();
let staged = json!({"uid": old_inner.to_string(), "len": 0}).to_string();
db.enqueue_op(&op(
OP_CREATE,
&old_inner,
&old_dir.to_string(),
Some(staged),
))
.unwrap();
db.pin_add(&old_dir.to_string(), "/Offline folder", true)
.unwrap();
let lids = (
db.lid_of(&old_dir.to_string()).unwrap().unwrap(),
db.lid_of(&old_inner.to_string()).unwrap().unwrap(),
);
drop(db);
rusqlite::Connection::open(&path)
.unwrap()
.execute(
"UPDATE sync_state SET value = '40' WHERE key = 'schema_version'",
[],
)
.unwrap();
lids
};
let db = Db::open(&path).unwrap();
let (dir_uid, inner_uid) = (local_uid(dir), local_uid(inner));
assert_eq!(db.uid_of_lid(dir).unwrap(), Some(dir_uid.clone()));
assert_eq!(db.uid_of_lid(inner).unwrap(), Some(inner_uid.clone()));
let stored = db.node_by_uid(&inner_uid).unwrap().unwrap();
assert_eq!(
stored.uid.to_string(),
inner_uid,
"node_json follows the row"
);
assert_eq!(
stored.parent_uid.map(|p| p.to_string()),
Some(dir_uid.clone())
);
let ops = db.pending_ops().unwrap();
assert_eq!(ops[0].uid, dir_uid);
assert_eq!(ops[1].uid, inner_uid);
assert_eq!(ops[1].parent_uid, Some(dir_uid.clone()));
let staged: serde_json::Value =
serde_json::from_str(ops[1].meta_json.as_deref().unwrap()).unwrap();
assert_eq!(staged["uid"], inner_uid.as_str());
let pins: Vec<String> = db.pin_list().unwrap().into_iter().map(|p| p.uid).collect();
assert_eq!(pins, vec![dir_uid]);
drop(db);
let _ = std::fs::remove_file(&path);
}
/// A database as 2.8.2 wrote it (`sqlite3 .dump`, schema 35), with queued ops
/// in every state the drain leaves them in: due, parked, failing, deferred for
/// access, and claimed by a worker when the daemon stopped. The creates below
@@ -3935,36 +4032,45 @@ fn a_2_8_database_migrates_with_every_node_and_op_intact() {
let (nodes, ops) = migrates_intact(&path);
assert_eq!((nodes, ops), (8, 6));
let db = Db::open(&path).unwrap();
let lid_named = |name: &str| -> i64 {
db.conn
.lock()
.query_row("SELECT lid FROM nodes WHERE name = ?1", [name], |r| {
r.get(0)
})
.unwrap()
};
let (dir, inner_lid) = (lid_named("Offline folder"), lid_named("inner.txt"));
let queued = db.pending_ops().unwrap();
let inner = queued.iter().find(|op| op.uid == "local~inner").unwrap();
assert_eq!(inner.parent_uid.as_deref(), Some("local~dir"));
let inner = queued
.iter()
.find(|op| op.uid == local_uid(inner_lid))
.unwrap();
assert_eq!(inner.parent_uid, Some(local_uid(dir)));
let lids: (Option<i64>, Option<i64>) = db
.conn
.lock()
.query_row(
"SELECT lid, parent_lid FROM pending_op WHERE uid = 'local~inner'",
[],
"SELECT lid, parent_lid FROM pending_op WHERE id = ?1",
[inner.id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(
lids,
(
db.lid_of("local~inner").unwrap(),
db.lid_of("local~dir").unwrap()
),
(Some(inner_lid), Some(dir)),
"the migration names the op's node and parent by local id"
);
let parent_lid: Option<i64> = db
.conn
.lock()
.query_row(
"SELECT parent_lid FROM nodes WHERE uid = 'local~inner'",
[],
"SELECT parent_lid FROM nodes WHERE lid = ?1",
[inner_lid],
|r| r.get(0),
)
.unwrap();
assert_eq!(parent_lid, db.lid_of("local~dir").unwrap());
assert_eq!(parent_lid, Some(dir));
let parked = db.parked_create_ops().unwrap();
assert_eq!(parked.len(), 1);
assert_eq!(parked[0].name.as_deref(), Some("movie.part"));
@@ -3997,7 +4103,7 @@ fn a_copy_of_a_real_database_migrates_with_every_node_and_op_intact() {
/// Opens the database at `path` and checks that the migration kept every node
/// on its row id and every queued op as it was. Returns how many of each.
fn migrates_intact(path: &std::path::Path) -> (usize, usize) {
let (version, nodes, ops, sealed) = {
let (version, nodes, mut ops, sealed) = {
let conn = rusqlite::Connection::open(path).unwrap();
let version: String = conn
.query_row(
@@ -4017,6 +4123,33 @@ fn migrates_intact(path: &std::path::Path) -> (usize, usize) {
let sealed = rows(&conn, "SELECT * FROM own_sealed_rev ORDER BY uid");
(version, nodes, ops, sealed)
};
// A node made on this machine goes by its row's stand-in from schema 41
// on, in its ops as everywhere else.
let stand_ins: Vec<(String, String)> = nodes
.iter()
.filter(|(uid, rowid)| uid.starts_with("local~") && *uid != local_uid(*rowid))
.map(|(uid, rowid)| (uid.clone(), local_uid(*rowid)))
.collect();
let nodes: Vec<(String, i64)> = nodes
.into_iter()
.map(
|(uid, rowid)| match stand_ins.iter().any(|(old, _)| *old == uid) {
true => (local_uid(rowid), rowid),
false => (uid, rowid),
},
)
.collect();
for value in ops.iter_mut().flatten() {
if let rusqlite::types::Value::Text(text) = value {
for (old, new) in &stand_ins {
if text == old {
*text = new.clone();
} else {
*text = text.replace(&format!("\"{old}\""), &format!("\"{new}\""));
}
}
}
}
let db = Db::open(path).unwrap();
{
let conn = rusqlite::Connection::open(path).unwrap();
+26 -32
View File
@@ -3650,14 +3650,17 @@ impl Core {
hold: bool,
) -> Result<Node, Errno> {
self.require_uid_writable(parent_uid)?;
let uid = mint_local_uid();
// The row the tree would give it anyway, now: the node goes by its
// local id until it lands.
let lid = self
.db
.add_local_row(&parent_uid.to_string(), name, is_dir, now_secs())
.map_err(|e| {
error!(%parent_uid, name, error = %e, "giving a local node its row failed");
Errno::EIO
})?;
let uid = local_node_uid(lid);
let node = local_node(uid.clone(), parent_uid.clone(), name.to_string(), is_dir);
// The row the tree would give it anyway, now, so the op is queued with
// the node's local id.
self.db.lids_for(&[&node]).map_err(|e| {
error!(%parent_uid, name, error = %e, "giving a local node its row failed");
Errno::EIO
})?;
let op = PendingOp {
id: 0,
kind: if is_dir { OP_MKDIR } else { OP_CREATE }.to_string(),
@@ -5752,21 +5755,12 @@ fn parse_node_uid(s: &str) -> Option<NodeUid> {
parse_uid(s)
}
/// Distinguishes placeholder uids minted by [`mint_local_uid`] within one run.
static LOCAL_UID_SEQ: AtomicU64 = AtomicU64::new(0);
/// Invent a uid for a node created while offline, so it can be interned, listed
/// and written to before the server has ever heard of it (offline.md Phase 3b).
///
/// Uniqueness only has to hold among this machine's undrained ops, so the clock
/// (which separates runs) plus a counter (which separates nodes within a run) is
/// enough without taking on a uuid dependency.
fn mint_local_uid() -> NodeUid {
let seq = LOCAL_UID_SEQ.fetch_add(1, Ordering::Relaxed);
NodeUid::new(
VolumeId::from(LOCAL_VOLUME),
LinkId::from(format!("{}-{seq}", now_millis())),
)
/// The uid a node made on this machine goes by until it lands, so it can be
/// interned, listed and written to before the server has ever heard of it
/// (offline.md Phase 3b): its row's local id, as [`pdfs_core::db::local_uid`]
/// has it.
fn local_node_uid(lid: i64) -> NodeUid {
NodeUid::new(VolumeId::from(LOCAL_VOLUME), LinkId::from(lid.to_string()))
}
/// Whether this node exists only on this machine, so far. Such a uid is
@@ -7022,24 +7016,24 @@ mod local_uid_tests {
use super::*;
#[test]
fn a_minted_uid_is_recognisable_and_round_trips() {
let uid = mint_local_uid();
fn a_local_node_uid_is_recognisable_and_round_trips() {
let uid = local_node_uid(42);
assert!(is_local_uid(&uid));
assert!(is_local_uid_str(&uid.to_string()));
assert_eq!(uid.to_string(), pdfs_core::db::local_uid(42));
// It has to survive the trip through `pending_op.uid` as text, like any
// other uid does.
// other uid does, and give back the row it stands for.
let parsed = parse_node_uid(&uid.to_string()).expect("parses back");
assert_eq!(parsed, uid);
assert_eq!(pdfs_core::db::local_lid(&uid.to_string()), Some(42));
}
#[test]
fn minted_uids_are_distinct_within_a_run() {
fn local_node_uids_are_distinct_per_row() {
// Two files created in the same millisecond must not collide — the whole
// queue is keyed by uid.
let a = mint_local_uid();
let b = mint_local_uid();
assert_ne!(a, b);
assert_ne!(local_node_uid(1), local_node_uid(2));
}
#[test]
@@ -7056,7 +7050,7 @@ mod local_uid_tests {
#[test]
fn a_placeholder_file_reports_itself_as_empty_and_unsealed() {
let parent = NodeUid::new(VolumeId::from("vol1"), LinkId::from("dir"));
let node = local_node(mint_local_uid(), parent.clone(), "notes.txt".into(), false);
let node = local_node(local_node_uid(7), parent.clone(), "notes.txt".into(), false);
assert_eq!(node.name, "notes.txt");
assert_eq!(node.parent_uid, Some(parent));
@@ -7080,7 +7074,7 @@ mod local_uid_tests {
#[test]
fn a_placeholder_folder_is_a_folder() {
let parent = NodeUid::new(VolumeId::from("vol1"), LinkId::from("root"));
let node = local_node(mint_local_uid(), parent, "photos".into(), true);
let node = local_node(local_node_uid(7), parent, "photos".into(), true);
assert!(node.is_folder());
}
}
@@ -8552,7 +8546,7 @@ mod tests {
assert_before(truncate, "require_uid_writable", "create_scratch");
for (signature, first_side_effect) in [
("fn queue_local_node(", "mint_local_uid"),
("fn queue_local_node(", "add_local_row"),
("fn queue_rename(", "queue_rename_authorized"),
("fn queue_trash(", "replace_ops_with_trash"),
] {
+3 -2
View File
@@ -11,10 +11,11 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
Schema: **40**. The queue table is rebuilt so an id is never given out twice; queued changes
Schema: **41**. The queue table is rebuilt so an id is never given out twice; queued changes
are kept. Queued changes get a column for the reason Drive refused them, and name their file
and folder by local id as well as by Drive's id. Stored files and folders name their parent
folder by local id too.
folder by local id too. A file or folder made on this machine that Drive does not have yet goes
by its local id, in the queue, the stored tree and pins alike.
### Added
- **Sync issues.** A change Drive refuses because the storage is full, access was taken away,
+8 -1
View File
@@ -37,7 +37,14 @@ Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase
- [ ] Queries over children and subtrees by `parent_lid`, and no rewrite of the children's
`parent_uid` when a folder lands (R3). Both go with the placeholders: until then the
strings are kept in step.
- [ ] No more placeholders: `nodes.uid` is `NULL` until a node lands.
- [ ] No more placeholders: `nodes.uid` is `NULL` until a node lands. In four parts:
- [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).
- [ ] 6b: ops by lid; a landing create rewrites no other op.
- [ ] 6c: a local node's row has `uid NULL`; children and subtrees by `parent_lid`; pins
by lid.
- [ ] 6d: in memory, `pending`, `hidden` and `creating` by lid; `is_local_uid` checks
become `remote_uid().is_none()`.
- [ ] An executor that runs ops in dependency order, many at once.
3. **Speed (§7)**: run independent ops in parallel, batch trashes and moves; measure 1,000 files
and 100 folders on Wi-Fi against LAN (target: within 10 %).