diff --git a/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts new file mode 100644 index 000000000..3c9826eda --- /dev/null +++ b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DISCOURSE_GRAPH_PROP_NAME } from "~/utils/createReifiedBlock"; +import { + findImportedNodeUidBySourceRid, + getImportedSourceRids, + IMPORTED_FROM_PROP_KEY, + parseImportedSourceIdentity, + readImportedSourceIdentity, + writeImportedSourceIdentity, +} from "~/utils/importedSourceIdentity"; +import type { json } from "~/utils/getBlockProps"; + +const SOURCE_NODE_RID = "orn:obsidian.note:vault-a/node-1"; +const SOURCE_MODIFIED_AT = "2026-06-14T15:00:00.000Z"; +const PAGE_UID = "page-uid"; + +const propsByUid = new Map>(); +const query = vi.fn(); + +const setRoamAlphaApi = (): void => { + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + data: { + async: { q: query }, + block: { + update: vi.fn( + ({ + block, + }: { + block: { props: Record; uid: string }; + }) => { + propsByUid.set(block.uid, block.props); + }, + ), + }, + }, + pull: (_pattern: string, [, uid]: [string, string]) => ({ + ":block/props": propsByUid.get(uid) ?? {}, + }), + }, + }; +}; + +beforeEach(() => { + propsByUid.clear(); + query.mockReset(); + setRoamAlphaApi(); +}); + +describe("imported source identity metadata", () => { + it("reads the source RID without depending on display metadata", () => { + const props = { + [DISCOURSE_GRAPH_PROP_NAME]: { + [IMPORTED_FROM_PROP_KEY]: { + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + sourceTitle: "Legacy title that may change", + }, + }, + }; + + expect(parseImportedSourceIdentity(props)).toEqual({ + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + }); + + it("returns undefined for missing or malformed source identity", () => { + expect(parseImportedSourceIdentity({})).toBeUndefined(); + expect( + parseImportedSourceIdentity({ + [DISCOURSE_GRAPH_PROP_NAME]: { + [IMPORTED_FROM_PROP_KEY]: { sourceNodeRid: 123 }, + }, + }), + ).toBeUndefined(); + }); + + it("writes the source RID and modified time while preserving sibling metadata", async () => { + propsByUid.set(PAGE_UID, { + [DISCOURSE_GRAPH_PROP_NAME]: { + "relation-migration": { relationUid: 1718000000000 }, + }, + "other-extension": { enabled: true }, + }); + + await writeImportedSourceIdentity({ + pageUid: PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + + expect(readImportedSourceIdentity(PAGE_UID)).toEqual({ + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + expect(propsByUid.get(PAGE_UID)).toEqual({ + [DISCOURSE_GRAPH_PROP_NAME]: { + "relation-migration": { relationUid: 1718000000000 }, + [IMPORTED_FROM_PROP_KEY]: { + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }, + }, + "other-extension": { enabled: true }, + }); + }); +}); + +describe("imported source identity lookup", () => { + it("returns the stored RID set used for duplicate prevention", async () => { + query.mockResolvedValue([SOURCE_NODE_RID, 123, null]); + + await expect(getImportedSourceRids()).resolves.toEqual( + new Set([SOURCE_NODE_RID]), + ); + expect(query).toHaveBeenCalledOnce(); + expect(query.mock.calls[0]?.[0]).toContain(":sourceNodeRid"); + }); + + it("finds the imported Roam page by source RID", async () => { + query.mockResolvedValue([[PAGE_UID]]); + + await expect(findImportedNodeUidBySourceRid(SOURCE_NODE_RID)).resolves.toBe( + PAGE_UID, + ); + expect(query).toHaveBeenCalledWith( + expect.stringContaining(":sourceNodeRid"), + SOURCE_NODE_RID, + ); + }); + + it("returns null when the source RID has not been imported", async () => { + query.mockResolvedValue([]); + + await expect( + findImportedNodeUidBySourceRid(SOURCE_NODE_RID), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts b/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts new file mode 100644 index 000000000..08171d44a --- /dev/null +++ b/apps/roam/src/utils/__tests__/materializeObsidianNode.test.ts @@ -0,0 +1,221 @@ +import { contentTypes } from "@repo/content-model"; +import type { CrossAppNode } from "@repo/database/crossAppContracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { materializeObsidianNode } from "~/utils/materializeObsidianNode"; + +const mocks = vi.hoisted(() => ({ + deleteBlock: vi.fn(), + findImportedNodeUidBySourceRid: vi.fn(), + getShallowTreeByParentUid: vi.fn(), + writeImportedSourceIdentity: vi.fn(), +})); + +vi.mock("roamjs-components/queries/getShallowTreeByParentUid", () => ({ + default: mocks.getShallowTreeByParentUid, +})); + +vi.mock("roamjs-components/writes/deleteBlock", () => ({ + default: mocks.deleteBlock, +})); + +vi.mock("~/utils/importedSourceIdentity", () => ({ + findImportedNodeUidBySourceRid: mocks.findImportedNodeUidBySourceRid, + writeImportedSourceIdentity: mocks.writeImportedSourceIdentity, +})); + +const SOURCE_NODE_RID = "orn:obsidian.note:vault-a/node-1"; +const SOURCE_MODIFIED_AT = "2026-06-14T15:00:00.000Z"; +const NEW_PAGE_UID = "new-page-uid"; +const EXISTING_PAGE_UID = "existing-page-uid"; +const MARKDOWN = "# REM sleep correlates with recall\n\nUpdated evidence."; + +const node: CrossAppNode = { + localId: "node-1", + nodeType: { localId: "evidence" }, + content: { + direct: { value: "EVD - REM sleep and recall" }, + full: { + contentType: contentTypes.obsidianMarkdown, + value: MARKDOWN, + }, + }, + createdAt: new Date("2026-06-14T10:30:00.000Z"), + modifiedAt: new Date(SOURCE_MODIFIED_AT), + author: { localId: "author" }, +}; + +const pageFromMarkdown = vi.fn(); +const blockFromMarkdown = vi.fn(); +const deletePage = vi.fn(); +const updatePage = vi.fn(); + +const setRoamAlphaApi = (): void => { + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + data: { + block: { fromMarkdown: blockFromMarkdown }, + page: { + delete: deletePage, + fromMarkdown: pageFromMarkdown, + update: updatePage, + }, + }, + util: { generateUID: () => NEW_PAGE_UID }, + }, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.deleteBlock.mockResolvedValue(undefined); + mocks.getShallowTreeByParentUid.mockReturnValue([]); + pageFromMarkdown.mockResolvedValue({ uid: NEW_PAGE_UID }); + blockFromMarkdown.mockResolvedValue({ uids: [] }); + deletePage.mockResolvedValue(undefined); + updatePage.mockResolvedValue(undefined); + setRoamAlphaApi(); +}); + +describe("materializeObsidianNode", () => { + it("creates a Roam page from Obsidian markdown and stores source identity", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); + + await expect( + materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }), + ).resolves.toEqual({ + success: true, + action: "created", + pageUid: NEW_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { + title: "EVD - REM sleep and recall", + uid: NEW_PAGE_UID, + }, + "markdown-string": MARKDOWN, + }); + expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({ + pageUid: NEW_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + }); + + it("replaces the existing imported page instead of creating a duplicate", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + mocks.getShallowTreeByParentUid.mockReturnValue([ + { uid: "old-child-1", text: "Old content" }, + { uid: "old-child-2", text: "More old content" }, + ]); + + await expect( + materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }), + ).resolves.toMatchObject({ + success: true, + action: "updated", + pageUid: EXISTING_PAGE_UID, + }); + + expect(pageFromMarkdown).not.toHaveBeenCalled(); + expect(updatePage).toHaveBeenCalledWith({ + page: { + title: "EVD - REM sleep and recall", + uid: EXISTING_PAGE_UID, + }, + "merge-pages": false, + }); + expect(mocks.deleteBlock).toHaveBeenCalledTimes(2); + expect(mocks.deleteBlock).toHaveBeenCalledWith("old-child-1"); + expect(mocks.deleteBlock).toHaveBeenCalledWith("old-child-2"); + expect(blockFromMarkdown).toHaveBeenCalledWith({ + location: { "parent-uid": EXISTING_PAGE_UID, order: "last" }, + "markdown-string": MARKDOWN, + }); + expect(mocks.writeImportedSourceIdentity).toHaveBeenCalledWith({ + pageUid: EXISTING_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + }); + + it("returns the source identity and failed stage when replacement fails", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + blockFromMarkdown.mockRejectedValue(new Error("markdown parser failed")); + + await expect( + materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }), + ).resolves.toEqual({ + success: false, + pageUid: EXISTING_PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + error: { + message: `Failed to replace Roam content for '${SOURCE_NODE_RID}': markdown parser failed`, + stage: "replace-page-content", + }, + }); + expect(mocks.writeImportedSourceIdentity).not.toHaveBeenCalled(); + expect(mocks.deleteBlock).not.toHaveBeenCalled(); + }); + + it("removes a new page if its source identity cannot be stored", async () => { + mocks.findImportedNodeUidBySourceRid.mockResolvedValue(null); + mocks.writeImportedSourceIdentity.mockRejectedValue( + new Error("props update failed"), + ); + + const result = await materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + + expect(result).toMatchObject({ + success: false, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + error: { + stage: "write-source-identity", + }, + }); + expect(result).not.toHaveProperty("pageUid"); + expect(deletePage).toHaveBeenCalledWith({ page: { uid: NEW_PAGE_UID } }); + }); + + it("rejects non-Obsidian payload identity before writing to Roam", async () => { + const sourceNodeRid = "orn:roam:graph-a/node-1"; + + await expect( + materializeObsidianNode({ + node, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid, + }), + ).resolves.toEqual({ + success: false, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid, + error: { + message: `Source node RID '${sourceNodeRid}' is not Obsidian-origin`, + stage: "validate-input", + }, + }); + expect(mocks.findImportedNodeUidBySourceRid).not.toHaveBeenCalled(); + expect(pageFromMarkdown).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/discoverSharedNodes.ts b/apps/roam/src/utils/discoverSharedNodes.ts index 80d4a8d3d..f2bf162d8 100644 --- a/apps/roam/src/utils/discoverSharedNodes.ts +++ b/apps/roam/src/utils/discoverSharedNodes.ts @@ -3,9 +3,7 @@ import { listGroupSharedNodes, type SharedNodeCandidate, } from "@repo/database/lib/sharedNodes"; -import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; - -const IMPORTED_FROM_PROP_KEY = "importedFrom"; +import { getImportedSourceRids } from "./importedSourceIdentity"; export type DiscoveredSharedNode = { alreadyImported: boolean; @@ -36,19 +34,6 @@ export const toDiscoveredSharedNodes = ({ title: candidate.title, })); -const getImportedSourceRids = async (): Promise> => { - const query = `[:find [?rid ...] - :where - [?page :block/props ?props] - [(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData] - [(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?imported] - [(get ?imported :sourceNodeRid) ?rid]]`; - const result = (await window.roamAlphaAPI.data.async.q(query)) as unknown[]; - return new Set( - result.filter((rid): rid is string => typeof rid === "string"), - ); -}; - export const discoverSharedNodes = async ({ client, currentSpaceId, diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts new file mode 100644 index 000000000..841081d98 --- /dev/null +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -0,0 +1,99 @@ +import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; +import getBlockProps, { type json } from "./getBlockProps"; + +export type ImportedSourceIdentity = { + sourceModifiedAt: string; + sourceNodeRid: string; +}; + +export const IMPORTED_FROM_PROP_KEY = "importedFrom"; + +const isJsonObject = (value: json): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const parseImportedSourceIdentity = ( + props: Record, +): ImportedSourceIdentity | undefined => { + const discourseGraphProps = props[DISCOURSE_GRAPH_PROP_NAME]; + if (!isJsonObject(discourseGraphProps)) return undefined; + + const importedFrom = discourseGraphProps[IMPORTED_FROM_PROP_KEY]; + if (!isJsonObject(importedFrom)) return undefined; + + const { sourceModifiedAt, sourceNodeRid } = importedFrom; + if (typeof sourceModifiedAt !== "string" || typeof sourceNodeRid !== "string") + return undefined; + + return { sourceModifiedAt, sourceNodeRid }; +}; + +export const readImportedSourceIdentity = ( + pageUid: string, +): ImportedSourceIdentity | undefined => + parseImportedSourceIdentity(getBlockProps(pageUid)); + +export const writeImportedSourceIdentity = ({ + pageUid, + sourceModifiedAt, + sourceNodeRid, +}: { + pageUid: string; + sourceModifiedAt: string; + sourceNodeRid: string; +}): Promise => { + const props = getBlockProps(pageUid); + const existing = props[DISCOURSE_GRAPH_PROP_NAME]; + const discourseGraphProps = isJsonObject(existing) ? existing : {}; + + return window.roamAlphaAPI.data.block.update({ + block: { + uid: pageUid, + props: { + ...props, + [DISCOURSE_GRAPH_PROP_NAME]: { + ...discourseGraphProps, + [IMPORTED_FROM_PROP_KEY]: { sourceModifiedAt, sourceNodeRid }, + }, + }, + }, + }); +}; + +export const getImportedSourceRids = async (): Promise> => { + const query = `[:find [?rid ...] + :where + [?page :block/props ?props] + [(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData] + [(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?importedFrom] + [(get ?importedFrom :sourceNodeRid) ?rid]]`; + const result = (await window.roamAlphaAPI.data.async.q(query)) as unknown[]; + + return new Set( + result.filter((rid): rid is string => typeof rid === "string"), + ); +}; + +export const findImportedNodeUidBySourceRid = async ( + sourceNodeRid: string, +): Promise => { + const query = `[:find ?uid + :in $ ?sourceNodeRid + :where + [?page :block/uid ?uid] + [?page :block/props ?props] + [(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData] + [(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?importedFrom] + [(get ?importedFrom :sourceNodeRid) ?sourceNodeRid]]`; + const result = (await window.roamAlphaAPI.data.async.q( + query, + sourceNodeRid, + )) as [string][]; + + if (result.length > 1) { + console.warn( + `findImportedNodeUidBySourceRid: ${result.length} pages share source RID '${sourceNodeRid}'`, + ); + } + + return result[0]?.[0] ?? null; +}; diff --git a/apps/roam/src/utils/materializeObsidianNode.ts b/apps/roam/src/utils/materializeObsidianNode.ts new file mode 100644 index 000000000..bcb4c8d64 --- /dev/null +++ b/apps/roam/src/utils/materializeObsidianNode.ts @@ -0,0 +1,261 @@ +import { contentTypes } from "@repo/content-model"; +import type { CrossAppNode } from "@repo/database/crossAppContracts"; +import { ridToSpaceUriAndLocalId } from "@repo/database/lib/rid"; +import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; +import deleteBlock from "roamjs-components/writes/deleteBlock"; +import { + findImportedNodeUidBySourceRid, + type ImportedSourceIdentity, + writeImportedSourceIdentity, +} from "./importedSourceIdentity"; + +type MaterializationStage = + | "validate-input" + | "find-imported-node" + | "create-page" + | "update-page-title" + | "replace-page-content" + | "write-source-identity"; + +type MaterializationFailure = ImportedSourceIdentity & { + success: false; + pageUid?: string; + error: { + message: string; + stage: MaterializationStage; + }; +}; + +type MaterializationSuccess = ImportedSourceIdentity & { + success: true; + action: "created" | "updated"; + pageUid: string; +}; + +export type MaterializeObsidianNodeResult = + | MaterializationFailure + | MaterializationSuccess; + +type RoamFromMarkdownApi = { + data: { + block: { + fromMarkdown: (args: { + location: { "parent-uid": string; order: "last" }; + "markdown-string": string; + }) => Promise<{ uids: string[] }>; + }; + page: { + delete: (args: { page: { uid: string } }) => Promise; + fromMarkdown: (args: { + page: { title: string; uid: string }; + "markdown-string": string; + }) => Promise<{ uid: string }>; + update: (args: { + page: { title: string; uid: string }; + "merge-pages": false; + }) => Promise; + }; + }; + util: { + generateUID: () => string; + }; +}; + +const getRoamFromMarkdownApi = (): RoamFromMarkdownApi => + window.roamAlphaAPI as unknown as RoamFromMarkdownApi; + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const failure = ({ + error, + identity, + message, + pageUid, + stage, +}: { + error?: unknown; + identity: ImportedSourceIdentity; + message: string; + pageUid?: string; + stage: MaterializationStage; +}): MaterializationFailure => ({ + ...identity, + success: false, + ...(pageUid ? { pageUid } : {}), + error: { + message: error ? `${message}: ${getErrorMessage(error)}` : message, + stage, + }, +}); + +const validateInput = ({ + node, + sourceModifiedAt, + sourceNodeRid, +}: { + node: CrossAppNode; +} & ImportedSourceIdentity): string | undefined => { + if (!sourceNodeRid.trim()) return "Source node RID is required"; + + const { spaceUri } = ridToSpaceUriAndLocalId(sourceNodeRid); + if (!spaceUri.startsWith("obsidian:")) + return `Source node RID '${sourceNodeRid}' is not Obsidian-origin`; + + if (Number.isNaN(Date.parse(sourceModifiedAt))) + return `Source modified time '${sourceModifiedAt}' is invalid`; + + if (!node.content.direct.value.trim()) return "Source node title is required"; + + const contentType = node.content.full.contentType; + if ( + contentType !== contentTypes.markdown && + contentType !== contentTypes.obsidianMarkdown + ) + return `Unsupported Obsidian full content type '${contentType}'`; + + return undefined; +}; + +const replacePageContent = async ({ + markdown, + pageUid, +}: { + markdown: string; + pageUid: string; +}): Promise => { + const children = getShallowTreeByParentUid(pageUid); + await getRoamFromMarkdownApi().data.block.fromMarkdown({ + location: { "parent-uid": pageUid, order: "last" }, + "markdown-string": markdown, + }); + await Promise.all(children.map(({ uid }) => deleteBlock(uid))); +}; + +export const materializeObsidianNode = async ({ + node, + sourceModifiedAt, + sourceNodeRid, +}: { + node: CrossAppNode; +} & ImportedSourceIdentity): Promise => { + const identity = { sourceModifiedAt, sourceNodeRid }; + const validationError = validateInput({ node, ...identity }); + if (validationError) + return failure({ + identity, + message: validationError, + stage: "validate-input", + }); + + const title = node.content.direct.value.trim(); + const markdown = node.content.full.value; + let existingPageUid: string | null; + + try { + existingPageUid = await findImportedNodeUidBySourceRid(sourceNodeRid); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to look up imported Roam node for '${sourceNodeRid}'`, + stage: "find-imported-node", + }); + } + + if (existingPageUid) { + try { + await getRoamFromMarkdownApi().data.page.update({ + page: { title, uid: existingPageUid }, + "merge-pages": false, + }); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to update the Roam page title for '${sourceNodeRid}'`, + pageUid: existingPageUid, + stage: "update-page-title", + }); + } + + try { + await replacePageContent({ markdown, pageUid: existingPageUid }); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to replace Roam content for '${sourceNodeRid}'`, + pageUid: existingPageUid, + stage: "replace-page-content", + }); + } + + try { + await writeImportedSourceIdentity({ + pageUid: existingPageUid, + ...identity, + }); + } catch (error) { + return failure({ + error, + identity, + message: `Content was updated, but source identity could not be refreshed for '${sourceNodeRid}'`, + pageUid: existingPageUid, + stage: "write-source-identity", + }); + } + + return { + ...identity, + success: true, + action: "updated", + pageUid: existingPageUid, + }; + } + + const pageUid = getRoamFromMarkdownApi().util.generateUID(); + try { + await getRoamFromMarkdownApi().data.page.fromMarkdown({ + page: { title, uid: pageUid }, + "markdown-string": markdown, + }); + } catch (error) { + return failure({ + error, + identity, + message: `Failed to create a Roam page for '${sourceNodeRid}'`, + stage: "create-page", + }); + } + + try { + await writeImportedSourceIdentity({ pageUid, ...identity }); + } catch (error) { + let cleanupError: unknown; + try { + await getRoamFromMarkdownApi().data.page.delete({ + page: { uid: pageUid }, + }); + } catch (caughtCleanupError) { + cleanupError = caughtCleanupError; + } + + const cleanupMessage = cleanupError + ? ` Cleanup also failed: ${getErrorMessage(cleanupError)}` + : " The newly created page was removed."; + return failure({ + identity, + message: `Roam content was created, but source identity could not be stored for '${sourceNodeRid}': ${getErrorMessage(error)}.${cleanupMessage}`, + ...(cleanupError ? { pageUid } : {}), + stage: "write-source-identity", + }); + } + + return { + ...identity, + success: true, + action: "created", + pageUid, + }; +};