Skip to content

[ENG-1855] Add Roam shared-node import discovery#1162

Open
sid597 wants to merge 1 commit into
mainfrom
eng-1855-add-roam-shared-node-import-discovery
Open

[ENG-1855] Add Roam shared-node import discovery#1162
sid597 wants to merge 1 commit into
mainfrom
eng-1855-add-roam-shared-node-import-discovery

Conversation

@sid597

@sid597 sid597 commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a read-only Roam command DG: Import - Discover shared nodes that lists group-visible shared nodes from other spaces — source app, source space, title, and upstream modified time — so a Roam user can see what's available to import from Obsidian/Roam. Discovery only; the import action itself is ENG-1859.

Part of the Roam↔Obsidian push-pull project, Milestone 2 (Bidirectional node import). Extends the existing Obsidian↔Obsidian discovery reference rather than building a new path.

What it does

  • discoverSharedNodes reads the RLS-scoped my_contents view, excludes the current space, groups by (space_id, source_local_id), prefers the direct variant for the title, and resolves space metadata via my_spaces.
  • Maps to an app-neutral shape aligned to the ENG-1847 CrossAppNode contract (sourceApp, sourceSpaceId/Name, sourceNodeId, sourceNodeRid, title, sourceModifiedAt, alreadyImported).
  • Read-only Blueprint modal with loading / error / empty / list states.

Design notes (the why)

  • Discovery requires a full variant (the ENG-1848 shared/published marker) so the list shows genuinely importable nodes, not every synced title — a deliberate scoping choice (diverges slightly from Obsidian's list-all discovery).
  • alreadyImported flows through an empty getImportedSourceRids() seam. Roam has no imported-node store yet (that's ENG-1856), so nothing is imported and nothing is marked; ENG-1859 wires this to ENG-1856's reader. Keys on source RID.
  • Visibility comes from the RLS my_* views + own-space exclusion, not app-side access logic.
  • Roam-local mirror of Obsidian's getPublishedNodesForGroups / getMyGroups (app-specific client + return shape); not promoted to a shared package yet (future-extraction candidate).
  • Gated behind a new Cross-app node import enabled feature flag (off by default; AdminPanel toggle "Cross-app node import"), mirroring Advanced node search enabled — the feature is incomplete (no import until ENG-1859), so it stays hidden from users by default. Later M2 commands reuse the same flag.

Testing

  • Unit tests for the pure assembleDiscoveredNodes (variant merge, full-variant filter, RID round-trip, already-imported marking, sorting).
  • vitest isn't installed in the local worktree (affects all existing tests) → it runs in CI; locally proven via a tsx harness (all cases pass). eslint / prettier / tsc green.

⚠️ Pending before merge

  • Runtime proof against a real group-member Roam graph with another space's shared nodes in Supabase (enable the flag → run the command → confirm the list, own-space exclusion, and empty states).
  • Loom walkthrough.

Linear: ENG-1855


Open in Devin Review

Read-only Roam command (DG: Import - Discover shared nodes) listing group-visible shared nodes from other spaces — source app, space, title, modified time — via the existing my_contents/my_spaces RLS views and the ENG-1847 cross-app contract. Already-imported marking flows through an empty getImportedSourceRids() seam (ENG-1856 fills it; ENG-1859 wires the join). Gated behind a new "Cross-app node import enabled" feature flag, off by default. Pure assembly logic covered by unit tests.
@linear-code

linear-code Bot commented Jun 26, 2026

Copy link
Copy Markdown

ENG-1855

@supabase

supabase Bot commented Jun 26, 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 ↗︎.

@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
discourse-graph Skipped Skipped Jun 26, 2026 4:22pm

Request Review

@graphite-app

graphite-app Bot commented Jun 26, 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

@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.

Open in Devin Review

Comment on lines +42 to +46
const dbTimestampToIso = (value: string | null): string | null => {
if (!value) return null;
const ms = new Date(`${value}Z`).valueOf();
return Number.isNaN(ms) ? null : new Date(ms).toISOString();
};

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.

🚩 Timestamp conversion assumes database returns timezone-naive strings

dbTimestampToIso (apps/roam/src/utils/discoverSharedNodes.ts:44) unconditionally appends Z to the raw last_modified value before parsing. If the Supabase view my_contents returns timestamps that already include timezone info (e.g. 2026-06-12T10:00:00+00:00 or 2026-06-12T10:00:00Z), the appended Z would create an invalid date string like 2026-06-12T10:00:00ZZ, causing NaN, which falls back to null and ultimately to epoch-zero (displayed as "Unknown"). The tests all use bare ISO strings without timezone suffixes, so they pass. This is fine if the underlying column is timestamp (without timezone), but would silently discard all modification dates if the column is timestamptz and the client serializes with timezone info.

Open in Devin Review

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

@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: 797e34e7f0

ℹ️ 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".


const nodes: DiscoveredSharedNode[] = [];
for (const { spaceId, sourceNodeId, rows } of byNode.values()) {
if (!rows.some((row) => row.variant === "full")) continue;

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 Publish full Roam rows before requiring them

This filter makes Roam-origin nodes undiscoverable with the current Roam producer: the only call sites for upsertNodesToSupabaseAsContentWithEmbeddings pass no options (for example apps/roam/src/utils/syncDgNodesToSupabase.ts:927 and apps/roam/src/components/settings/DiscourseNodeSuggestiveRules.tsx:59), so includeFullContent stays false and no variant === "full" rows are created. In that setup, a user in one Roam graph will see zero shared nodes from another Roam graph even after sync; either enable publishing full content for shared Roam nodes or avoid using full as the only discoverability marker for Roam rows.

Useful? React with 👍 / 👎.

const title = (direct?.text ?? full?.text ?? "").trim();
if (!title) continue;

const sourceNodeRid = spaceUriAndLocalIdToRid(meta.url, sourceNodeId);

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 Generate note RIDs for Obsidian nodes

When the source space is Obsidian, this creates orn:obsidian:<vault>/<id> without the note subtype, but the cross-app contract and Obsidian importer persist imported notes with spaceUriAndLocalIdToRid(..., "note") (packages/database/src/crossAppNodeContract.example.ts:53 and apps/obsidian/src/utils/importNodes.ts:1265). Any future importedRids read from Obsidian imports therefore will not match discovered Obsidian nodes, so already-imported notes are shown as new and can be duplicated.

Useful? React with 👍 / 👎.

Comment on lines +98 to +100
const direct = rows.find((row) => row.variant === "direct");
const full = rows.find((row) => row.variant === "full");
const title = (direct?.text ?? full?.text ?? "").trim();

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 Use direct_and_description titles when present

For Roam block-backed discourse nodes, convertRoamNodeToLocalContent writes variant: "direct_and_description" whenever node.node_title exists (apps/roam/src/utils/upsertNodesAsContentWithEmbeddings.ts:23), while the full row contains the full markdown document. Since discovery only checks for direct, those shared nodes fall back to the full markdown as title, causing the dialog and later import UI to treat the entire document body as the node title.

Useful? React with 👍 / 👎.

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