Skip to content

Commit 222ad32

Browse files
committed
feat(crdt): wire document-row DocUpsert/DocDelete ops end-to-end
Adds CrdtOp::DocUpsert/DocDelete physical ops for scalar field insert/replace/partial-update/delete on top-level LoroMap rows in crdt=true document collections, and threads them through every layer a Data Plane write touches: a new self-describing CrdtDocOp WAL record (intent-carrying, since the Control Plane has no LoroDoc to diff), replay handlers that re-run the same live upsert/delete logic, Raft WAL replication encode/decode, dispatch routing, write-class/ permission classification, CDC change-event extraction, and response shaping.
1 parent 5ead0c6 commit 222ad32

33 files changed

Lines changed: 1141 additions & 32 deletions

File tree

nodedb-crdt/src/state/core.rs

Lines changed: 110 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,48 @@ impl CrdtState {
5050
Ok(Self { doc, peer_id })
5151
}
5252

53+
/// Fetch a row's existing `LoroMap` container, or create one if absent.
54+
/// Shared by `upsert` and `set_fields` — both need the same row handle
55+
/// before diverging on prune-vs-preserve semantics.
56+
fn row_container(&self, collection: &str, row_id: &str) -> Result<LoroMap> {
57+
let coll = self.doc.get_map(collection);
58+
match coll.get(row_id) {
59+
Some(ValueOrContainer::Container(loro::Container::Map(m))) => Ok(m),
60+
_ => coll
61+
.insert_container(row_id, LoroMap::new())
62+
.map_err(|e| CrdtError::Loro(e.to_string())),
63+
}
64+
}
65+
66+
/// Write `fields` onto `row_container` as scalar LWW inserts, rejecting
67+
/// any key that currently holds a container value. Shared by `upsert`
68+
/// and `set_fields` — both write the same way, only the prune step
69+
/// (upsert-only) differs.
70+
fn write_scalar_fields(
71+
row_container: &LoroMap,
72+
collection: &str,
73+
row_id: &str,
74+
fields: &[(&str, LoroValue)],
75+
) -> Result<()> {
76+
for (field, value) in fields {
77+
// A container-valued key can never legitimately appear in the
78+
// incoming scalar projection. Overwriting one would destroy the
79+
// nested container; skipping it would silently discard the
80+
// caller's write. Reject instead of doing either.
81+
if key_is_container(row_container, field) {
82+
return Err(CrdtError::ScalarFieldShadowsContainer {
83+
collection: collection.to_string(),
84+
row_id: row_id.to_string(),
85+
field: (*field).to_string(),
86+
});
87+
}
88+
row_container
89+
.insert(field, value.clone())
90+
.map_err(|e| CrdtError::Loro(e.to_string()))?;
91+
}
92+
Ok(())
93+
}
94+
5395
/// Insert or update a row in a collection.
5496
///
5597
/// This is a REPLACE for scalar fields — every caller passes the
@@ -66,13 +108,7 @@ impl CrdtState {
66108
row_id: &str,
67109
fields: &[(&str, LoroValue)],
68110
) -> Result<()> {
69-
let coll = self.doc.get_map(collection);
70-
let row_container = match coll.get(row_id) {
71-
Some(ValueOrContainer::Container(loro::Container::Map(m))) => m,
72-
_ => coll
73-
.insert_container(row_id, LoroMap::new())
74-
.map_err(|e| CrdtError::Loro(e.to_string()))?,
75-
};
111+
let row_container = self.row_container(collection, row_id)?;
76112

77113
let incoming_keys: HashSet<&str> = fields.iter().map(|(field, _)| *field).collect();
78114

@@ -94,23 +130,21 @@ impl CrdtState {
94130
.map_err(|e| CrdtError::Loro(e.to_string()))?;
95131
}
96132

97-
for (field, value) in fields {
98-
// A container-valued key can never legitimately appear in the
99-
// incoming scalar projection. Overwriting one would destroy the
100-
// nested container; skipping it would silently discard the
101-
// caller's write. Reject instead of doing either.
102-
if key_is_container(&row_container, field) {
103-
return Err(CrdtError::ScalarFieldShadowsContainer {
104-
collection: collection.to_string(),
105-
row_id: row_id.to_string(),
106-
field: (*field).to_string(),
107-
});
108-
}
109-
row_container
110-
.insert(field, value.clone())
111-
.map_err(|e| CrdtError::Loro(e.to_string()))?;
112-
}
113-
Ok(())
133+
Self::write_scalar_fields(&row_container, collection, row_id, fields)
134+
}
135+
136+
/// Partial-merge write: set exactly the provided scalar `fields` on a row
137+
/// (LWW-per-field), creating the row if absent, leaving every untouched
138+
/// key intact. This is `upsert` WITHOUT the full-projection prune step —
139+
/// the UPDATE-SET semantic for `CrdtOp::DocUpsert { partial: true }`.
140+
pub fn set_fields(
141+
&self,
142+
collection: &str,
143+
row_id: &str,
144+
fields: &[(&str, LoroValue)],
145+
) -> Result<()> {
146+
let row_container = self.row_container(collection, row_id)?;
147+
Self::write_scalar_fields(&row_container, collection, row_id, fields)
114148
}
115149

116150
/// Delete a row from a collection.
@@ -315,3 +349,55 @@ impl RowLookup for CrdtState {
315349
self.field_value_exists_live(collection, field, value, exclude_row_id)
316350
}
317351
}
352+
353+
#[cfg(test)]
354+
mod tests {
355+
use super::*;
356+
357+
const COLL: &str = "c";
358+
const ROW: &str = "r";
359+
360+
#[test]
361+
fn set_fields_preserves_untouched_keys_and_upsert_prunes() {
362+
let state = CrdtState::new(0).expect("state");
363+
364+
// Full projection {a:1, b:2}.
365+
state
366+
.upsert(
367+
COLL,
368+
ROW,
369+
&[("a", LoroValue::I64(1)), ("b", LoroValue::I64(2))],
370+
)
371+
.expect("upsert");
372+
373+
// Partial-merge {b:9}: `a` must survive untouched, `b` overwritten.
374+
state
375+
.set_fields(COLL, ROW, &[("b", LoroValue::I64(9))])
376+
.expect("set_fields");
377+
assert_eq!(
378+
state.read_field(COLL, ROW, "a"),
379+
Some(LoroValue::I64(1)),
380+
"set_fields must leave the untouched key `a` intact"
381+
);
382+
assert_eq!(
383+
state.read_field(COLL, ROW, "b"),
384+
Some(LoroValue::I64(9)),
385+
"set_fields must overwrite `b` to 9"
386+
);
387+
388+
// Full-projection replace {a:5}: absent key `b` must be pruned.
389+
state
390+
.upsert(COLL, ROW, &[("a", LoroValue::I64(5))])
391+
.expect("upsert replace");
392+
assert_eq!(
393+
state.read_field(COLL, ROW, "a"),
394+
Some(LoroValue::I64(5)),
395+
"upsert must set `a` to 5"
396+
);
397+
assert_eq!(
398+
state.read_field(COLL, ROW, "b"),
399+
None,
400+
"upsert must prune key `b` absent from the projection"
401+
);
402+
}
403+
}

nodedb-physical/src/physical_plan/crdt.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,31 @@ pub enum CrdtOp {
180180
/// Surrogate of the parent document hosting this block list.
181181
surrogate: Surrogate,
182182
},
183+
184+
// ─── Document row (top-level LoroMap) field-carrying ops ─────────
185+
/// Insert-or-replace / partial-update a document row's scalar fields,
186+
/// server-built from `fields_json` (a JSON object). Mirrors the
187+
/// `ListInsert` intent-carrying contract: the Control Plane has no
188+
/// `LoroDoc`, so the Data Plane handler builds the Loro mutation.
189+
///
190+
/// `partial = false`: INSERT / full replace — scalar keys absent from
191+
/// `fields_json` are pruned (LWW full-projection replace).
192+
/// `partial = true`: UPDATE SET — only the provided fields are written
193+
/// (LWW-per-field), untouched keys survive.
194+
///
195+
/// `surrogate` is this row's OWN stable top-level cross-engine identity.
196+
DocUpsert {
197+
collection: String,
198+
document_id: String,
199+
fields_json: String,
200+
surrogate: Surrogate,
201+
partial: bool,
202+
},
203+
/// Delete a document row: tombstone in the collection's Loro doc + remove
204+
/// from the sparse document store.
205+
DocDelete {
206+
collection: String,
207+
document_id: String,
208+
surrogate: Surrogate,
209+
},
183210
}

nodedb-wal/src/record/types.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ pub enum RecordType {
9393
/// was acknowledged to the client.
9494
CrdtListOp = 21 | 0x8000,
9595

96+
/// CRDT engine: document-row intent op (`DocUpsert` / `DocDelete`) —
97+
/// field-carrying insert-or-replace / partial-update / delete of a
98+
/// top-level `LoroMap` row for SQL DML on a `crdt='true'` collection.
99+
///
100+
/// Like `CrdtListOp`, the Data Plane builds the Loro mutation server-side
101+
/// and the Control Plane has no `LoroDoc` to compute a delta from, so the
102+
/// record carries the **intent** (collection, document, surrogate, fields,
103+
/// partial flag) rather than a Loro delta; replay re-executes the exact
104+
/// same live handler that ran on first application.
105+
///
106+
/// Required: skipping this record on replay silently drops an
107+
/// acknowledged document write, diverging row content from what was
108+
/// acknowledged to the client.
109+
CrdtDocOp = 22 | 0x8000,
110+
96111
/// Timeseries engine: metric sample batch.
97112
TimeseriesBatch = 30,
98113

@@ -293,6 +308,7 @@ impl RecordType {
293308
x if x == 17 | 0x8000 => Some(Self::MultiVectorDelete),
294309
x if x == 20 | 0x8000 => Some(Self::CrdtDelta),
295310
x if x == 21 | 0x8000 => Some(Self::CrdtListOp),
311+
x if x == 22 | 0x8000 => Some(Self::CrdtDocOp),
296312
x if x == 50 | 0x8000 => Some(Self::Transaction),
297313
x if x == 58 | 0x8000 => Some(Self::TransactionRedo),
298314
x if x == 51 | 0x8000 => Some(Self::SurrogateAlloc),
@@ -346,6 +362,7 @@ mod tests {
346362
RecordType::GraphNodeLabelRemove as u32
347363
));
348364
assert!(RecordType::is_required(RecordType::CrdtListOp as u32));
365+
assert!(RecordType::is_required(RecordType::CrdtDocOp as u32));
349366
}
350367

351368
#[test]
@@ -364,6 +381,7 @@ mod tests {
364381
RecordType::MultiVectorDelete,
365382
RecordType::CrdtDelta,
366383
RecordType::CrdtListOp,
384+
RecordType::CrdtDocOp,
367385
RecordType::TimeseriesBatch,
368386
RecordType::LogBatch,
369387
RecordType::ArrayPut,

nodedb/src/control/cluster/calvin/scheduler/driver/core/routing.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ fn crdt_routing(op: &CrdtOp) -> PlanRouting {
253253
| CrdtOp::ListInsert { collection, .. }
254254
| CrdtOp::ListDelete { collection, .. }
255255
| CrdtOp::ListMove { collection, .. }
256+
| CrdtOp::DocUpsert { collection, .. }
257+
| CrdtOp::DocDelete { collection, .. }
256258
| CrdtOp::SetConstraints { collection, .. }
257259
| CrdtOp::DropConstraints { collection, .. }
258260
| CrdtOp::RestoreToVersion { collection, .. }

nodedb/src/control/gateway/version_set.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,9 @@ pub fn touched_collections(plan: &PhysicalPlan) -> Vec<String> {
330330
| RestoreToVersion { collection, .. }
331331
| ListInsert { collection, .. }
332332
| ListDelete { collection, .. }
333-
| ListMove { collection, .. } => out.push(collection.clone()),
333+
| ListMove { collection, .. }
334+
| DocUpsert { collection, .. }
335+
| DocDelete { collection, .. } => out.push(collection.clone()),
334336

335337
// `ImportSnapshot` is a whole-tenant Loro import and
336338
// `SetConstraints` / `DropConstraints` install validator rules —

nodedb/src/control/planner/calvin/write_class.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ fn crdt_is_write(op: &CrdtOp) -> bool {
199199
| CrdtOp::ListInsert { .. }
200200
| CrdtOp::ListDelete { .. }
201201
| CrdtOp::ListMove { .. }
202+
| CrdtOp::DocUpsert { .. }
203+
| CrdtOp::DocDelete { .. }
202204
| CrdtOp::SetConstraints { .. }
203205
| CrdtOp::DropConstraints { .. }
204206
| CrdtOp::RestoreToVersion { .. }

nodedb/src/control/security/identity/plan_permission.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ pub fn required_permission(plan: &crate::bridge::envelope::PhysicalPlan) -> Perm
118118
| CrdtOp::RestoreToVersion { .. }
119119
| CrdtOp::ListInsert { .. }
120120
| CrdtOp::ListDelete { .. }
121-
| CrdtOp::ListMove { .. },
121+
| CrdtOp::ListMove { .. }
122+
| CrdtOp::DocUpsert { .. }
123+
| CrdtOp::DocDelete { .. },
122124
) => Permission::Write,
123125

124126
PhysicalPlan::Vector(

nodedb/src/control/server/dispatch_utils/change_events/extract.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,31 @@ pub(super) fn extract_write_metadata(
361361
PhysicalPlan::Crdt(CrdtOp::ImportSnapshot { collection, .. }) => {
362362
vec![(collection.clone(), "*".into(), ChangeOperation::Update)]
363363
}
364+
// Document-row field-carrying ops: a full replace / partial-update is
365+
// an Insert / Update respectively; a delete is a Delete.
366+
PhysicalPlan::Crdt(CrdtOp::DocUpsert {
367+
collection,
368+
document_id,
369+
partial,
370+
..
371+
}) => vec![(
372+
collection.clone(),
373+
document_id.clone(),
374+
if *partial {
375+
ChangeOperation::Update
376+
} else {
377+
ChangeOperation::Insert
378+
},
379+
)],
380+
PhysicalPlan::Crdt(CrdtOp::DocDelete {
381+
collection,
382+
document_id,
383+
..
384+
}) => vec![(
385+
collection.clone(),
386+
document_id.clone(),
387+
ChangeOperation::Delete,
388+
)],
364389
// Read (Read/ReadConstraints/GetPolicy/ReadAtVersion/
365390
// GetVersionVector/ExportDelta), history maintenance
366391
// (CompactAtVersion), and config/DDL (SetConstraints/

nodedb/src/control/server/response_shape/types.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ pub fn describe_plan(plan: &PhysicalPlan) -> PlanKind {
3131
match plan {
3232
PhysicalPlan::Document(DocumentOp::PointGet { .. })
3333
| PhysicalPlan::Crdt(CrdtOp::Read { .. })
34-
| PhysicalPlan::Crdt(CrdtOp::GetPolicy { .. }) => PlanKind::SingleDocument,
34+
| PhysicalPlan::Crdt(CrdtOp::GetPolicy { .. })
35+
| PhysicalPlan::Crdt(CrdtOp::DocUpsert { .. })
36+
| PhysicalPlan::Crdt(CrdtOp::DocDelete { .. }) => PlanKind::SingleDocument,
3537

3638
PhysicalPlan::Vector(VectorOp::Search { .. })
3739
| PhysicalPlan::Vector(VectorOp::MultiSearch { .. })

nodedb/src/control/server/shared/plan_util.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ pub(crate) fn extract_collection(plan: &PhysicalPlan) -> Option<&str> {
1919
| PhysicalPlan::Document(DocumentOp::RangeScan { collection, .. })
2020
| PhysicalPlan::Crdt(CrdtOp::Read { collection, .. })
2121
| PhysicalPlan::Crdt(CrdtOp::Apply { collection, .. })
22+
| PhysicalPlan::Crdt(CrdtOp::DocUpsert { collection, .. })
23+
| PhysicalPlan::Crdt(CrdtOp::DocDelete { collection, .. })
2224
| PhysicalPlan::Vector(VectorOp::Insert { collection, .. })
2325
| PhysicalPlan::Vector(VectorOp::BatchInsert { collection, .. })
2426
| PhysicalPlan::Vector(VectorOp::MultiSearch { collection, .. })

0 commit comments

Comments
 (0)