Skip to content

Commit 5ead0c6

Browse files
committed
feat(control): support WITH (crdt=true) on document collections
Adds a persisted `crdt` flag to StoredCollection/CollectionDescriptor so document collections can opt into CRDT (Loro) storage at CREATE time and have that choice sync across nodes. DDL validates that `crdt=true` is document-engine-only, the descriptor conversion carries the flag over sync, and the doc-config builder now honors it instead of hardcoding CRDT off.
1 parent ef46ac6 commit 5ead0c6

8 files changed

Lines changed: 117 additions & 3 deletions

File tree

nodedb-types/src/sync/wire/collection_schema.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ pub struct CollectionDescriptor {
3535
/// Whether the collection tracks system-time + valid-time versions.
3636
#[msgpack(default)]
3737
pub bitemporal: bool,
38+
/// Whether this collection uses CRDT (Loro) storage for offline-first sync.
39+
#[msgpack(default)]
40+
pub crdt: bool,
3841
/// Lightweight field type hints, e.g. `[("email", "string")]`.
3942
#[msgpack(default)]
4043
pub fields: Vec<(String, String)>,
@@ -83,6 +86,7 @@ mod tests {
8386
name: "users".into(),
8487
collection_type: CollectionType::document(),
8588
bitemporal: true,
89+
crdt: true,
8690
fields: vec![("email".into(), "string".into())],
8791
primary: PrimaryEngine::Document,
8892
vector_primary: None,
@@ -105,6 +109,7 @@ mod tests {
105109
assert_eq!(decoded_msg.descriptor.name, "users");
106110
assert_eq!(decoded_msg.descriptor.tenant_id, 7);
107111
assert!(decoded_msg.descriptor.bitemporal);
112+
assert!(decoded_msg.descriptor.crdt);
108113
assert_eq!(decoded_msg.descriptor.primary, PrimaryEngine::Document);
109114
}
110115

nodedb/src/control/security/catalog/collection.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ pub struct StoredCollection {
146146
/// other engines ignore it.
147147
#[msgpack(default)]
148148
pub bitemporal: bool,
149+
/// Whether this collection uses CRDT (Loro) storage for offline-first
150+
/// sync. Document-engine-only: `CREATE COLLECTION ... WITH (crdt=true)`
151+
/// is rejected at DDL time for any other collection type, so a `true`
152+
/// value here always implies a document collection.
153+
#[msgpack(default)]
154+
pub crdt: bool,
149155
/// Durable CRDT conflict-resolution policy (JSON-serialized
150156
/// `nodedb_crdt::policy::CollectionPolicy`), set via
151157
/// `ALTER COLLECTION ... SET ON CONFLICT ... FOR ...`. `None` = no
@@ -292,6 +298,7 @@ impl StoredCollection {
292298
materialized_sums: Vec::new(),
293299
lvc_enabled: false,
294300
bitemporal: false,
301+
crdt: false,
295302
conflict_policy: None,
296303
permission_tree_def: None,
297304
indexes: Vec::new(),

nodedb/src/control/security/catalog/collection_descriptor_convert.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! `CREATE COLLECTION` and CRDT sync produce the same descriptor shape.
66
//!
77
//! `From<&StoredCollection> for CollectionDescriptor` is the emit side: it
8-
//! reads only the 11 fields the descriptor carries, dropping ownership,
8+
//! reads only the 12 fields the descriptor carries, dropping ownership,
99
//! timestamps, and enforcement/index/constraint state that never travels
1010
//! over sync. [`stored_from_descriptor`] is the receive side: it starts
1111
//! from [`StoredCollection::new`] (which sets sane enforcement defaults
@@ -24,6 +24,7 @@ impl From<&StoredCollection> for CollectionDescriptor {
2424
name: stored.name.clone(),
2525
collection_type: stored.collection_type.clone(),
2626
bitemporal: stored.bitemporal,
27+
crdt: stored.crdt,
2728
fields: stored.fields.clone(),
2829
primary: stored.primary,
2930
vector_primary: stored.vector_primary.clone(),
@@ -49,6 +50,7 @@ pub(crate) fn stored_from_descriptor(
4950
stored.database_id = descriptor.database_id;
5051
stored.collection_type = descriptor.collection_type.clone();
5152
stored.bitemporal = descriptor.bitemporal;
53+
stored.crdt = descriptor.crdt;
5254
stored.fields = descriptor.fields.clone();
5355
stored.primary = descriptor.primary;
5456
stored.vector_primary = descriptor.vector_primary.clone();
@@ -73,6 +75,7 @@ mod tests {
7375
assert_eq!(back.database_id, stored.database_id);
7476
assert_eq!(back.collection_type, stored.collection_type);
7577
assert_eq!(back.bitemporal, stored.bitemporal);
78+
assert_eq!(back.crdt, stored.crdt);
7679
assert_eq!(back.fields, stored.fields);
7780
assert_eq!(back.primary, stored.primary);
7881
assert_eq!(back.vector_primary, stored.vector_primary);
@@ -87,6 +90,7 @@ mod tests {
8790
PartitionStrategy::default_for_collection_type(&collection_type);
8891
stored.collection_type = collection_type;
8992
stored.bitemporal = true;
93+
stored.crdt = true;
9094
stored.declared_primary_key = Some("id".to_string());
9195
stored.descriptor_version = 5;
9296
stored
@@ -188,6 +192,16 @@ mod tests {
188192
assert!(back.bitemporal);
189193
}
190194

195+
#[test]
196+
fn crdt_flag_round_trips() {
197+
let mut stored = base_stored("synced_docs", CollectionType::document());
198+
stored.crdt = true;
199+
let descriptor = CollectionDescriptor::from(&stored);
200+
assert!(descriptor.crdt);
201+
let back = stored_from_descriptor(&descriptor, "sync");
202+
assert!(back.crdt);
203+
}
204+
191205
#[test]
192206
fn owner_assigned_on_receive() {
193207
let stored = base_stored("owned", CollectionType::document());

nodedb/src/control/server/shared/ddl/neutral/collection/create/build.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,43 @@ fn err(sqlstate: &str, message: String) -> DdlError {
3737
}
3838
}
3939

40+
/// Parse a `WITH (crdt=...)` option value as a boolean, accepting
41+
/// `"true"`/`"false"` case-insensitively. Any other value is a
42+
/// user error surfaced as a typed DDL error (SQLSTATE 42601).
43+
fn parse_crdt_flag(value: &str) -> Result<bool, DdlError> {
44+
match value.trim() {
45+
v if v.eq_ignore_ascii_case("true") => Ok(true),
46+
v if v.eq_ignore_ascii_case("false") => Ok(false),
47+
other => Err(err(
48+
"42601",
49+
format!("invalid value for WITH (crdt=...): '{other}'; expected 'true' or 'false'"),
50+
)),
51+
}
52+
}
53+
54+
/// Resolve the CRDT storage flag from the `WITH (...)` option list.
55+
///
56+
/// A missing `crdt` option defaults to `false`. CRDT (Loro) storage is a
57+
/// document-engine capability, so `crdt=true` is rejected with SQLSTATE
58+
/// 42601 on any non-document collection rather than persisting a flag no
59+
/// engine would honor.
60+
fn resolve_crdt_flag(
61+
options: &[(String, String)],
62+
collection_type: &nodedb_types::CollectionType,
63+
) -> Result<bool, DdlError> {
64+
let crdt = match options.iter().find(|(k, _)| k.eq_ignore_ascii_case("crdt")) {
65+
Some((_, v)) => parse_crdt_flag(v)?,
66+
None => false,
67+
};
68+
if crdt && !matches!(collection_type, nodedb_types::CollectionType::Document(_)) {
69+
return Err(err(
70+
"42601",
71+
"WITH (crdt=true) is only supported on document collections".to_string(),
72+
));
73+
}
74+
Ok(crdt)
75+
}
76+
4077
/// Per-surface configuration. The fields are the entire surface-level
4178
/// difference between `CREATE COLLECTION` and `CREATE TABLE`.
4279
pub struct Variant {
@@ -172,6 +209,8 @@ pub async fn build_and_persist(
172209
if hash_chain && !append_only {
173210
return Err(err("42601", "HASH_CHAIN requires APPEND_ONLY".to_string()));
174211
}
212+
213+
let crdt = resolve_crdt_flag(options, &collection_type)?;
175214
let balanced =
176215
parse_balanced_clause_from_raw(balanced_raw.unwrap_or("")).map_err(|e| err("42601", e))?;
177216

@@ -217,6 +256,7 @@ pub async fn build_and_persist(
217256
materialized_sums: Vec::new(),
218257
lvc_enabled: false,
219258
bitemporal,
259+
crdt,
220260
permission_tree_def: None,
221261
indexes: Vec::new(),
222262
size_bytes_estimate: 0,
@@ -389,6 +429,47 @@ mod tests {
389429
//! Collection name validation tests. Relocated verbatim from the pgwire
390430
//! `pgwire::ddl::collection::create::tests` module (now deleted).
391431
432+
use super::resolve_crdt_flag;
433+
434+
fn opts(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
435+
pairs
436+
.iter()
437+
.map(|(k, v)| (k.to_string(), v.to_string()))
438+
.collect()
439+
}
440+
441+
#[test]
442+
fn crdt_true_on_document_collection_resolves_true() {
443+
let options = opts(&[("crdt", "true")]);
444+
let flag = resolve_crdt_flag(&options, &nodedb_types::CollectionType::document())
445+
.expect("crdt=true on a document collection must resolve");
446+
assert!(flag);
447+
}
448+
449+
#[test]
450+
fn crdt_true_on_non_document_collection_rejected() {
451+
let options = opts(&[("crdt", "true")]);
452+
let err = resolve_crdt_flag(&options, &nodedb_types::CollectionType::columnar())
453+
.expect_err("crdt=true on a non-document collection must be rejected");
454+
assert_eq!(err.sqlstate, "42601");
455+
}
456+
457+
#[test]
458+
fn crdt_garbage_value_rejected() {
459+
let options = opts(&[("crdt", "maybe")]);
460+
let err = resolve_crdt_flag(&options, &nodedb_types::CollectionType::document())
461+
.expect_err("a non-boolean crdt value must be rejected");
462+
assert_eq!(err.sqlstate, "42601");
463+
}
464+
465+
#[test]
466+
fn crdt_absent_defaults_false() {
467+
let options = opts(&[("engine", "kv")]);
468+
let flag = resolve_crdt_flag(&options, &nodedb_types::CollectionType::document())
469+
.expect("absent crdt option must resolve to a default");
470+
assert!(!flag);
471+
}
472+
392473
/// Collection name validation: allowed chars are `[a-zA-Z0-9_-]`.
393474
fn validate_name(name: &str) -> bool {
394475
!name.is_empty()

nodedb/src/control/server/shared/ddl/neutral/collection/register.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ pub async fn dispatch_register_if_needed(
5757
crate::control::server::shared::ddl::schema_validation::parse_fields_clause(parts);
5858
let mut indexes = derive_auto_indexes(fields.iter().map(|(n, _)| n.as_str()));
5959
extend_with_catalog_indexes(&mut indexes, &coll);
60-
let _ = sql; // Reserved for future CRDT detection from SQL.
60+
// `sql` is unused on this leader-side path: index derivation reads
61+
// `parts`/the catalog row directly, and the `crdt` flag already
62+
// travels on `StoredCollection` (set at CREATE time from `WITH
63+
// (crdt=...)`), so no SQL re-parsing is needed here.
64+
let _ = sql;
6165
dispatch_register_from_stored_inner(state, tenant_id, &coll, indexes).await
6266
}
6367

@@ -244,7 +248,7 @@ pub(crate) fn build_doc_config_from_stored(
244248
};
245249

246250
let mut config = crate::engine::document::store::CollectionConfig::new(&name);
247-
config.crdt_enabled = false;
251+
config.crdt_enabled = coll.crdt;
248252
config.storage_mode = storage_mode;
249253
config.enforcement = enforcement;
250254
config.bitemporal = coll.bitemporal;

nodedb/src/control/server/shared/ddl/neutral/continuous_agg/create.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ pub async fn create_continuous_aggregate(
209209
materialized_sums: Vec::new(),
210210
lvc_enabled: false,
211211
bitemporal: false,
212+
crdt: false,
212213
permission_tree_def: None,
213214
indexes: Vec::new(),
214215
size_bytes_estimate: 0,

nodedb/src/control/server/shared/ddl/neutral/materialized_view/create.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ pub async fn create_materialized_view(
138138
materialized_sums: Vec::new(),
139139
lvc_enabled: false,
140140
bitemporal: false,
141+
crdt: false,
141142
permission_tree_def: None,
142143
indexes: Vec::new(),
143144
size_bytes_estimate: 0,

nodedb/src/control/server/shared/ddl/neutral/timeseries/create.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ pub fn create_timeseries(
8686
materialized_sums: Vec::new(),
8787
lvc_enabled: false,
8888
bitemporal: false,
89+
crdt: false,
8990
permission_tree_def: None,
9091
indexes: Vec::new(),
9192
size_bytes_estimate: 0,

0 commit comments

Comments
 (0)