Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import MiniSearch from "minisearch";
import getDiscourseNodes from "~/utils/getDiscourseNodes";
import { searchDiscourseNodes } from "~/utils/searchDiscourseNodes";
import {
searchIndexedNodes,
searchDiscourseNodesWithMiniSearch,
sortSearchResults,
type ScoredSearchResult,
type SearchResult,
type SortConfig,
} from "./utils";
Expand Down Expand Up @@ -32,37 +35,91 @@ export const useAdvancedNodeSearchResults = ({
searchIndex,
dockedQuery,
dockedResults,
}: UseAdvancedNodeSearchResultsArgs): SearchResult[] =>
useMemo(() => {
if (!debouncedSearchTerm) return [];

const isDockedQuery =
}: UseAdvancedNodeSearchResultsArgs): SearchResult[] => {
const isDockedQuery = useMemo(
() =>
dockedQuery !== undefined &&
debouncedSearchTerm.trim() === dockedQuery.trim();
debouncedSearchTerm.trim() === dockedQuery.trim(),
[debouncedSearchTerm, dockedQuery],
);

const [unsortedScoredResults, setUnsortedScoredResults] = useState<
ScoredSearchResult[]
>([]);

if (isDockedQuery && dockedResults) {
return dockedResults;
useEffect(() => {
if (isDockedQuery) {
setUnsortedScoredResults([]);
return;
}

if (!debouncedSearchTerm) {
setUnsortedScoredResults([]);
return;
}
Comment thread
trangdoan982 marked this conversation as resolved.

if (isIndexLoading || indexError || !searchIndex) {
return [];
setUnsortedScoredResults([]);
return;
}

const scoredHits = searchIndexedNodes({
miniSearch: searchIndex.miniSearch,
allResults: searchIndex.allResults,
searchTerm: debouncedSearchTerm,
typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined,
});
setUnsortedScoredResults([]);
let cancelled = false;
const typeFilter = selectedNodeTypeIds.length
? selectedNodeTypeIds
: undefined;
const discourseNodes = getDiscourseNodes().filter(
(node) =>
node.backedBy === "user" &&
(!typeFilter || typeFilter.includes(node.type)),
);
const resultsByUid = new Map(
searchIndex.allResults.map((result) => [result.uid, result]),
);

const runMiniSearch = (): ScoredSearchResult[] =>
searchDiscourseNodesWithMiniSearch({
miniSearch: searchIndex.miniSearch,
allResults: searchIndex.allResults,
searchTerm: debouncedSearchTerm,
typeFilter,
});

return sortSearchResults({ hits: scoredHits, sort });
void searchDiscourseNodes({
nodeTypes: discourseNodes,
query: debouncedSearchTerm,
resultsByUid,
runMiniSearch,
})
.then((results) => {
if (cancelled) return;
setUnsortedScoredResults(results);
})
.catch(() => {
if (cancelled) return;
setUnsortedScoredResults(runMiniSearch());
});

return () => {
cancelled = true;
};
}, [
debouncedSearchTerm,
dockedQuery,
dockedResults,
indexError,
isDockedQuery,
isIndexLoading,
searchIndex,
selectedNodeTypeIds,
sort,
]);
Comment thread
trangdoan982 marked this conversation as resolved.

const results = useMemo(
() => sortSearchResults({ scoredResults: unsortedScoredResults, sort }),
[unsortedScoredResults, sort],
);

if (isDockedQuery && dockedResults) {
return dockedResults;
}

return results;
};
57 changes: 29 additions & 28 deletions apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import {
getPulledDiscourseNodeUid,
queryDiscourseNodesByFormat,
} from "~/utils/discourseNodeSearch";
import type {
ScoredSearchResult,
SearchResult,
} from "~/utils/discourseNodeSearchTypes";

export type {
ScoredSearchResult,
SearchResult,
} from "~/utils/discourseNodeSearchTypes";

export const DEBOUNCE_MS = 250;
export const MAX_RESULTS = 50;
Expand Down Expand Up @@ -36,17 +45,6 @@ export const SORT_FIELD_LABELS: Record<SortField, string> = {
author: "Author",
};

export type SearchResult = {
uid: string;
title: string;
type: string;
nodeTypeLabel: string;
excerpt: string;
createdAt: string;
lastModified: string;
authorName: string;
};

export type DockedSearchState = {
query: string;
results: SearchResult[];
Expand All @@ -56,11 +54,6 @@ export type DockedSearchState = {
dgSearchId?: string;
};

export type ScoredSearchHit = {
result: SearchResult;
score: number;
};

type MiniSearchDocument = SearchResult & {
id: string;
};
Expand Down Expand Up @@ -214,22 +207,26 @@ export const isNonDefaultSort = (sort: SortConfig): boolean =>
sort.direction !== DEFAULT_SORT_CONFIG.direction;

export const sortSearchResults = ({
hits,
scoredResults,
sort,
}: {
hits: ScoredSearchHit[];
scoredResults: ScoredSearchResult[];
sort: SortConfig;
}): SearchResult[] => {
const sorted = [...hits];
const sorted = [...scoredResults];

sorted.sort((aHit, bHit) => {
const a = aHit.result;
const b = bHit.result;
sorted.sort((aEntry, bEntry) => {
const a = aEntry.result;
const b = bEntry.result;
let comparison = 0;

switch (sort.field) {
case "relevance":
comparison = compareNumbers(aHit.score, bHit.score, sort.direction);
if (aEntry.source !== bEntry.source) {
comparison = aEntry.source === "semantic" ? -1 : 1;
break;
}
comparison = compareNumbers(aEntry.score, bEntry.score, sort.direction);
Comment thread
trangdoan982 marked this conversation as resolved.
break;
case "alphabetical":
comparison = compareStrings(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is doing some odd scoring even when roam's semantic search returns properly scored results:

Ours:
Image

Roam's:

Image

Expand Down Expand Up @@ -259,10 +256,10 @@ export const sortSearchResults = ({
return comparison || a.uid.localeCompare(b.uid);
});

return sorted.map((hit) => hit.result);
return sorted.map((entry) => entry.result);
};

export const searchIndexedNodes = ({
export const searchDiscourseNodesWithMiniSearch = ({
miniSearch,
allResults,
searchTerm,
Expand All @@ -272,7 +269,7 @@ export const searchIndexedNodes = ({
allResults: SearchResult[];
searchTerm: string;
typeFilter?: string[];
}): ScoredSearchHit[] => {
}): ScoredSearchResult[] => {
const resultsByUid = new Map(
allResults.map((result) => [result.uid, result]),
);
Expand All @@ -291,7 +288,11 @@ export const searchIndexedNodes = ({
.map((result) => {
const searchResult = resultsByUid.get(String(result.id));
if (!searchResult) return null;
return { result: searchResult, score: result.score };
return {
result: searchResult,
score: result.score,
source: "miniSearch",
};
})
.filter((hit): hit is ScoredSearchHit => !!hit);
.filter((entry): entry is ScoredSearchResult => !!entry);
};
2 changes: 1 addition & 1 deletion apps/roam/src/utils/discourseNodeSearchProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export const searchSemanticNodeTitles = async ({
);
};

const runRoamSemanticSearch = async ({
export const runRoamSemanticSearch = async ({
nodeTypes,
query,
}: {
Expand Down
73 changes: 73 additions & 0 deletions apps/roam/src/utils/discourseNodeSearchTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
export type DiscourseNodeSearchSource = "semantic" | "miniSearch";

export type SearchResult = {
uid: string;
title: string;
type: string;
nodeTypeLabel: string;
excerpt: string;
createdAt: string;
lastModified: string;
authorName: string;
};

export type ScoredSearchResult = {
result: SearchResult;
score: number;
source: DiscourseNodeSearchSource;
};

export const combineSemanticAndMiniSearchResults = ({
semantic,
miniSearch,
}: {
semantic: ScoredSearchResult[];
miniSearch: ScoredSearchResult[];
}): ScoredSearchResult[] => {
const seenUids = new Set(semantic.map((entry) => entry.result.uid));
const combined = [...semantic];

miniSearch.forEach((entry) => {
if (seenUids.has(entry.result.uid)) return;
seenUids.add(entry.result.uid);
combined.push(entry);
});

return combined;
};
Comment on lines +20 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline apps/roam/src/utils/discourseNodeSearchTypes.ts --view expanded || true

printf '\n== File contents ==\n'
cat -n apps/roam/src/utils/discourseNodeSearchTypes.ts

printf '\n== Search usages ==\n'
rg -n "combineSemanticAndMiniSearchResults|ScoredSearchResult|semantic.*result.uid|miniSearch" apps/roam/src -S

Repository: DiscourseGraphs/discourse-graph

Length of output: 8447


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== searchDiscourseNodes ==\n'
cat -n apps/roam/src/utils/searchDiscourseNodes.ts

printf '\n== Advanced search semantic path ==\n'
sed -n '250,320p' apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts

printf '\n== useAdvancedNodeSearchResults ==\n'
sed -n '1,140p' apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts

printf '\n== semantic provider result shaping ==\n'
sed -n '330,420p' apps/roam/src/utils/discourseNodeSearchProviders.ts

Repository: DiscourseGraphs/discourse-graph

Length of output: 9516


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== runRoamSemanticSearch definition ==\n'
rg -n "export .*runRoamSemanticSearch|const runRoamSemanticSearch|function runRoamSemanticSearch" apps/roam/src/utils/discourseNodeSearchProviders.ts apps/roam/src -S

printf '\n== relevant provider section ==\n'
sed -n '480,620p' apps/roam/src/utils/discourseNodeSearchProviders.ts

Repository: DiscourseGraphs/discourse-graph

Length of output: 3341


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '220,320p' apps/roam/src/utils/discourseNodeSearchProviders.ts

printf '\n== filterResultsToDiscourseNodeTitles ==\n'
rg -n "filterResultsToDiscourseNodeTitles" apps/roam/src/utils/discourseNodeSearchProviders.ts apps/roam/src -n -A 80 -B 20

Repository: DiscourseGraphs/discourse-graph

Length of output: 46659


🌐 Web query:

Roam Alpha API semanticSearch returns duplicate page hits unique uid contract

💡 Result:

The Roam Alpha API provides a semanticSearch functionality, which is separate from the standard Datalog-based q query API [1][2][3]. If you are encountering duplicate page hits when using this function, it is generally not a violation of the underlying UID contract, as Roam's database architecture relies on unique UIDs for all entities (blocks and pages) [3]. Technical considerations regarding this behavior include: 1. Data Normalization and Duplication: The semantic search index may occasionally include multiple references or "hits" that resolve to the same underlying entity UID due to the nature of how content is chunked or indexed by the semantic engine [4]. If your application logic expects a one-to-one mapping between search hits and unique entities, you should implement client-side deduplication using the unique uid (or block-uid) field present in the result object [1][5][3]. 2. Querying Alternatives: If semanticSearch is returning redundant data that complicates your integration, you can use the more deterministic Datalog query interface (roamAlphaAPI.q) [2][6][3]. Datalog queries allow you to use the distinct keyword or explicitly group results by UID, ensuring that you only retrieve a single, unique record for each entity regardless of how many blocks or page references match your criteria [3]. 3. API Contracts: There is no formal "unique hit" contract for the semantic search API that guarantees a distinct list of results [1]. The API is intended to provide relevant search context, which may include multiple fragments of a single page if they meet the semantic threshold. Always rely on the uid property as the primary key for identifying and de-duplicating entities within your application [5][3]. If you are building an extension or integration, verify that you are checking window.roamAlphaAPI.data.semanticSearchEnabled() before invocation to ensure the environment is correctly initialized [1].

Citations:


Dedup semantic hits before combining The Roam semantic path still passes repeated uids through, so duplicate semantic hits can appear twice in the final list. Dedup semantic with the same seenUids pass used for miniSearch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/roam/src/utils/discourseNodeSearchTypes.ts` around lines 20 - 37, The
combineSemanticAndMiniSearchResults helper currently seeds seenUids from
semantic but does not deduplicate semantic entries themselves, so repeated uid
values can still appear in the returned list. Update
combineSemanticAndMiniSearchResults to filter the semantic array with the same
seenUids logic used for miniSearch before building combined, keeping only the
first ScoredSearchResult per uid.


export const toScoredSearchResultFromSemantic = ({
uid,
title,
type,
nodeTypeLabel,
score,
resultsByUid,
}: {
uid: string;
title: string;
type?: string;
nodeTypeLabel?: string;
score: number;
resultsByUid: Map<string, SearchResult>;
}): ScoredSearchResult => {
const indexedResult = resultsByUid.get(uid);
if (indexedResult) {
return { result: indexedResult, score, source: "semantic" };
}

return {
result: {
uid,
title,
type: type || "",
nodeTypeLabel: nodeTypeLabel || "",
excerpt: "",
createdAt: "",
lastModified: "",
authorName: "Unknown",
},
score,
source: "semantic",
};
};
69 changes: 69 additions & 0 deletions apps/roam/src/utils/searchDiscourseNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { DiscourseNode } from "~/utils/getDiscourseNodes";
import {
combineSemanticAndMiniSearchResults,
toScoredSearchResultFromSemantic,
type ScoredSearchResult,
type SearchResult,
} from "~/utils/discourseNodeSearchTypes";
import { runRoamSemanticSearch } from "~/utils/discourseNodeSearchProviders";

export const SEMANTIC_SEARCH_MIN_DISCOURSE_RESULTS = 5;

export type {
DiscourseNodeSearchSource,
ScoredSearchResult,
SearchResult,
} from "~/utils/discourseNodeSearchTypes";

export const isRoamSemanticSearchEnabled = (): boolean =>
window.roamAlphaAPI.data.semanticSearchEnabled();

export const searchDiscourseNodes = async ({
nodeTypes,
query,
resultsByUid,
runMiniSearch,
}: {
nodeTypes: DiscourseNode[];
query: string;
resultsByUid: Map<string, SearchResult>;
runMiniSearch: () => ScoredSearchResult[];
}): Promise<ScoredSearchResult[]> => {
const trimmedQuery = query.trim();
if (!trimmedQuery) return [];

if (!isRoamSemanticSearchEnabled()) {
return runMiniSearch();
}

try {
const providerResult = await runRoamSemanticSearch({
nodeTypes,
query: trimmedQuery,
});
const semanticResults = providerResult.filteredResults.map((item) =>
toScoredSearchResultFromSemantic({
uid: item.uid,
title: item.text,
type: item.type,
nodeTypeLabel: item.nodeTypeLabel,
score: item.score ?? 0,
resultsByUid,
}),
);

if (
providerResult.filteredResultCount >=
SEMANTIC_SEARCH_MIN_DISCOURSE_RESULTS
) {
return semanticResults;
}

return combineSemanticAndMiniSearchResults({
semantic: semanticResults,
miniSearch: runMiniSearch(),
});
} catch {
return runMiniSearch();
}
};
Comment on lines +21 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant search utilities and call sites.
git ls-files 'apps/roam/src/**/*search*' 'apps/roam/src/**/*Search*' 'apps/roam/src/**/*mini*' | sed -n '1,200p'

printf '\n--- searchDiscourseNodes.ts ---\n'
cat -n apps/roam/src/utils/searchDiscourseNodes.ts | sed -n '1,220p'

printf '\n--- useAdvancedNodeSearchResults references ---\n'
rg -n "useAdvancedNodeSearchResults|searchDiscourseNodes|runMiniSearch|MiniSearch" apps/roam/src -A 3 -B 3

Repository: DiscourseGraphs/discourse-graph

Length of output: 29073


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- AdvancedNodeSearchDialog/utils.ts (search helper) ---\n'
cat -n apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts | sed -n '240,340p'

printf '\n--- AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts (fallback path) ---\n'
cat -n apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts | sed -n '70,110p'

printf '\n--- MiniSearch search-error handling in the repo ---\n'
rg -n "try \\{|catch \\{|miniSearch\\.search\\(|search\\(MiniSearch\\.wildcard|searchTerm" apps/roam/src/components/AdvancedNodeSearchDialog apps/roam/src/utils -A 3 -B 3

Repository: DiscourseGraphs/discourse-graph

Length of output: 50387


🌐 Web query:

MiniSearch search() throws invalid query syntax malformed query exception documentation

💡 Result:

MiniSearch does not explicitly throw a "malformed query" exception or a generic "invalid query syntax" error in its official documentation or API. Instead, errors encountered during the search process typically manifest as JavaScript runtime exceptions—such as TypeErrors—triggered when the search() method receives unexpected input types or structures [1][2]. Common causes for these runtime errors include: 1. Wildcard Symbol Misalignment: If you use MiniSearch.wildcard, errors can occur if there is a mismatch in the MiniSearch instance or package version importing the symbol versus the instance performing the search [1][2]. This often happens in environments like Next.js or edge runtime setups where multiple instances of the library might be active, causing the MiniSearch.wildcard check to fail and the engine to incorrectly process the input as a different type of query [1][2]. 2. Incorrect Query Expression Tree: When passing an object as a query (an expression tree) rather than a string, the structure must strictly adhere to the Query type, which expects queries (an array of subqueries) and a combineWith operator [3][4][5]. Providing a malformed object that lacks these required properties or passes invalid types to the queries field will lead to runtime errors when the internal executeQuery method attempts to map or iterate over them [6][1][3]. If you are experiencing search-time errors, verify the following: - Ensure the input is either a plain string or a correctly formatted query expression tree object (containing a queries array and a combineWith field) [4][5]. - If using MiniSearch.wildcard, ensure it is being imported from the same package bundle that initialized the MiniSearch instance [1][2]. - Check your environment for potential multiple-instance issues (e.g., singleton patterns or conflicting dependency versions) if running in complex frameworks like Next.js [1]. For further debugging, inspect the stack trace to see if the error occurs within executeQuery or executeQuerySpec, as these are the primary internal methods where query processing occurs [6][3].

Citations:


Guard the MiniSearch fallback too
runMiniSearch() is called directly in the disabled-search branch and again in the fallback path. If that helper ever throws, the recovery path throws as well and the search result update can still reject; wrap it in a safe fallback so this degrades to [] instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/roam/src/utils/searchDiscourseNodes.ts` around lines 21 - 69, The
MiniSearch fallback in searchDiscourseNodes is not protected, so a thrown error
from runMiniSearch() can still reject the search update. Update
searchDiscourseNodes to invoke runMiniSearch through a safe wrapper in both the
isRoamSemanticSearchEnabled() disabled branch and the catch/combine fallback
path, and return an empty array if MiniSearch fails so the function always
degrades cleanly.