From 3cc039102c64309651acf8627df088d0cb469c9a Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Thu, 13 Nov 2025 13:48:05 -0500 Subject: [PATCH 1/9] ENG-1013: Single-query for discourse context --- .../src/utils/getDiscourseContextResults.ts | 66 ++++++++++++++++++- apps/roam/src/utils/predefinedSelections.ts | 26 +++++++- .../registerDiscourseDatalogTranslators.ts | 2 + 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 3794f758f..2c0d3c61f 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -7,6 +7,9 @@ import getDiscourseRelations, { DiscourseRelation, } from "./getDiscourseRelations"; import { Selection } from "./types"; +import { getSetting } from "./extensionSettings"; +import { ANY_RELATION_REGEX } from "./deriveDiscourseNodeAttribute"; +import { use } from "cytoscape"; const resultCache: Record>> = {}; const CACHE_TIMEOUT = 1000 * 60 * 5; @@ -51,6 +54,18 @@ const buildSelections = ({ text: `node:${conditionUid}-Context`, }); } + if (ANY_RELATION_REGEX.test(r.label)) { + selections.push({ + uid: window.roamAlphaAPI.util.generateUID(), + label: "relationUid", + text: "hasSchema", + }); + selections.push({ + uid: window.roamAlphaAPI.util.generateUID(), + label: "effectiveSource", + text: "effectiveSource", + }); + } return selections; }; @@ -180,6 +195,10 @@ const getDiscourseContextResults = async ({ const discourseNode = findDiscourseNode({ uid: targetUid }); if (!discourseNode) return []; + const useReifiedRelations = getSetting( + "use-reified-relations", + false, + ); const nodeType = discourseNode?.type; const nodeTextByType = Object.fromEntries( nodes.map(({ type, text }) => [type, text]), @@ -211,9 +230,24 @@ const getDiscourseContextResults = async ({ }); const relationsWithComplement = Array.from(uniqueRelations.values()); + const queryRelations = useReifiedRelations + ? [ + { + r: { + id: "null", + complement: "Has Any Relation To", + label: "Has Any Relation To", + triples: [], + source: "*", + destination: "*", + }, + complement: false, + }, + ] + : relationsWithComplement; const context = { nodes, relations }; - const queryConfigs = relationsWithComplement.map((relation) => + const queryConfigs = queryRelations.map((relation) => buildQueryConfig({ args, targetUid, @@ -226,12 +260,40 @@ const getDiscourseContextResults = async ({ }), ); - const resultsWithRelation = await executeQueries( + let resultsWithRelation = await executeQueries( queryConfigs, targetUid, nodeTextByType, onResult, ); + if ( + useReifiedRelations && + resultsWithRelation.length > 0 && + resultsWithRelation[0].results.length > 0 + ) { + const byRel: Record = {}; + const results = resultsWithRelation[0].results; + resultsWithRelation = []; + for (const r of results) { + const relKey = `${r.relationUid}-${r.effectiveSource !== targetUid}`; + byRel[relKey] = byRel[relKey] || []; + byRel[relKey].push(r); + } + resultsWithRelation = Object.entries(byRel).map(([ruid, results]) => ({ + relation: { + id: ruid, + label: ruid.endsWith("-false") + ? uniqueRelations.get(ruid)!.r.label + : uniqueRelations.get(ruid)!.r.complement, + isComplement: ruid.endsWith("-false"), + text: ruid.endsWith("-false") + ? uniqueRelations.get(ruid)!.r.label + : uniqueRelations.get(ruid)!.r.complement, + target: targetUid, + }, + results, + })); + } const groupedResults = Object.fromEntries( resultsWithRelation.map((r) => [ r.relation.text, diff --git a/apps/roam/src/utils/predefinedSelections.ts b/apps/roam/src/utils/predefinedSelections.ts index 89a2209ff..5c9f6ac9d 100644 --- a/apps/roam/src/utils/predefinedSelections.ts +++ b/apps/roam/src/utils/predefinedSelections.ts @@ -30,6 +30,8 @@ const NODE_TEST = /^node:(\s*[^:]+\s*)(:.*)?$/i; const ACTION_TEST = /^action:\s*([^:]+)\s*(?::(.*))?$/i; const DATE_FORMAT_TEST = /^date-format\(([^,)]+),([^,)]+)\)$/i; const MILLISECONDS_IN_DAY = 1000 * 60 * 60 * 24; +const HAS_SCHEMA_TEST = /^hasSchema$/; +const EFFECTIVE_SOURCE_TEST = /^effectiveSource$/; const getArgValue = (key: string, result: QueryResult) => { if (/^today$/i.test(key)) return new Date(); @@ -285,6 +287,26 @@ const predefinedSelections: PredefinedSelection[] = [ }, suggestions: EDIT_BY_SUGGESTIONS, }, + { + test: HAS_SCHEMA_TEST, + pull: ({ match, returnNode, where }) => { + return "?relSchema"; + }, + mapper: (r, key) => { + // not sure here? + return "?relSchema"; + }, + }, + { + test: EFFECTIVE_SOURCE_TEST, + pull: ({ match, returnNode, where }) => { + return "?relSource"; + }, + mapper: (r, key) => { + // not sure here? + return "?relSource"; + }, + }, { test: NODE_TEST, pull: ({ match, returnNode, where }) => { @@ -367,9 +389,7 @@ const predefinedSelections: PredefinedSelection[] = [ (c): c is QBClause => c.type === "clause" && c.target === selectedVar, ); if (introducedCondition?.relation === "references") { - const sourceUid = result[ - `${introducedCondition.source}-uid` - ] as string; + const sourceUid = result[`${introducedCondition.source}-uid`]; if (sourceUid) { const blockText = getTextByBlockUid(sourceUid); await updateBlock({ diff --git a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts index 0deb4f176..be24c36eb 100644 --- a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts +++ b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts @@ -816,6 +816,8 @@ const registerDiscourseDatalogTranslators = (snapshot?: SettingsSnapshot) => { [ { from: source, to: source }, { from: target, to: target }, + { from: "relSchema", to: "relSchema" }, + { from: "relSource", to: "relSource" }, { from: true, to: (v) => `${uid}-${v}` }, ], clauses, From c6a079eda736c5f0a7727db248ac921d0676a923 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 29 Apr 2026 15:11:50 -0400 Subject: [PATCH 2/9] Case of relations with obsolete rel schema --- .../src/utils/getDiscourseContextResults.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 2c0d3c61f..813602ee1 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -279,20 +279,29 @@ const getDiscourseContextResults = async ({ byRel[relKey] = byRel[relKey] || []; byRel[relKey].push(r); } - resultsWithRelation = Object.entries(byRel).map(([ruid, results]) => ({ - relation: { - id: ruid, - label: ruid.endsWith("-false") - ? uniqueRelations.get(ruid)!.r.label - : uniqueRelations.get(ruid)!.r.complement, - isComplement: ruid.endsWith("-false"), - text: ruid.endsWith("-false") - ? uniqueRelations.get(ruid)!.r.label - : uniqueRelations.get(ruid)!.r.complement, - target: targetUid, - }, - results, - })); + resultsWithRelation = Object.entries(byRel) + .map(([ruid, results]) => { + const relation = uniqueRelations.get(ruid); + if (!relation) { + console.error("Relation with obsolete relation type:" + ruid); + return { relation, results }; + } + return { + relation: { + id: ruid, + label: ruid.endsWith("-false") + ? relation.r.label + : relation.r.complement, + isComplement: ruid.endsWith("-false"), + text: ruid.endsWith("-false") + ? relation.r.label + : relation.r.complement, + target: targetUid, + }, + results, + }; + }) + .filter((o) => !!o.relation); } const groupedResults = Object.fromEntries( resultsWithRelation.map((r) => [ From 926d8a8cdafc2ee93fd51fed8716edf8f07ebe45 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 29 Apr 2026 15:18:27 -0400 Subject: [PATCH 3/9] linting --- apps/roam/src/utils/getDiscourseContextResults.ts | 3 +-- apps/roam/src/utils/predefinedSelections.ts | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 813602ee1..f91c63dc8 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -9,7 +9,6 @@ import getDiscourseRelations, { import { Selection } from "./types"; import { getSetting } from "./extensionSettings"; import { ANY_RELATION_REGEX } from "./deriveDiscourseNodeAttribute"; -import { use } from "cytoscape"; const resultCache: Record>> = {}; const CACHE_TIMEOUT = 1000 * 60 * 5; @@ -275,7 +274,7 @@ const getDiscourseContextResults = async ({ const results = resultsWithRelation[0].results; resultsWithRelation = []; for (const r of results) { - const relKey = `${r.relationUid}-${r.effectiveSource !== targetUid}`; + const relKey = `${r.relationUid as string}-${r.effectiveSource !== targetUid}`; byRel[relKey] = byRel[relKey] || []; byRel[relKey].push(r); } diff --git a/apps/roam/src/utils/predefinedSelections.ts b/apps/roam/src/utils/predefinedSelections.ts index 5c9f6ac9d..5bbb4951c 100644 --- a/apps/roam/src/utils/predefinedSelections.ts +++ b/apps/roam/src/utils/predefinedSelections.ts @@ -289,20 +289,20 @@ const predefinedSelections: PredefinedSelection[] = [ }, { test: HAS_SCHEMA_TEST, - pull: ({ match, returnNode, where }) => { + pull: () => { return "?relSchema"; }, - mapper: (r, key) => { + mapper: () => { // not sure here? return "?relSchema"; }, }, { test: EFFECTIVE_SOURCE_TEST, - pull: ({ match, returnNode, where }) => { + pull: () => { return "?relSource"; }, - mapper: (r, key) => { + mapper: () => { // not sure here? return "?relSource"; }, From e4855d051bf47e90aac80d41b7e3ebb4e8997742 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 24 Jun 2026 09:24:16 -0400 Subject: [PATCH 4/9] solve issues identified by Devin. Also filter on empty results --- .../src/utils/getDiscourseContextResults.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index f91c63dc8..27c7cd130 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -12,6 +12,7 @@ import { ANY_RELATION_REGEX } from "./deriveDiscourseNodeAttribute"; const resultCache: Record>> = {}; const CACHE_TIMEOUT = 1000 * 60 * 5; +const ANY_RELATION_ID = "any_relation"; type BuildQueryConfig = { args: { @@ -233,7 +234,7 @@ const getDiscourseContextResults = async ({ ? [ { r: { - id: "null", + id: ANY_RELATION_ID, complement: "Has Any Relation To", label: "Has Any Relation To", triples: [], @@ -263,7 +264,6 @@ const getDiscourseContextResults = async ({ queryConfigs, targetUid, nodeTextByType, - onResult, ); if ( useReifiedRelations && @@ -288,14 +288,16 @@ const getDiscourseContextResults = async ({ return { relation: { id: ruid, - label: ruid.endsWith("-false") + label: ruid.endsWith("-true") ? relation.r.label : relation.r.complement, - isComplement: ruid.endsWith("-false"), - text: ruid.endsWith("-false") + isComplement: ruid.endsWith("-true"), + text: ruid.endsWith("-true") ? relation.r.label : relation.r.complement, - target: targetUid, + target: ruid.endsWith("-true") + ? relation.r.source + : relation.r.destination, }, results, }; @@ -326,10 +328,14 @@ const getDiscourseContextResults = async ({ }), ), ); - return Object.entries(groupedResults).map(([label, results]) => ({ - label, - results, - })); + const asResultList = Object.entries(groupedResults) + .filter(([, results]) => Object.keys(results).length > 0) + .map(([label, results]) => ({ + label, + results, + })); + if (onResult) asResultList.map((r) => onResult(r)); + return asResultList; }; export default getDiscourseContextResults; From d3ea208791e876876fef41ee866b179bf06a97ee Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 26 Jun 2026 16:36:03 -0400 Subject: [PATCH 5/9] nits --- apps/roam/src/utils/deriveDiscourseNodeAttribute.ts | 3 ++- apps/roam/src/utils/getDiscourseContextResults.ts | 10 +++++++--- .../src/utils/registerDiscourseDatalogTranslators.ts | 7 +++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts b/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts index 81c78acce..3e6cf9cc9 100644 --- a/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts +++ b/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts @@ -6,7 +6,8 @@ import findDiscourseNode from "./findDiscourseNode"; import getDiscourseNodes from "./getDiscourseNodes"; import getDiscourseRelations from "./getDiscourseRelations"; -export const ANY_RELATION_REGEX = /Has Any Relation To/i; +export const ANY_RELATION_NAME = "Has Any Relation To"; +export const ANY_RELATION_REGEX = RegExp(ANY_RELATION_NAME, "i"); const evaluate = async (s: string) => window.RoamLazy diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 27c7cd130..5d12acb09 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -8,7 +8,10 @@ import getDiscourseRelations, { } from "./getDiscourseRelations"; import { Selection } from "./types"; import { getSetting } from "./extensionSettings"; -import { ANY_RELATION_REGEX } from "./deriveDiscourseNodeAttribute"; +import { + ANY_RELATION_NAME, + ANY_RELATION_REGEX, +} from "./deriveDiscourseNodeAttribute"; const resultCache: Record>> = {}; const CACHE_TIMEOUT = 1000 * 60 * 5; @@ -233,10 +236,11 @@ const getDiscourseContextResults = async ({ const queryRelations = useReifiedRelations ? [ { + id: ANY_RELATION_ID, r: { id: ANY_RELATION_ID, - complement: "Has Any Relation To", - label: "Has Any Relation To", + complement: ANY_RELATION_NAME, + label: ANY_RELATION_NAME, triples: [], source: "*", destination: "*", diff --git a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts index be24c36eb..a5fb7f6af 100644 --- a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts +++ b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts @@ -8,7 +8,10 @@ import conditionToDatalog, { getTitleDatalog, registerDatalogTranslator, } from "./conditionToDatalog"; -import { ANY_RELATION_REGEX } from "./deriveDiscourseNodeAttribute"; +import { + ANY_RELATION_NAME, + ANY_RELATION_REGEX, +} from "./deriveDiscourseNodeAttribute"; import getDiscourseNodes, { excludeDefaultNodes, type DiscourseNode, @@ -404,7 +407,7 @@ const registerDiscourseDatalogTranslators = (snapshot?: SettingsSnapshot) => { const relationLabels = new Set( discourseRelations.flatMap((d) => [d.label, d.complement].filter(Boolean)), ); - relationLabels.add(ANY_RELATION_REGEX.source); + relationLabels.add(ANY_RELATION_NAME); relationLabels.forEach((label) => { unregisters.add( registerDatalogTranslator({ From 1f3d21aa93f231462aa9f92f261b74f1ed04c651 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Fri, 26 Jun 2026 16:46:42 -0400 Subject: [PATCH 6/9] batch onResult only in reifiedRel case --- apps/roam/src/utils/getDiscourseContextResults.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 5d12acb09..53161a326 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -263,11 +263,14 @@ const getDiscourseContextResults = async ({ complement: relation.complement, }), ); + const postQueryOnResult = useReifiedRelations ? onResult : undefined; + onResult = postQueryOnResult ? undefined : onResult; let resultsWithRelation = await executeQueries( queryConfigs, targetUid, nodeTextByType, + onResult, ); if ( useReifiedRelations && @@ -338,7 +341,7 @@ const getDiscourseContextResults = async ({ label, results, })); - if (onResult) asResultList.map((r) => onResult(r)); + if (postQueryOnResult) asResultList.map((r) => postQueryOnResult(r)); return asResultList; }; From cc45bf268e0b4850f8651c105437c1c199f21701 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 27 Jun 2026 23:28:27 -0600 Subject: [PATCH 7/9] ENG-1062 use internal find variables for relation metadata --- .../src/utils/__tests__/fireQuery.test.ts | 28 +++ .../getDiscourseContextResults.test.ts | 163 ++++++++++++++++++ apps/roam/src/utils/fireQuery.ts | 69 +++++--- .../src/utils/getDiscourseContextResults.ts | 48 +++--- apps/roam/src/utils/predefinedSelections.ts | 22 --- .../registerDiscourseDatalogTranslators.ts | 2 - 6 files changed, 256 insertions(+), 76 deletions(-) create mode 100644 apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts diff --git a/apps/roam/src/utils/__tests__/fireQuery.test.ts b/apps/roam/src/utils/__tests__/fireQuery.test.ts index dcc1efb54..6b5686605 100644 --- a/apps/roam/src/utils/__tests__/fireQuery.test.ts +++ b/apps/roam/src/utils/__tests__/fireQuery.test.ts @@ -66,6 +66,34 @@ describe("getDatalogQuery", () => { ]); expect(formatted).toMatchObject({ text: "A", uid: "u1", Created: "123" }); }); + + it("adds internal find variables to the query output", async () => { + const built = getDatalogQuery({ + conditions: [], + selections: [], + findVariables: [ + { label: "relationUid", variable: "condition-relSchema" }, + { label: "effectiveSource", variable: "?condition-relSource" }, + ], + }); + + expect(built.query).toContain("?condition-relSchema"); + expect(built.query).toContain("?condition-relSource"); + + const formatted = await built.formatResult([ + { ":node/title": "A", ":block/uid": "u1" }, + { ":block/uid": "u1" }, + "rel-1", + "source-1", + ]); + + expect(formatted).toMatchObject({ + text: "A", + uid: "u1", + relationUid: "rel-1", + effectiveSource: "source-1", + }); + }); }); describe("fireQuery", () => { diff --git a/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts b/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts new file mode 100644 index 000000000..8c7ea70a3 --- /dev/null +++ b/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; +import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; + +const mocks = vi.hoisted(() => ({ + findDiscourseNode: vi.fn(), + fireQuery: vi.fn(), + generateUID: vi.fn(), + getSetting: vi.fn(), +})); + +vi.mock("~/utils/deriveDiscourseNodeAttribute", () => ({ + ANY_RELATION_NAME: "Has Any Relation To", + ANY_RELATION_REGEX: /Has Any Relation To/i, +})); + +vi.mock("~/utils/extensionSettings", () => ({ + getSetting: mocks.getSetting, +})); + +vi.mock("~/utils/findDiscourseNode", () => ({ + default: mocks.findDiscourseNode, +})); + +vi.mock("~/utils/fireQuery", () => ({ + default: mocks.fireQuery, +})); + +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: () => [], +})); + +vi.mock("~/utils/getDiscourseRelations", () => ({ + default: () => [], +})); + +import getDiscourseContextResults from "~/utils/getDiscourseContextResults"; + +const makeNode = ({ + type, + text, +}: { + type: string; + text: string; +}): DiscourseNode => ({ + type, + text, + shortcut: "", + specification: [], + backedBy: "user", + canvasSettings: {}, + format: "{content}", +}); + +describe("getDiscourseContextResults", () => { + beforeEach(() => { + vi.clearAllMocks(); + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + util: { + generateUID: mocks.generateUID, + }, + }, + }; + + mocks.generateUID.mockReturnValue("condition"); + mocks.getSetting.mockReturnValue(true); + mocks.findDiscourseNode.mockReturnValue({ type: "CLM" }); + }); + + it("regroups all-relation reified query results by relation direction", async () => { + const onResult = vi.fn(); + const nodes: DiscourseNode[] = [ + makeNode({ type: "CLM", text: "Claim" }), + makeNode({ type: "QUE", text: "Question" }), + makeNode({ type: "EVD", text: "Evidence" }), + ]; + const relations: DiscourseRelation[] = [ + { + id: "supports", + label: "Supports", + complement: "Supported By", + source: "CLM", + destination: "QUE", + triples: [], + }, + { + id: "informs", + label: "Informs", + complement: "Informed By", + source: "EVD", + destination: "CLM", + triples: [], + }, + ]; + + mocks.fireQuery.mockResolvedValue([ + { + text: "Question A", + uid: "question-a", + relationUid: "supports", + effectiveSource: "claim-a", + }, + { + text: "Evidence A", + uid: "evidence-a", + relationUid: "informs", + effectiveSource: "evidence-a", + }, + ]); + + const results = await getDiscourseContextResults({ + uid: "claim-a", + nodes, + relations, + onResult, + }); + + expect(mocks.fireQuery).toHaveBeenCalledWith( + expect.objectContaining({ + returnNode: "Any", + selections: [], + findVariables: [ + { label: "relationUid", variable: "condition-relSchema" }, + { label: "effectiveSource", variable: "condition-relSource" }, + ], + }), + ); + + expect(results[0]).toEqual({ + label: "Supports", + results: { + "question-a": { + text: "Question A", + uid: "question-a", + relationUid: "supports", + effectiveSource: "claim-a", + target: "Question", + complement: 0, + id: "supports", + ctxTargetUid: "claim-a", + }, + }, + }); + expect(results[1]).toEqual({ + label: "Informed By", + results: { + "evidence-a": { + text: "Evidence A", + uid: "evidence-a", + relationUid: "informs", + effectiveSource: "evidence-a", + target: "Evidence", + complement: 1, + id: "informs", + ctxTargetUid: "claim-a", + }, + }, + }); + expect(onResult).toHaveBeenNthCalledWith(1, results[0]); + expect(onResult).toHaveBeenNthCalledWith(2, results[1]); + }); +}); diff --git a/apps/roam/src/utils/fireQuery.ts b/apps/roam/src/utils/fireQuery.ts index 1d68589d6..05b5bb27f 100644 --- a/apps/roam/src/utils/fireQuery.ts +++ b/apps/roam/src/utils/fireQuery.ts @@ -21,6 +21,10 @@ export type QueryArgs = { conditions: Condition[]; selections: Selection[]; inputs?: Record; + findVariables?: { + label: string; + variable: string; + }[]; }; type RelationInQuery = { id: string; @@ -39,6 +43,12 @@ export type FireQueryArgs = QueryArgs & { }; type FireQuery = (query: FireQueryArgs) => Promise; +type DefinedSelection = { + mapper?: PredefinedSelection["mapper"]; + pull: string; + label: string; + key: string; +}; const firstVariable = ( clause: DatalogClause | DatalogAndClause, @@ -200,6 +210,7 @@ export const getDatalogQuery = ({ selections, returnNode = DEFAULT_RETURN_NODE, inputs = {}, + findVariables = [], }: FireQueryArgs) => { const expectedInputs = getConditionTargets(conditions) .filter((c) => /^:in /.test(c)) @@ -211,12 +222,7 @@ export const getDatalogQuery = ({ new Set([]), ); - const defaultSelections: { - mapper: PredefinedSelection["mapper"]; - pull: string; - label: string; - key: string; - }[] = [ + const defaultSelections: DefinedSelection[] = [ { mapper: (r) => { return { @@ -237,26 +243,35 @@ export const getDatalogQuery = ({ key: "", }, ]; + const userSelections: DefinedSelection[] = selections + .map((s) => ({ + defined: predefinedSelections.find((p) => p.test.test(s.text)), + s, + })) + .filter( + (p): p is { defined: PredefinedSelection; s: Selection } => !!p.defined, + ) + .map((p) => ({ + mapper: p.defined.mapper, + pull: p.defined.pull({ + where: whereClauses, + returnNode, + match: p.defined.test.exec(p.s.text), + }), + label: p.s.label || p.s.text, + key: p.s.text, + })) + .filter((p) => !!p.pull); + const internalFindVariables: DefinedSelection[] = findVariables.map( + ({ label, variable }) => ({ + pull: variable.startsWith("?") ? variable : `?${variable}`, + label, + key: variable, + }), + ); const definedSelections = defaultSelections.concat( - selections - .map((s) => ({ - defined: predefinedSelections.find((p) => p.test.test(s.text)), - s, - })) - .filter( - (p): p is { defined: PredefinedSelection; s: Selection } => !!p.defined, - ) - .map((p) => ({ - mapper: p.defined.mapper, - pull: p.defined.pull({ - where: whereClauses, - returnNode, - match: p.defined.test.exec(p.s.text), - }), - label: p.s.label || p.s.text, - key: p.s.text, - })) - .filter((p) => !!p.pull), + userSelections, + internalFindVariables, ); const find = definedSelections.map((p) => p.pull).join("\n "); const where = whereClauses.map((c) => compileDatalog(c, 1)).join("\n"); @@ -274,7 +289,9 @@ export const getDatalogQuery = ({ definedSelections .map((c, i) => (prev: QueryResult) => { const pullResult = result[i]; - return typeof pullResult === "object" && pullResult !== null + return c.mapper && + typeof pullResult === "object" && + pullResult !== null ? Promise.resolve( c.mapper(pullResult as PullBlock, c.key, prev), ).then((output) => ({ diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 53161a326..032781857 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -57,19 +57,6 @@ const buildSelections = ({ text: `node:${conditionUid}-Context`, }); } - if (ANY_RELATION_REGEX.test(r.label)) { - selections.push({ - uid: window.roamAlphaAPI.util.generateUID(), - label: "relationUid", - text: "hasSchema", - }); - selections.push({ - uid: window.roamAlphaAPI.util.generateUID(), - label: "effectiveSource", - text: "effectiveSource", - }); - } - return selections; }; @@ -148,7 +135,20 @@ const buildQueryConfig = ({ const returnNode = nodeTextByType[target]; const conditionUid = window.roamAlphaAPI.util.generateUID(); + const isAllRelationsQuery = ANY_RELATION_REGEX.test(r.label); const selections = buildSelections({ r, conditionUid }); + const findVariables = isAllRelationsQuery + ? [ + { + label: "relationUid", + variable: `${conditionUid}-relSchema`, + }, + { + label: "effectiveSource", + variable: `${conditionUid}-relSource`, + }, + ] + : []; const { nodes, relations } = fireQueryContext; return { relation, @@ -166,6 +166,7 @@ const buildQueryConfig = ({ }, ], selections, + findVariables, context: { relationsInQuery: [relation], customNodes: nodes, @@ -290,26 +291,21 @@ const getDiscourseContextResults = async ({ const relation = uniqueRelations.get(ruid); if (!relation) { console.error("Relation with obsolete relation type:" + ruid); - return { relation, results }; + return undefined; } + const isComplement = relation.complement; return { relation: { - id: ruid, - label: ruid.endsWith("-true") - ? relation.r.label - : relation.r.complement, - isComplement: ruid.endsWith("-true"), - text: ruid.endsWith("-true") - ? relation.r.label - : relation.r.complement, - target: ruid.endsWith("-true") - ? relation.r.source - : relation.r.destination, + id: relation.id, + label: isComplement ? relation.r.complement : relation.r.label, + isComplement, + text: isComplement ? relation.r.complement : relation.r.label, + target: isComplement ? relation.r.source : relation.r.destination, }, results, }; }) - .filter((o) => !!o.relation); + .filter((o): o is NonNullable => !!o); } const groupedResults = Object.fromEntries( resultsWithRelation.map((r) => [ diff --git a/apps/roam/src/utils/predefinedSelections.ts b/apps/roam/src/utils/predefinedSelections.ts index 5bbb4951c..060a33750 100644 --- a/apps/roam/src/utils/predefinedSelections.ts +++ b/apps/roam/src/utils/predefinedSelections.ts @@ -30,8 +30,6 @@ const NODE_TEST = /^node:(\s*[^:]+\s*)(:.*)?$/i; const ACTION_TEST = /^action:\s*([^:]+)\s*(?::(.*))?$/i; const DATE_FORMAT_TEST = /^date-format\(([^,)]+),([^,)]+)\)$/i; const MILLISECONDS_IN_DAY = 1000 * 60 * 60 * 24; -const HAS_SCHEMA_TEST = /^hasSchema$/; -const EFFECTIVE_SOURCE_TEST = /^effectiveSource$/; const getArgValue = (key: string, result: QueryResult) => { if (/^today$/i.test(key)) return new Date(); @@ -287,26 +285,6 @@ const predefinedSelections: PredefinedSelection[] = [ }, suggestions: EDIT_BY_SUGGESTIONS, }, - { - test: HAS_SCHEMA_TEST, - pull: () => { - return "?relSchema"; - }, - mapper: () => { - // not sure here? - return "?relSchema"; - }, - }, - { - test: EFFECTIVE_SOURCE_TEST, - pull: () => { - return "?relSource"; - }, - mapper: () => { - // not sure here? - return "?relSource"; - }, - }, { test: NODE_TEST, pull: ({ match, returnNode, where }) => { diff --git a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts index a5fb7f6af..f89108983 100644 --- a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts +++ b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts @@ -819,8 +819,6 @@ const registerDiscourseDatalogTranslators = (snapshot?: SettingsSnapshot) => { [ { from: source, to: source }, { from: target, to: target }, - { from: "relSchema", to: "relSchema" }, - { from: "relSource", to: "relSource" }, { from: true, to: (v) => `${uid}-${v}` }, ], clauses, From 7baea10f8d1d4e842ede47769ba35e1d5ed5fca4 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 27 Jun 2026 23:58:17 -0600 Subject: [PATCH 8/9] ENG-1062 warn for obsolete relation metadata --- apps/roam/src/utils/getDiscourseContextResults.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 032781857..102c24990 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -290,7 +290,13 @@ const getDiscourseContextResults = async ({ .map(([ruid, results]) => { const relation = uniqueRelations.get(ruid); if (!relation) { - console.error("Relation with obsolete relation type:" + ruid); + /* + * A stored reified relation can outlive its relation schema, or the + * schema can stop applying to this node type after source/destination + * settings change. Drop that stale result from discourse context + * instead of treating it as a runtime failure. + */ + console.warn("Relation with obsolete relation type:" + ruid); return undefined; } const isComplement = relation.complement; From 9b387007b296e95432432b4c74eac45c33e5218d Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 28 Jun 2026 00:14:07 -0600 Subject: [PATCH 9/9] ENG-1062 preserve relation order in reified context --- .../getDiscourseContextResults.test.ts | 14 ++--- .../src/utils/getDiscourseContextResults.ts | 51 ++++++++++--------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts b/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts index 8c7ea70a3..f95e761ce 100644 --- a/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts +++ b/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts @@ -68,7 +68,7 @@ describe("getDiscourseContextResults", () => { mocks.findDiscourseNode.mockReturnValue({ type: "CLM" }); }); - it("regroups all-relation reified query results by relation direction", async () => { + it("regroups all-relation reified query results by schema order", async () => { const onResult = vi.fn(); const nodes: DiscourseNode[] = [ makeNode({ type: "CLM", text: "Claim" }), @@ -95,18 +95,18 @@ describe("getDiscourseContextResults", () => { ]; mocks.fireQuery.mockResolvedValue([ - { - text: "Question A", - uid: "question-a", - relationUid: "supports", - effectiveSource: "claim-a", - }, { text: "Evidence A", uid: "evidence-a", relationUid: "informs", effectiveSource: "evidence-a", }, + { + text: "Question A", + uid: "question-a", + relationUid: "supports", + effectiveSource: "claim-a", + }, ]); const results = await getDiscourseContextResults({ diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 102c24990..8962dd5cf 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -286,32 +286,35 @@ const getDiscourseContextResults = async ({ byRel[relKey] = byRel[relKey] || []; byRel[relKey].push(r); } - resultsWithRelation = Object.entries(byRel) - .map(([ruid, results]) => { - const relation = uniqueRelations.get(ruid); - if (!relation) { - /* - * A stored reified relation can outlive its relation schema, or the - * schema can stop applying to this node type after source/destination - * settings change. Drop that stale result from discourse context - * instead of treating it as a runtime failure. - */ - console.warn("Relation with obsolete relation type:" + ruid); - return undefined; - } + resultsWithRelation = Array.from(uniqueRelations.entries()).flatMap( + ([ruid, relation]) => { + const results = byRel[ruid]; + if (!results?.length) return []; + delete byRel[ruid]; const isComplement = relation.complement; - return { - relation: { - id: relation.id, - label: isComplement ? relation.r.complement : relation.r.label, - isComplement, - text: isComplement ? relation.r.complement : relation.r.label, - target: isComplement ? relation.r.source : relation.r.destination, + return [ + { + relation: { + id: relation.id, + label: isComplement ? relation.r.complement : relation.r.label, + isComplement, + text: isComplement ? relation.r.complement : relation.r.label, + target: isComplement ? relation.r.source : relation.r.destination, + }, + results, }, - results, - }; - }) - .filter((o): o is NonNullable => !!o); + ]; + }, + ); + Object.keys(byRel).forEach((ruid) => { + /* + * A stored reified relation can outlive its relation schema, or the + * schema can stop applying to this node type after source/destination + * settings change. Drop that stale result from discourse context + * instead of treating it as a runtime failure. + */ + console.warn("Relation with obsolete relation type:" + ruid); + }); } const groupedResults = Object.fromEntries( resultsWithRelation.map((r) => [