From e7114b41f7c8bf5b4dc7004ffaa1185294a05b32 Mon Sep 17 00:00:00 2001 From: Siddharth Yadav Date: Wed, 25 Feb 2026 13:07:26 +0530 Subject: [PATCH 01/60] ENG-1465: Add "Use new settings store" feature flag (#811) * ENG-1454: Enable dual read feature flag * add caller * resolve merge conflicts * review * example fix * review --- apps/roam/src/components/settings/AdminPanel.tsx | 9 +++++++++ apps/roam/src/components/settings/utils/accessors.ts | 4 ++++ .../src/components/settings/utils/zodSchema.example.ts | 2 ++ apps/roam/src/components/settings/utils/zodSchema.ts | 1 + apps/roam/src/index.ts | 2 +- 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/settings/AdminPanel.tsx b/apps/roam/src/components/settings/AdminPanel.tsx index 85ba13269..e9ad85d87 100644 --- a/apps/roam/src/components/settings/AdminPanel.tsx +++ b/apps/roam/src/components/settings/AdminPanel.tsx @@ -39,6 +39,8 @@ import deleteBlock from "roamjs-components/writes/deleteBlock"; import { USE_REIFIED_RELATIONS } from "~/data/userSettings"; import posthog from "posthog-js"; import { setFeatureFlag } from "~/components/settings/utils/accessors"; +import { FeatureFlagPanel } from "./components/BlockPropSettingPanels"; +import { getFeatureFlag } from "./utils/accessors"; const NodeRow = ({ node }: { node: PConceptFull }) => { return ( @@ -467,6 +469,13 @@ const FeatureFlagsTab = (): React.ReactElement => { } /> + + + + ) : ( +
+ {label} +
+ )} + ); }; const PersonalSectionItem = ({ section, sectionIndex, + dragHandle, + onChildrenReorder, + onloadArgs, }: { section: LeftSidebarPersonalSectionConfig; sectionIndex: number; + dragHandle: SortableHandle; + onChildrenReorder: (args: { + sectionUid: string; + oldIndex: number; + newIndex: number; + }) => void; + onloadArgs: OnloadArgs; }) => { const titleRef = parseReference(section.text); const blockText = useMemo( @@ -250,7 +280,11 @@ const PersonalSectionItem = ({ return ( <> -
+
- c.uid} + onReorder={(oldIndex, newIndex) => + onChildrenReorder({ sectionUid: section.uid, oldIndex, newIndex }) + } + renderItem={(child, handle) => ( +
+ +
+ )} />
); }; -const PersonalSections = ({ config }: { config: LeftSidebarConfig }) => { +const PersonalSections = ({ + config, + setConfig, + onloadArgs, +}: { + config: LeftSidebarConfig; + setConfig: Dispatch>; + onloadArgs: OnloadArgs; +}) => { const sections = config.personal.sections || []; if (!sections.length) return null; + const reorderSections = (oldIndex: number, newIndex: number) => { + const moved = sections[oldIndex]; + if (!moved) return; + const reordered = arrayMove(sections, oldIndex, newIndex); + setConfig({ + ...config, + personal: { ...config.personal, sections: reordered }, + }); + void moveRoamBlockToIndex({ + blockUid: moved.uid, + parentUid: config.personal.uid, + sourceIndex: oldIndex, + destIndex: newIndex, + }).then(() => { + refreshAndNotify(); + }); + }; + + const reorderChildren = ({ + sectionUid, + oldIndex, + newIndex, + }: { + sectionUid: string; + oldIndex: number; + newIndex: number; + }) => { + const section = sections.find((s) => s.uid === sectionUid); + const children = section?.children; + if (!section || !children || !section.childrenUid) return; + const child = children[oldIndex]; + if (!child) return; + const reorderedChildren = arrayMove(children, oldIndex, newIndex); + const newSections = sections.map((s) => + s.uid === sectionUid ? { ...s, children: reorderedChildren } : s, + ); + setConfig({ + ...config, + personal: { ...config.personal, sections: newSections }, + }); + void moveRoamBlockToIndex({ + blockUid: child.uid, + parentUid: section.childrenUid, + sourceIndex: oldIndex, + destIndex: newIndex, + }).then(() => { + refreshAndNotify(); + }); + }; + return ( -
- {sections.map((section, index) => ( -
- -
- ))} -
+ s.uid} + onReorder={reorderSections} + className="personal-left-sidebar-sections" + renderItem={(section, handle) => ( + s.uid === section.uid)} + dragHandle={handle} + onChildrenReorder={reorderChildren} + onloadArgs={onloadArgs} + /> + )} + /> ); }; -const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => { +const GlobalSection = ({ + config, + onGlobalChildrenReorder, + onloadArgs, +}: { + config: LeftSidebarConfig["global"]; + onGlobalChildrenReorder: (oldIndex: number, newIndex: number) => void; + onloadArgs: OnloadArgs; +}) => { const [isOpen, setIsOpen] = useState( !!config.settings?.folded.value, ); @@ -323,6 +443,19 @@ const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => { } }; + const children = ( + c.uid} + onReorder={onGlobalChildrenReorder} + renderItem={(child, handle) => ( +
+ +
+ )} + /> + ); + return ( <>
{
{isCollapsable ? ( - - - + {children} ) : ( - + children )} ); @@ -528,13 +659,41 @@ const LeftSidebarView = ({ onloadArgs: OnloadArgs; initialSnapshot?: SettingsSnapshot; }) => { - const { config } = useConfig(initialSnapshot); + const { config, setConfig } = useConfig(initialSnapshot); + + const reorderGlobalChildren = (oldIndex: number, newIndex: number) => { + const children = config.global.children; + if (!children) return; + const moved = children[oldIndex]; + if (!moved) return; + const reordered = arrayMove(children, oldIndex, newIndex); + setConfig({ + ...config, + global: { ...config.global, children: reordered }, + }); + void moveRoamBlockToIndex({ + blockUid: moved.uid, + parentUid: config.global.childrenUid, + sourceIndex: oldIndex, + destIndex: newIndex, + }).then(() => { + refreshAndNotify(); + }); + }; return ( <> - - + + ); }; @@ -564,7 +723,7 @@ const migrateFavorites = async () => { } const results = window.roamAlphaAPI.q(` - [:find ?uid + [:find ?uid :where [?e :page/sidebar] [?e :block/uid ?uid]] `); diff --git a/apps/roam/src/components/SortableList.tsx b/apps/roam/src/components/SortableList.tsx new file mode 100644 index 000000000..e7cc48d76 --- /dev/null +++ b/apps/roam/src/components/SortableList.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, + type DraggableAttributes, + type DraggableSyntheticListeners, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +const DRAG_ACTIVATION_DISTANCE = 8; + +export type SortableHandle = { + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners; +}; + +type SortableListProps = { + items: T[]; + getId: (item: T) => string; + onReorder: (oldIndex: number, newIndex: number) => void; + renderItem: (item: T, handle: SortableHandle) => React.ReactNode; + className?: string; +}; + +const SortableItem = ({ + id, + item, + renderItem, +}: { + id: string; + item: T; + renderItem: (item: T, handle: SortableHandle) => React.ReactNode; +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + const style: React.CSSProperties = { + transform: CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + position: "relative", + zIndex: isDragging ? 1 : 0, + }; + return ( +
+ {renderItem(item, { attributes, listeners })} +
+ ); +}; + +export const SortableList = ({ + items, + getId, + onReorder, + renderItem, + className, +}: SortableListProps) => { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE }, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = items.findIndex((item) => getId(item) === active.id); + const newIndex = items.findIndex((item) => getId(item) === over.id); + if (oldIndex === -1 || newIndex === -1) return; + onReorder(oldIndex, newIndex); + }; + + const ids = items.map(getId); + + return ( + + +
+ {items.map((item) => ( + + ))} +
+
+
+ ); +}; diff --git a/apps/roam/src/utils/moveRoamBlock.ts b/apps/roam/src/utils/moveRoamBlock.ts new file mode 100644 index 000000000..ba4b40ead --- /dev/null +++ b/apps/roam/src/utils/moveRoamBlock.ts @@ -0,0 +1,18 @@ +export const moveRoamBlockToIndex = ({ + blockUid, + parentUid, + sourceIndex, + destIndex, +}: { + blockUid: string; + parentUid: string; + sourceIndex: number; + destIndex: number; +}) => { + const finalIndex = destIndex > sourceIndex ? destIndex + 1 : destIndex; + return window.roamAlphaAPI.moveBlock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + location: { "parent-uid": parentUid, order: finalIndex }, + block: { uid: blockUid }, + }); +}; diff --git a/packages/database/scripts/serveAndTest.ts b/packages/database/scripts/serveAndTest.ts new file mode 100644 index 000000000..032f05ad7 --- /dev/null +++ b/packages/database/scripts/serveAndTest.ts @@ -0,0 +1,87 @@ +import { spawn, execSync } from "node:child_process"; +import { join, dirname } from "path"; + +const scriptDir = dirname(__filename); +const projectRoot = join(scriptDir, ".."); + +if ( + process.env.GITHUB_ACTIONS === "true" && + process.env.GITHUB_TEST !== "test" +) { + console.error("Please set the GITHUB_TEST variable to 'test'"); + process.exit(2); +} +if (process.env.SUPABASE_PROJECT_ID !== "test") { + console.error("Please set the SUPABASE_PROJECT_ID variable to 'test'"); + process.exit(2); +} + +const serve = spawn("supabase", ["functions", "serve"], { + cwd: projectRoot, + detached: true, + stdio: ["ignore", "pipe", "inherit"], +}); + +let resolveCallback: ((value: unknown) => void) | undefined = undefined; +let rejectCallback: ((reason: unknown) => void) | undefined = undefined; +let serveSuccess = false; +let timeoutClear: NodeJS.Timeout | undefined = undefined; + +const servingReady = new Promise((rsc, rjc) => { + resolveCallback = rsc; + rejectCallback = rjc; + + // Add timeout + timeoutClear = setTimeout(() => { + rjc(new Error("Timeout waiting for functions to serve")); + }, 30000); // 30 second timeout +}); + +serve.stdout.on("data", (data: Buffer) => { + const output = data.toString(); + console.log(`stdout: ${output}`); + if (output.includes("Serving functions ")) { + console.log("Found serving functions"); + serveSuccess = true; + clearTimeout(timeoutClear); + if (resolveCallback === undefined) throw new Error("did not get callback"); + resolveCallback(null); + } +}); +serve.on("close", () => { + if (!serveSuccess && rejectCallback) + rejectCallback(new Error("serve closed without being ready")); +}); +serve.on("error", (err) => { + if (rejectCallback) rejectCallback(err); +}); + +const doTest = async () => { + try { + await servingReady; + execSync("cucumber-js", { + cwd: projectRoot, + stdio: "inherit", + }); + // will throw on failure + } finally { + if (serve.pid) + try { + process.kill(-serve.pid); + } catch (e) { + console.error("Could not kill the process"); + // maybe it just ended on its own. + } + } +}; + +doTest() + .then(() => { + console.log("success"); + clearTimeout(timeoutClear); + }) + .catch((err) => { + console.error(err); + clearTimeout(timeoutClear); + process.exit(1); + }); diff --git a/packages/database/supabase/migrations/20260421141140_select_platform_account_of_partial_space.sql b/packages/database/supabase/migrations/20260421141140_select_platform_account_of_partial_space.sql new file mode 100644 index 000000000..4bc62fc83 --- /dev/null +++ b/packages/database/supabase/migrations/20260421141140_select_platform_account_of_partial_space.sql @@ -0,0 +1,55 @@ +CREATE OR REPLACE FUNCTION public.account_in_shared_space(p_account_id BIGINT, access_level public."SpaceAccessPermissions" = 'reader') RETURNS boolean +STABLE SECURITY DEFINER +SET search_path = '' +LANGUAGE sql AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public."LocalAccess" AS la + JOIN public."SpaceAccess" AS sa USING (space_id) + JOIN public.my_user_accounts() ON (sa.account_uid = my_user_accounts) + WHERE la.account_id = p_account_id + AND sa.permissions >= access_level + ); +$$; + +DROP POLICY IF EXISTS platform_account_select_policy ON public."PlatformAccount"; +CREATE POLICY platform_account_select_policy ON public."PlatformAccount" FOR SELECT USING (dg_account = (SELECT auth.uid() LIMIT 1) OR public.account_in_shared_space(id, 'partial')); + +DROP POLICY IF EXISTS agent_identifier_select_policy ON public."AgentIdentifier"; + +DROP function public.account_in_shared_space(p_account_id BIGINT); + +CREATE POLICY agent_identifier_select_policy ON public."AgentIdentifier" FOR SELECT USING (public.account_in_shared_space(account_id)); + +CREATE OR REPLACE VIEW public.my_accounts AS +SELECT + id, + name, + platform, + account_local_id, + write_permission, + active, + agent_type, + metadata, + dg_account +FROM public."PlatformAccount" +WHERE id IN ( + SELECT "LocalAccess".account_id FROM public."LocalAccess" + JOIN public."SpaceAccess" USING (space_id) + JOIN public.my_user_accounts() ON (account_uid = my_user_accounts) + WHERE permissions >= 'partial' +); + +CREATE OR REPLACE FUNCTION public.generic_entity_access(target_id BIGINT, target_type public."EntityType") RETURNS boolean +STABLE SECURITY DEFINER +SET search_path = '' +LANGUAGE sql AS $$ + SELECT CASE + WHEN target_type = 'Space' THEN public.in_space(target_id) + WHEN target_type = 'Content' THEN public.content_in_space(target_id) + WHEN target_type = 'Concept' THEN public.concept_in_space(target_id) + WHEN target_type = 'Document' THEN public.document_in_space(target_id) + WHEN target_type = 'PlatformAccount' THEN public.account_in_shared_space(target_id) + ELSE false + END; +$$; diff --git a/packages/database/supabase/migrations/20260507150056_database_secret_tokens.sql b/packages/database/supabase/migrations/20260507150056_database_secret_tokens.sql new file mode 100644 index 000000000..8f86c7258 --- /dev/null +++ b/packages/database/supabase/migrations/20260507150056_database_secret_tokens.sql @@ -0,0 +1,50 @@ +CREATE TABLE IF NOT EXISTS public.secret_token ( + id varchar PRIMARY KEY DEFAULT encode(extensions.gen_random_bytes(12), 'base64'), + creator UUID NOT NULL DEFAULT auth.uid(), + payload JSONB NOT NULL, + expiry_date timestamp without time zone, + one_time_use boolean DEFAULT true +); + +CREATE INDEX IF NOT EXISTS secret_token_expiry_idx ON public.secret_token (expiry_date) WHERE expiry_date IS NOT null; + +CREATE OR REPLACE FUNCTION public.create_secret_token(v_payload JSONB, v_one_time_use BOOLEAN DEFAULT true, expiry_interval INTERVAL DEFAULT '30d') RETURNS VARCHAR +SECURITY DEFINER +SET search_path = '' +LANGUAGE sql AS $$ + INSERT INTO public.secret_token (payload, expiry_date, one_time_use) VALUES (v_payload, now()+expiry_interval, v_one_time_use) RETURNING id; +$$; + +CREATE OR REPLACE FUNCTION public.get_secret_token(token VARCHAR) RETURNS JSONB +SECURITY DEFINER +SET search_path = '' +LANGUAGE plpgsql AS $$ +DECLARE +v_payload JSONB; +v_one_time_use BOOLEAN; +BEGIN + DELETE FROM public.secret_token WHERE expiry_date < now(); + DELETE FROM public.secret_token WHERE id=token AND one_time_use = true RETURNING payload INTO v_payload; + IF v_payload IS NULL THEN + SELECT payload INTO v_payload FROM public.secret_token WHERE id=token; + END IF; + RETURN v_payload; +END; +$$; + +ALTER TABLE secret_token OWNER TO "postgres"; + +REVOKE ALL ON TABLE public.secret_token FROM anon; +GRANT ALL ON TABLE public.secret_token TO authenticated; +GRANT ALL ON TABLE public.secret_token TO service_role; + +ALTER TABLE public.secret_token ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS concept_policy ON public.secret_token; +DROP POLICY IF EXISTS concept_select_policy ON public.secret_token; +CREATE POLICY concept_select_policy ON public.secret_token FOR SELECT USING (creator = auth.uid()); +DROP POLICY IF EXISTS concept_delete_policy ON public.secret_token; +CREATE POLICY concept_delete_policy ON public.secret_token FOR DELETE USING (creator = auth.uid()); +DROP POLICY IF EXISTS concept_insert_policy ON public.secret_token; +-- Do not allow insert except through create_secret_token +DROP POLICY IF EXISTS concept_update_policy ON public.secret_token; +CREATE POLICY concept_update_policy ON public.secret_token FOR UPDATE USING (creator = auth.uid()); diff --git a/packages/database/supabase/schemas/secret_tokens.sql b/packages/database/supabase/schemas/secret_tokens.sql new file mode 100644 index 000000000..8f86c7258 --- /dev/null +++ b/packages/database/supabase/schemas/secret_tokens.sql @@ -0,0 +1,50 @@ +CREATE TABLE IF NOT EXISTS public.secret_token ( + id varchar PRIMARY KEY DEFAULT encode(extensions.gen_random_bytes(12), 'base64'), + creator UUID NOT NULL DEFAULT auth.uid(), + payload JSONB NOT NULL, + expiry_date timestamp without time zone, + one_time_use boolean DEFAULT true +); + +CREATE INDEX IF NOT EXISTS secret_token_expiry_idx ON public.secret_token (expiry_date) WHERE expiry_date IS NOT null; + +CREATE OR REPLACE FUNCTION public.create_secret_token(v_payload JSONB, v_one_time_use BOOLEAN DEFAULT true, expiry_interval INTERVAL DEFAULT '30d') RETURNS VARCHAR +SECURITY DEFINER +SET search_path = '' +LANGUAGE sql AS $$ + INSERT INTO public.secret_token (payload, expiry_date, one_time_use) VALUES (v_payload, now()+expiry_interval, v_one_time_use) RETURNING id; +$$; + +CREATE OR REPLACE FUNCTION public.get_secret_token(token VARCHAR) RETURNS JSONB +SECURITY DEFINER +SET search_path = '' +LANGUAGE plpgsql AS $$ +DECLARE +v_payload JSONB; +v_one_time_use BOOLEAN; +BEGIN + DELETE FROM public.secret_token WHERE expiry_date < now(); + DELETE FROM public.secret_token WHERE id=token AND one_time_use = true RETURNING payload INTO v_payload; + IF v_payload IS NULL THEN + SELECT payload INTO v_payload FROM public.secret_token WHERE id=token; + END IF; + RETURN v_payload; +END; +$$; + +ALTER TABLE secret_token OWNER TO "postgres"; + +REVOKE ALL ON TABLE public.secret_token FROM anon; +GRANT ALL ON TABLE public.secret_token TO authenticated; +GRANT ALL ON TABLE public.secret_token TO service_role; + +ALTER TABLE public.secret_token ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS concept_policy ON public.secret_token; +DROP POLICY IF EXISTS concept_select_policy ON public.secret_token; +CREATE POLICY concept_select_policy ON public.secret_token FOR SELECT USING (creator = auth.uid()); +DROP POLICY IF EXISTS concept_delete_policy ON public.secret_token; +CREATE POLICY concept_delete_policy ON public.secret_token FOR DELETE USING (creator = auth.uid()); +DROP POLICY IF EXISTS concept_insert_policy ON public.secret_token; +-- Do not allow insert except through create_secret_token +DROP POLICY IF EXISTS concept_update_policy ON public.secret_token; +CREATE POLICY concept_update_policy ON public.secret_token FOR UPDATE USING (creator = auth.uid()); From 9593e711376ab4483f0ad7a2bab6586fbe3f50b8 Mon Sep 17 00:00:00 2001 From: Siddharth Yadav Date: Tue, 12 May 2026 21:11:43 +0530 Subject: [PATCH 55/60] ENG-1716: Bug fix: Template does not render from block props (#1016) * ENG-1716: Render template settings from block props When the new settings store flag is on, materialize the block-props template into an ephemeral Template-Block-props block as the last child of node.type, render that, and delete it on unmount. Flag-off behavior is unchanged. * ENG-1716: Dual-write template edits back to legacy Template block When the new settings store flag is on, edits in the buffer Template-Block-props block are mirrored to the legacy Template block via position-walked block.update calls. On length mismatch (only expected during testing/manual divergence) the mirror logs a warning and skips that subtree. * ENG-1716: Extend buffer->legacy mirror to handle add/delete Mirror now creates legacy children when buffer has extras and deletes legacy children when buffer has fewer, in addition to in-place block.update for matching positions. Replaces the prior length-match guard which caused dual-write to silently skip whenever the user added or removed template lines. * ENG-1716: Guard render effect against stale async continuation The ensureChildren promise resolves asynchronously, so its .then callback could run after the effect's cleanup fired - re-registering a pull watch on a stale renderUid and stomping pullWatchArgsRef. Add a cancelled flag (same pattern as the buffer-lifecycle effect) so the continuation aborts after teardown. * ENG-1716: Mirror heading and open alongside string The mirror only pushed block string to legacy, so a heading-level or collapse-state change in the buffer never propagated. Update the same block.update call to include heading and open whenever any of the three differ between buffer and legacy. Also drops uid from the buffer-creation effect's dependency array since the body no longer references it. * ENG-1716: Rename useNewStore -> isNewStore useNewStore reads like a React hook (use* convention) but is a boolean. Rename to isNewStore so the name matches the type. --- .../components/EphemeralBlocksPanel.tsx | 118 ++++++++++++++++-- 1 file changed, 107 insertions(+), 11 deletions(-) diff --git a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx index 076cd4168..64666c3bd 100644 --- a/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx +++ b/apps/roam/src/components/settings/components/EphemeralBlocksPanel.tsx @@ -1,15 +1,20 @@ -import React, { useRef, useEffect, useCallback } from "react"; +import React, { useRef, useEffect, useCallback, useState } from "react"; import { Label } from "@blueprintjs/core"; import Description from "roamjs-components/components/Description"; import createBlock from "roamjs-components/writes/createBlock"; +import deleteBlock from "roamjs-components/writes/deleteBlock"; import getFullTreeByParentUid from "roamjs-components/queries/getFullTreeByParentUid"; import getFirstChildUidByBlockUid from "roamjs-components/queries/getFirstChildUidByBlockUid"; import type { InputTextNode, TreeNode } from "roamjs-components/types"; import type { RoamNodeType } from "~/components/settings/utils/zodSchema"; -import { setDiscourseNodeSetting } from "~/components/settings/utils/accessors"; +import { + isNewSettingsStoreEnabled, + setDiscourseNodeSetting, +} from "~/components/settings/utils/accessors"; import type { DiscourseNodeBaseProps } from "./BlockPropSettingPanels"; const DEBOUNCE_MS = 250; +const TEMPLATE_BUFFER_TEXT = "Template-Block-props"; type DualWriteBlocksPanelProps = DiscourseNodeBaseProps & { uid: string; @@ -28,6 +33,62 @@ const serializeBlockTree = (children: TreeNode[]): RoamNodeType[] => }), })); +const treeNodeToInputTextNode = (node: TreeNode): InputTextNode => ({ + text: node.text, + ...(node.heading && { heading: node.heading as 0 | 1 | 2 | 3 }), + ...(node.open === false && { open: false }), + ...(node.children.length > 0 && { + children: [...node.children] + .sort((a, b) => a.order - b.order) + .map(treeNodeToInputTextNode), + }), +}); + +const mirrorBufferToLegacyChildren = ( + bufferChildren: TreeNode[], + legacyChildren: TreeNode[], + legacyParentUid: string, +): void => { + const sortedBuffer = [...bufferChildren].sort((a, b) => a.order - b.order); + const sortedLegacy = [...legacyChildren].sort((a, b) => a.order - b.order); + const minLen = Math.min(sortedBuffer.length, sortedLegacy.length); + + for (let i = 0; i < minLen; i++) { + const bufferNode = sortedBuffer[i]; + const legacyNode = sortedLegacy[i]; + if ( + bufferNode.text !== legacyNode.text || + bufferNode.heading !== legacyNode.heading || + bufferNode.open !== legacyNode.open + ) { + void window.roamAlphaAPI.data.block.update({ + block: { + uid: legacyNode.uid, + string: bufferNode.text, + ...(bufferNode.heading !== undefined && { + heading: bufferNode.heading, + }), + ...(bufferNode.open !== undefined && { open: bufferNode.open }), + }, + }); + } + mirrorBufferToLegacyChildren( + bufferNode.children, + legacyNode.children, + legacyNode.uid, + ); + } + + for (let i = minLen; i < sortedBuffer.length; i++) { + const node = treeNodeToInputTextNode(sortedBuffer[i]); + void createBlock({ node, parentUid: legacyParentUid, order: i }); + } + + for (let i = minLen; i < sortedLegacy.length; i++) { + void deleteBlock(sortedLegacy[i].uid); + } +}; + const DualWriteBlocksPanel = ({ nodeType, settingKeys, @@ -44,21 +105,51 @@ const DualWriteBlocksPanel = ({ [string, string, (before: unknown, after: unknown) => void] | null >(null); + const isNewStore = isNewSettingsStoreEnabled(); + const [bufferUid, setBufferUid] = useState(null); + const renderUid = isNewStore ? bufferUid : uid; + + useEffect(() => { + if (!isNewStore || !nodeType) return; + let cancelled = false; + const newUid = window.roamAlphaAPI.util.generateUID(); + const dv = defaultValueRef.current; + const seed: InputTextNode[] = dv && dv.length > 0 ? dv : [{ text: " " }]; + void createBlock({ + node: { text: TEMPLATE_BUFFER_TEXT, uid: newUid, children: seed }, + parentUid: nodeType, + order: "last", + }).then(() => { + if (!cancelled) setBufferUid(newUid); + }); + return () => { + cancelled = true; + setBufferUid(null); + void deleteBlock(newUid); + }; + }, [isNewStore, nodeType]); + const handleChange = useCallback(() => { + if (!renderUid) return; window.clearTimeout(debounceRef.current); debounceRef.current = window.setTimeout(() => { - const tree = getFullTreeByParentUid(uid); + const tree = getFullTreeByParentUid(renderUid); const serialized = serializeBlockTree(tree.children); setDiscourseNodeSetting(nodeType, settingKeys, serialized); + if (isNewStore && renderUid !== uid) { + const legacyTree = getFullTreeByParentUid(uid); + mirrorBufferToLegacyChildren(tree.children, legacyTree.children, uid); + } }, DEBOUNCE_MS); - }, [uid, nodeType, settingKeys]); + }, [renderUid, uid, isNewStore, nodeType, settingKeys]); useEffect(() => { const el = containerRef.current; - if (!el || !uid) return; + if (!el || !renderUid) return; + let cancelled = false; const pattern = "[:block/string :block/order {:block/children ...}]"; - const entityId = `[:block/uid "${uid}"]`; + const entityId = `[:block/uid "${renderUid}"]`; const callback = () => handleChange(); const registerPullWatch = () => { @@ -67,31 +158,36 @@ const DualWriteBlocksPanel = ({ }; const dv = defaultValueRef.current; - const ensureChildren = getFirstChildUidByBlockUid(uid) + const ensureChildren = getFirstChildUidByBlockUid(renderUid) ? Promise.resolve() : (dv && dv.length > 0 ? Promise.all( dv.map((node, i) => - createBlock({ node, parentUid: uid, order: i }), + createBlock({ node, parentUid: renderUid, order: i }), ), ) - : createBlock({ node: { text: " " }, parentUid: uid }) + : createBlock({ node: { text: " " }, parentUid: renderUid }) ).then(() => {}); void ensureChildren.then(() => { + if (cancelled) return; el.innerHTML = ""; - void window.roamAlphaAPI.ui.components.renderBlock({ uid, el }); + void window.roamAlphaAPI.ui.components.renderBlock({ + uid: renderUid, + el, + }); registerPullWatch(); }); return () => { + cancelled = true; window.clearTimeout(debounceRef.current); if (pullWatchArgsRef.current) { window.roamAlphaAPI.data.removePullWatch(...pullWatchArgsRef.current); pullWatchArgsRef.current = null; } }; - }, [uid, handleChange]); + }, [renderUid, handleChange]); return ( <> From 98c64023381c6d451b8d3c9766e032ea35a6c645 Mon Sep 17 00:00:00 2001 From: Siddharth Yadav Date: Fri, 15 May 2026 11:53:55 +0530 Subject: [PATCH 56/60] ENG-1755: Normalize legacy query-pages snapshot before array operations (#1034) setInitialQueryPages reads the personal query-pages setting from the snapshot and immediately calls .includes / spreads it. With the new settings store flag OFF, bulkReadSettings returns the raw legacy value cast as PersonalSettings, but the legacy extensionAPI shape is string | string[] | Record. That throws on objects and spreads strings per-character. Route through the existing getQueryPages helper (already used by isQueryPage, listActiveQueries, resolveQueryBuilderRef), which coerces the legacy shapes to string[] before the append logic runs. --- apps/roam/src/utils/setQueryPages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/roam/src/utils/setQueryPages.ts b/apps/roam/src/utils/setQueryPages.ts index f75fb521b..84ee6ce5b 100644 --- a/apps/roam/src/utils/setQueryPages.ts +++ b/apps/roam/src/utils/setQueryPages.ts @@ -1,4 +1,5 @@ import { OnloadArgs } from "roamjs-components/types"; +import { getQueryPages } from "~/components/settings/QueryPagesPanel"; import { setPersonalSetting, type SettingsSnapshot, @@ -12,8 +13,7 @@ export const setInitialQueryPages = ( onloadArgs: OnloadArgs, snapshot: SettingsSnapshot, ) => { - const queryPageArray = - snapshot.personalSettings[PERSONAL_KEYS.query][QUERY_KEYS.queryPages]; + const queryPageArray = getQueryPages(snapshot); if (!queryPageArray.includes("discourse-graph/queries/*")) { const updated = [...queryPageArray, "discourse-graph/queries/*"]; void onloadArgs.extensionAPI.settings.set("query-pages", updated); From 1d4c37f504f44f5590322c0bfe6470e7e1f1f453 Mon Sep 17 00:00:00 2001 From: Siddharth Yadav Date: Fri, 15 May 2026 12:13:03 +0530 Subject: [PATCH 57/60] ENG-1757: Read fresh relation settings on edit to prevent duplicate data loss (#1035) * ENG-1757: Read fresh relation settings in editor to prevent duplicate-edit data loss RelationEditPanel was reading the relation from the globalSettings prop, which is a snapshot taken when the Relations tab was opened. After handleDuplicate writes a new relation via setGlobalSetting, the parent's snapshot is not refreshed. Opening the editor on the duplicated row found no entry, initialized source/destination/complement to blanks, and saving overwrote the relation with empty endpoints. Switch the lookup to getGlobalSettings().Relations[uid] so the editor always reads current state. The prop is now unused in both panels and is removed along with the Settings.tsx pass-through. * ENG-1757 Refresh relation state after relation mutations --- .../settings/DiscourseRelationConfigPanel.tsx | 37 +++++++------------ .../roam/src/components/settings/Settings.tsx | 1 - 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx index 22ed163b7..1b714a070 100644 --- a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx @@ -52,7 +52,6 @@ import { getStoredRelationsEnabled } from "~/utils/storedRelations"; import { setGlobalSetting, getGlobalSettings, - type SettingsSnapshot, } from "~/components/settings/utils/accessors"; import { GLOBAL_KEYS } from "~/components/settings/utils/settingKeys"; import { RenderRoamBlock } from "~/utils/roamReactComponents"; @@ -77,14 +76,12 @@ export const RelationEditPanel = ({ back, translatorKeys, previewUid, - globalSettings, }: { editingRelationInfo: TreeNode; back: () => void; nodes: Record; translatorKeys: string[]; previewUid: string; - globalSettings: SettingsSnapshot["globalSettings"]; }) => { const nodeFormatsByLabel = useMemo( () => @@ -125,7 +122,7 @@ export const RelationEditPanel = ({ DEFAULT_SELECTED_RELATION, ); const [tab, setTab] = useState(0); - const relation = globalSettings.Relations[editingRelationInfo.uid]; + const relation = getGlobalSettings().Relations[editingRelationInfo.uid]; const initialSourceUid = relation?.source ?? ""; const initialSource = useMemo( () => edgeDisplayByUid(initialSourceUid), @@ -963,13 +960,11 @@ type Relation = { const DiscourseRelationConfigPanel = ({ uid, parentUid, - globalSettings, }: { uid: string; parentUid: string; defaultValue: unknown; title: string; - globalSettings: SettingsSnapshot["globalSettings"]; }) => { const refreshRelations = useCallback( (): Relation[] => @@ -1042,12 +1037,15 @@ const DiscourseRelationConfigPanel = ({ }; const handleDelete = (rel: Relation) => { - deleteBlock(rel.uid); - setRelations(relations.filter((r) => r.uid !== rel.uid)); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/naming-convention - const { [rel.uid]: _, ...remaining } = getGlobalSettings().Relations; - setGlobalSetting([GLOBAL_KEYS.relations], remaining); + void deleteBlock(rel.uid).then(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/naming-convention + const { [rel.uid]: _, ...remaining } = getGlobalSettings().Relations; + setGlobalSetting([GLOBAL_KEYS.relations], remaining); + setTimeout(() => { + refreshConfigTree(); + setRelations(refreshRelations()); + }, 50); + }); }; const handleDuplicate = (rel: Relation) => { const text = rel.text; @@ -1072,16 +1070,10 @@ const DiscourseRelationConfigPanel = ({ label: text, }); } - - setRelations([ - ...relations, - { - uid: newUid, - source: rel.source, - destination: rel.destination, - text, - }, - ]); + setTimeout(() => { + refreshConfigTree(); + setRelations(refreshRelations()); + }, 50); }); }; const handleBack = () => { @@ -1102,7 +1094,6 @@ const DiscourseRelationConfigPanel = ({ back={handleBack} translatorKeys={translatorKeys} previewUid={previewUid} - globalSettings={globalSettings} />
); diff --git a/apps/roam/src/components/settings/Settings.tsx b/apps/roam/src/components/settings/Settings.tsx index d0ec022a0..5e71d3adc 100644 --- a/apps/roam/src/components/settings/Settings.tsx +++ b/apps/roam/src/components/settings/Settings.tsx @@ -250,7 +250,6 @@ export const SettingsDialog = ({ title="Relations" parentUid={grammarNode?.uid || ""} uid={relationsNode?.uid || ""} - globalSettings={settings.globalSettings} /> } /> From 34d8e9ee68b31817e6eb923a98b94332d86d9802 Mon Sep 17 00:00:00 2001 From: Siddharth Yadav Date: Fri, 15 May 2026 14:04:25 +0530 Subject: [PATCH 58/60] ENG-1756: Preserve existing feature flag values during legacy migration (#1036) * ENG-1756: Preserve existing feature flag block-prop values during legacy migration readAllLegacyFeatureFlags only sources Enable left sidebar from legacy config. The migration wrote the full FeatureFlags object (with Zod defaults filling the unsourced keys as false) via setBlockProps, which clobbered any pre-existing true values for Duplicate node alert enabled and Suggestive mode overlay enabled. Expose LEGACY_SOURCED_FEATURE_FLAG_KEYS to identify which keys the legacy reader actually sources. The migration now overlays only those keys onto the existing block-prop values, leaving everything else untouched. * ENG-1756: Remove type assertion on LEGACY_SOURCED_FEATURE_FLAG_KEYS Declare the keys as a const tuple and derive the map's key type from it, so the array's type accurately describes its contents without an assertion. --- apps/roam/src/components/settings/utils/accessors.ts | 9 +++++++-- .../settings/utils/migrateLegacyToBlockProps.ts | 12 +++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 1538261ab..e5489322a 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -696,9 +696,14 @@ export const getFeatureFlags = (): FeatureFlags => { return bulkReadSettings().featureFlags; }; +export const LEGACY_SOURCED_FEATURE_FLAG_KEYS = [ + "Enable left sidebar", +] as const satisfies ReadonlyArray; + /* eslint-disable @typescript-eslint/naming-convention */ -const FEATURE_FLAG_LEGACY_MAP: Partial< - Record boolean> +const FEATURE_FLAG_LEGACY_MAP: Record< + (typeof LEGACY_SOURCED_FEATURE_FLAG_KEYS)[number], + () => boolean > = { "Enable left sidebar": () => getUidAndBooleanSetting({ diff --git a/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts index 41df345d7..e026982a0 100644 --- a/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts +++ b/apps/roam/src/components/settings/utils/migrateLegacyToBlockProps.ts @@ -6,6 +6,7 @@ import { createBlock } from "roamjs-components/writes"; import { getSetting, setSetting } from "~/utils/extensionSettings"; import internalError from "~/utils/internalError"; import { + LEGACY_SOURCED_FEATURE_FLAG_KEYS, readAllLegacyFeatureFlags, readAllLegacyGlobalSettings, readAllLegacyPersonalSettings, @@ -199,12 +200,21 @@ export const migrateGraphLevel = async ( failures++; } else { const legacyFlags = readAllLegacyFeatureFlags(); + const mergedFlags: Record = { + ...getBlockProps(featureFlagUid), + }; + for (const key of LEGACY_SOURCED_FEATURE_FLAG_KEYS) { + const value = legacyFlags[key]; + if (value !== undefined) { + mergedFlags[key] = value; + } + } if ( !migrateSection({ label: "Feature Flags", blockUid: featureFlagUid, schema: FeatureFlagsSchema, - legacyData: legacyFlags as Record, + legacyData: mergedFlags, }) ) { failures++; From f80620cfecf817945c86d4dc4ef23558105b1d8b Mon Sep 17 00:00:00 2001 From: Siddharth Yadav Date: Fri, 15 May 2026 22:44:55 +0530 Subject: [PATCH 59/60] remove nuke command (#1039) --- apps/roam/src/index.ts | 40 +--------------------------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index 0d9aa6aaf..fde454338 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -136,45 +136,7 @@ export default runExtension(async (onloadArgs) => { } const { extensionAPI } = onloadArgs; - if (isNewSettingsStoreEnabled()) { - const personalLegacyKeys = [ - "discourse-context-overlay", - "text-selection-popup", - "disable-sidebar-open", - "page-preview", - "hide-feedback-button", - "auto-canvas-relations", - "discourse-context-overlay-in-canvas", - "streamline-styling", - "disallow-diagnostics", - "discourse-tool-shortcut", - "personal-node-menu-trigger", - "node-search-trigger", - "hide-metadata", - "default-page-size", - "query-pages", - "default-filters", - "use-reified-relations", - "canvas-page-format", - ]; - void extensionAPI.ui.commandPalette.addCommand({ - label: "DG: Dev - Nuke personal legacy settings", - callback: () => { - const before = Object.fromEntries( - personalLegacyKeys.map((k) => [k, extensionAPI.settings.get(k)]), - ); - console.log("[dg] personal legacy BEFORE nuke:", before); - void Promise.all( - personalLegacyKeys.map((k) => extensionAPI.settings.set(k, null)), - ).then(() => { - const after = Object.fromEntries( - personalLegacyKeys.map((k) => [k, extensionAPI.settings.get(k)]), - ); - console.log("[dg] personal legacy AFTER nuke:", after); - }); - }, - }); - } + window.roamjs.extension.queryBuilder = { runQuery: (parentUid: string) => runQuery({ parentUid, extensionAPI }).then( From 8ab6e6e0e901131953e186803db77b4a322344b4 Mon Sep 17 00:00:00 2001 From: sid597 Date: Fri, 15 May 2026 23:03:43 +0530 Subject: [PATCH 60/60] remove unused --- apps/roam/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index fde454338..1e1581329 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -36,7 +36,6 @@ import { initPostHog } from "./utils/posthog"; import { initSchema } from "./components/settings/utils/init"; import { bulkReadSettings, - isNewSettingsStoreEnabled, isSyncEnabled, } from "./components/settings/utils/accessors"; import { PERSONAL_KEYS } from "./components/settings/utils/settingKeys";