Skip to content

[ENG-1859] Add Roam import action for selected shared nodes - #1269

Open
sid597 wants to merge 4 commits into
eng-1858-materialize-obsidian-origin-markdown-into-roam-v3from
eng-1859-add-roam-import-action-for-selected-shared-nodes-v3
Open

[ENG-1859] Add Roam import action for selected shared nodes#1269
sid597 wants to merge 4 commits into
eng-1858-materialize-obsidian-origin-markdown-into-roam-v3from
eng-1859-add-roam-import-action-for-selected-shared-nodes-v3

Conversation

@sid597

@sid597 sid597 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator
eng-1859.mp4

Implements ENG-1859: wires the shared-node discovery dialog (ENG-1855) to the materializer (ENG-1858) so a Roam user can select visible Obsidian-origin nodes and import them, with imported / skipped / failed counts and per-node failure messages.

Stacked on eng-1858-materialize-obsidian-origin-markdown-into-roam-v3 — only the [ENG-1859] commits are new here.

What's in the diff

  • DiscoverSharedNodesDialog.tsx — checkbox column (enabled only for Obsidian-origin rows, since the materializer only supports those), select-all header checkbox over visible importable rows, primary Import selected (N) button that reads Importing X of N… while running, and a results Callout with the three counts plus per-node failure messages. Failures are also reported to PostHog via internalError. Inherits the existing isSyncEnabled() gate on the dialog command.
  • importSharedNodes.ts (new) — sequential loop over the selected SharedNodes; each item is try/caught so partial failures never abort the batch (done-when Releases storage url rewrite #3); returns per-node imported | skipped | failed(+message) items. Mirrors Obsidian's importSelectedNodes shape (onProgress(current, total), sequential, counts).
  • materializeSharedNode.ts — gains the "skipped" action this ticket requires: when the existing imported page's stored sourceModifiedAt is at least as recent as the source's, it returns without fetching content or touching the page.
  • discoverSharedNodes.tsDiscoveredSharedNode gains one flat key, sourceSpaceDbId (the numeric Space id), plus a toSharedNode mapper that rebuilds the SharedNode the materializer takes; the listing shape from ENG-1855/ENG-2019 is otherwise unchanged.

Decisions a reviewer would ask about

  • Why sourceSpaceDbId and toSharedNode? The materializer's content query filters by the numeric space_id + source_local_id — and the RID can't replace them without an extra Space lookup per node (the URI→id resolution that getSpaceNameIdFromRid does for RID-only flows like refresh). Discovery already holds the numeric id, so it's passed through as one flat key, consistent with the existing flattened view model. toSharedNode rebuilds the SharedNode input at import time; created/directMetadata are null-filled there because the materializer never reads them (verified against every use) — if a future flow starts reading them, they need to be carried through instead.
  • Why does skip live in the materializer, not the dialog/util? The materializer already owns the find-imported-node lookup; deciding skip there avoids a second datalog lookup per node and keeps all source-identity logic in one place. The refresh flow (ENG-1860) gets the same semantics for free. "skipped" slots into the established staged-result shape ({ success: true, action }).
  • Skip predicate: stored sourceModifiedAt >= source lastModified, compared via Date.parse — both values are canonical UTC written/normalized by this same module. Missing or unparseable stored identity falls through to update (never skips on bad data).
  • Stage reorder (find-imported-node now before fetch-content): skipped nodes shouldn't fetch the heavy full payload. No caller observes the old order — ENG-1858 is unreleased on this same stack, and all its tests still pass unchanged.
  • After an import, failed nodes stay selected (successful ones are cleared) so retrying after fixing e.g. a title collision is one click.
  • The dialog can't be closed mid-import (escape / outside click / X / Close all disabled) because the underlying Roam writes can't be cancelled; the lock lifts when the run finishes.
  • getErrorMessage is now exported from materializeSharedNode.ts and reused by importSharedNodes.ts instead of adding a third private copy of the same helper.

Known deferral

  • The loop fetches each node's full content in a per-node query (inside the materializer). Batching the content fetch for a multi-select import would require the materializer to accept prefetched content; payloads are heavy and the skip path avoids the fetch entirely for up-to-date nodes, so this is deferred rather than reshaped here.

Out of scope (per ticket)

Refresh after import (ENG-1860), relations and assets, advanced title-collision UX (collisions fail with an actionable message naming the page to rename/remove).

Testing

  • eslint --max-warnings 0, prettier --check, tsc --noEmit --skipLibCheck all pass.
  • 74 unit tests pass in apps/roam/src/utils, including new coverage: skip-when-unchanged (no content fetch, no writes), update-when-changed, update-when-stored-identity-invalid, per-node outcomes + progress reporting, and continue-after-throw.
  • Runtime demo of the real flow (select → import → counts/failures) to follow before review.

@linear-code

linear-code Bot commented Jul 30, 2026

Copy link
Copy Markdown

ENG-1859

@vercel

vercel Bot commented Jul 30, 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 Jul 30, 2026 8:08pm

Request Review

@graphite-app

graphite-app Bot commented Jul 30, 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 Jul 30, 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.

Guidepost comments on the non-obvious decisions (details in the PR description).

if (
importedPageUid &&
storedIdentity &&
isImportUpToDate({

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 skip decision lives here (not in the import loop) so the find-imported-node lookup happens once and all source-identity logic stays in this module — the ENG-1860 refresh flow inherits the same semantics. Predicate: stored sourceModifiedAt >= source lastModified, both canonical UTC written by this module; missing/unparseable stored identity falls through to update, never to skip.

let storedIdentity: ImportedSourceIdentity | undefined;
try {
importedPageUid = await findImportedNodeUidBySourceRid(sharedNode.rid);
storedIdentity = importedPageUid

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.

find-imported-node moved ahead of fetch-content so an up-to-date node skips without fetching the heavy full payload. No caller observes the old order (ENG-1858 is unreleased on this stack) and all existing stage tests pass unchanged.

}
: { sharedNode, status: "failed", message: result.error.message },
);
} catch (error) {

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.

materializeSharedNode is designed to return failures rather than throw, but a few sync roamjs queries sit outside its try blocks — this catch is what guarantees the ticket's "partial failures do not abort the whole selected import" even if one materialization throws.

),
);
const failedImports = results.filter(isFailedSharedNodeImport);
setSelectedRids(

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.

Selection is not fully cleared after a run: failed nodes stay selected so a retry after fixing the reported problem (e.g. renaming a colliding page) is one click; successful ones are deselected.

autoFocus={false}
canEscapeKeyClose
canOutsideClickClose
canEscapeKeyClose={!importing}

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.

All close paths (escape, outside click, X, Close button) are disabled while an import runs because the underlying Roam writes can't be cancelled — closing would just hide the progress and the failure report.

@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

@sid597

sid597 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 1c17ecbcd6

ℹ️ 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 +230 to +238
const toggleAllVisibleSelected = (): void => {
setSelectedRids((previous) => {
const next = new Set(previous);
if (allVisibleSelected)
importableVisibleRids.forEach((rid) => next.delete(rid));
else importableVisibleRids.forEach((rid) => next.add(rid));
return next;
});
};

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: allVisibleSelected is a stale closure captured from render time, but the state updater function receives the current state. If the checkbox is toggled rapidly or state updates between renders, the toggle logic will use outdated allVisibleSelected values, causing incorrect selection behavior.

const toggleAllVisibleSelected = (): void => {
  setSelectedRids((previous) => {
    const next = new Set(previous);
    // Compute allSelected based on current state, not captured render value
    const allSelected = importableVisibleRids.length > 0 &&
      importableVisibleRids.every((rid) => previous.has(rid));
    if (allSelected)
      importableVisibleRids.forEach((rid) => next.delete(rid));
    else importableVisibleRids.forEach((rid) => next.add(rid));
    return next;
  });
};

The fix computes the selection state inside the updater function based on the actual previous state rather than the captured allVisibleSelected value.

Suggested change
const toggleAllVisibleSelected = (): void => {
setSelectedRids((previous) => {
const next = new Set(previous);
if (allVisibleSelected)
importableVisibleRids.forEach((rid) => next.delete(rid));
else importableVisibleRids.forEach((rid) => next.add(rid));
return next;
});
};
const toggleAllVisibleSelected = (): void => {
setSelectedRids((previous) => {
const next = new Set(previous);
const allSelected =
importableVisibleRids.length > 0 &&
importableVisibleRids.every((rid) => previous.has(rid));
if (allSelected)
importableVisibleRids.forEach((rid) => next.delete(rid));
else importableVisibleRids.forEach((rid) => next.add(rid));
return next;
});
};

Spotted by Graphite

Fix in Graphite


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

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

Inline notes on the shape change in the latest commit: the DiscoveredSharedNode type from #1214 is removed in favor of using SharedNode directly. Each note explains what replaced the removed piece.

client: DGSupabaseClient;
currentSpaceId: number;
}): Promise<DiscoveredSharedNode[]> => {
}): Promise<{

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.

Discovery now returns its two sources separately instead of pre-joining them into the DiscoveredSharedNode shape this file had since #1214. That shape was a field-renamed copy of SharedNode plus a baked-in alreadyImported flag — and wiring up import meant mapping it back into a SharedNode while fabricating the fields the renamed copy didn't carry (created, directMetadata). The dialog is the shape's only consumer, so the parallel type is gone: rows stay SharedNode end to end (list → selection → importSharedNodes), and "already imported" is derived from importedSourceRids at render.

const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
const [nodes, setNodes] = useState<DiscoveredSharedNode[]>([]);
const [nodes, setNodes] = useState<SharedNode[]>([]);
const [importedRids, setImportedRids] = useState<Set<string>>(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.

Single source of truth for imported-ness. Rows don't carry an alreadyImported flag anymore — the badge derives from importedRids.has(node.rid) at render, and a successful import just adds rids to this set (previously this meant walking the nodes array and patching a stored flag on each matching row).

node.sourceNodeId,
].some((value) => value?.toLocaleLowerCase().includes(normalizedSearch)),
node.sourceLocalId,
].some((value) => value.toLocaleLowerCase().includes(normalizedSearch)),

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.

No ?. here on purpose: all five searched fields are non-optional strings on SharedNode.

@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import { toDiscoveredSharedNodes } from "~/utils/discoverSharedNodes";

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.

Deleted with its subject: this file only tested the SharedNodeDiscoveredSharedNode field renaming, which no longer exists. The replacement join is importedRids.has(node.rid) at render — nothing left to unit-test.

@sid597
sid597 requested review from maparent and mdroidian July 30, 2026 20:20

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

Second set of inline notes from the pre-PR pass: decisions a reviewer would otherwise have to ask about (staged-result shape, timestamp provenance, sequential loop, Obsidian-only gate).

type MaterializationSuccess = SourceIdentity & {
success: true;
action: "created" | "updated";
action: "created" | "updated" | "skipped";

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.

skipped extends the staged-result shape ENG-1858 established (identity echo + success/action or failure/stage). Consumers report per-item outcomes from action/stage; importSharedNodes collapses created/updated to a single "imported" status because the dialog doesn't distinguish them.

export const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);

const isImportUpToDate = ({

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.

Timestamp provenance for the Date.parse here: both sides are canonical ISO-with-Z strings — sourceModifiedAt comes from SharedNode.lastModified, which packages/database normalizes (normalizeUtcTimestamptoISOString()), and storedModifiedAt is that same value echoed back from the block props we wrote at import time. So this is not naive DB-timestamp parsing; the Z-less-PostgREST hazard is handled a layer down. The NaN guard covers hand-edited or legacy props (block props are user-editable), in which case we fall through to a normal re-import.

onProgress: (current: number, total: number) => void;
}): Promise<SharedNodeImportItem[]> => {
const items: SharedNodeImportItem[] = [];
for (const sharedNode of sharedNodes) {

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, not an overlooked batch: each iteration is a full materialization (skip check → content fetch → page write) with per-item staged failure reporting and the onProgress callback driving the dialog's progress label. Batching the content fetches would front-load every node's full markdown (the payload discovery deliberately doesn't fetch) and lose per-item progress. Selections are user-sized (a handful), not data-scale.

new Date(modifiedAt).toLocaleString();

const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
const isImportableSharedNode = (node: SharedNode): boolean =>

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.

Obsidian-only by MVP scope: Obsidian is currently the only producer of importable full content, so its rows are the only ones with something to materialize. Roam-origin rows still show in the list (visibility of what's shared) but aren't selectable. When another producer lands, this predicate is the one place to widen.

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