Skip to content

Commit 11fc512

Browse files
Merge bounded Notion root setup discovery
Merge reviewed bounded Notion root discovery and validation on top of the OAuth boundary.
2 parents 82f74b0 + dbbd414 commit 11fc512

5 files changed

Lines changed: 1145 additions & 21 deletions

File tree

crates/locality-notion/src/client.rs

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,19 @@ pub trait NotionApi: std::fmt::Debug + Send + Sync {
151151
let _ = start_cursor;
152152
Err(LocalityError::NotImplemented("search Notion databases"))
153153
}
154+
/// Search database metadata while allowing callers to bound provider-side
155+
/// work. Existing implementations remain source-compatible, but the
156+
/// default fails closed so setup cannot silently use an unbounded search.
157+
/// The HTTP client enforces the requested result bound before retrieving
158+
/// database metadata.
159+
fn search_databases_bounded(
160+
&self,
161+
start_cursor: Option<&str>,
162+
max_results: usize,
163+
) -> LocalityResult<DatabaseListDto> {
164+
let _ = (start_cursor, max_results);
165+
Err(LocalityError::Unsupported("bounded Notion database search"))
166+
}
154167
fn update_block(&self, block_id: &str, body: serde_json::Value) -> LocalityResult<BlockDto>;
155168
fn move_block(
156169
&self,
@@ -850,21 +863,25 @@ impl NotionApi for HttpNotionApi {
850863
}
851864

852865
fn search_databases(&self, start_cursor: Option<&str>) -> LocalityResult<DatabaseListDto> {
853-
let data_sources: DataSourceListDto =
854-
self.post_read_json("/v1/search", data_source_search_body(start_cursor))?;
855-
let mut seen = BTreeSet::new();
866+
self.search_databases_bounded(start_cursor, 100)
867+
}
868+
869+
fn search_databases_bounded(
870+
&self,
871+
start_cursor: Option<&str>,
872+
max_results: usize,
873+
) -> LocalityResult<DatabaseListDto> {
874+
if max_results == 0 {
875+
return Ok(DatabaseListDto::default());
876+
}
877+
let max_results = max_results.min(100);
878+
let data_sources: DataSourceListDto = self.post_read_json(
879+
"/v1/search",
880+
data_source_search_body(start_cursor, max_results),
881+
)?;
856882
let mut databases = Vec::new();
857-
for data_source in data_sources.results {
858-
let Some(database_id) = data_source
859-
.parent
860-
.as_ref()
861-
.and_then(|parent| parent.database_id.as_deref())
862-
else {
863-
continue;
864-
};
865-
if seen.insert(database_id.to_string()) {
866-
databases.push(self.retrieve_database(database_id)?);
867-
}
883+
for database_id in unique_database_ids(&data_sources.results, max_results) {
884+
databases.push(self.retrieve_database(&database_id)?);
868885
}
869886

870887
Ok(DatabaseListDto {
@@ -931,9 +948,9 @@ impl NotionApi for HttpNotionApi {
931948
}
932949
}
933950

934-
fn data_source_search_body(start_cursor: Option<&str>) -> Value {
951+
fn data_source_search_body(start_cursor: Option<&str>, page_size: usize) -> Value {
935952
let mut body = json!({
936-
"page_size": 100,
953+
"page_size": page_size,
937954
"filter": {
938955
"property": "object",
939956
"value": "data_source"
@@ -951,13 +968,30 @@ fn data_source_search_body(start_cursor: Option<&str>) -> Value {
951968
body
952969
}
953970

971+
fn unique_database_ids(data_sources: &[DataSourceDto], max_results: usize) -> Vec<String> {
972+
let mut seen = BTreeSet::new();
973+
data_sources
974+
.iter()
975+
.filter_map(|data_source| {
976+
data_source
977+
.parent
978+
.as_ref()
979+
.and_then(|parent| parent.database_id.as_deref())
980+
})
981+
.filter(|database_id| seen.insert((*database_id).to_string()))
982+
.take(max_results)
983+
.map(str::to_string)
984+
.collect()
985+
}
986+
954987
#[cfg(test)]
955988
mod tests {
956989
use super::{
957990
HttpNotionApi, NotionResponseInterpretation, NotionRetryClass, data_source_search_body,
958991
notion_http_client_builder, notion_network_config, notion_page_lookup_reports_database,
959-
rate_limit_backoff, retry_after_header,
992+
rate_limit_backoff, retry_after_header, unique_database_ids,
960993
};
994+
use crate::dto::{DataSourceDto, ParentDto};
961995
use locality_core::LocalityError;
962996
use reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER};
963997
use serde_json::Value;
@@ -972,11 +1006,39 @@ mod tests {
9721006

9731007
#[test]
9741008
fn search_databases_uses_current_notion_data_source_filter() {
975-
let body = data_source_search_body(Some("cursor-1"));
1009+
let body = data_source_search_body(Some("cursor-1"), 1);
9761010

9771011
assert_eq!(body["filter"]["property"], "object");
9781012
assert_eq!(body["filter"]["value"], "data_source");
9791013
assert_eq!(body["start_cursor"], "cursor-1");
1014+
assert_eq!(body["page_size"], 1);
1015+
}
1016+
1017+
#[test]
1018+
fn bounded_database_search_selects_only_the_requested_unique_metadata_retrievals() {
1019+
let data_sources = vec![
1020+
data_source("source-1", "database-1"),
1021+
data_source("source-2", "database-1"),
1022+
data_source("source-3", "database-2"),
1023+
];
1024+
1025+
assert_eq!(unique_database_ids(&data_sources, 1), vec!["database-1"]);
1026+
assert_eq!(
1027+
unique_database_ids(&data_sources, 2),
1028+
vec!["database-1", "database-2"]
1029+
);
1030+
}
1031+
1032+
fn data_source(id: &str, database_id: &str) -> DataSourceDto {
1033+
DataSourceDto {
1034+
id: id.to_string(),
1035+
parent: Some(ParentDto {
1036+
kind: "database_id".to_string(),
1037+
database_id: Some(database_id.to_string()),
1038+
..Default::default()
1039+
}),
1040+
..Default::default()
1041+
}
9801042
}
9811043

9821044
#[test]

crates/locality-notion/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub mod oauth;
1616
mod portable;
1717
pub mod projection;
1818
pub mod render;
19+
pub mod root_setup;
1920
pub mod schema;
2021

2122
use std::collections::BTreeSet;
@@ -48,6 +49,7 @@ use crate::projection::{
4849
use crate::render::{
4950
NotionRenderedEntity, RenderOptions, render_native_entity, render_native_entity_with_options,
5051
};
52+
use crate::root_setup::NotionRootSetup;
5153

5254
#[derive(Clone, PartialEq, Eq)]
5355
pub struct NotionConfig {
@@ -144,6 +146,12 @@ impl NotionConnector {
144146
&self.config
145147
}
146148

149+
/// Return the provider-specific, metadata-only facade used to choose and
150+
/// revalidate explicit Notion roots during source setup.
151+
pub fn root_setup(&self) -> NotionRootSetup {
152+
NotionRootSetup::with_api(Arc::clone(&self.api))
153+
}
154+
147155
pub fn with_root_page_id(&self, root_page_id: locality_core::model::RemoteId) -> Self {
148156
let mut config = self.config.clone();
149157
config.root_page_id = Some(root_page_id.clone());

crates/locality-notion/src/projection.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ fn retrieve_explicit_root(
9090
}
9191
}
9292

93-
fn validate_explicit_root_identity(
93+
pub(crate) fn validate_explicit_root_identity(
9494
requested: &RemoteId,
9595
returned: &str,
9696
kind: &str,
@@ -521,7 +521,7 @@ impl TreeEntrySink for ExplicitRootSink<'_> {
521521
}
522522
}
523523

524-
fn explicit_root_identity_key(value: &str) -> String {
524+
pub(crate) fn explicit_root_identity_key(value: &str) -> String {
525525
value
526526
.chars()
527527
.filter(|character| *character != '-')
@@ -1192,7 +1192,7 @@ fn notion_source_url(id: &str) -> String {
11921192
format!("https://www.notion.so/{}", compact_notion_id(id))
11931193
}
11941194

1195-
fn database_title(database: &DatabaseDto) -> Option<String> {
1195+
pub(crate) fn database_title(database: &DatabaseDto) -> Option<String> {
11961196
let title = rich_text_plain_text(&database.title);
11971197
if title.trim().is_empty() {
11981198
None

0 commit comments

Comments
 (0)