Skip to content

ENG-2156 Decorate imported node titles in Roam from core_title - #1331

Open
sid597 wants to merge 1 commit into
eng-2157-decorate-imported-node-titles-in-obsidian-from-core_titlefrom
eng-2156-decorate-imported-node-titles-in-roam-from-core_title
Open

ENG-2156 Decorate imported node titles in Roam from core_title#1331
sid597 wants to merge 1 commit into
eng-2157-decorate-imported-node-titles-in-obsidian-from-core_titlefrom
eng-2156-decorate-imported-node-titles-in-roam-from-core_title

Conversation

@sid597

@sid597 sid597 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Imported nodes used to land in Roam with the source's title verbatim, so an Obsidian EVD - REM sleep and recall became a page Roam's format regex didn't recognize as an [[EVD]]. The importer now rebuilds the title from the publisher's core_title with the local node type's format, and falls back to the incoming title whenever either piece is missing.

The pull query already fetched schema_id and threw it away. It now carries schemaId and coreTitle onto SharedNode, both read as JSON scalars by path (literal_content->>core_title) rather than by pulling literal_content, so discovery still selects no payload columns. postgrest-js types those projections as string; they are null at runtime for rows without the key, which is what the ?? undefined handles.

resolveSharedNodeTypes maps each shared node to a local node type in one batched query: match a local type whose uid equals the remote schema's source_local_id, else match on name, else create a local type from the schema. A created type reuses the remote id as its page uid, so the next import from that space matches on id. This is the same algorithm Obsidian runs in mapNodeTypeIdToLocal. One deliberate difference: when a schema carries no format, Obsidian invents ABC - {content} and we store an empty format instead, which leaves those titles verbatim until ENG-2158's format reaches the schema row. Types created this way get an empty shortcut; the user can set one in settings. Resolution is best-effort: a schema row RLS hides, a failed query, or a failed type creation reports to PostHog and leaves those nodes with their incoming title rather than failing the import.

Refresh is unchanged in shape: it resolves the type for its one node and passes it through, and updateImportedPage still renames only when the title differs, so refreshing an already-decorated page rewrites nothing. A format with {Source} decorates to an empty source and the title ends in " - " for now.

Stacked on eng-2157-decorate-imported-node-titles-in-obsidian-from-core_title (#1330) for the shared decorateTitle helper; this PR's diff is against that branch.

Deferred: resolving {Source} into a real source (ENG-2142), prop-based node identification (ENG-2133), a review/accept flow for types created during import (v0 creates them directly), and an importedFrom breadcrumb on created types (Roam's DiscourseNode has no such field yet). Until ENG-1861's imported-uid filter (#1286) lands, periodic sync treats decorated imported pages as local nodes. Created types from Obsidian schemas get 26-char page uids (node_<nanoid>); nothing in our code reads uid shape, but it shows in URLs.


Open in Devin Review

@linear-code

linear-code Bot commented Aug 23, 2026

Copy link
Copy Markdown

ENG-2156

@vercel

vercel Bot commented Aug 23, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
discourse-graph Ready Ready Preview Aug 23, 2026 8:16am

Request Review

@graphite-app

graphite-app Bot commented Aug 23, 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 Aug 23, 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 ↗︎.

@sid597 sid597 left a comment

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.

Guideposts for the non-obvious decisions in this diff.


const CONCEPT_COLUMNS =
"is_schema, last_modified, schema_id, source_local_id, space_id";
"core_title:literal_content->>core_title, is_schema, last_modified, schema_id, source_local_id, space_id";

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.

core_title lives inside literal_content, so this selects the one JSON key by path instead of the whole column. Listing queries must not pull payload columns, and literal_content is named in db-domain-invariants as one. postgrest-js types the alias as string; it is null at runtime when the key is absent, which is why SharedConcept widens it to string | null and buildSharedNodes maps null to undefined.

{
rid,
sourceLocalId: node.source_local_id,
schemaId: node.schema_id,

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.

The query already fetched schema_id and dropped it. Roam needs it to find the node type schema the title format comes from. The null guard above this block is what lets the field be required rather than optional.

spaceUri: space.url,
platform: space.platform,
title: direct.text,
coreTitle: node.core_title ?? 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.

Optional on purpose. Nodes published before ENG-2153 carry no core_title, and those keep their incoming title verbatim.

} from "./getDiscourseNodes";
import internalError from "./internalError";

const SCHEMA_COLUMNS =

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.

Roam publishes the node type format flat in literal_content (ENG-2158). Obsidian nests it under literal_content.source_data, because its frontmatter is dual-homed there. Both are read by JSON path, so this select carries no payload column.

source_data_format: string | null;
};

const findOrCreateNodeType = async (

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.

Match order is id, then name, then create (MG, team chat 2026-08-19). Obsidian already runs the same algorithm in mapNodeTypeIdToLocal (apps/obsidian/src/utils/importNodes.ts:1059). A created type reuses the remote schema's source_local_id as its page uid, so the next import from that space matches on id instead of matching on name again.

.eq("is_relation", false)
.in("id", schemaIds);
if (error) {
internalError({

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.

Decoration is cosmetic, so neither a failed schema query nor a failed type creation aborts the import. The affected nodes land with their incoming title and we hear about it in PostHog.

}

const nodeTypeBySchemaId = new Map<number, DiscourseNode>();
for (const schema of data) {

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.

Sequential on purpose. Creating a type invalidates the node type caches, and two remote schemas can share a name, so the second iteration has to see what the first created.

}: {
client: DGSupabaseClient;
sharedNode: SharedNode;
nodeType?: Pick<DiscourseNode, "format">;

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.

Only the format is used, so the param asks for only that. Resolution happens in the callers, which keeps this function free of a new I/O stage. Passing nothing reproduces today's behavior exactly.

sourceModifiedAt: validated.sourceModifiedAt,
sourceNodeRid: sharedNode.rid,
};
const pageTitle =

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.

Computed once, before the create/update branch, so both paths and the title collision checks agree. validateSharedNode stays pure and still validates the incoming title. fetchFullMarkdown keeps using the raw sharedNode.title to strip a Roam origin H1, which a test pins.

text: string;
shortcut: string;
format: string;
uid?: 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.

The settings panel must not pass a uid, since Roam generates one. The importer must, to reuse the remote type id so later imports match on id. createPage already accepts an optional uid and falls back to generateUID().

@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: 564ee2d636

ℹ️ 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 on lines +43 to +48
return createDiscourseNodeType({
text: schema.name,
shortcut: "",
format: schema.format ?? schema.source_data_format ?? "",
uid: schema.source_local_id,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Persist imported types in the active settings store

When Use new settings store is false—which is the schema default—getDiscourseNodes reads only discourseConfigRef.nodes, but this path always creates the type through the block-prop-only createDiscourseNodeType. An unknown remote type is therefore available only through the returned object for the current import batch; after a reload it is not recognized as a configured node type, and subsequent imports can try to create it again. Create the type through the active legacy store or dual-write both stores.

Useful? React with 👍 / 👎.

Comment on lines +322 to +325
const pageTitle =
sharedNode.coreTitle && nodeType?.format
? decorateTitle(nodeType.format, sharedNode.coreTitle)
: validated.title;

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 Re-title up-to-date imports before skipping

For a page imported before this change, or after its local node-type format changes, the stored source timestamp can already equal lastModified while the local title still differs from this new pageTitle. The unchanged up-to-date branch below returns skipped without comparing titles, so selecting the node again in the discovery dialog never applies the decoration unless the source content changes or the user separately invokes forced refresh. Include title equality in the skip decision so existing imports receive the new naming behavior.

Useful? React with 👍 / 👎.

@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: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 2 additional findings.

Open in Devin Review

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