Skip to content

Commit 0beb9f1

Browse files
committed
feat(catalog): add StoredCollection <-> CollectionDescriptor conversion
Provides the bidirectional mapping between the catalog's StoredCollection record and the engine-agnostic sync wire descriptor, so CREATE COLLECTION and CRDT sync produce and consume the same descriptor shape.
1 parent 9818924 commit 0beb9f1

2 files changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Bidirectional conversions between [`StoredCollection`] (catalog record)
4+
//! and [`CollectionDescriptor`] (engine-agnostic sync wire type), so
5+
//! `CREATE COLLECTION` and CRDT sync produce the same descriptor shape.
6+
//!
7+
//! `From<&StoredCollection> for CollectionDescriptor` is the emit side: it
8+
//! reads only the 11 fields the descriptor carries, dropping ownership,
9+
//! timestamps, and enforcement/index/constraint state that never travels
10+
//! over sync. [`stored_from_descriptor`] is the receive side: it starts
11+
//! from [`StoredCollection::new`] (which sets sane enforcement defaults
12+
//! and a fresh `created_at`) and overlays only the descriptor-carried
13+
//! fields.
14+
15+
use nodedb_types::sync::wire::CollectionDescriptor;
16+
17+
use super::collection::StoredCollection;
18+
19+
impl From<&StoredCollection> for CollectionDescriptor {
20+
fn from(stored: &StoredCollection) -> Self {
21+
Self {
22+
tenant_id: stored.tenant_id,
23+
database_id: stored.database_id,
24+
name: stored.name.clone(),
25+
collection_type: stored.collection_type.clone(),
26+
bitemporal: stored.bitemporal,
27+
fields: stored.fields.clone(),
28+
primary: stored.primary,
29+
vector_primary: stored.vector_primary.clone(),
30+
partition_strategy: stored.partition_strategy.clone(),
31+
declared_primary_key: stored.declared_primary_key.clone(),
32+
descriptor_version: stored.descriptor_version,
33+
}
34+
}
35+
}
36+
37+
/// Materialize a [`StoredCollection`] from a synced [`CollectionDescriptor`].
38+
///
39+
/// `owner` is assigned to the receiving peer's identity — the descriptor
40+
/// carries no owner, since ownership is a local catalog concept, not a
41+
/// sync-wire one. All fields not carried by the descriptor (field_defs,
42+
/// event_defs, indexes, constraints, `is_active`, etc.) are left at the
43+
/// [`StoredCollection::new`] defaults.
44+
// Consumed by the sync receive handler that materializes announced
45+
// collections into the catalog; only the round-trip tests call it today.
46+
#[allow(dead_code)]
47+
pub(crate) fn stored_from_descriptor(
48+
descriptor: &CollectionDescriptor,
49+
owner: &str,
50+
) -> StoredCollection {
51+
let mut stored = StoredCollection::new(descriptor.tenant_id, &descriptor.name, owner);
52+
stored.database_id = descriptor.database_id;
53+
stored.collection_type = descriptor.collection_type.clone();
54+
stored.bitemporal = descriptor.bitemporal;
55+
stored.fields = descriptor.fields.clone();
56+
stored.primary = descriptor.primary;
57+
stored.vector_primary = descriptor.vector_primary.clone();
58+
stored.partition_strategy = descriptor.partition_strategy.clone();
59+
stored.declared_primary_key = descriptor.declared_primary_key.clone();
60+
stored.descriptor_version = descriptor.descriptor_version;
61+
stored
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use nodedb_types::CollectionType;
67+
use nodedb_types::collection_config::{PartitionStrategy, PrimaryEngine, VectorPrimaryConfig};
68+
use nodedb_types::columnar::{ColumnDef, ColumnType, StrictSchema};
69+
use nodedb_types::kv::KvConfig;
70+
71+
use super::*;
72+
73+
fn assert_mapped_fields_match(stored: &StoredCollection, back: &StoredCollection) {
74+
assert_eq!(back.name, stored.name);
75+
assert_eq!(back.tenant_id, stored.tenant_id);
76+
assert_eq!(back.database_id, stored.database_id);
77+
assert_eq!(back.collection_type, stored.collection_type);
78+
assert_eq!(back.bitemporal, stored.bitemporal);
79+
assert_eq!(back.fields, stored.fields);
80+
assert_eq!(back.primary, stored.primary);
81+
assert_eq!(back.vector_primary, stored.vector_primary);
82+
assert_eq!(back.partition_strategy, stored.partition_strategy);
83+
assert_eq!(back.declared_primary_key, stored.declared_primary_key);
84+
assert_eq!(back.descriptor_version, stored.descriptor_version);
85+
}
86+
87+
fn base_stored(name: &str, collection_type: CollectionType) -> StoredCollection {
88+
let mut stored = StoredCollection::new(7, name, "alice");
89+
stored.partition_strategy =
90+
PartitionStrategy::default_for_collection_type(&collection_type);
91+
stored.collection_type = collection_type;
92+
stored.bitemporal = true;
93+
stored.declared_primary_key = Some("id".to_string());
94+
stored.descriptor_version = 5;
95+
stored
96+
}
97+
98+
#[test]
99+
fn document_schemaless_round_trips() {
100+
let stored = base_stored("users", CollectionType::document());
101+
let descriptor = CollectionDescriptor::from(&stored);
102+
let back = stored_from_descriptor(&descriptor, "sync");
103+
assert_mapped_fields_match(&stored, &back);
104+
}
105+
106+
#[test]
107+
fn document_strict_round_trips() {
108+
let schema = StrictSchema {
109+
columns: vec![
110+
ColumnDef::required("name", ColumnType::String),
111+
ColumnDef::nullable("bio", ColumnType::String),
112+
],
113+
version: 1,
114+
dropped_columns: Vec::new(),
115+
bitemporal: false,
116+
};
117+
let stored = base_stored("people", CollectionType::strict(schema));
118+
let descriptor = CollectionDescriptor::from(&stored);
119+
let back = stored_from_descriptor(&descriptor, "sync");
120+
assert_mapped_fields_match(&stored, &back);
121+
}
122+
123+
#[test]
124+
fn key_value_round_trips() {
125+
let schema = StrictSchema {
126+
columns: vec![ColumnDef::required("id", ColumnType::Int64).with_primary_key()],
127+
version: 1,
128+
dropped_columns: Vec::new(),
129+
bitemporal: false,
130+
};
131+
let stored = base_stored("sessions", CollectionType::kv(schema));
132+
let descriptor = CollectionDescriptor::from(&stored);
133+
let back = stored_from_descriptor(&descriptor, "sync");
134+
assert_mapped_fields_match(&stored, &back);
135+
// Sanity: the KvConfig payload itself survived intact.
136+
match (&stored.collection_type, &back.collection_type) {
137+
(CollectionType::KeyValue(a), CollectionType::KeyValue(b)) => {
138+
let a: &KvConfig = a;
139+
let b: &KvConfig = b;
140+
assert_eq!(a.schema, b.schema);
141+
}
142+
_ => panic!("expected KeyValue collection type"),
143+
}
144+
}
145+
146+
#[test]
147+
fn columnar_plain_round_trips() {
148+
let stored = base_stored("events", CollectionType::columnar());
149+
let descriptor = CollectionDescriptor::from(&stored);
150+
let back = stored_from_descriptor(&descriptor, "sync");
151+
assert_mapped_fields_match(&stored, &back);
152+
}
153+
154+
#[test]
155+
fn timeseries_round_trips() {
156+
let stored = base_stored("metrics", CollectionType::timeseries("ts", "1m"));
157+
let descriptor = CollectionDescriptor::from(&stored);
158+
let back = stored_from_descriptor(&descriptor, "sync");
159+
assert_mapped_fields_match(&stored, &back);
160+
}
161+
162+
#[test]
163+
fn spatial_round_trips() {
164+
let stored = base_stored("places", CollectionType::spatial("geom"));
165+
let descriptor = CollectionDescriptor::from(&stored);
166+
let back = stored_from_descriptor(&descriptor, "sync");
167+
assert_mapped_fields_match(&stored, &back);
168+
}
169+
170+
#[test]
171+
fn vector_primary_round_trips() {
172+
let mut stored = base_stored("embeddings", CollectionType::document());
173+
stored.primary = PrimaryEngine::Vector;
174+
stored.vector_primary = Some(VectorPrimaryConfig {
175+
vector_field: "emb".to_string(),
176+
dim: 768,
177+
..VectorPrimaryConfig::default()
178+
});
179+
let descriptor = CollectionDescriptor::from(&stored);
180+
let back = stored_from_descriptor(&descriptor, "sync");
181+
assert_mapped_fields_match(&stored, &back);
182+
}
183+
184+
#[test]
185+
fn bitemporal_flag_round_trips() {
186+
let mut stored = base_stored("audit_log", CollectionType::document());
187+
stored.bitemporal = true;
188+
let descriptor = CollectionDescriptor::from(&stored);
189+
assert!(descriptor.bitemporal);
190+
let back = stored_from_descriptor(&descriptor, "sync");
191+
assert!(back.bitemporal);
192+
}
193+
194+
#[test]
195+
fn owner_assigned_on_receive() {
196+
let stored = base_stored("owned", CollectionType::document());
197+
assert_eq!(stored.owner, "alice");
198+
let descriptor = CollectionDescriptor::from(&stored);
199+
let back = stored_from_descriptor(&descriptor, "sync");
200+
assert_eq!(back.owner, "sync");
201+
}
202+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod checkpoints;
1313
pub mod clone_catalog;
1414
pub mod collection;
1515
pub mod collection_constraints;
16+
pub mod collection_descriptor_convert;
1617
pub mod collections;
1718
pub mod column_stats;
1819
pub mod constraint_translate;

0 commit comments

Comments
 (0)