Skip to content

Commit 8cc5189

Browse files
authored
ENG-1403 create migration from local to real-time (#893)
* Enhance canvas synchronization features and migration handling - Introduced new types and functions for managing canvas sync modes and migration states. - Implemented migration of local canvas data to cloud sync, including user prompts for confirmation. - Updated UI components to support cloud sync settings and actions. - Refactored existing functions to improve clarity and maintainability, ensuring seamless integration with the new sync features. * move posthog to onConfirm * Implement canvas synchronization on block prop changes
1 parent f575944 commit 8cc5189

4 files changed

Lines changed: 469 additions & 45 deletions

File tree

apps/roam/src/components/canvas/Tldraw.tsx

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Icon } from "@blueprintjs/core";
1010
import ExtensionApiContextProvider, {
1111
useExtensionAPI,
1212
} from "roamjs-components/components/ExtensionApiContext";
13-
import { OnloadArgs } from "roamjs-components/types";
13+
import { OnloadArgs, PullBlock } from "roamjs-components/types";
1414
import renderWithUnmount from "roamjs-components/util/renderWithUnmount";
1515

1616
import {
@@ -104,15 +104,19 @@ import { BLOCK_REF_REGEX } from "roamjs-components/dom";
104104
import { defaultHandleExternalTextContent } from "./defaultHandleExternalTextContent";
105105
import {
106106
CanvasSyncMode,
107+
getCanvasSyncMigrationState,
108+
getReadyCanvasStore,
107109
ensureCanvasSyncMode,
108110
getEffectiveCanvasSyncMode,
109-
setCanvasSyncMode,
111+
migrateLocalCanvasToCloud,
112+
setCanvasSyncSettings,
110113
} from "./canvasSyncMode";
111114
import {
112115
CanvasStoreAdapterArgs,
113116
useCanvasStoreAdapterArgs,
114117
} from "./useCanvasStoreAdapterArgs";
115118
import posthog from "posthog-js";
119+
import { json, normalizeProps } from "~/utils/getBlockProps";
116120

117121
declare global {
118122
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -171,9 +175,44 @@ const TldrawCanvas = ({ title }: { title: string }) => {
171175
setCanvasSyncModeState(ensureCanvasSyncMode({ pageUid }));
172176
}, [pageUid]);
173177

178+
// Convert from local to sync when the block props change
179+
// EG: When UserA sets the canvas sync mode to sync
180+
// UserB's canvas should be converted to sync
181+
useEffect(() => {
182+
if (!pageUid || canvasSyncMode !== "local") return;
183+
184+
const pullWatchProps = [
185+
"[:block/props]",
186+
`[:block/uid "${pageUid}"]`,
187+
(_before: PullBlock | null, after: PullBlock | null) => {
188+
const blockProps = normalizeProps(
189+
(after?.[":block/props"] || {}) as json,
190+
) as Record<string, json>;
191+
const rjsqb = blockProps?.["roamjs-query-builder"] as Record<
192+
string,
193+
unknown
194+
>;
195+
if (rjsqb?.canvasSyncMode !== "sync") return;
196+
setCanvasSyncModeState("sync");
197+
},
198+
] as const;
199+
200+
window.roamAlphaAPI.data.addPullWatch(...pullWatchProps);
201+
return () => {
202+
window.roamAlphaAPI.data.removePullWatch(...pullWatchProps);
203+
};
204+
}, [canvasSyncMode, pageUid]);
205+
174206
const onCanvasSyncModeChange = useCallback(
175207
(mode: CanvasSyncMode) => {
176-
setCanvasSyncMode({ pageUid, mode });
208+
const currentMigrationState = getCanvasSyncMigrationState({ pageUid });
209+
const nextMigrationState =
210+
mode === "sync" && !currentMigrationState ? "pending" : undefined;
211+
setCanvasSyncSettings({
212+
pageUid,
213+
mode,
214+
migrationState: nextMigrationState,
215+
});
177216
setCanvasSyncModeState(mode);
178217
},
179218
[pageUid],
@@ -254,6 +293,7 @@ const useCloudflareCanvasStore = ({
254293
performUpgrade: () => {},
255294
};
256295
};
296+
257297
const TldrawCanvasRoam = ({
258298
title,
259299
pageUid,
@@ -632,7 +672,6 @@ const TldrawCanvasShared = ({
632672
allRelationNames,
633673
allAddReferencedNodeActions,
634674
canvasSyncMode,
635-
onCanvasSyncModeChange,
636675
});
637676

638677
const storeAdapterArgs = useCanvasStoreAdapterArgs({
@@ -643,6 +682,7 @@ const TldrawCanvasShared = ({
643682
allAddReferencedNodeByAction,
644683
});
645684
const {
685+
migrations,
646686
customShapeUtils,
647687
customBindingUtils,
648688
customShapeTypes,
@@ -671,6 +711,8 @@ const TldrawCanvasShared = ({
671711
allNodes,
672712
allRelationNames,
673713
allAddReferencedNodeByAction,
714+
canvasSyncMode,
715+
onCanvasSyncModeChange,
674716
toggleMaximized: handleMaximizedChange,
675717
setConvertToDialogOpen,
676718
discourseContext,
@@ -685,6 +727,55 @@ const TldrawCanvasShared = ({
685727
}, [pageUid]);
686728
const { store, needsUpgrade, performUpgrade, error, isLoading } =
687729
useStoreAdapter(storeAdapterArgs);
730+
const migratedCloudStoreRef = useRef<string | null>(null);
731+
732+
// Migrate local canvas to cloud sync
733+
useEffect(() => {
734+
if (!isCloudflareSync || canvasSyncMode !== "sync") return;
735+
if (getCanvasSyncMigrationState({ pageUid }) !== "pending") return;
736+
737+
const readyStore = getReadyCanvasStore(store);
738+
if (!readyStore) return;
739+
740+
const storeId = `${pageUid}:${readyStore.id}`;
741+
if (migratedCloudStoreRef.current === storeId) return;
742+
migratedCloudStoreRef.current = storeId;
743+
744+
try {
745+
const { migrated } = migrateLocalCanvasToCloud({
746+
pageUid,
747+
store: readyStore,
748+
migrations,
749+
customShapeUtils,
750+
customBindingUtils,
751+
});
752+
if (migrated)
753+
renderToast({
754+
id: "tldraw-cloud-migration",
755+
intent: "success",
756+
content: "Migrated local canvas to cloud sync.",
757+
});
758+
} catch (migrationError) {
759+
migratedCloudStoreRef.current = null;
760+
internalError({
761+
error: migrationError,
762+
type: "Canvas: Local to cloud migration failed",
763+
context: {
764+
pageUid,
765+
title,
766+
},
767+
});
768+
}
769+
}, [
770+
canvasSyncMode,
771+
customBindingUtils,
772+
customShapeUtils,
773+
isCloudflareSync,
774+
migrations,
775+
pageUid,
776+
store,
777+
title,
778+
]);
688779

689780
// ASSETS
690781
const assetLoading = usePreloadAssets(defaultEditorAssetUrls);

apps/roam/src/components/canvas/canvasSyncMode.ts

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
11
import getBlockProps, { json } from "~/utils/getBlockProps";
22
import setBlockProps from "~/utils/setBlockProps";
3+
import {
4+
TLAnyBindingUtilConstructor,
5+
TLAnyShapeUtilConstructor,
6+
TLStore,
7+
TLStoreWithStatus,
8+
MigrationSequence,
9+
loadSnapshot,
10+
} from "tldraw";
11+
import { getRoamCanvasSnapshot } from "./useRoamStore";
312

413
export type CanvasSyncMode = "local" | "sync";
14+
export type CanvasSyncMigrationState = "pending" | "done";
515

616
const QUERY_BUILDER_PROP_KEY = "roamjs-query-builder";
717
const CANVAS_SYNC_MODE_KEY = "canvasSyncMode";
18+
const CANVAS_SYNC_MIGRATION_STATE_KEY = "canvasSyncMigrationState";
819
const DEFAULT_CANVAS_SYNC_MODE: CanvasSyncMode = "local";
920

1021
const isCanvasSyncMode = (value: unknown): value is CanvasSyncMode =>
1122
value === "local" || value === "sync";
1223

24+
const isCanvasSyncMigrationState = (
25+
value: unknown,
26+
): value is CanvasSyncMigrationState => value === "pending" || value === "done";
27+
1328
const getRoamJsQueryBuilderProps = (pageUid: string): Record<string, json> => {
1429
const props = getBlockProps(pageUid);
1530
const value = props[QUERY_BUILDER_PROP_KEY];
@@ -19,6 +34,40 @@ const getRoamJsQueryBuilderProps = (pageUid: string): Record<string, json> => {
1934
return {};
2035
};
2136

37+
const setRoamJsQueryBuilderProps = ({
38+
pageUid,
39+
nextRjsqb,
40+
}: {
41+
pageUid: string;
42+
nextRjsqb: Record<string, json>;
43+
}): void => {
44+
setBlockProps(pageUid, {
45+
[QUERY_BUILDER_PROP_KEY]: nextRjsqb,
46+
});
47+
};
48+
49+
const updateCanvasSyncProps = ({
50+
pageUid,
51+
updates,
52+
}: {
53+
pageUid: string;
54+
updates: Partial<
55+
Record<
56+
typeof CANVAS_SYNC_MODE_KEY | typeof CANVAS_SYNC_MIGRATION_STATE_KEY,
57+
json
58+
>
59+
>;
60+
}): void => {
61+
const rjsqb = getRoamJsQueryBuilderProps(pageUid);
62+
setRoamJsQueryBuilderProps({
63+
pageUid,
64+
nextRjsqb: {
65+
...rjsqb,
66+
...updates,
67+
},
68+
});
69+
};
70+
2271
export const getPersistedCanvasSyncMode = ({
2372
pageUid,
2473
}: {
@@ -44,11 +93,55 @@ export const setCanvasSyncMode = ({
4493
pageUid: string;
4594
mode: CanvasSyncMode;
4695
}): void => {
96+
updateCanvasSyncProps({
97+
pageUid,
98+
updates: {
99+
[CANVAS_SYNC_MODE_KEY]: mode,
100+
},
101+
});
102+
};
103+
104+
export const getCanvasSyncMigrationState = ({
105+
pageUid,
106+
}: {
107+
pageUid: string;
108+
}): CanvasSyncMigrationState | null => {
47109
const rjsqb = getRoamJsQueryBuilderProps(pageUid);
48-
setBlockProps(pageUid, {
49-
[QUERY_BUILDER_PROP_KEY]: {
50-
...rjsqb,
110+
const migrationState = rjsqb[CANVAS_SYNC_MIGRATION_STATE_KEY];
111+
return isCanvasSyncMigrationState(migrationState) ? migrationState : null;
112+
};
113+
114+
export const setCanvasSyncMigrationState = ({
115+
pageUid,
116+
state,
117+
}: {
118+
pageUid: string;
119+
state: CanvasSyncMigrationState;
120+
}): void => {
121+
updateCanvasSyncProps({
122+
pageUid,
123+
updates: {
124+
[CANVAS_SYNC_MIGRATION_STATE_KEY]: state,
125+
},
126+
});
127+
};
128+
129+
export const setCanvasSyncSettings = ({
130+
pageUid,
131+
mode,
132+
migrationState,
133+
}: {
134+
pageUid: string;
135+
mode: CanvasSyncMode;
136+
migrationState?: CanvasSyncMigrationState;
137+
}): void => {
138+
updateCanvasSyncProps({
139+
pageUid,
140+
updates: {
51141
[CANVAS_SYNC_MODE_KEY]: mode,
142+
...(migrationState
143+
? { [CANVAS_SYNC_MIGRATION_STATE_KEY]: migrationState }
144+
: {}),
52145
},
53146
});
54147
};
@@ -63,3 +156,78 @@ export const ensureCanvasSyncMode = ({
63156
setCanvasSyncMode({ pageUid, mode: DEFAULT_CANVAS_SYNC_MODE });
64157
return DEFAULT_CANVAS_SYNC_MODE;
65158
};
159+
160+
export const getReadyCanvasStore = (
161+
store: TLStore | TLStoreWithStatus | null,
162+
): TLStore | null => {
163+
if (!store) return null;
164+
if ("status" in store) {
165+
return store.status === "synced-remote" ? store.store : null;
166+
}
167+
return store;
168+
};
169+
170+
const getShapeRecordCount = (store: TLStore): number => {
171+
return Object.values(store.serialize()).filter(
172+
(record) => record.typeName === "shape",
173+
).length;
174+
};
175+
176+
export const migrateLocalCanvasToCloud = ({
177+
pageUid,
178+
store,
179+
migrations,
180+
customShapeUtils,
181+
customBindingUtils,
182+
}: {
183+
pageUid: string;
184+
store: TLStore;
185+
migrations: MigrationSequence[];
186+
customShapeUtils: readonly TLAnyShapeUtilConstructor[];
187+
customBindingUtils: readonly TLAnyBindingUtilConstructor[];
188+
}): { migrated: boolean; migrationState: CanvasSyncMigrationState } => {
189+
if (getShapeRecordCount(store) > 0) {
190+
setCanvasSyncSettings({
191+
pageUid,
192+
mode: "sync",
193+
migrationState: "done",
194+
});
195+
return {
196+
migrated: false,
197+
migrationState: "done",
198+
};
199+
}
200+
201+
const localSnapshot = getRoamCanvasSnapshot({
202+
pageUid,
203+
migrations,
204+
customShapeUtils,
205+
customBindingUtils,
206+
includePersonalRecords: false,
207+
});
208+
209+
if (!localSnapshot) {
210+
setCanvasSyncSettings({
211+
pageUid,
212+
mode: "sync",
213+
migrationState: "done",
214+
});
215+
return {
216+
migrated: false,
217+
migrationState: "done",
218+
};
219+
}
220+
221+
loadSnapshot(store, localSnapshot);
222+
223+
setCanvasSyncSettings({
224+
pageUid,
225+
mode: "sync",
226+
migrationState: "done",
227+
});
228+
229+
return {
230+
migrated: true,
231+
migrationState: "done",
232+
};
233+
};

0 commit comments

Comments
 (0)