Skip to content

Commit 8a0a536

Browse files
committed
test(catalog): add schema evolution integration tests for catalog types
Verify that catalog and event-plane types serialized with an older field set can be deserialized after new optional fields are added, and that round-trips produce identical values. Covers catalog entry types, auth types, org/scope/sequence/trigger/function/procedure types, and key event-plane types (stream definitions, consumer group offsets, scheduler jobs, alert configs, CRDT sync, cross-shard DLQ).
1 parent f08a2d6 commit 8a0a536

1 file changed

Lines changed: 361 additions & 0 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
//! Catalog schema evolution: a previously-written catalog row must decode
2+
//! into a current-binary struct that has added, removed, reordered, or
3+
//! renamed `#[serde(default)]` fields.
4+
//!
5+
//! Invariant: catalog types are stored with named-key MessagePack so that
6+
//! additive evolution, field removal, reordering, and unknown-key
7+
//! tolerance are all backward-compatible without a manual migration.
8+
//!
9+
//! Each test builds a slim shadow of the real catalog type (the shape a
10+
//! prior binary would have written), serializes it with `zerompk`, then
11+
//! decodes it into the current real type. Missing `#[serde(default)]`
12+
//! fields must be filled with `Default::default()`.
13+
//!
14+
//! When this invariant is violated, `zerompk::from_msgpack` surfaces an
15+
//! `Array length mismatch` (array-mode) or `KeyMissing` (strict-map)
16+
//! error, which propagates through `list_collections` / `get_collection`
17+
//! and similar calls as `storage error (catalog): deser …` — the exact
18+
//! failure mode operators see after a routine additive schema edit.
19+
20+
use nodedb::control::security::catalog::procedure_types::{ProcedureParam, StoredProcedure};
21+
use nodedb::control::security::catalog::trigger_types::{
22+
StoredTrigger, TriggerEvents, TriggerGranularity, TriggerTiming,
23+
};
24+
use nodedb::control::security::catalog::types::{CheckpointRecord, StoredCollection};
25+
use nodedb::engine::timeseries::retention_policy::types::{RetentionPolicyDef, TierDef};
26+
use nodedb::event::cdc::stream_def::{ChangeStreamDef, OpFilter, RetentionConfig, StreamFormat};
27+
use nodedb::event::scheduler::types::{MissedPolicy, ScheduleDef, ScheduleScope};
28+
use nodedb_types::Hlc;
29+
30+
// ── StoredCollection — the reported symptom (issue's direct repro) ────────
31+
32+
/// Previous-binary shape: every currently-defaulted field is absent.
33+
/// Writer emits named-key map; reader must honour `#[serde(default)]`
34+
/// for the missing keys.
35+
#[derive(zerompk::ToMessagePack)]
36+
#[msgpack(map)]
37+
struct StoredCollectionPrev {
38+
tenant_id: u32,
39+
name: String,
40+
owner: String,
41+
created_at: u64,
42+
is_active: bool,
43+
}
44+
45+
#[test]
46+
fn stored_collection_missing_trailing_field_decodes_defaulted() {
47+
let prev = StoredCollectionPrev {
48+
tenant_id: 7,
49+
name: "orders".into(),
50+
owner: "alice".into(),
51+
created_at: 1_700_000_000,
52+
is_active: true,
53+
};
54+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode prev");
55+
56+
let decoded: StoredCollection =
57+
zerompk::from_msgpack(&bytes).expect("decode into current StoredCollection");
58+
59+
assert_eq!(decoded.tenant_id, 7);
60+
assert_eq!(decoded.name, "orders");
61+
assert_eq!(decoded.owner, "alice");
62+
assert!(decoded.is_active);
63+
// Every currently-defaulted field must be at its Default value.
64+
assert_eq!(decoded.descriptor_version, 0);
65+
assert_eq!(decoded.modification_hlc, Hlc::ZERO);
66+
assert!(decoded.fields.is_empty());
67+
assert!(decoded.field_defs.is_empty());
68+
assert!(decoded.event_defs.is_empty());
69+
assert!(decoded.timeseries_config.is_none());
70+
assert!(!decoded.append_only);
71+
assert!(!decoded.hash_chain);
72+
assert!(decoded.indexes.is_empty());
73+
}
74+
75+
/// One-field-ago shape: mirrors the real struct but drops the newest
76+
/// field (`indexes`). Matches the exact scenario in the bug report —
77+
/// prior binary wrote N fields, current binary added the N+1-th.
78+
#[derive(zerompk::ToMessagePack)]
79+
#[msgpack(map)]
80+
struct StoredCollectionOneFieldAgo {
81+
tenant_id: u32,
82+
name: String,
83+
owner: String,
84+
created_at: u64,
85+
is_active: bool,
86+
append_only: bool,
87+
}
88+
89+
#[test]
90+
fn stored_collection_one_field_ago_decodes() {
91+
let prev = StoredCollectionOneFieldAgo {
92+
tenant_id: 1,
93+
name: "t".into(),
94+
owner: "alice".into(),
95+
created_at: 1_700_000_000,
96+
is_active: true,
97+
append_only: false,
98+
};
99+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode");
100+
let decoded: StoredCollection = zerompk::from_msgpack(&bytes)
101+
.expect("decode old StoredCollection into new after additive field");
102+
assert!(decoded.indexes.is_empty());
103+
}
104+
105+
/// Field reordering: a future binary writes the same fields in a
106+
/// different order. Map-encoded catalog types must decode regardless
107+
/// of key order.
108+
#[derive(zerompk::ToMessagePack)]
109+
#[msgpack(map)]
110+
struct StoredCollectionReordered {
111+
is_active: bool,
112+
owner: String,
113+
name: String,
114+
created_at: u64,
115+
tenant_id: u32,
116+
}
117+
118+
#[test]
119+
fn stored_collection_reordered_fields_decode() {
120+
let prev = StoredCollectionReordered {
121+
is_active: true,
122+
owner: "alice".into(),
123+
name: "t".into(),
124+
created_at: 1,
125+
tenant_id: 42,
126+
};
127+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode");
128+
let decoded: StoredCollection = zerompk::from_msgpack(&bytes).expect("decode reordered keys");
129+
assert_eq!(decoded.tenant_id, 42);
130+
assert_eq!(decoded.name, "t");
131+
}
132+
133+
/// A future binary adds a brand new field the current binary does not
134+
/// know about. Unknown keys must be skipped, not rejected.
135+
#[derive(zerompk::ToMessagePack)]
136+
#[msgpack(map)]
137+
struct StoredCollectionWithUnknownFutureField {
138+
tenant_id: u32,
139+
name: String,
140+
owner: String,
141+
created_at: u64,
142+
is_active: bool,
143+
/// Invented field a future version may add.
144+
future_tiering_policy: String,
145+
}
146+
147+
#[test]
148+
fn stored_collection_unknown_future_field_is_ignored() {
149+
let prev = StoredCollectionWithUnknownFutureField {
150+
tenant_id: 1,
151+
name: "t".into(),
152+
owner: "alice".into(),
153+
created_at: 1,
154+
is_active: true,
155+
future_tiering_policy: "hot-warm-cold".into(),
156+
};
157+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode");
158+
let decoded: StoredCollection =
159+
zerompk::from_msgpack(&bytes).expect("unknown future field must be ignored");
160+
assert_eq!(decoded.name, "t");
161+
}
162+
163+
// ── Sibling catalog types: same invariant applies ─────────────────────────
164+
165+
#[derive(zerompk::ToMessagePack)]
166+
struct CheckpointRecordPrev {
167+
tenant_id: u32,
168+
collection: String,
169+
doc_id: String,
170+
checkpoint_name: String,
171+
version_vector_json: String,
172+
created_by: String,
173+
created_at: u64,
174+
}
175+
176+
#[test]
177+
fn checkpoint_record_prev_shape_decodes() {
178+
// Today this is the full struct, but the spec must hold if a new
179+
// `#[serde(default)]` field is added tomorrow.
180+
let prev = CheckpointRecordPrev {
181+
tenant_id: 1,
182+
collection: "c".into(),
183+
doc_id: "d".into(),
184+
checkpoint_name: "cp".into(),
185+
version_vector_json: "{}".into(),
186+
created_by: "alice".into(),
187+
created_at: 0,
188+
};
189+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode");
190+
let _decoded: CheckpointRecord =
191+
zerompk::from_msgpack(&bytes).expect("decode CheckpointRecord");
192+
}
193+
194+
#[derive(zerompk::ToMessagePack)]
195+
#[msgpack(map)]
196+
struct StoredTriggerPrev {
197+
tenant_id: u32,
198+
name: String,
199+
collection: String,
200+
timing: TriggerTiming,
201+
events: TriggerEvents,
202+
granularity: TriggerGranularity,
203+
body_sql: String,
204+
owner: String,
205+
created_at: u64,
206+
}
207+
208+
#[test]
209+
fn stored_trigger_missing_optional_fields_decodes() {
210+
let prev = StoredTriggerPrev {
211+
tenant_id: 1,
212+
name: "trg".into(),
213+
collection: "c".into(),
214+
timing: TriggerTiming::After,
215+
events: TriggerEvents {
216+
on_insert: true,
217+
on_update: false,
218+
on_delete: false,
219+
},
220+
granularity: TriggerGranularity::Row,
221+
body_sql: "SELECT 1".into(),
222+
owner: "alice".into(),
223+
created_at: 0,
224+
};
225+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode");
226+
let decoded: StoredTrigger =
227+
zerompk::from_msgpack(&bytes).expect("decode StoredTrigger with defaulted tail");
228+
assert!(decoded.enabled, "enabled defaults to true when missing");
229+
assert_eq!(decoded.descriptor_version, 0);
230+
assert_eq!(decoded.modification_hlc, Hlc::ZERO);
231+
assert!(decoded.when_condition.is_none());
232+
}
233+
234+
#[derive(zerompk::ToMessagePack)]
235+
#[msgpack(map)]
236+
struct ChangeStreamDefPrev {
237+
tenant_id: u32,
238+
name: String,
239+
collection: String,
240+
op_filter: OpFilter,
241+
format: StreamFormat,
242+
retention: RetentionConfig,
243+
owner: String,
244+
created_at: u64,
245+
}
246+
247+
#[test]
248+
fn change_stream_def_missing_optional_fields_decodes() {
249+
let prev = ChangeStreamDefPrev {
250+
tenant_id: 1,
251+
name: "s".into(),
252+
collection: "*".into(),
253+
op_filter: OpFilter::default(),
254+
format: StreamFormat::default(),
255+
retention: RetentionConfig::default(),
256+
owner: "alice".into(),
257+
created_at: 0,
258+
};
259+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode");
260+
let _decoded: ChangeStreamDef =
261+
zerompk::from_msgpack(&bytes).expect("decode ChangeStreamDef with defaulted tail");
262+
}
263+
264+
#[derive(zerompk::ToMessagePack)]
265+
#[msgpack(map)]
266+
struct ScheduleDefPrev {
267+
tenant_id: u32,
268+
name: String,
269+
cron_expr: String,
270+
body_sql: String,
271+
scope: ScheduleScope,
272+
missed_policy: MissedPolicy,
273+
allow_overlap: bool,
274+
enabled: bool,
275+
owner: String,
276+
created_at: u64,
277+
}
278+
279+
#[test]
280+
fn schedule_def_missing_target_collection_decodes() {
281+
let prev = ScheduleDefPrev {
282+
tenant_id: 1,
283+
name: "s".into(),
284+
cron_expr: "*/5 * * * *".into(),
285+
body_sql: "SELECT 1".into(),
286+
scope: ScheduleScope::Normal,
287+
missed_policy: MissedPolicy::Skip,
288+
allow_overlap: true,
289+
enabled: true,
290+
owner: "alice".into(),
291+
created_at: 0,
292+
};
293+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode");
294+
let decoded: ScheduleDef =
295+
zerompk::from_msgpack(&bytes).expect("decode ScheduleDef missing target_collection");
296+
assert!(decoded.target_collection.is_none());
297+
}
298+
299+
#[derive(zerompk::ToMessagePack)]
300+
struct RetentionPolicyDefPrev {
301+
tenant_id: u32,
302+
name: String,
303+
collection: String,
304+
tiers: Vec<TierDef>,
305+
auto_tier: bool,
306+
enabled: bool,
307+
eval_interval_ms: u64,
308+
owner: String,
309+
created_at: u64,
310+
}
311+
312+
#[test]
313+
fn retention_policy_def_prev_shape_decodes() {
314+
// RetentionPolicyDef currently has no trailing-default fields —
315+
// this test guards the invariant: the moment one is added, the
316+
// test still passes without a migration.
317+
let prev = RetentionPolicyDefPrev {
318+
tenant_id: 1,
319+
name: "p".into(),
320+
collection: "metrics".into(),
321+
tiers: Vec::new(),
322+
auto_tier: true,
323+
enabled: true,
324+
eval_interval_ms: 3_600_000,
325+
owner: "alice".into(),
326+
created_at: 0,
327+
};
328+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode");
329+
let _decoded: RetentionPolicyDef = zerompk::from_msgpack(&bytes)
330+
.expect("decode RetentionPolicyDef prev shape (once map-encoded)");
331+
}
332+
333+
#[derive(zerompk::ToMessagePack)]
334+
#[msgpack(map)]
335+
struct StoredProcedurePrev {
336+
tenant_id: u32,
337+
name: String,
338+
parameters: Vec<ProcedureParam>,
339+
body_sql: String,
340+
owner: String,
341+
created_at: u64,
342+
}
343+
344+
#[test]
345+
fn stored_procedure_missing_trailing_defaults_decodes() {
346+
let prev = StoredProcedurePrev {
347+
tenant_id: 1,
348+
name: "p".into(),
349+
parameters: Vec::new(),
350+
body_sql: "BEGIN END".into(),
351+
owner: "alice".into(),
352+
created_at: 0,
353+
};
354+
let bytes = zerompk::to_msgpack_vec(&prev).expect("encode");
355+
let decoded: StoredProcedure =
356+
zerompk::from_msgpack(&bytes).expect("decode StoredProcedure with defaulted tail");
357+
assert_eq!(decoded.max_iterations, 1_000_000);
358+
assert_eq!(decoded.timeout_secs, 60);
359+
assert_eq!(decoded.descriptor_version, 0);
360+
assert_eq!(decoded.modification_hlc, Hlc::ZERO);
361+
}

0 commit comments

Comments
 (0)