Skip to content

ENG-1869 Review and accept imported relation instances in Roam - #1384

Open
sid597 wants to merge 7 commits into
mainfrom
eng-1869-review-and-accept-imported-relation-instances-in-roam
Open

ENG-1869 Review and accept imported relation instances in Roam#1384
sid597 wants to merge 7 commits into
mainfrom
eng-1869-review-and-accept-imported-relation-instances-in-roam

Conversation

@sid597

@sid597 sid597 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Reviewer brief

  • Result: tentative imported relation instances now render in their own "Imported relations pending review" section of the Discourse Context overlay, with relation label, other endpoint, and provenance (space, local id, modified time) derived from the stored RID. They are excluded from the accepted relation tabs unless an accepted block asserts the same triple. Accept removes the tentative flag in place (provenance kept, no new block); remove deletes the reified block. Failures report through internalError (PostHog + toast) and leave the relation tentative. The section is gated on getStoredRelationsEnabled().
  • Review focus: behavior changes outside the overlay itself. (1) strictQueryForReifiedBlocks ignores the tentative and importedFrom annotation keys when matching role parameters; without this, an accepted imported relation (4 prop keys) could never be found by the existing delete action, and local creation would duplicate an imported relation instead of deduping. (2) A local create whose dedupe hit is a tentative import promotes it, best-effort (explicit local creation counts as acceptance); otherwise the create dialog, suggestions, and canvas would report success while the relation stayed hidden as pending review. This also means migrateRelations promotes a tentative import that matches a legacy local relation, which is intentional: the local relation already existed.
  • Risk or follow-up: re-import of an upstream-modified relation deletes and recreates the block (existing ENG-1867 behavior), which resets acceptance and returns the instance to pending review. Separately, the publishNodesToGroups filter on importedFromRid is a no-op in Roam (nothing writes that key), so imported relations, including unaccepted ones, are currently publishable; pre-existing ENG-1867 gap, follow-up ticket drafted. Chain coverage: this PR handles Roam-side instance acceptance only; Obsidian-side instance acceptance already exists in RelationshipSection.tsx, and type/triple acceptance is ENG-2131.

Verification

  • pnpm install --frozen-lockfile and pnpm ci:validate pass from the repo root (check-types across all packages; 134 roam unit tests).
  • New unit tests: tentative exclusion in getDiscourseContextResults (both directions), accepted-twin visibility in getTentativeOnlyRelationKeys, accept promote/no-op/failure, local-create promotion vs re-import non-promotion in createReifiedRelation, and annotation-aware strictQueryForReifiedBlocks.

Loom video

eng-1869-20260907.mp4

Scope check

  • Ran $scope-check against ENG-1869 and the final diff.
  • Scope beyond Done When: strictQueryForReifiedBlocks treats tentative and importedFrom as annotation keys when counting role parameters, and a local create that matches a tentative import promotes it. Both change relation lookup/creation behavior outside the overlay UI.
  • Required now: Done When 2. Acceptance keeps importedFrom on the block, so without these the promoted relation cannot be found by the existing delete action and local creation either duplicates the imported relation or silently returns a hidden one.
  • Anyone affected or consulted: No.
  • Decision: recorded on ENG-1869 (comment, 2026-09-02).

Local delegated full review

  • Ran a comprehensive review of the entire final diff in a subagent with a fresh context (verification-first pre-PR review, full pass + delta re-review); all blocking findings addressed in c811292 and 3903104.

@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown

ENG-1869

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
discourse-graph Skipped Skipped Sep 6, 2026 7:56pm UTC

Request Review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T18:35:27.534193Z eec212b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@graphite-app

graphite-app Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

PR size/scope check

This PR is over our review-size guideline.

  • Recommended: ~200 lines changed
  • Acceptable limit: up to 400 lines when well-scoped/self-contained
  • Preferred file count: fewer than 5 files

Please split this into smaller PRs unless there is a clear reason the changes need to land together.

If keeping it as one PR, please add a brief justification covering:

  • What single problem this PR solves
  • Why the files/changes are coupled

@supabase

supabase Bot commented Sep 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eec212b386

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/roam/src/utils/getDiscourseContextResults.ts
Comment on lines +282 to +285
const tentativeKeys = new Set(
(await getTentativeRelationInstances()).map(
(t) => `${t.schemaUid}|${t.sourceUid}|${t.destinationUid}`,
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid rescanning all tentative relations per overlay query

When a page contains many mounted discourse-context overlay buttons, every button's getInfo invokes this path, which calls getTentativeRelationInstances; that helper scans the global relations page and synchronously pulls block props for every tentative relation. Rendering N overlay badges with M imports therefore adds N global scans and N×M pulls even though this path only needs identity keys, which can make ordinary page rendering and score calculation sluggish in larger graphs; query the tentative keys directly or cache/index them rather than loading full provenance each time.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deliberately left uncached for now: getReifiedRelations is a single datalog query over the children of one page, and it rides alongside the much heavier relation fireQuery each overlay button already runs. A cache would need invalidation wired through the accept/remove/create refresh paths, which is more risk than the query costs today. Happy to memoize next to resultCache if profiling on a large graph shows it matters.

Comment thread apps/roam/src/components/DiscourseContext.tsx Outdated
const SANE_ROLE_NAME_RE = new RegExp(/^[\w\-]*$/);
// Annotations describe a relation's review/provenance state; they are not part
// of its identity, so lookups by role parameters must ignore them.
const RELATION_ANNOTATION_KEYS = new Set<string>([

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Lookups by role parameters now ignore tentative and importedFrom. Without this, the delete action in ResultsTable.onDelete can never find an accepted imported relation: it queries with 3 role params against a block carrying 4 or 5 prop keys. A block with a genuine extra role key (e.g. contextUid) is still rejected; covered by the "still rejects blocks with extra role keys" test.

const existing = await strictQueryForReifiedBlocks(data);
if (existing !== null) return existing;
if (existing !== null) {
if (parameterUids[TENTATIVE_PROP_KEY] === undefined) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

A local create that matches an existing tentative import clears the tentative flag rather than returning the block untouched: explicit local creation is acceptance. Without this, the create dialog, suggestions accept, and canvas relation draw would all report success while the relation stayed hidden as pending review. The import path passes tentative: true, so re-import does not promote.

resultsWithRelation.length > 0 &&
resultsWithRelation[0].results.length > 0
) {
const tentativeKeys = new Set(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tentative instances are excluded here rather than in the shared datalog translator, because the translator's basis reads only sourceUid/destinationUid/hasSchema (ENG-1867, PR #1302). Filtering here also covers deriveDiscourseNodeAttribute, which routes through this function, so the overlay score stays consistent with the tab counts.

@sid597

sid597 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Size check ack: 647 lines includes roughly 330 lines of unit tests. The non-test change is one coupled unit: the pending-review section, the exclusion of tentative instances from accepted results, and the strictQueryForReifiedBlocks annotation-key fix. Splitting the lookup fix into its own PR would leave acceptance promoting relations into a set whose existing delete action cannot find them, so the pieces ship together. Reasoning is in the Reviewer brief and Scope check sections.

Comment on lines +54 to +84
const loadRows = useCallback(async () => {
if (!getStoredRelationsEnabled()) return;
const instances = await getTentativeRelationInstances();
const relevant = instances.filter(
(instance) =>
instance.sourceUid === uid || instance.destinationUid === uid,
);
const relationById = new Map(getDiscourseRelations().map((r) => [r.id, r]));
const nextRows = relevant.map((instance) => {
const isOutgoing = instance.sourceUid === uid;
const otherUid = isOutgoing
? instance.destinationUid
: instance.sourceUid;
const schema = relationById.get(instance.schemaUid);
const label =
(isOutgoing ? schema?.label : schema?.complement || schema?.label) ||
"Unknown relation";
return {
...instance,
label,
otherText: getPageTitleByPageUid(otherUid) || otherUid,
provenance: buildProvenance(instance.importedFrom),
};
});
setRows(nextRows);
onCountChange(nextRows.length);
}, [uid, onCountChange]);

useEffect(() => {
void loadRows();
}, [loadRows]);

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.

Unhandled promise rejections in async operations. The loadRows function is called with void (lines 83, 86), which prevents error handling. If getTentativeRelationInstances() or any other async operation fails, it will cause an unhandled promise rejection that could crash the application or leave the UI in a broken state.

const loadRows = useCallback(async () => {
  if (!getStoredRelationsEnabled()) return;
  try {
    const instances = await getTentativeRelationInstances();
    // ... rest of the logic
  } catch (error) {
    internalError({
      error,
      type: "Load Tentative Relations Failed",
      context: { uid },
      sendEmail: false,
    });
    setRows([]);
    onCountChange(0);
  }
}, [uid, onCountChange]);
Suggested change
const loadRows = useCallback(async () => {
if (!getStoredRelationsEnabled()) return;
const instances = await getTentativeRelationInstances();
const relevant = instances.filter(
(instance) =>
instance.sourceUid === uid || instance.destinationUid === uid,
);
const relationById = new Map(getDiscourseRelations().map((r) => [r.id, r]));
const nextRows = relevant.map((instance) => {
const isOutgoing = instance.sourceUid === uid;
const otherUid = isOutgoing
? instance.destinationUid
: instance.sourceUid;
const schema = relationById.get(instance.schemaUid);
const label =
(isOutgoing ? schema?.label : schema?.complement || schema?.label) ||
"Unknown relation";
return {
...instance,
label,
otherText: getPageTitleByPageUid(otherUid) || otherUid,
provenance: buildProvenance(instance.importedFrom),
};
});
setRows(nextRows);
onCountChange(nextRows.length);
}, [uid, onCountChange]);
useEffect(() => {
void loadRows();
}, [loadRows]);
const loadRows = useCallback(async () => {
if (!getStoredRelationsEnabled()) return;
try {
const instances = await getTentativeRelationInstances();
const relevant = instances.filter(
(instance) =>
instance.sourceUid === uid || instance.destinationUid === uid,
);
const relationById = new Map(getDiscourseRelations().map((r) => [r.id, r]));
const nextRows = relevant.map((instance) => {
const isOutgoing = instance.sourceUid === uid;
const otherUid = isOutgoing
? instance.destinationUid
: instance.sourceUid;
const schema = relationById.get(instance.schemaUid);
const label =
(isOutgoing ? schema?.label : schema?.complement || schema?.label) ||
"Unknown relation";
return {
...instance,
label,
otherText: getPageTitleByPageUid(otherUid) || otherUid,
provenance: buildProvenance(instance.importedFrom),
};
});
setRows(nextRows);
onCountChange(nextRows.length);
} catch (error) {
internalError({
error,
type: "Load Tentative Relations Failed",
context: { uid },
sendEmail: false,
});
setRows([]);
onCountChange(0);
}
}, [uid, onCountChange]);
useEffect(() => {
void loadRows();
}, [loadRows]);

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

) : (
<div className="flex flex-col items-start">
<span>No discourse relations found.</span>
{!tentativeCount && <span>No discourse relations found.</span>}

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.

Race condition: tentativeCount starts at 0 and is only updated asynchronously after TentativeRelationInstances mounts and loads data. This causes "No discourse relations found." to briefly flash on screen even when tentative relations exist, then disappear once setTentativeCount is called.

// Fix: Initialize with undefined to indicate loading state
const [tentativeCount, setTentativeCount] = useState<number | undefined>(undefined);
// Then update the condition:
{tentativeCount === 0 && <span>No discourse relations found.</span>}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 potential issue.

Devin Review

Comment on lines +44 to +46
const numParams = countRoleKeys(parameterUids);
const resultF = result
.filter(([, params]) => Object.keys(params).length === numParams)
.filter(([, params]) => countRoleKeys(params) === numParams)

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.

🟡 Deletion targets the wrong relation

When accepted and pending copies share endpoints and type, strictQueryForReifiedBlocks returns both in unspecified order. Deletion can remove the pending copy while leaving the selected accepted relation visible.

Prompt for agents
Update relation lookup and deletion so annotation-insensitive deduplication does not make an accepted relation and its tentative twin interchangeable. `strictQueryForReifiedBlocks` in apps/roam/src/utils/createReifiedBlock.ts now returns both blocks for an unannotated identity query, while `ResultsTable.onDelete` in apps/roam/src/components/results-view/ResultsTable.tsx deletes the first result. Preserve annotation-insensitive matching for creation, but let deletion select the accepted block deterministically. Add coverage for deleting a visible accepted relation when a tentative twin exists.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant