diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
index a45a52556..f5d0b57d1 100644
--- a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
+++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
@@ -1,6 +1,7 @@
import {
Button,
Callout,
+ Checkbox,
Classes,
Dialog,
HTMLTable,
@@ -17,13 +18,41 @@ import {
discoverSharedNodes,
type DiscoveredSharedNode,
} from "~/utils/discoverSharedNodes";
+import { importDiscoveredSharedNode } from "~/utils/importDiscoveredSharedNode";
+import {
+ importSelectedSharedNodes,
+ type SelectedSharedNodeImportResult,
+} from "~/utils/importSelectedSharedNodes";
import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";
const formatModifiedAt = (modifiedAt: string): string =>
new Date(modifiedAt).toLocaleString();
-const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
+const SharedNodeRow = ({
+ disabled,
+ node,
+ onToggle,
+ selected,
+}: {
+ disabled: boolean;
+ node: DiscoveredSharedNode;
+ onToggle: (node: DiscoveredSharedNode) => void;
+ selected: boolean;
+}) => (
+ |
+ onToggle(node)}
+ title={
+ node.sourceApp === "Obsidian"
+ ? undefined
+ : "Only Obsidian-origin nodes can be imported into Roam"
+ }
+ />
+ |
{node.sourceApp}
|
@@ -76,15 +105,62 @@ const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
);
+const ImportResultCallout = ({
+ result,
+}: {
+ result: SelectedSharedNodeImportResult;
+}) => {
+ const failedCount = result.failed.length;
+ const intent =
+ failedCount === 0
+ ? Intent.SUCCESS
+ : result.imported > 0 || result.skipped > 0
+ ? Intent.WARNING
+ : Intent.DANGER;
+
+ return (
+
+ {result.updated > 0 && (
+ 0 ? "mb-2" : undefined}>
+ {result.updated} previously imported{" "}
+ {result.updated === 1 ? "node was" : "nodes were"} updated.
+
+ )}
+ {failedCount > 0 && (
+
+ {result.failed.map(({ message, node }) => (
+ -
+ {node.title}: {message}
+
+ ))}
+
+ )}
+
+ );
+};
+
const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
const [nodes, setNodes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
+ const [importError, setImportError] = useState("");
+ const [importing, setImporting] = useState(false);
+ const [importResult, setImportResult] =
+ useState();
const [search, setSearch] = useState("");
+ const [selectedSourceRids, setSelectedSourceRids] = useState>(
+ new Set(),
+ );
const loadNodes = useCallback(async (): Promise => {
setLoading(true);
setError("");
+ setImportError("");
+ setImportResult(undefined);
+ setSelectedSourceRids(new Set());
try {
const [client, context] = await Promise.all([
getLoggedInClient(),
@@ -128,11 +204,88 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
);
}, [nodes, search]);
+ const selectedNodes = useMemo(
+ () => nodes.filter((node) => selectedSourceRids.has(node.sourceNodeRid)),
+ [nodes, selectedSourceRids],
+ );
+ const visibleImportableNodes = useMemo(
+ () => visibleNodes.filter((node) => node.sourceApp === "Obsidian"),
+ [visibleNodes],
+ );
+ const allVisibleSelected =
+ visibleImportableNodes.length > 0 &&
+ visibleImportableNodes.every((node) =>
+ selectedSourceRids.has(node.sourceNodeRid),
+ );
+ const someVisibleSelected = visibleImportableNodes.some((node) =>
+ selectedSourceRids.has(node.sourceNodeRid),
+ );
+
+ const toggleNode = useCallback((node: DiscoveredSharedNode): void => {
+ setSelectedSourceRids((current) => {
+ const next = new Set(current);
+ if (next.has(node.sourceNodeRid)) next.delete(node.sourceNodeRid);
+ else next.add(node.sourceNodeRid);
+ return next;
+ });
+ }, []);
+
+ const toggleAllVisible = useCallback((): void => {
+ setSelectedSourceRids((current) => {
+ const next = new Set(current);
+ if (allVisibleSelected) {
+ visibleImportableNodes.forEach((node) =>
+ next.delete(node.sourceNodeRid),
+ );
+ } else {
+ visibleImportableNodes.forEach((node) => next.add(node.sourceNodeRid));
+ }
+ return next;
+ });
+ }, [allVisibleSelected, visibleImportableNodes]);
+
+ const importSelected = useCallback(async (): Promise => {
+ setImporting(true);
+ setImportError("");
+ setImportResult(undefined);
+ try {
+ const client = await getLoggedInClient();
+ if (!client) throw new Error("Could not connect to shared persistence.");
+
+ const result = await importSelectedSharedNodes({
+ materializeNode: (node) => importDiscoveredSharedNode({ client, node }),
+ nodes: selectedNodes,
+ });
+ const failedSourceRids = new Set(
+ result.failed.map(({ node }) => node.sourceNodeRid),
+ );
+ setNodes((current) =>
+ current.map((node) =>
+ selectedSourceRids.has(node.sourceNodeRid) &&
+ !failedSourceRids.has(node.sourceNodeRid)
+ ? { ...node, alreadyImported: true }
+ : node,
+ ),
+ );
+ setSelectedSourceRids(failedSourceRids);
+ setImportResult(result);
+ } catch (importError) {
+ console.error("Failed to import selected shared nodes:", importError);
+ setImportError(
+ importError instanceof Error
+ ? importError.message
+ : "Could not import selected shared nodes.",
+ );
+ } finally {
+ setImporting(false);
+ }
+ }, [selectedNodes, selectedSourceRids]);
+
return (
diff --git a/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts b/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
index 4a3a9637e..3b852dbb2 100644
--- a/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
+++ b/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
@@ -30,6 +30,7 @@ describe("toDiscoveredSharedNodes", () => {
sourceApp: "Obsidian",
sourceNodeId: "node-1",
sourceNodeRid: "orn:obsidian.note:vault-a/node-1",
+ sourceSpaceDatabaseId: 20,
sourceSpaceId: "obsidian:vault-a",
sourceSpaceName: "Research vault",
title: "EVD - REM sleep and recall",
diff --git a/apps/roam/src/utils/__tests__/importDiscoveredSharedNode.test.ts b/apps/roam/src/utils/__tests__/importDiscoveredSharedNode.test.ts
new file mode 100644
index 000000000..e6c55f5ad
--- /dev/null
+++ b/apps/roam/src/utils/__tests__/importDiscoveredSharedNode.test.ts
@@ -0,0 +1,90 @@
+import type { CrossAppNode } from "@repo/database/crossAppContracts";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { DiscoveredSharedNode } from "~/utils/discoverSharedNodes";
+import { importDiscoveredSharedNode } from "~/utils/importDiscoveredSharedNode";
+
+const mocks = vi.hoisted(() => ({
+ getSharedNodePayload: vi.fn(),
+ materializeObsidianNode: vi.fn(),
+}));
+
+vi.mock("@repo/database/lib/sharedNodes", () => ({
+ getSharedNodePayload: mocks.getSharedNodePayload,
+}));
+vi.mock("~/utils/materializeObsidianNode", () => ({
+ materializeObsidianNode: mocks.materializeObsidianNode,
+}));
+
+const node: DiscoveredSharedNode = {
+ alreadyImported: false,
+ modifiedAt: "2026-06-14T15:00:00.000Z",
+ sourceApp: "Obsidian",
+ sourceNodeId: "node-1",
+ sourceNodeRid: "orn:obsidian.note:vault-a/node-1",
+ sourceSpaceDatabaseId: 20,
+ sourceSpaceId: "obsidian:vault-a",
+ sourceSpaceName: "Research vault",
+ title: "EVD - REM sleep and recall",
+};
+const payload = { localId: "node-1" } as CrossAppNode;
+const client = {} as Parameters[0]["client"];
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getSharedNodePayload.mockResolvedValue(payload);
+});
+
+describe("importDiscoveredSharedNode", () => {
+ it.each([
+ ["created", "imported"],
+ ["updated", "updated"],
+ ] as const)("maps a %s materialization to %s", async (action, status) => {
+ mocks.materializeObsidianNode.mockResolvedValue({
+ action,
+ pageUid: "page-uid",
+ sourceModifiedAt: node.modifiedAt,
+ sourceNodeRid: node.sourceNodeRid,
+ success: true,
+ });
+
+ await expect(importDiscoveredSharedNode({ client, node })).resolves.toBe(
+ status,
+ );
+ expect(mocks.getSharedNodePayload).toHaveBeenCalledWith({
+ client,
+ sourceLocalId: "node-1",
+ spaceId: 20,
+ });
+ expect(mocks.materializeObsidianNode).toHaveBeenCalledWith({
+ node: payload,
+ sourceModifiedAt: node.modifiedAt,
+ sourceNodeRid: node.sourceNodeRid,
+ });
+ });
+
+ it("surfaces the materializer's actionable failure message", async () => {
+ mocks.materializeObsidianNode.mockResolvedValue({
+ error: {
+ message: "Markdown could not be imported",
+ stage: "create-page",
+ },
+ sourceModifiedAt: node.modifiedAt,
+ sourceNodeRid: node.sourceNodeRid,
+ success: false,
+ });
+
+ await expect(importDiscoveredSharedNode({ client, node })).rejects.toThrow(
+ "Markdown could not be imported",
+ );
+ });
+
+ it("skips non-Obsidian nodes without loading their payload", async () => {
+ await expect(
+ importDiscoveredSharedNode({
+ client,
+ node: { ...node, sourceApp: "Roam" },
+ }),
+ ).resolves.toBe("skipped");
+ expect(mocks.getSharedNodePayload).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/roam/src/utils/__tests__/importSelectedSharedNodes.test.ts b/apps/roam/src/utils/__tests__/importSelectedSharedNodes.test.ts
new file mode 100644
index 000000000..2e8e24b53
--- /dev/null
+++ b/apps/roam/src/utils/__tests__/importSelectedSharedNodes.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it, vi } from "vitest";
+import type { DiscoveredSharedNode } from "~/utils/discoverSharedNodes";
+import { importSelectedSharedNodes } from "~/utils/importSelectedSharedNodes";
+
+const createNode = ({
+ sourceNodeRid,
+ title,
+}: {
+ sourceNodeRid: string;
+ title: string;
+}): DiscoveredSharedNode => ({
+ alreadyImported: false,
+ modifiedAt: "2026-06-14T15:00:00.000Z",
+ sourceApp: "Obsidian",
+ sourceNodeId: sourceNodeRid.split("/").at(-1),
+ sourceNodeRid,
+ sourceSpaceDatabaseId: 20,
+ sourceSpaceId: "obsidian:vault-a",
+ sourceSpaceName: "Research vault",
+ title,
+});
+
+const firstNode = createNode({
+ sourceNodeRid: "orn:obsidian.note:vault-a/node-1",
+ title: "First node",
+});
+const secondNode = createNode({
+ sourceNodeRid: "orn:obsidian.note:vault-a/node-2",
+ title: "Second node",
+});
+const thirdNode = createNode({
+ sourceNodeRid: "orn:obsidian.note:vault-a/node-3",
+ title: "Third node",
+});
+
+describe("importSelectedSharedNodes", () => {
+ it("reports imported, updated, and skipped nodes", async () => {
+ const materializeNode = vi
+ .fn()
+ .mockResolvedValueOnce("imported")
+ .mockResolvedValueOnce("updated")
+ .mockResolvedValueOnce("skipped");
+
+ await expect(
+ importSelectedSharedNodes({
+ materializeNode,
+ nodes: [firstNode, secondNode, thirdNode],
+ }),
+ ).resolves.toEqual({
+ failed: [],
+ imported: 2,
+ skipped: 1,
+ updated: 1,
+ });
+ expect(materializeNode.mock.calls).toEqual([
+ [firstNode],
+ [secondNode],
+ [thirdNode],
+ ]);
+ });
+
+ it("continues importing after an individual node fails", async () => {
+ const materializeNode = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("Markdown could not be imported"))
+ .mockResolvedValueOnce("imported")
+ .mockRejectedValueOnce("Source content is missing");
+
+ await expect(
+ importSelectedSharedNodes({
+ materializeNode,
+ nodes: [firstNode, secondNode, thirdNode],
+ }),
+ ).resolves.toEqual({
+ failed: [
+ {
+ message: "Markdown could not be imported",
+ node: firstNode,
+ },
+ {
+ message: "Source content is missing",
+ node: thirdNode,
+ },
+ ],
+ imported: 1,
+ skipped: 0,
+ updated: 0,
+ });
+ expect(materializeNode).toHaveBeenCalledTimes(3);
+ });
+
+ it("returns empty counts when no nodes are selected", async () => {
+ const materializeNode = vi.fn();
+
+ await expect(
+ importSelectedSharedNodes({ materializeNode, nodes: [] }),
+ ).resolves.toEqual({
+ failed: [],
+ imported: 0,
+ skipped: 0,
+ updated: 0,
+ });
+ expect(materializeNode).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/roam/src/utils/discoverSharedNodes.ts b/apps/roam/src/utils/discoverSharedNodes.ts
index f2bf162d8..6585fc26e 100644
--- a/apps/roam/src/utils/discoverSharedNodes.ts
+++ b/apps/roam/src/utils/discoverSharedNodes.ts
@@ -11,6 +11,7 @@ export type DiscoveredSharedNode = {
sourceApp: "Roam" | "Obsidian";
sourceNodeId?: string;
sourceNodeRid: string;
+ sourceSpaceDatabaseId: number;
sourceSpaceId: string;
sourceSpaceName: string;
title: string;
@@ -29,6 +30,7 @@ export const toDiscoveredSharedNodes = ({
sourceApp: candidate.platform,
sourceNodeId: candidate.sourceLocalId || undefined,
sourceNodeRid: candidate.rid,
+ sourceSpaceDatabaseId: candidate.spaceId,
sourceSpaceId: candidate.spaceUri,
sourceSpaceName: candidate.spaceName,
title: candidate.title,
diff --git a/apps/roam/src/utils/importDiscoveredSharedNode.ts b/apps/roam/src/utils/importDiscoveredSharedNode.ts
new file mode 100644
index 000000000..2242daab6
--- /dev/null
+++ b/apps/roam/src/utils/importDiscoveredSharedNode.ts
@@ -0,0 +1,31 @@
+import { getSharedNodePayload } from "@repo/database/lib/sharedNodes";
+import type { DGSupabaseClient } from "@repo/database/lib/client";
+import type { DiscoveredSharedNode } from "./discoverSharedNodes";
+import type { SharedNodeImportStatus } from "./importSelectedSharedNodes";
+import { materializeObsidianNode } from "./materializeObsidianNode";
+
+export const importDiscoveredSharedNode = async ({
+ client,
+ node,
+}: {
+ client: DGSupabaseClient;
+ node: DiscoveredSharedNode;
+}): Promise => {
+ if (node.sourceApp !== "Obsidian") return "skipped";
+ if (!node.sourceNodeId)
+ throw new Error(`Shared node '${node.sourceNodeRid}' has no source ID`);
+
+ const payload = await getSharedNodePayload({
+ client,
+ sourceLocalId: node.sourceNodeId,
+ spaceId: node.sourceSpaceDatabaseId,
+ });
+ const result = await materializeObsidianNode({
+ node: payload,
+ sourceModifiedAt: node.modifiedAt,
+ sourceNodeRid: node.sourceNodeRid,
+ });
+ if (!result.success) throw new Error(result.error.message);
+
+ return result.action === "updated" ? "updated" : "imported";
+};
diff --git a/apps/roam/src/utils/importSelectedSharedNodes.ts b/apps/roam/src/utils/importSelectedSharedNodes.ts
new file mode 100644
index 000000000..f286d7795
--- /dev/null
+++ b/apps/roam/src/utils/importSelectedSharedNodes.ts
@@ -0,0 +1,56 @@
+import type { DiscoveredSharedNode } from "./discoverSharedNodes";
+
+export type SharedNodeImportStatus = "imported" | "skipped" | "updated";
+
+export type SharedNodeImportFailure = {
+ message: string;
+ node: DiscoveredSharedNode;
+};
+
+export type SelectedSharedNodeImportResult = {
+ failed: SharedNodeImportFailure[];
+ imported: number;
+ skipped: number;
+ updated: number;
+};
+
+export type MaterializeSharedNode = (
+ node: DiscoveredSharedNode,
+) => Promise;
+
+const getErrorMessage = (error: unknown): string => {
+ if (error instanceof Error && error.message) return error.message;
+ if (typeof error === "string" && error) return error;
+ return "Unknown import failure";
+};
+
+export const importSelectedSharedNodes = async ({
+ materializeNode,
+ nodes,
+}: {
+ materializeNode: MaterializeSharedNode;
+ nodes: DiscoveredSharedNode[];
+}): Promise => {
+ const result: SelectedSharedNodeImportResult = {
+ failed: [],
+ imported: 0,
+ skipped: 0,
+ updated: 0,
+ };
+
+ for (const node of nodes) {
+ try {
+ const status = await materializeNode(node);
+ if (status === "updated") {
+ result.imported += 1;
+ result.updated += 1;
+ } else {
+ result[status] += 1;
+ }
+ } catch (error) {
+ result.failed.push({ message: getErrorMessage(error), node });
+ }
+ }
+
+ return result;
+};
diff --git a/packages/database/src/crossAppContracts.ts b/packages/database/src/crossAppContracts.ts
index c996332c3..4166528a1 100644
--- a/packages/database/src/crossAppContracts.ts
+++ b/packages/database/src/crossAppContracts.ts
@@ -1,4 +1,4 @@
-import type { ContentType } from "@repo/content-model";
+import type { ContentType } from "@repo/content-model/constants";
import { Enums } from "./dbTypes";
export type LocalRef = {
diff --git a/packages/database/src/lib/__tests__/sharedNodes.test.ts b/packages/database/src/lib/__tests__/sharedNodes.test.ts
index 138a80dba..208815ec5 100644
--- a/packages/database/src/lib/__tests__/sharedNodes.test.ts
+++ b/packages/database/src/lib/__tests__/sharedNodes.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
-import { buildSharedNodeCandidates } from "../sharedNodes";
+import {
+ buildSharedNodeCandidates,
+ buildSharedNodePayload,
+} from "../sharedNodes";
type BuildArgs = Parameters[0];
@@ -159,3 +162,51 @@ describe("buildSharedNodeCandidates", () => {
).toEqual(["node-1", "node-2"]);
});
});
+
+describe("buildSharedNodePayload", () => {
+ const concept = {
+ author_id: 42,
+ created: "2026-06-14T10:00:00.000Z",
+ last_modified: "2026-06-14T12:00:00.000Z",
+ schema_id: 200,
+ source_local_id: "node-1",
+ };
+
+ it("builds the CrossAppNode consumed by the Roam materializer", () => {
+ expect(buildSharedNodePayload({ concept, contents })).toEqual({
+ author: { dbId: 42 },
+ content: {
+ direct: {
+ contentType: "text/plain",
+ value: "EVD - REM sleep and recall",
+ },
+ full: {
+ contentType: "text/markdown",
+ value: "# EVD - REM sleep and recall",
+ },
+ },
+ createdAt: new Date("2026-06-14T10:00:00.000Z"),
+ localId: "node-1",
+ modifiedAt: new Date("2026-06-14T15:00:00.000Z"),
+ nodeType: { dbId: 200 },
+ });
+ });
+
+ it("rejects an incomplete payload before it reaches the materializer", () => {
+ expect(() =>
+ buildSharedNodePayload({
+ concept,
+ contents: contents.filter((content) => content.variant !== "full"),
+ }),
+ ).toThrow("Shared node is missing its full content");
+ });
+
+ it("uses the modified time when the source has no created time", () => {
+ const payload = buildSharedNodePayload({
+ concept: { ...concept, created: null },
+ contents: contents.map((content) => ({ ...content, created: null })),
+ });
+
+ expect(payload.createdAt).toEqual(new Date("2026-06-14T15:00:00.000Z"));
+ });
+});
diff --git a/packages/database/src/lib/sharedNodes.ts b/packages/database/src/lib/sharedNodes.ts
index eb0405f24..d06331d1a 100644
--- a/packages/database/src/lib/sharedNodes.ts
+++ b/packages/database/src/lib/sharedNodes.ts
@@ -1,3 +1,5 @@
+import { isSupportedContentType } from "@repo/content-model/constants";
+import type { CrossAppNode } from "../crossAppContracts";
import type { DGSupabaseClient } from "./client";
import { getAvailableGroupIds } from "./groups";
import { getAllPages } from "./pagination";
@@ -31,6 +33,19 @@ type SharedSpace = Pick<
Tables<"my_spaces">,
"id" | "name" | "platform" | "url"
>;
+type SharedNodePayloadConcept = Pick<
+ Tables<"my_concepts">,
+ "author_id" | "created" | "last_modified" | "schema_id" | "source_local_id"
+>;
+type SharedNodePayloadContent = Pick<
+ Tables<"my_contents">,
+ | "author_id"
+ | "content_type"
+ | "created"
+ | "last_modified"
+ | "text"
+ | "variant"
+>;
type ValidSharedSpace = {
name: string;
platform: "Roam" | "Obsidian";
@@ -58,6 +73,77 @@ export type SharedNodeRows = {
spaces: SharedSpace[];
};
+const getValidDate = (values: (string | null)[]): Date | undefined => {
+ const value = values.find(
+ (candidate): candidate is string =>
+ typeof candidate === "string" && !Number.isNaN(Date.parse(candidate)),
+ );
+ return value ? new Date(value) : undefined;
+};
+
+export const buildSharedNodePayload = ({
+ concept,
+ contents,
+}: {
+ concept: SharedNodePayloadConcept;
+ contents: SharedNodePayloadContent[];
+}): CrossAppNode => {
+ const direct = contents.find((content) => content.variant === "direct");
+ const full = contents.find((content) => content.variant === "full");
+ const authorId = concept.author_id ?? direct?.author_id ?? full?.author_id;
+ const modifiedAt = getValidDate(
+ [concept.last_modified, direct?.last_modified, full?.last_modified]
+ .filter((value): value is string => typeof value === "string")
+ .sort((left, right) => Date.parse(right) - Date.parse(left)),
+ );
+ const sourceCreatedAt = getValidDate([
+ concept.created,
+ direct?.created ?? null,
+ full?.created ?? null,
+ ]);
+
+ if (typeof concept.source_local_id !== "string")
+ throw new Error("Shared node is missing its source-local ID");
+ if (typeof concept.schema_id !== "number")
+ throw new Error("Shared node is missing its node type");
+ if (typeof authorId !== "number")
+ throw new Error("Shared node is missing its author");
+ if (!modifiedAt) throw new Error("Shared node is missing its modified time");
+ const createdAt = sourceCreatedAt ?? modifiedAt;
+ if (typeof direct?.text !== "string")
+ throw new Error("Shared node is missing its direct content");
+ if (typeof full?.text !== "string")
+ throw new Error("Shared node is missing its full content");
+ if (
+ typeof full.content_type !== "string" ||
+ !isSupportedContentType(full.content_type)
+ )
+ throw new Error(
+ `Shared node has unsupported full content type '${String(full.content_type)}'`,
+ );
+
+ return {
+ author: { dbId: authorId },
+ content: {
+ direct: {
+ value: direct.text,
+ ...(typeof direct.content_type === "string" &&
+ isSupportedContentType(direct.content_type)
+ ? { contentType: direct.content_type }
+ : {}),
+ },
+ full: {
+ contentType: full.content_type,
+ value: full.text,
+ },
+ },
+ createdAt,
+ localId: concept.source_local_id,
+ modifiedAt,
+ nodeType: { dbId: concept.schema_id },
+ };
+};
+
const getResourceKey = ({
sourceLocalId,
spaceId,
@@ -317,3 +403,38 @@ export const listGroupSharedNodes = async ({
const rows = await getSharedNodeRows({ client, currentSpaceId, groupIds });
return buildSharedNodeCandidates({ ...rows, currentSpaceId });
};
+
+export const getSharedNodePayload = async ({
+ client,
+ sourceLocalId,
+ spaceId,
+}: {
+ client: DGSupabaseClient;
+ sourceLocalId: string;
+ spaceId: number;
+}): Promise => {
+ const [conceptResponse, contentsResponse] = await Promise.all([
+ client
+ .from("my_concepts")
+ .select("author_id, created, last_modified, schema_id, source_local_id")
+ .eq("space_id", spaceId)
+ .eq("source_local_id", sourceLocalId)
+ .eq("is_schema", false)
+ .maybeSingle(),
+ client
+ .from("my_contents")
+ .select("author_id, content_type, created, last_modified, text, variant")
+ .eq("space_id", spaceId)
+ .eq("source_local_id", sourceLocalId)
+ .in("variant", ["direct", "full"]),
+ ]);
+ if (conceptResponse.error) throw conceptResponse.error;
+ if (contentsResponse.error) throw contentsResponse.error;
+ if (!conceptResponse.data)
+ throw new Error(`Shared node '${sourceLocalId}' is no longer available`);
+
+ return buildSharedNodePayload({
+ concept: conceptResponse.data,
+ contents: contentsResponse.data,
+ });
+};