Skip to main content

reddb_server/runtime/
impl_catalog_accessors.rs

1//! Runtime catalog / stats / maintenance accessors.
2//!
3//! Extracted verbatim from `impl_core.rs` (impl_core slice 8/10, issue #1629).
4//! Houses connection `acquire`, `checkpoint`, the remote-write assertion,
5//! `run_maintenance`, `scan_collection`, and the catalog /
6//! attention-summary / `stats` readers.
7use super::*;
8
9fn runtime_pool_lock(runtime: &RedDBRuntime) -> std::sync::MutexGuard<'_, PoolState> {
10    runtime
11        .inner
12        .pool
13        .lock()
14        .unwrap_or_else(|poisoned| poisoned.into_inner())
15}
16
17impl RedDBRuntime {
18    pub fn acquire(&self) -> RedDBResult<RuntimeConnection> {
19        self.acquire_connection(true)
20    }
21
22    // Transport admission already bounds wire sessions. Share connection IDs
23    // and rollback cleanup without imposing the embedded pool's separate cap.
24    pub(crate) fn acquire_wire_connection(&self) -> RedDBResult<RuntimeConnection> {
25        self.acquire_connection(false)
26    }
27
28    fn acquire_connection(&self, uses_pool_slot: bool) -> RedDBResult<RuntimeConnection> {
29        let mut pool = self
30            .inner
31            .pool
32            .lock()
33            .map_err(|e| RedDBError::Internal(format!("connection pool lock poisoned: {e}")))?;
34        if uses_pool_slot && pool.pooled_active >= self.inner.pool_config.max_connections {
35            return Err(RedDBError::Internal(
36                "connection pool exhausted".to_string(),
37            ));
38        }
39
40        let id = if let Some(id) = pool.idle.pop() {
41            id
42        } else {
43            let id = pool.next_id;
44            pool.next_id += 1;
45            id
46        };
47        pool.active += 1;
48        if uses_pool_slot {
49            pool.pooled_active += 1;
50        }
51        pool.total_checkouts += 1;
52        drop(pool);
53
54        // Issue #1245 — record the connection acquisition after releasing
55        // the pool lock so the lock hold time is unchanged.
56        self.inner.node_load_telemetry.record_connect();
57
58        Ok(RuntimeConnection {
59            background_tasks: self.background_tasks.clone(),
60            id,
61            uses_pool_slot,
62            inner: Arc::clone(&self.inner),
63        })
64    }
65
66    pub fn checkpoint(&self) -> RedDBResult<()> {
67        // Local fsync always allowed — losing the lease shouldn't
68        // prevent us from durably persisting what's already in memory.
69        // The remote upload is the side-effect that risks clobbering a
70        // peer's state, so it's behind the lease gate.
71        self.checkpoint_local()?;
72        if let Err(err) = self.assert_remote_write_allowed("checkpoint") {
73            tracing::warn!(
74                target: "reddb::serverless::lease",
75                error = %err,
76                "checkpoint: skipping remote upload — lease not held"
77            );
78            return Ok(());
79        }
80        self.publish_remote_snapshot()
81    }
82
83    pub(super) fn checkpoint_local(&self) -> RedDBResult<()> {
84        let started = std::time::Instant::now();
85        self.seal_hypertable_chunks_for_checkpoint(
86            self.inner.checkpoint_columnar_emission_budget_chunks,
87        )?;
88        let store = self.inner.db.store();
89        let publication = store.vault_publication_lock.lock();
90        // Collections created since startup need the compact visibility
91        // inventory before their first resident-to-disk publication too.
92        self.prepare_paged_counts()?;
93        self.persist_probabilistic_snapshots()?;
94        self.inner
95            .db
96            .flush_local_after_runtime_snapshots()
97            .map_err(|err| {
98                // Issue #205 — local flush failure is a CheckpointFailed
99                // operator-grade event. The local-flush path also covers
100                // the WAL fsync we depend on, so a failure here doubles as
101                // the WalFsyncFailed signal for the runtime entry point.
102                let msg = err.to_string();
103                crate::telemetry::operator_event::OperatorEvent::CheckpointFailed {
104                    lsn: 0,
105                    error: msg.clone(),
106                }
107                .emit_global();
108                crate::telemetry::operator_event::OperatorEvent::WalFsyncFailed {
109                    path: "<flush_local_only>".to_string(),
110                    error: msg.clone(),
111                }
112                .emit_global();
113                RedDBError::Engine(msg)
114            })?;
115        // The checkpoint now contains every mutation fenced above. Rebase the
116        // ordered live overlay while publication is still exclusive so a
117        // concurrent writer cannot be cleared after its WAL change.
118        self.index_store_ref().clear_paged_btree_overlays();
119        self.inner.probabilistic.replayed_names.lock().clear();
120        drop(publication);
121        let checkpoint_lsn = self.replication_commit_lsn();
122        self.inner
123            .checkpoint_projection_stats
124            .record_checkpoint(checkpoint_lsn, started.elapsed().as_millis() as u64);
125        Ok(())
126    }
127
128    /// Guard remote mutations on replication role and writer lease.
129    /// With a backend configured, replicas cannot publish and other nodes
130    /// require a `NotRequired` or `Held` lease state. Local checkpoints can
131    /// still finish when this guard returns `RedDBError::ReadOnly`.
132    pub(crate) fn assert_remote_write_allowed(&self, action: &str) -> RedDBResult<()> {
133        if self.inner.db.remote_backend.is_none() {
134            return Ok(());
135        }
136        if matches!(
137            self.inner.write_gate.role(),
138            crate::replication::ReplicationRole::Replica { .. }
139        ) {
140            return Err(RedDBError::ReadOnly(format!(
141                "replica cannot publish remote data — {action} blocked"
142            )));
143        }
144        match self.inner.write_gate.lease_state() {
145            crate::runtime::write_gate::LeaseGateState::NotHeld => {
146                self.inner.audit_log.record(
147                    action,
148                    "system",
149                    "remote_backend",
150                    "err: writer lease not held",
151                    crate::json::Value::Null,
152                );
153                Err(RedDBError::ReadOnly(format!(
154                    "writer lease not held — {action} blocked (serverless fence)"
155                )))
156            }
157            _ => Ok(()),
158        }
159    }
160
161    pub(super) fn publish_remote_snapshot(&self) -> RedDBResult<()> {
162        self.assert_remote_write_allowed("snapshot publication")?;
163        if let Some(lifecycle) = self.lease_lifecycle() {
164            let store = self.inner.db.store();
165            // Other snapshot entrypoints already hold this reentrant lock.
166            // Keep a consistent publication -> lease transition lock order.
167            let _publication = store.vault_publication_lock.lock();
168            if lifecycle.publish_snapshot(|| self.capture_serverless_snapshot())? {
169                return Ok(());
170            }
171        }
172        if self.inner.db.is_paged()
173            && self.inner.db.remote_backend.is_some()
174            && self.inner.db.remote_key.is_some()
175        {
176            // Native writers hold a lifetime file lock. Copy through the
177            // pager's own handle before handing the checkpoint to a backend.
178            let snapshot = self.capture_serverless_snapshot()?;
179            self.assert_remote_write_allowed("checkpoint upload")?;
180            return self
181                .inner
182                .db
183                .upload_checkpoint_to_remote_backend(snapshot.path())
184                .map_err(|err| RedDBError::Engine(err.to_string()));
185        }
186        self.inner
187            .db
188            .upload_to_remote_backend()
189            .map_err(|err| RedDBError::Engine(err.to_string()))
190    }
191
192    pub(crate) fn capture_serverless_snapshot(
193        &self,
194    ) -> RedDBResult<reddb_file::ServerlessWriterLeaseTempFile> {
195        use reddb_file::VfsFile;
196        use std::io::Write;
197        let store = self.inner.db.store();
198        let _publication = store.vault_publication_lock.lock();
199        self.checkpoint_local()?;
200        let snapshot = reddb_file::ServerlessWriterLeaseTempFile::new("snapshot-payload");
201        if let Some(pager) = store.pager() {
202            // Use the pager's own handle: Windows excludes reads through any
203            // second handle while the native writer owns the file lock.
204            pager
205                .with_file(|source| -> std::io::Result<()> {
206                    let mut output = std::fs::File::create(snapshot.path())?;
207                    source.seek(std::io::SeekFrom::Start(0))?;
208                    let mut buffer = [0u8; 65536];
209                    loop {
210                        let count = source.read(&mut buffer)?;
211                        if count == 0 {
212                            break;
213                        }
214                        output.write_all(&buffer[..count])?;
215                    }
216                    output.sync_all()
217                })
218                .map_err(|err| RedDBError::Internal(err.to_string()))?
219                .map_err(|err| RedDBError::Internal(err.to_string()))?;
220        } else {
221            let path = self.inner.db.path().ok_or_else(|| {
222                RedDBError::InvalidOperation(
223                    "serverless snapshots require persistent local storage".into(),
224                )
225            })?;
226            std::fs::copy(path, snapshot.path())
227                .map_err(|err| RedDBError::Internal(err.to_string()))?;
228        }
229        Ok(snapshot)
230    }
231
232    pub fn run_maintenance(&self) -> RedDBResult<()> {
233        self.inner
234            .db
235            .run_maintenance()
236            .map_err(|err| RedDBError::Internal(err.to_string()))
237    }
238
239    pub fn scan_collection(
240        &self,
241        collection: &str,
242        cursor: Option<ScanCursor>,
243        limit: usize,
244    ) -> RedDBResult<ScanPage> {
245        use reddb_rql::ast::PolicyAction;
246
247        self.check_collection_privilege(PolicyAction::Select, collection)?;
248        let store = self.inner.db.store();
249        let manager = store
250            .get_collection(collection)
251            .ok_or_else(|| RedDBError::NotFound(collection.to_string()))?;
252
253        let offset = cursor.map(|cursor| cursor.offset).unwrap_or(0);
254        let limit = limit.max(1);
255        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
256            .unwrap_or(usize::MAX)
257            .clamp(1, 16 * 1024 * 1024);
258        let (snapshot, _retention) = self.native_read_snapshot()?;
259        let _snapshot_guard = super::impl_core::CurrentSnapshotGuard::install(snapshot.clone());
260        let (total, items) = manager.try_page_in_id_order_where_at_snapshot(
261            Some(&snapshot),
262            offset,
263            limit,
264            read_bytes,
265            |entity| {
266                crate::runtime::rls_injection::entity_passes_rls_action(
267                    self,
268                    collection,
269                    PolicyAction::Select,
270                    entity,
271                )
272            },
273        )?;
274        let end = total.min(offset.saturating_add(limit));
275        let next = (end < total).then_some(ScanCursor { offset: end });
276
277        Ok(ScanPage {
278            collection: collection.to_string(),
279            items,
280            next,
281            total,
282        })
283    }
284
285    pub fn get_collection_entity(
286        &self,
287        collection: &str,
288        id: crate::storage::EntityId,
289    ) -> RedDBResult<Option<crate::storage::UnifiedEntity>> {
290        self.get_collection_entity_for_action(collection, id, reddb_rql::ast::PolicyAction::Select)
291    }
292
293    pub(crate) fn get_collection_entity_for_action(
294        &self,
295        collection: &str,
296        id: crate::storage::EntityId,
297        action: reddb_rql::ast::PolicyAction,
298    ) -> RedDBResult<Option<crate::storage::UnifiedEntity>> {
299        self.check_collection_privilege(action, collection)?;
300        let store = self.inner.db.store();
301        let Some(manager) = store.get_collection(collection) else {
302            return Ok(None);
303        };
304        let (snapshot, _retention) = self.native_read_snapshot()?;
305        let _snapshot_guard = super::impl_core::CurrentSnapshotGuard::install(snapshot.clone());
306        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
307            .unwrap_or(usize::MAX)
308            .clamp(1, 16 * 1024 * 1024);
309        let mut entity = manager.try_get(id, read_bytes)?.filter(|candidate| {
310            candidate.logical_id() == id
311                && super::impl_core::entity_visible_with_context(Some(&snapshot), candidate)
312        });
313        // Public IDs are logical identities. A direct physical lookup can
314        // return a superseded version or miss the version visible to a reader.
315        if entity.is_none() {
316            manager.try_for_each_entity(read_bytes, |candidate| {
317                if candidate.logical_id() == id
318                    && super::impl_core::entity_visible_with_context(Some(&snapshot), candidate)
319                    && entity.as_ref().is_none_or(|selected: &UnifiedEntity| {
320                        (candidate.xmin, candidate.id) > (selected.xmin, selected.id)
321                    })
322                {
323                    entity = Some(candidate.clone());
324                }
325                true
326            })?;
327        }
328        manager.restore_owned_row_schemas(entity.iter_mut());
329        Ok(entity.filter(|entity| {
330            crate::runtime::rls_injection::entity_passes_rls_action(
331                self, collection, action, entity,
332            )
333        }))
334    }
335
336    pub(crate) fn native_read_snapshot(
337        &self,
338    ) -> RedDBResult<(
339        super::impl_core::SnapshotContext,
340        crate::storage::transaction::snapshot::SnapshotRetentionGuard,
341    )> {
342        let state = &self.inner.transaction_state;
343        let manager = state.snapshot_manager();
344        if let Some(context) = super::impl_core::capture_current_snapshot()
345            .filter(|context| Arc::ptr_eq(&context.manager, &manager))
346        {
347            let (_, retention) = manager.retain_snapshot(context.snapshot.clone());
348            return Ok((context, retention));
349        }
350        let connection_id = super::impl_core::current_connection_id();
351        let store = self.inner.db.store();
352        let uses_vcs = self.vcs_has_versioned_collections()?;
353        let _publication = uses_vcs.then(|| store.vault_publication_lock.lock());
354        let (snapshot, retention) = state.retained_current_snapshot(connection_id);
355        let branch = if uses_vcs {
356            self.vcs_branch_read_view(&snapshot, None)?
357        } else {
358            None
359        };
360        let context = state.context(connection_id);
361        Ok((
362            super::impl_core::SnapshotContext {
363                branch,
364                snapshot,
365                manager,
366                own_xids: self.own_transaction_xids(),
367                requires_index_fallback: true,
368                serializable_reader: context
369                    .filter(|context| {
370                        context.isolation
371                            == crate::storage::transaction::IsolationLevel::Serializable
372                    })
373                    .map(|context| context.xid),
374            },
375            retention,
376        ))
377    }
378
379    pub fn catalog(&self) -> CatalogModelSnapshot {
380        self.inner.db.catalog_model_snapshot()
381    }
382
383    /// Fallible catalog for disk-resident databases. The decode workspace shares
384    /// query admission with concurrent reads and is released on every error.
385    pub fn try_catalog(&self) -> RedDBResult<CatalogModelSnapshot> {
386        if self
387            .inner
388            .db
389            .store()
390            .config()
391            .resident_memory_bytes_max
392            .is_none()
393        {
394            return Ok(self.catalog());
395        }
396        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
397            .unwrap_or(usize::MAX)
398            .clamp(1, 16 * 1024 * 1024);
399        let _scratch = self
400            .try_reserve_query_buffer(read_bytes * 2)
401            .ok_or_else(|| {
402                self.memory_accounting().record_admission_denied();
403                RedDBError::MaterializationLimitExceeded {
404                    executor: "catalog",
405                    limit: usize::try_from(self.memory_budget().resolved_bytes)
406                        .unwrap_or(usize::MAX),
407                    current: usize::try_from(self.memory_accounting().total_used_bytes())
408                        .unwrap_or(usize::MAX)
409                        .saturating_add(1),
410                }
411            })?;
412        Ok(self.inner.db.try_catalog_model_snapshot(read_bytes)?)
413    }
414
415    /// Fallible catalog inventory scoped to known collection names. This keeps
416    /// filtered `red.collections` queries from decoding every unrelated
417    /// collection before their predicate is applied.
418    pub(crate) fn try_catalog_for_collections(
419        &self,
420        collection_names: &std::collections::HashSet<String>,
421        observe_legacy_payloads: bool,
422    ) -> RedDBResult<CatalogModelSnapshot> {
423        if self
424            .inner
425            .db
426            .store()
427            .config()
428            .resident_memory_bytes_max
429            .is_none()
430        {
431            return Ok(self.inner.db.try_catalog_model_snapshot_for_collections(
432                collection_names,
433                observe_legacy_payloads,
434                usize::MAX,
435            )?);
436        }
437        let read_bytes = usize::try_from(self.memory_budget().resolved_bytes / 16)
438            .unwrap_or(usize::MAX)
439            .clamp(1, 16 * 1024 * 1024);
440        let _scratch = self
441            .try_reserve_query_buffer(read_bytes * 2)
442            .ok_or_else(|| {
443                self.memory_accounting().record_admission_denied();
444                RedDBError::MaterializationLimitExceeded {
445                    executor: "catalog",
446                    limit: usize::try_from(self.memory_budget().resolved_bytes)
447                        .unwrap_or(usize::MAX),
448                    current: usize::try_from(self.memory_accounting().total_used_bytes())
449                        .unwrap_or(usize::MAX)
450                        .saturating_add(1),
451                }
452            })?;
453        Ok(self.inner.db.try_catalog_model_snapshot_for_collections(
454            collection_names,
455            observe_legacy_payloads,
456            read_bytes,
457        )?)
458    }
459
460    pub fn catalog_consistency_report(&self) -> crate::catalog::CatalogConsistencyReport {
461        self.inner.db.catalog_consistency_report()
462    }
463
464    pub fn catalog_attention_summary(&self) -> CatalogAttentionSummary {
465        crate::catalog::attention_summary(&self.catalog())
466    }
467
468    pub fn collection_attention(&self) -> Vec<CollectionDescriptor> {
469        crate::catalog::collection_attention(&self.catalog())
470    }
471
472    pub fn index_attention(&self) -> Vec<CatalogIndexStatus> {
473        crate::catalog::index_attention(&self.catalog())
474    }
475
476    pub fn graph_projection_attention(&self) -> Vec<CatalogGraphProjectionStatus> {
477        crate::catalog::graph_projection_attention(&self.catalog())
478    }
479
480    pub fn analytics_job_attention(&self) -> Vec<CatalogAnalyticsJobStatus> {
481        crate::catalog::analytics_job_attention(&self.catalog())
482    }
483
484    pub fn stats(&self) -> RuntimeStats {
485        let pool = runtime_pool_lock(self);
486        RuntimeStats {
487            active_connections: pool.active,
488            idle_connections: pool.idle.len(),
489            total_checkouts: pool.total_checkouts,
490            paged_mode: self.inner.db.is_paged(),
491            started_at_unix_ms: self.inner.started_at_unix_ms,
492            store: self.inner.db.stats(),
493            system: SystemInfo::collect(),
494            result_blob_cache: self.inner.result_blob_cache.stats(),
495            kv: self.inner.kv_stats.snapshot(),
496            metrics_ingest: self.inner.metrics_ingest_stats.snapshot(),
497        }
498    }
499}
500
501#[cfg(test)]
502mod security_boundary_tests {
503    use super::*;
504    use crate::application::entity::{DeleteEntityInput, PatchEntityInput};
505    use crate::application::ports::RuntimeEntityPort;
506    use crate::auth::Role;
507    use crate::runtime::execution_context::{with_snapshot_bundle, SnapshotBundle};
508    use crate::storage::{EntityData, UnifiedEntity};
509    use reddb_types::Value;
510
511    fn text_field<'a>(entity: &'a UnifiedEntity, name: &str) -> Option<&'a str> {
512        let EntityData::Row(row) = &entity.data else {
513            return None;
514        };
515        let value = row
516            .named
517            .as_ref()
518            .and_then(|named| named.get(name))
519            .or_else(|| {
520                row.schema.as_ref().and_then(|schema| {
521                    schema
522                        .iter()
523                        .position(|column| column == name)
524                        .and_then(|index| row.columns.get(index))
525                })
526            })?;
527        match value {
528            Value::Text(value) => Some(value.as_ref()),
529            _ => None,
530        }
531    }
532
533    #[test]
534    fn typed_entity_boundary_filters_before_pagination_and_hides_foreign_ids() {
535        let runtime = RedDBRuntime::in_memory().expect("runtime");
536        runtime
537            .execute_query(
538                "CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT) TENANT BY (tenant_id)",
539            )
540            .expect("tenant table");
541        runtime
542            .execute_query(
543                "INSERT INTO docs (id, tenant_id, body) VALUES \
544                 (1, 'acme', 'first'), (2, 'globex', 'hidden'), (3, 'acme', 'second')",
545            )
546            .expect("seed platform rows");
547
548        let unfiltered = runtime
549            .scan_collection("docs", None, 10)
550            .expect("platform scan");
551        let foreign_id = unfiltered
552            .items
553            .iter()
554            .find(|entity| text_field(entity, "tenant_id") == Some("globex"))
555            .map(|entity| entity.id)
556            .expect("foreign row");
557
558        let bundle = SnapshotBundle {
559            snapshot: None,
560            auth: Some(("alice".to_string(), Role::Write)),
561            auth_principal: Some(crate::auth::UserId::scoped("acme", "alice")),
562            tenant: Some("acme".to_string()),
563        };
564        with_snapshot_bundle(&bundle, || {
565            let first = runtime
566                .scan_collection("docs", None, 1)
567                .expect("first visible page");
568            assert_eq!(first.total, 2);
569            assert_eq!(first.items.len(), 1);
570            assert_eq!(text_field(&first.items[0], "tenant_id"), Some("acme"));
571            assert_eq!(first.next, Some(ScanCursor { offset: 1 }));
572
573            let second = runtime
574                .scan_collection("docs", first.next, 1)
575                .expect("second visible page");
576            assert_eq!(second.total, 2);
577            assert_eq!(second.items.len(), 1);
578            assert_eq!(text_field(&second.items[0], "tenant_id"), Some("acme"));
579            assert_eq!(second.next, None);
580
581            assert!(runtime
582                .get_collection_entity("docs", foreign_id)
583                .expect("typed get")
584                .is_none());
585            assert!(matches!(
586                runtime.patch_entity(PatchEntityInput {
587                    collection: "docs".to_string(),
588                    id: foreign_id,
589                    payload: crate::json::Value::Object(Default::default()),
590                    operations: Vec::new(),
591                }),
592                Err(crate::RedDBError::NotFound(_))
593            ));
594            let deleted = runtime
595                .delete_entity(DeleteEntityInput {
596                    collection: "docs".to_string(),
597                    id: foreign_id,
598                })
599                .expect("typed delete");
600            assert!(!deleted.deleted);
601        });
602
603        assert!(runtime.db().store().get("docs", foreign_id).is_some());
604    }
605
606    #[test]
607    fn scan_collection_pages_the_mvcc_view_across_rollback() {
608        let runtime = RedDBRuntime::in_memory().expect("runtime");
609        runtime
610            .execute_query("CREATE TABLE docs (id INT, body TEXT)")
611            .expect("table");
612        runtime
613            .execute_query("INSERT INTO docs (id, body) VALUES (1, 'kept')")
614            .expect("seed row");
615
616        runtime.execute_query("BEGIN").expect("begin");
617        runtime
618            .execute_query("DELETE FROM docs WHERE id = 1")
619            .expect("delete in transaction");
620        let deleted = runtime
621            .scan_collection("docs", None, 1)
622            .expect("scan own transaction");
623        assert_eq!(deleted.total, 0);
624        assert!(deleted.items.is_empty());
625
626        runtime.execute_query("ROLLBACK").expect("rollback");
627        let restored = runtime
628            .scan_collection("docs", None, 1)
629            .expect("scan after rollback");
630        assert_eq!(restored.total, 1);
631        assert_eq!(restored.items.len(), 1);
632        assert_eq!(text_field(&restored.items[0], "body"), Some("kept"));
633        assert_eq!(restored.next, None);
634    }
635
636    #[test]
637    fn typed_entity_boundary_uses_model_specific_rls_policies() {
638        let runtime = RedDBRuntime::in_memory().expect("runtime");
639        for query in [
640            "CREATE GRAPH links",
641            "INSERT INTO links NODE (label,node_type,visible) VALUES ('shown','Item',true)",
642            "INSERT INTO links NODE (label,node_type,visible) VALUES ('hidden','Item',false)",
643            "CREATE POLICY visible_nodes ON NODES OF links FOR SELECT USING (properties.visible=true)",
644            "ALTER TABLE links ENABLE ROW LEVEL SECURITY",
645            "CREATE DOCUMENT papers",
646            "INSERT INTO papers DOCUMENT VALUES ({name:'shown',visible:true})",
647            "INSERT INTO papers DOCUMENT VALUES ({name:'hidden',visible:false})",
648            "CREATE POLICY visible_documents ON DOCUMENTS OF papers FOR SELECT USING (visible=true)",
649            "ALTER TABLE papers ENABLE ROW LEVEL SECURITY",
650        ] {
651            runtime.execute_query(query).expect(query);
652        }
653
654        let bundle = SnapshotBundle {
655            snapshot: None,
656            auth: Some(("alice".to_string(), Role::Read)),
657            auth_principal: Some(crate::auth::UserId::platform("alice")),
658            tenant: None,
659        };
660        with_snapshot_bundle(&bundle, || {
661            let nodes = runtime
662                .scan_collection("links", None, 10)
663                .expect("typed graph scan");
664            assert_eq!(nodes.total, 1);
665            assert_eq!(nodes.items.len(), 1);
666
667            let documents = runtime
668                .scan_collection("papers", None, 10)
669                .expect("typed document scan");
670            assert_eq!(documents.total, 1);
671            assert_eq!(documents.items.len(), 1);
672        });
673    }
674}