Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 80 additions & 18 deletions crates/locality-notion/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,19 @@ pub trait NotionApi: std::fmt::Debug + Send + Sync {
let _ = start_cursor;
Err(LocalityError::NotImplemented("search Notion databases"))
}
/// Search database metadata while allowing callers to bound provider-side
/// work. Existing implementations remain source-compatible, but the
/// default fails closed so setup cannot silently use an unbounded search.
/// The HTTP client enforces the requested result bound before retrieving
/// database metadata.
fn search_databases_bounded(
&self,
start_cursor: Option<&str>,
max_results: usize,
) -> LocalityResult<DatabaseListDto> {
let _ = (start_cursor, max_results);
Err(LocalityError::Unsupported("bounded Notion database search"))
}
fn update_block(&self, block_id: &str, body: serde_json::Value) -> LocalityResult<BlockDto>;
fn move_block(
&self,
Expand Down Expand Up @@ -850,21 +863,25 @@ impl NotionApi for HttpNotionApi {
}

fn search_databases(&self, start_cursor: Option<&str>) -> LocalityResult<DatabaseListDto> {
let data_sources: DataSourceListDto =
self.post_read_json("/v1/search", data_source_search_body(start_cursor))?;
let mut seen = BTreeSet::new();
self.search_databases_bounded(start_cursor, 100)
}

fn search_databases_bounded(
&self,
start_cursor: Option<&str>,
max_results: usize,
) -> LocalityResult<DatabaseListDto> {
if max_results == 0 {
return Ok(DatabaseListDto::default());
}
let max_results = max_results.min(100);
let data_sources: DataSourceListDto = self.post_read_json(
"/v1/search",
data_source_search_body(start_cursor, max_results),
)?;
let mut databases = Vec::new();
for data_source in data_sources.results {
let Some(database_id) = data_source
.parent
.as_ref()
.and_then(|parent| parent.database_id.as_deref())
else {
continue;
};
if seen.insert(database_id.to_string()) {
databases.push(self.retrieve_database(database_id)?);
}
for database_id in unique_database_ids(&data_sources.results, max_results) {
databases.push(self.retrieve_database(&database_id)?);
}

Ok(DatabaseListDto {
Expand Down Expand Up @@ -931,9 +948,9 @@ impl NotionApi for HttpNotionApi {
}
}

fn data_source_search_body(start_cursor: Option<&str>) -> Value {
fn data_source_search_body(start_cursor: Option<&str>, page_size: usize) -> Value {
let mut body = json!({
"page_size": 100,
"page_size": page_size,
"filter": {
"property": "object",
"value": "data_source"
Expand All @@ -951,13 +968,30 @@ fn data_source_search_body(start_cursor: Option<&str>) -> Value {
body
}

fn unique_database_ids(data_sources: &[DataSourceDto], max_results: usize) -> Vec<String> {
let mut seen = BTreeSet::new();
data_sources
.iter()
.filter_map(|data_source| {
data_source
.parent
.as_ref()
.and_then(|parent| parent.database_id.as_deref())
})
.filter(|database_id| seen.insert((*database_id).to_string()))
.take(max_results)
.map(str::to_string)
.collect()
}

#[cfg(test)]
mod tests {
use super::{
HttpNotionApi, NotionResponseInterpretation, NotionRetryClass, data_source_search_body,
notion_http_client_builder, notion_network_config, notion_page_lookup_reports_database,
rate_limit_backoff, retry_after_header,
rate_limit_backoff, retry_after_header, unique_database_ids,
};
use crate::dto::{DataSourceDto, ParentDto};
use locality_core::LocalityError;
use reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER};
use serde_json::Value;
Expand All @@ -972,11 +1006,39 @@ mod tests {

#[test]
fn search_databases_uses_current_notion_data_source_filter() {
let body = data_source_search_body(Some("cursor-1"));
let body = data_source_search_body(Some("cursor-1"), 1);

assert_eq!(body["filter"]["property"], "object");
assert_eq!(body["filter"]["value"], "data_source");
assert_eq!(body["start_cursor"], "cursor-1");
assert_eq!(body["page_size"], 1);
}

#[test]
fn bounded_database_search_selects_only_the_requested_unique_metadata_retrievals() {
let data_sources = vec![
data_source("source-1", "database-1"),
data_source("source-2", "database-1"),
data_source("source-3", "database-2"),
];

assert_eq!(unique_database_ids(&data_sources, 1), vec!["database-1"]);
assert_eq!(
unique_database_ids(&data_sources, 2),
vec!["database-1", "database-2"]
);
}

fn data_source(id: &str, database_id: &str) -> DataSourceDto {
DataSourceDto {
id: id.to_string(),
parent: Some(ParentDto {
kind: "database_id".to_string(),
database_id: Some(database_id.to_string()),
..Default::default()
}),
..Default::default()
}
}

#[test]
Expand Down
8 changes: 8 additions & 0 deletions crates/locality-notion/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod oauth;
mod portable;
pub mod projection;
pub mod render;
pub mod root_setup;
pub mod schema;

use std::collections::BTreeSet;
Expand Down Expand Up @@ -48,6 +49,7 @@ use crate::projection::{
use crate::render::{
NotionRenderedEntity, RenderOptions, render_native_entity, render_native_entity_with_options,
};
use crate::root_setup::NotionRootSetup;

#[derive(Clone, PartialEq, Eq)]
pub struct NotionConfig {
Expand Down Expand Up @@ -144,6 +146,12 @@ impl NotionConnector {
&self.config
}

/// Return the provider-specific, metadata-only facade used to choose and
/// revalidate explicit Notion roots during source setup.
pub fn root_setup(&self) -> NotionRootSetup {
NotionRootSetup::with_api(Arc::clone(&self.api))
}

pub fn with_root_page_id(&self, root_page_id: locality_core::model::RemoteId) -> Self {
let mut config = self.config.clone();
config.root_page_id = Some(root_page_id.clone());
Expand Down
6 changes: 3 additions & 3 deletions crates/locality-notion/src/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ fn retrieve_explicit_root(
}
}

fn validate_explicit_root_identity(
pub(crate) fn validate_explicit_root_identity(
requested: &RemoteId,
returned: &str,
kind: &str,
Expand Down Expand Up @@ -521,7 +521,7 @@ impl TreeEntrySink for ExplicitRootSink<'_> {
}
}

fn explicit_root_identity_key(value: &str) -> String {
pub(crate) fn explicit_root_identity_key(value: &str) -> String {
value
.chars()
.filter(|character| *character != '-')
Expand Down Expand Up @@ -1192,7 +1192,7 @@ fn notion_source_url(id: &str) -> String {
format!("https://www.notion.so/{}", compact_notion_id(id))
}

fn database_title(database: &DatabaseDto) -> Option<String> {
pub(crate) fn database_title(database: &DatabaseDto) -> Option<String> {
let title = rich_text_plain_text(&database.title);
if title.trim().is_empty() {
None
Expand Down
Loading
Loading