Skip to main content

es_entity/
tree_query.rs

1//! Dynamically assembles the single-statement tree query a nested repo's
2//! generated read fns execute at runtime: a tagged `UNION ALL` over one CTE
3//! per tree node, so a parent and all of its nested children (recursively)
4//! come back in one statement instead of one per level.
5
6use std::collections::HashMap;
7
8use crate::{
9    db,
10    events::{GenericEvent, HydrationRow},
11};
12
13/// Static description of one node (repo) in a nested tree, used to build the query.
14#[derive(Debug, Clone)]
15pub struct TreeSpec {
16    pub table_name: &'static str,
17    pub events_table_name: &'static str,
18    pub parent_column: Option<&'static str>,
19    pub soft_delete: bool,
20    pub forgettable_table_name: Option<&'static str>,
21    pub event_context: bool,
22    /// `Some(<tbl>_snapshots)` when this node's repo enables `snapshot`.
23    pub snapshot_table_name: Option<&'static str>,
24    /// This node's `<Entity as EsEntity>::Snapshot as EsSnapshot>::FINGERPRINT`.
25    /// Meaningless when `snapshot_table_name` is `None`.
26    pub snapshot_fingerprint: i64,
27    pub children: Vec<TreeSpec>,
28}
29
30/// The root user query plus the bits needed to fold it into the tree query.
31pub struct TreeQuerySource<Id> {
32    pub user_sql: &'static str,
33    pub order_by_cols: &'static [&'static str],
34    pub n_user_args: usize,
35    pub decode: fn(&db::Row) -> Result<HydrationRow<Id>, sqlx::Error>,
36    /// The root's fingerprint bind expression, as passed to `es_query!`.
37    /// `NO_SNAPSHOT_FINGERPRINT` signals a full-history load: every node in
38    /// the tree (not just the root) binds `NO_SNAPSHOT_FINGERPRINT`, forcing
39    /// every snapshot join in the tree to match nothing.
40    pub snapshot_fingerprint: i64,
41}
42
43pub fn decode_tag(row: &db::Row) -> Result<i32, sqlx::Error> {
44    sqlx::Row::try_get(row, "tag")
45}
46
47/// Decodes a row from a tree branch with no snapshot columns (the branch's
48/// own node has no snapshot table, and no sibling forced padding). Identical
49/// to what every tree branch decoded before snapshotting existed.
50pub fn decode_tagged_row<Id>(row: &db::Row) -> Result<HydrationRow<Id>, sqlx::Error>
51where
52    Id: for<'r> sqlx::Decode<'r, db::Db> + sqlx::Type<db::Db>,
53{
54    use sqlx::Row;
55    Ok(GenericEvent {
56        entity_id: row.try_get("entity_id")?,
57        sequence: row.try_get("sequence")?,
58        event: row.try_get("event")?,
59        context: row.try_get("context")?,
60        recorded_at: row.try_get("recorded_at")?,
61        forgettable_payload: row.try_get("forgettable_payload")?,
62    }
63    .into())
64}
65
66/// Decodes a row from a snapshot-enabled node's own branch: `event` and
67/// `recorded_at` may be `NULL` (the lone snapshot-only row when the tail is
68/// empty), and the five `snapshot*` columns are present.
69pub fn decode_tagged_snapshot_row<Id>(row: &db::Row) -> Result<HydrationRow<Id>, sqlx::Error>
70where
71    Id: for<'r> sqlx::Decode<'r, db::Db> + sqlx::Type<db::Db>,
72{
73    use sqlx::Row;
74    Ok(HydrationRow {
75        entity_id: row.try_get("entity_id")?,
76        sequence: row.try_get("sequence")?,
77        event: row.try_get("event")?,
78        context: row.try_get("context")?,
79        recorded_at: row.try_get("recorded_at")?,
80        forgettable_payload: row.try_get("forgettable_payload")?,
81        snapshot: row.try_get("snapshot")?,
82        snapshot_sequence: row.try_get("snapshot_sequence")?,
83        snapshot_recorded_at: row.try_get("snapshot_recorded_at")?,
84        snapshot_first_recorded_at: row.try_get("snapshot_first_recorded_at")?,
85        snapshot_forgettable_payload: row.try_get("snapshot_forgettable_payload")?,
86    })
87}
88
89/// `true` if this node or any descendant enables `snapshot`. Determines
90/// whether every branch in the tree's `UNION ALL` needs the same (wider)
91/// column list — a tree with no snapshot node anywhere emits the same SQL
92/// as before this feature existed.
93pub fn tree_has_snapshot(spec: &TreeSpec) -> bool {
94    spec.snapshot_table_name.is_some() || spec.children.iter().any(tree_has_snapshot)
95}
96
97/// Walks the tree in the same DFS order `build_tree_query` assigns
98/// fingerprint bind-parameter indices in (root, then children depth-first),
99/// returning the fingerprint of every node that has a snapshot table.
100pub fn snapshot_fingerprints(spec: &TreeSpec) -> Vec<i64> {
101    let mut out = Vec::new();
102    collect_snapshot_fingerprints(spec, &mut out);
103    out
104}
105
106fn collect_snapshot_fingerprints(spec: &TreeSpec, out: &mut Vec<i64>) {
107    if spec.snapshot_table_name.is_some() {
108        out.push(spec.snapshot_fingerprint);
109    }
110    for child in &spec.children {
111        collect_snapshot_fingerprints(child, out);
112    }
113}
114
115pub fn partition_by_tag(rows: Vec<db::Row>) -> Result<HashMap<i32, Vec<db::Row>>, sqlx::Error> {
116    let mut by_tag: HashMap<i32, Vec<db::Row>> = HashMap::new();
117    for row in rows {
118        let tag = decode_tag(&row)?;
119        by_tag.entry(tag).or_default().push(row);
120    }
121    Ok(by_tag)
122}
123
124#[allow(clippy::too_many_arguments)]
125fn branch_sql(
126    tag: i32,
127    cte_name: &str,
128    node: &TreeSpec,
129    is_root: bool,
130    ctx_param_idx: usize,
131    fp_idx: Option<usize>,
132    uniform_snapshot_columns: bool,
133) -> String {
134    let context_expr = if is_root {
135        format!("CASE WHEN ${ctx_param_idx} THEN e.context ELSE NULL::jsonb END")
136    } else if node.event_context {
137        "e.context".to_string()
138    } else {
139        "NULL::jsonb".to_string()
140    };
141    let (payload_expr, forgettable_join) = match node.forgettable_table_name {
142        Some(tbl) => (
143            "p.payload".to_string(),
144            format!(" LEFT JOIN {tbl} p ON e.id = p.entity_id AND e.sequence = p.sequence"),
145        ),
146        None => ("NULL::jsonb".to_string(), String::new()),
147    };
148    let ord_expr = if is_root { "i.__ord" } else { "NULL::BIGINT" };
149    let events_table = node.events_table_name;
150
151    if let Some(snap_tbl) = node.snapshot_table_name {
152        let fp_idx = fp_idx.expect("a snapshot node always has a fingerprint bind index");
153        let (snapshot_payload_expr, snapshot_payload_join) =
154            match (node.forgettable_table_name, node.snapshot_table_name) {
155                (Some(fp_tbl), Some(_)) => (
156                    "sp.payload".to_string(),
157                    format!(" LEFT JOIN {fp_tbl} sp ON sp.entity_id = i.id AND sp.sequence = 0"),
158                ),
159                _ => ("NULL::jsonb".to_string(), String::new()),
160            };
161        format!(
162            "SELECT {tag} AS tag, i.id AS entity_id, COALESCE(e.sequence, s.sequence) AS sequence, \
163             e.event, {context_expr} AS context, e.recorded_at, {payload_expr} AS forgettable_payload, \
164             CASE WHEN e.sequence IS NULL OR e.sequence = s.sequence + 1 THEN s.snapshot END AS snapshot, \
165             s.sequence AS snapshot_sequence, s.recorded_at AS snapshot_recorded_at, \
166             s.first_recorded_at AS snapshot_first_recorded_at, \
167             {snapshot_payload_expr} AS snapshot_forgettable_payload, {ord_expr} AS __ord \
168             FROM {cte_name} i \
169             LEFT JOIN {snap_tbl} s ON s.id = i.id AND s.fingerprint = ${fp_idx} \
170             LEFT JOIN {events_table} e ON e.id = i.id AND e.sequence > COALESCE(s.sequence, 0)\
171             {forgettable_join}{snapshot_payload_join}"
172        )
173    } else if uniform_snapshot_columns {
174        format!(
175            "SELECT {tag} AS tag, i.id AS entity_id, e.sequence, e.event, {context_expr} AS context, \
176             e.recorded_at, {payload_expr} AS forgettable_payload, \
177             NULL::jsonb AS snapshot, NULL::INT AS snapshot_sequence, \
178             NULL::TIMESTAMPTZ AS snapshot_recorded_at, NULL::TIMESTAMPTZ AS snapshot_first_recorded_at, \
179             NULL::jsonb AS snapshot_forgettable_payload, {ord_expr} AS __ord \
180             FROM {cte_name} i JOIN {events_table} e ON i.id = e.id{forgettable_join}"
181        )
182    } else {
183        format!(
184            "SELECT {tag} AS tag, i.id AS entity_id, e.sequence, e.event, {context_expr} AS context, \
185             e.recorded_at, {payload_expr} AS forgettable_payload, {ord_expr} AS __ord \
186             FROM {cte_name} i JOIN {events_table} e ON i.id = e.id{forgettable_join}"
187        )
188    }
189}
190
191pub fn build_tree_query(
192    user_sql: &str,
193    order_by_cols: &[&str],
194    spec: &TreeSpec,
195    include_deleted: bool,
196    ctx_param_idx: usize,
197) -> String {
198    let order_clause = if order_by_cols.is_empty() {
199        "id".to_string()
200    } else {
201        order_by_cols.join(", ")
202    };
203
204    let mut ctes = vec![
205        format!("__user AS ({user_sql})"),
206        format!(
207            "entities AS (SELECT *, ROW_NUMBER() OVER (ORDER BY {order_clause}) AS __ord FROM __user)"
208        ),
209    ];
210
211    let uniform = tree_has_snapshot(spec);
212    let mut fp_cursor = ctx_param_idx + 1;
213    let root_fp_idx = take_fp_idx(spec, &mut fp_cursor);
214    let mut branches = vec![branch_sql(
215        0,
216        "entities",
217        spec,
218        true,
219        ctx_param_idx,
220        root_fp_idx,
221        uniform,
222    )];
223    let mut cursor: i32 = 1;
224
225    walk_children(
226        spec,
227        "entities",
228        include_deleted,
229        ctx_param_idx,
230        &mut fp_cursor,
231        &mut cursor,
232        &mut ctes,
233        &mut branches,
234        uniform,
235    );
236
237    format!(
238        "WITH {} {} ORDER BY tag, __ord, entity_id, sequence",
239        ctes.join(", "),
240        branches.join(" UNION ALL "),
241    )
242}
243
244fn take_fp_idx(node: &TreeSpec, fp_cursor: &mut usize) -> Option<usize> {
245    if node.snapshot_table_name.is_some() {
246        let idx = *fp_cursor;
247        *fp_cursor += 1;
248        Some(idx)
249    } else {
250        None
251    }
252}
253
254#[allow(clippy::too_many_arguments)]
255fn walk_children(
256    node: &TreeSpec,
257    parent_cte: &str,
258    include_deleted: bool,
259    ctx_param_idx: usize,
260    fp_cursor: &mut usize,
261    cursor: &mut i32,
262    ctes: &mut Vec<String>,
263    branches: &mut Vec<String>,
264    uniform: bool,
265) {
266    for child in &node.children {
267        let tag = *cursor;
268        *cursor += 1;
269        let cte_name = format!("n{tag}");
270        let parent_col = child
271            .parent_column
272            .expect("non-root tree node must declare a parent column");
273        let deleted_cond = if child.soft_delete && !include_deleted {
274            " AND deleted = FALSE"
275        } else {
276            ""
277        };
278        ctes.push(format!(
279            "{cte_name} AS (SELECT id FROM {} WHERE {parent_col} IN (SELECT id FROM {parent_cte}){deleted_cond})",
280            child.table_name
281        ));
282        let fp_idx = take_fp_idx(child, fp_cursor);
283        branches.push(branch_sql(
284            tag,
285            &cte_name,
286            child,
287            false,
288            ctx_param_idx,
289            fp_idx,
290            uniform,
291        ));
292        walk_children(
293            child,
294            &cte_name,
295            include_deleted,
296            ctx_param_idx,
297            fp_cursor,
298            cursor,
299            ctes,
300            branches,
301            uniform,
302        );
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::snapshot::NO_SNAPSHOT_FINGERPRINT;
310
311    fn leaf(table: &'static str, events: &'static str, parent_col: &'static str) -> TreeSpec {
312        TreeSpec {
313            table_name: table,
314            events_table_name: events,
315            parent_column: Some(parent_col),
316            soft_delete: false,
317            forgettable_table_name: None,
318            event_context: false,
319            snapshot_table_name: None,
320            snapshot_fingerprint: NO_SNAPSHOT_FINGERPRINT,
321            children: Vec::new(),
322        }
323    }
324
325    fn snapshotted(spec: TreeSpec, tbl: &'static str, fingerprint: i64) -> TreeSpec {
326        TreeSpec {
327            snapshot_table_name: Some(tbl),
328            snapshot_fingerprint: fingerprint,
329            ..spec
330        }
331    }
332
333    fn root(table: &'static str, events: &'static str, children: Vec<TreeSpec>) -> TreeSpec {
334        TreeSpec {
335            table_name: table,
336            events_table_name: events,
337            parent_column: None,
338            soft_delete: false,
339            forgettable_table_name: None,
340            event_context: false,
341            snapshot_table_name: None,
342            snapshot_fingerprint: NO_SNAPSHOT_FINGERPRINT,
343            children,
344        }
345    }
346
347    #[test]
348    fn leaf_only_root() {
349        let spec = root("subscriptions", "subscription_events", Vec::new());
350        let sql = build_tree_query(
351            "SELECT id FROM subscriptions WHERE id = $1",
352            &[],
353            &spec,
354            false,
355            2,
356        );
357        assert_eq!(
358            sql,
359            "WITH __user AS (SELECT id FROM subscriptions WHERE id = $1), \
360             entities AS (SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS __ord FROM __user) \
361             SELECT 0 AS tag, i.id AS entity_id, e.sequence, e.event, \
362             CASE WHEN $2 THEN e.context ELSE NULL::jsonb END AS context, e.recorded_at, \
363             NULL::jsonb AS forgettable_payload, i.__ord AS __ord \
364             FROM entities i JOIN subscription_events e ON i.id = e.id \
365             ORDER BY tag, __ord, entity_id, sequence"
366        );
367    }
368
369    #[test]
370    fn one_child() {
371        let child = TreeSpec {
372            soft_delete: true,
373            ..leaf(
374                "billing_periods",
375                "billing_period_events",
376                "subscription_id",
377            )
378        };
379        let spec = root("subscriptions", "subscription_events", vec![child]);
380        let sql = build_tree_query(
381            "SELECT id FROM subscriptions WHERE id = $1",
382            &[],
383            &spec,
384            false,
385            2,
386        );
387        assert!(sql.contains(
388            "n1 AS (SELECT id FROM billing_periods WHERE subscription_id IN (SELECT id FROM entities) AND deleted = FALSE)"
389        ));
390        assert!(sql.contains(
391            "SELECT 1 AS tag, i.id AS entity_id, e.sequence, e.event, NULL::jsonb AS context, \
392             e.recorded_at, NULL::jsonb AS forgettable_payload, NULL::BIGINT AS __ord \
393             FROM n1 i JOIN billing_period_events e ON i.id = e.id"
394        ));
395    }
396
397    #[test]
398    fn two_children_first_has_grandchild() {
399        let grandchild = leaf("line_items", "line_item_events", "billing_period_id");
400        let child_a = TreeSpec {
401            children: vec![grandchild],
402            ..leaf(
403                "billing_periods",
404                "billing_period_events",
405                "subscription_id",
406            )
407        };
408        let child_b = leaf("invoices", "invoice_events", "subscription_id");
409        let spec = root(
410            "subscriptions",
411            "subscription_events",
412            vec![child_a, child_b],
413        );
414        let sql = build_tree_query(
415            "SELECT id FROM subscriptions WHERE id = $1",
416            &[],
417            &spec,
418            false,
419            2,
420        );
421        assert!(sql.contains("n1 AS (SELECT id FROM billing_periods WHERE subscription_id IN (SELECT id FROM entities)"));
422        assert!(sql.contains(
423            "n2 AS (SELECT id FROM line_items WHERE billing_period_id IN (SELECT id FROM n1)"
424        ));
425        assert!(sql.contains(
426            "n3 AS (SELECT id FROM invoices WHERE subscription_id IN (SELECT id FROM entities)"
427        ));
428        assert!(sql.contains("SELECT 1 AS tag"));
429        assert!(sql.contains("SELECT 2 AS tag"));
430        assert!(sql.contains("SELECT 3 AS tag"));
431    }
432
433    #[test]
434    fn soft_delete_and_forgettable_and_inlined_context() {
435        let child = TreeSpec {
436            soft_delete: true,
437            forgettable_table_name: Some("order_items_forgettable_payloads"),
438            event_context: true,
439            ..leaf("order_items", "order_item_events", "order_id")
440        };
441        let spec = root("orders", "order_events", vec![child]);
442        let sql = build_tree_query("SELECT id FROM orders WHERE id = $1", &[], &spec, false, 2);
443        assert!(sql.contains("AND deleted = FALSE)"));
444        assert!(sql.contains(
445            "LEFT JOIN order_items_forgettable_payloads p ON e.id = p.entity_id AND e.sequence = p.sequence"
446        ));
447        assert!(sql.contains("p.payload AS forgettable_payload"));
448        assert!(sql.contains(
449            "SELECT 1 AS tag, i.id AS entity_id, e.sequence, e.event, e.context AS context"
450        ));
451    }
452
453    #[test]
454    fn include_deleted_drops_deleted_condition_transitively() {
455        let grandchild = TreeSpec {
456            soft_delete: true,
457            ..leaf("line_items", "line_item_events", "billing_period_id")
458        };
459        let child = TreeSpec {
460            soft_delete: true,
461            children: vec![grandchild],
462            ..leaf(
463                "billing_periods",
464                "billing_period_events",
465                "subscription_id",
466            )
467        };
468        let spec = root("subscriptions", "subscription_events", vec![child]);
469        let sql = build_tree_query(
470            "SELECT id FROM subscriptions WHERE id = $1",
471            &[],
472            &spec,
473            true,
474            2,
475        );
476        assert!(!sql.contains("deleted = FALSE"));
477    }
478
479    #[test]
480    fn empty_order_by_defaults_to_id() {
481        let spec = root("subscriptions", "subscription_events", Vec::new());
482        let sql = build_tree_query(
483            "SELECT id FROM subscriptions WHERE id = $1",
484            &[],
485            &spec,
486            false,
487            2,
488        );
489        assert!(sql.contains("ROW_NUMBER() OVER (ORDER BY id) AS __ord"));
490    }
491
492    #[test]
493    fn explicit_order_by_is_unprefixed_in_the_entities_cte() {
494        let spec = root("entities", "entity_events", Vec::new());
495        let sql = build_tree_query(
496            "SELECT name, id FROM entities ORDER BY name, id LIMIT $1",
497            &["name", "id"],
498            &spec,
499            false,
500            2,
501        );
502        assert!(sql.contains("ROW_NUMBER() OVER (ORDER BY name, id) AS __ord"));
503    }
504
505    #[test]
506    fn plain_tree_has_no_snapshot_columns_at_all() {
507        // A tree with no snapshot node anywhere emits no `snapshot` columns
508        // and no fingerprint bind, matching the pre-snapshot query exactly.
509        let child = leaf("meters", "meter_events", "site_id");
510        let spec = root("sites", "site_events", vec![child]);
511        assert!(!tree_has_snapshot(&spec));
512        let sql = build_tree_query("SELECT id FROM sites WHERE id = $1", &[], &spec, false, 2);
513        assert!(!sql.contains("snapshot"));
514        assert!(sql.contains(
515            "SELECT 0 AS tag, i.id AS entity_id, e.sequence, e.event, \
516             CASE WHEN $2 THEN e.context ELSE NULL::jsonb END AS context, e.recorded_at, \
517             NULL::jsonb AS forgettable_payload, i.__ord AS __ord \
518             FROM entities i JOIN site_events e ON i.id = e.id"
519        ));
520    }
521
522    #[test]
523    fn snapshot_root_plain_child() {
524        let child = leaf("meters", "meter_events", "site_id");
525        let spec = snapshotted(
526            root("sites", "site_events", vec![child]),
527            "site_snapshots",
528            111,
529        );
530        assert!(tree_has_snapshot(&spec));
531        let sql = build_tree_query("SELECT id FROM sites WHERE id = $1", &[], &spec, false, 2);
532        // root branch: snapshot-aware, fingerprint bound at $3 (ctx_param_idx=2, +1)
533        assert!(sql.contains("LEFT JOIN site_snapshots s ON s.id = i.id AND s.fingerprint = $3"));
534        assert!(sql.contains("COALESCE(e.sequence, s.sequence) AS sequence"));
535        // child branch: plain, but padded with NULL snapshot columns to match
536        assert!(sql.contains(
537            "SELECT 1 AS tag, i.id AS entity_id, e.sequence, e.event, NULL::jsonb AS context, \
538             e.recorded_at, NULL::jsonb AS forgettable_payload, \
539             NULL::jsonb AS snapshot, NULL::INT AS snapshot_sequence, \
540             NULL::TIMESTAMPTZ AS snapshot_recorded_at, NULL::TIMESTAMPTZ AS snapshot_first_recorded_at, \
541             NULL::jsonb AS snapshot_forgettable_payload, NULL::BIGINT AS __ord \
542             FROM n1 i JOIN meter_events e ON i.id = e.id"
543        ));
544    }
545
546    #[test]
547    fn plain_root_snapshot_child() {
548        let child = snapshotted(
549            leaf("meters", "meter_events", "site_id"),
550            "meter_snapshots",
551            222,
552        );
553        let spec = root("sites", "site_events", vec![child]);
554        assert!(tree_has_snapshot(&spec));
555        let sql = build_tree_query("SELECT id FROM sites WHERE id = $1", &[], &spec, false, 2);
556        // root branch: plain, padded (no fingerprint bind consumed for root)
557        assert!(sql.contains(
558            "SELECT 0 AS tag, i.id AS entity_id, e.sequence, e.event, \
559             CASE WHEN $2 THEN e.context ELSE NULL::jsonb END AS context, e.recorded_at, \
560             NULL::jsonb AS forgettable_payload, \
561             NULL::jsonb AS snapshot, NULL::INT AS snapshot_sequence, \
562             NULL::TIMESTAMPTZ AS snapshot_recorded_at, NULL::TIMESTAMPTZ AS snapshot_first_recorded_at, \
563             NULL::jsonb AS snapshot_forgettable_payload, i.__ord AS __ord \
564             FROM entities i JOIN site_events e ON i.id = e.id"
565        ));
566        // child branch: snapshot-aware, fingerprint bound at $3 (root has none, child is first)
567        assert!(sql.contains("LEFT JOIN meter_snapshots s ON s.id = i.id AND s.fingerprint = $3"));
568    }
569
570    #[test]
571    fn both_snapshot_param_numbering() {
572        let child = snapshotted(
573            leaf("meters", "meter_events", "site_id"),
574            "meter_snapshots",
575            222,
576        );
577        let spec = snapshotted(
578            root("sites", "site_events", vec![child]),
579            "site_snapshots",
580            111,
581        );
582        let sql = build_tree_query("SELECT id FROM sites WHERE id = $1", &[], &spec, false, 2);
583        // root's fingerprint is $3 (ctx_param_idx + 1), child's is $4 (DFS order).
584        assert!(sql.contains("LEFT JOIN site_snapshots s ON s.id = i.id AND s.fingerprint = $3"));
585        assert!(sql.contains("LEFT JOIN meter_snapshots s ON s.id = i.id AND s.fingerprint = $4"));
586    }
587
588    #[test]
589    fn snapshot_fingerprints_walks_dfs_order_only_snapshot_nodes() {
590        let grandchild = snapshotted(
591            leaf("line_items", "line_item_events", "billing_period_id"),
592            "line_item_snapshots",
593            3,
594        );
595        let child_a = TreeSpec {
596            children: vec![grandchild],
597            ..snapshotted(
598                leaf(
599                    "billing_periods",
600                    "billing_period_events",
601                    "subscription_id",
602                ),
603                "billing_period_snapshots",
604                2,
605            )
606        };
607        let child_b = leaf("invoices", "invoice_events", "subscription_id");
608        let spec = snapshotted(
609            root(
610                "subscriptions",
611                "subscription_events",
612                vec![child_a, child_b],
613            ),
614            "subscription_snapshots",
615            1,
616        );
617        assert_eq!(snapshot_fingerprints(&spec), vec![1, 2, 3]);
618    }
619}