Skip to content

Commit cd16a66

Browse files
committed
ENG-1519: Add legacy-to-blockprops migration, remove backedBy from schema
1 parent fc58bf3 commit cd16a66

6 files changed

Lines changed: 315 additions & 8 deletions

File tree

apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ const DiscourseNodeConfigPanel: React.FC<DiscourseNodeConfigPanelProps> = ({
107107
type: valueUid,
108108
shortcut,
109109
format,
110-
backedBy: "user",
111110
}),
112111
);
113112
setNodes([

apps/roam/src/components/settings/utils/accessors.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getSubTree } from "roamjs-components/util";
1010
import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree";
1111
import internalError from "~/utils/internalError";
1212
import { getSetting } from "~/utils/extensionSettings";
13+
import { USE_REIFIED_RELATIONS } from "~/data/userSettings";
1314
import { getFormattedConfigTree } from "~/utils/discourseConfigRef";
1415
import { roamNodeToCondition } from "~/utils/parseQuery";
1516
import type { DiscourseRelation } from "~/utils/getDiscourseRelations";
@@ -32,7 +33,7 @@ import {
3233
type DiscourseNodeSettings,
3334
type Condition as SchemaCondition,
3435
} from "./zodSchema";
35-
import { PERSONAL_KEYS, QUERY_KEYS } from "./settingKeys";
36+
import { PERSONAL_KEYS, QUERY_KEYS, GLOBAL_KEYS } from "./settingKeys";
3637

3738
const isRecord = (value: unknown): value is Record<string, unknown> =>
3839
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -718,6 +719,44 @@ export const isNewSettingsStoreEnabled = (): boolean => {
718719
return getFeatureFlag("Use new settings store");
719720
};
720721

722+
export const readAllLegacyFeatureFlags = (): Partial<FeatureFlags> => {
723+
const flags: Partial<FeatureFlags> = {};
724+
for (const [key, reader] of Object.entries(FEATURE_FLAG_LEGACY_MAP)) {
725+
flags[key as keyof FeatureFlags] = reader();
726+
}
727+
flags["Reified relation triples"] = getSetting<boolean>(
728+
USE_REIFIED_RELATIONS,
729+
false,
730+
);
731+
flags["Use new settings store"] = false;
732+
return flags;
733+
};
734+
735+
export const readAllLegacyGlobalSettings = (): Record<string, unknown> => {
736+
const result: Record<string, unknown> = {};
737+
for (const key of Object.values(GLOBAL_KEYS)) {
738+
result[key] = getLegacyGlobalSetting([key]);
739+
}
740+
return result;
741+
};
742+
743+
export const readAllLegacyPersonalSettings = (): Record<string, unknown> => {
744+
const result: Record<string, unknown> = {};
745+
for (const key of Object.values(PERSONAL_KEYS)) {
746+
result[key] = getLegacyPersonalSetting([key]);
747+
}
748+
return result;
749+
};
750+
751+
export const readAllLegacyDiscourseNodeSettings = (
752+
nodeType: string,
753+
nodeTitle: string,
754+
): Record<string, unknown> | undefined => {
755+
const raw = getLegacyDiscourseNodeSetting(nodeType, []);
756+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
757+
return { ...(raw as Record<string, unknown>), text: nodeTitle };
758+
};
759+
721760
export const setFeatureFlag = (
722761
key: keyof FeatureFlags,
723762
value: boolean,

apps/roam/src/components/settings/utils/init.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import type { json } from "~/utils/getBlockProps";
77
import INITIAL_NODE_VALUES from "~/data/defaultDiscourseNodes";
88
import DEFAULT_RELATIONS_BLOCK_PROPS from "~/components/settings/data/defaultRelationsBlockProps";
99
import { getAllDiscourseNodes } from "./accessors";
10+
import {
11+
migrateGraphLevel,
12+
migratePersonalSettings,
13+
} from "./migrateLegacyToBlockProps";
1014
import {
1115
DiscourseNodeSchema,
1216
getTopLevelBlockPropsConfig,
@@ -147,7 +151,6 @@ const initSingleDiscourseNode = async (
147151
tag: node.tag || "",
148152
graphOverview: node.graphOverview ?? false,
149153
canvasSettings: node.canvasSettings || {},
150-
backedBy: "user",
151154
});
152155

153156
setBlockProps(pageUid, nodeData, false);
@@ -256,6 +259,8 @@ export type InitSchemaResult = {
256259

257260
export const initSchema = async (): Promise<InitSchemaResult> => {
258261
const blockUids = await initSettingsPageBlocks();
262+
await migrateGraphLevel(blockUids);
259263
const nodePageUids = await initDiscourseNodePages();
264+
await migratePersonalSettings(blockUids);
260265
return { blockUids, nodePageUids };
261266
};
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import getBlockProps from "~/utils/getBlockProps";
2+
import type { json } from "~/utils/getBlockProps";
3+
import setBlockProps from "~/utils/setBlockProps";
4+
import getBlockUidByTextOnPage from "roamjs-components/queries/getBlockUidByTextOnPage";
5+
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
6+
import { createBlock } from "roamjs-components/writes";
7+
import { getSetting, setSetting } from "~/utils/extensionSettings";
8+
import {
9+
readAllLegacyFeatureFlags,
10+
readAllLegacyGlobalSettings,
11+
readAllLegacyPersonalSettings,
12+
readAllLegacyDiscourseNodeSettings,
13+
} from "./accessors";
14+
import {
15+
FeatureFlagsSchema,
16+
GlobalSettingsSchema,
17+
PersonalSettingsSchema,
18+
DiscourseNodeSchema,
19+
DG_BLOCK_PROP_SETTINGS_PAGE_TITLE,
20+
DISCOURSE_NODE_PAGE_PREFIX,
21+
TOP_LEVEL_BLOCK_PROP_KEYS,
22+
getPersonalSettingsKey,
23+
} from "./zodSchema";
24+
import type { z } from "zod";
25+
26+
const LOG_PREFIX = "[DG BlockProps Migration]";
27+
const GRAPH_MIGRATION_MARKER = "Block props migrated";
28+
const PERSONAL_MIGRATION_MARKER = "dg-personal-settings-migrated";
29+
30+
const hasGraphMigrationMarker = (): boolean =>
31+
!!getBlockUidByTextOnPage({
32+
text: GRAPH_MIGRATION_MARKER,
33+
title: DG_BLOCK_PROP_SETTINGS_PAGE_TITLE,
34+
});
35+
36+
const isPropsValid = (
37+
schema: z.ZodTypeAny,
38+
props: Record<string, json> | null,
39+
): boolean =>
40+
!!props && Object.keys(props).length > 0 && schema.safeParse(props).success;
41+
42+
const shouldWrite = (
43+
schema: z.ZodTypeAny,
44+
currentProps: Record<string, json> | null,
45+
parsedLegacy: Record<string, json>,
46+
): boolean => {
47+
if (!isPropsValid(schema, currentProps)) {
48+
return true;
49+
}
50+
51+
// safeParse because some schemas (DiscourseNodeSchema) have required fields
52+
// with no defaults, so parse({}) would throw.
53+
const defaultsResult = schema.safeParse({});
54+
if (!defaultsResult.success) {
55+
// Can't determine schema defaults (e.g. DiscourseNodeSchema).
56+
// Compare Zod-normalized parsed legacy against current props directly.
57+
// Both sides are normalized so the comparison is apples-to-apples.
58+
// Safe on retry: if prior run already wrote parsedLegacy, they'll match
59+
// → skip. If user edited via settings UI, dual-write keeps both sides in
60+
// sync → match → skip. The only write happens when legacy genuinely
61+
// differs from current (first migration or tree-only edit).
62+
return JSON.stringify(parsedLegacy) !== JSON.stringify(currentProps);
63+
}
64+
65+
const defaults = defaultsResult.data as Record<string, unknown>;
66+
const propsMatch = JSON.stringify(currentProps) === JSON.stringify(defaults);
67+
const legacyDiffers =
68+
JSON.stringify(parsedLegacy) !== JSON.stringify(defaults);
69+
70+
return propsMatch && legacyDiffers;
71+
};
72+
73+
const migrateSection = ({
74+
label,
75+
blockUid,
76+
schema,
77+
legacyData,
78+
}: {
79+
label: string;
80+
blockUid: string;
81+
schema: z.ZodTypeAny;
82+
legacyData: Record<string, unknown>;
83+
}): boolean => {
84+
const currentProps = getBlockProps(blockUid);
85+
86+
const parseResult = schema.safeParse(legacyData);
87+
if (!parseResult.success) {
88+
// Legacy malformed — succeed if current props are already valid.
89+
// migrateGraphLevel runs before initDiscourseNodePages, so valid props
90+
// at this point were written by a prior migration run, not init-seeded.
91+
if (isPropsValid(schema, currentProps)) {
92+
console.log(
93+
`${LOG_PREFIX} ${label}: legacy malformed but props already valid, skipping`,
94+
);
95+
return true;
96+
}
97+
console.warn(`${LOG_PREFIX} ${label}: Zod validation failed, skipping`, {
98+
error: parseResult.error.message,
99+
});
100+
return false;
101+
}
102+
103+
const parsedLegacy = parseResult.data as Record<string, json>;
104+
if (!shouldWrite(schema, currentProps, parsedLegacy)) {
105+
console.log(`${LOG_PREFIX} ${label}: props already non-default, skipping`);
106+
return true;
107+
}
108+
109+
setBlockProps(blockUid, parsedLegacy, false);
110+
console.log(`${LOG_PREFIX} ${label}: migrated`);
111+
return true;
112+
};
113+
114+
const migrateDiscourseNodes = (): boolean => {
115+
const nodePages = window.roamAlphaAPI.data.fast.q(`
116+
[:find ?uid ?title
117+
:where
118+
[?page :node/title ?title]
119+
[?page :block/uid ?uid]
120+
[(clojure.string/starts-with? ?title "${DISCOURSE_NODE_PAGE_PREFIX}")]]
121+
`) as [string, string][];
122+
123+
let allOk = true;
124+
125+
for (const [nodePageUid, title] of nodePages) {
126+
if (typeof nodePageUid !== "string" || typeof title !== "string") continue;
127+
128+
const nodeText = title.replace(DISCOURSE_NODE_PAGE_PREFIX, "");
129+
const legacyData = readAllLegacyDiscourseNodeSettings(
130+
nodePageUid,
131+
nodeText,
132+
);
133+
if (!legacyData) {
134+
// Legacy unreadable — if current props are already valid, treat as
135+
// success so a missing/malformed legacy tree doesn't block the marker.
136+
if (isPropsValid(DiscourseNodeSchema, getBlockProps(nodePageUid))) {
137+
console.log(
138+
`${LOG_PREFIX} Discourse Node (${nodeText}): legacy unreadable but props already valid, skipping`,
139+
);
140+
continue;
141+
}
142+
console.warn(
143+
`${LOG_PREFIX} Discourse Node (${nodeText}): legacy data unreadable`,
144+
);
145+
allOk = false;
146+
continue;
147+
}
148+
149+
if (
150+
!migrateSection({
151+
label: `Discourse Node (${nodeText})`,
152+
blockUid: nodePageUid,
153+
schema: DiscourseNodeSchema,
154+
legacyData,
155+
})
156+
) {
157+
allOk = false;
158+
}
159+
}
160+
161+
return allOk;
162+
};
163+
164+
export const migrateGraphLevel = async (
165+
blockUids: Record<string, string>,
166+
): Promise<void> => {
167+
const pageUid = getPageUidByPageTitle(DG_BLOCK_PROP_SETTINGS_PAGE_TITLE);
168+
if (!pageUid) return;
169+
170+
if (hasGraphMigrationMarker()) {
171+
console.log(`${LOG_PREFIX} graph-level: skipped (already migrated)`);
172+
return;
173+
}
174+
175+
let failures = 0;
176+
177+
// Feature flags
178+
const featureFlagUid = blockUids[TOP_LEVEL_BLOCK_PROP_KEYS.featureFlags];
179+
if (featureFlagUid) {
180+
const legacyFlags = readAllLegacyFeatureFlags();
181+
if (
182+
!migrateSection({
183+
label: "Feature Flags",
184+
blockUid: featureFlagUid,
185+
schema: FeatureFlagsSchema,
186+
legacyData: legacyFlags as Record<string, unknown>,
187+
})
188+
) {
189+
failures++;
190+
}
191+
}
192+
193+
// Global settings
194+
const globalUid = blockUids[TOP_LEVEL_BLOCK_PROP_KEYS.global];
195+
if (globalUid) {
196+
const legacyGlobal = readAllLegacyGlobalSettings();
197+
if (
198+
!migrateSection({
199+
label: "Global",
200+
blockUid: globalUid,
201+
schema: GlobalSettingsSchema,
202+
legacyData: legacyGlobal,
203+
})
204+
) {
205+
failures++;
206+
}
207+
}
208+
209+
// Discourse nodes
210+
if (!migrateDiscourseNodes()) {
211+
failures++;
212+
}
213+
214+
if (failures === 0) {
215+
try {
216+
await createBlock({
217+
parentUid: pageUid,
218+
node: { text: GRAPH_MIGRATION_MARKER },
219+
});
220+
console.log(`${LOG_PREFIX} graph-level: completed`);
221+
} catch (e) {
222+
console.warn(
223+
`${LOG_PREFIX} graph-level: data migrated but marker write failed (will retry next load)`,
224+
e,
225+
);
226+
}
227+
} else {
228+
console.warn(
229+
`${LOG_PREFIX} graph-level: ${failures} section(s) failed, marker not created (will retry next load)`,
230+
);
231+
}
232+
};
233+
234+
export const migratePersonalSettings = async (
235+
blockUids: Record<string, string>,
236+
): Promise<void> => {
237+
if (getSetting<boolean>(PERSONAL_MIGRATION_MARKER, false)) {
238+
console.log(`${LOG_PREFIX} personal: skipped (already migrated)`);
239+
return;
240+
}
241+
242+
const personalKey = getPersonalSettingsKey();
243+
const personalUid = blockUids[personalKey];
244+
if (!personalUid) return;
245+
246+
const legacyPersonal = readAllLegacyPersonalSettings();
247+
const ok = migrateSection({
248+
label: "Personal",
249+
blockUid: personalUid,
250+
schema: PersonalSettingsSchema,
251+
legacyData: legacyPersonal,
252+
});
253+
254+
if (ok) {
255+
try {
256+
await setSetting(PERSONAL_MIGRATION_MARKER, true);
257+
console.log(`${LOG_PREFIX} personal: completed`);
258+
} catch (e) {
259+
console.warn(
260+
`${LOG_PREFIX} personal: data migrated but marker write failed (will retry next load)`,
261+
e,
262+
);
263+
}
264+
} else {
265+
console.warn(
266+
`${LOG_PREFIX} personal: failed, marker not created (will retry next load)`,
267+
);
268+
}
269+
};

apps/roam/src/components/settings/utils/zodSchema.example.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ const discourseNodeSettings: DiscourseNodeSettings = {
8383
returnNode: "node",
8484
},
8585
suggestiveRules,
86-
backedBy: "user",
8786
};
8887

8988
const featureFlags: FeatureFlags = {

apps/roam/src/components/settings/utils/zodSchema.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,6 @@ export const DiscourseNodeSchema = z.object({
140140
.optional()
141141
.transform((val) => val ?? defaultNodeIndex()),
142142
suggestiveRules: SuggestiveRulesSchema.default({}),
143-
backedBy: z
144-
.enum(["user", "default", "relation"])
145-
.nullable()
146-
.transform((val) => val ?? "user"),
147143
});
148144

149145
export const TripleSchema = z.tuple([z.string(), z.string(), z.string()]);

0 commit comments

Comments
 (0)