Skip to content

Commit e06de53

Browse files
authored
refactor: add server as storage to sync engine (#4475)
Another layer in sync engine is composable storage. Now you can specify a chain of storages. Each can listen transactions to update some state. Though subscription for state is invoked one by one until non-undefined is provided. See tests to understand API. Here I also migrated our server data loading and synchronization to this storage api.
1 parent 05ab9cd commit e06de53

File tree

8 files changed

+207
-85
lines changed

8 files changed

+207
-85
lines changed

apps/builder/app/builder/builder.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import type { Project } from "@webstudio-is/project";
77
import { theme, Box, type CSS, Flex, Grid } from "@webstudio-is/design-system";
88
import type { AuthPermit } from "@webstudio-is/trpc-interface/index.server";
99
import { createImageLoader } from "@webstudio-is/image";
10+
import { registerContainers, createObjectPool } from "~/shared/sync";
1011
import {
11-
builderClient,
12-
registerContainers,
13-
useBuilderStore,
14-
} from "~/shared/sync";
15-
import { startProjectSync, useSyncServer } from "./shared/sync/sync-server";
12+
ServerSyncStorage,
13+
startProjectSync,
14+
useSyncServer,
15+
} from "./shared/sync/sync-server";
1616
import { SidebarLeft } from "./sidebar-left";
1717
import { Inspector } from "./features/inspector";
1818
import { Topbar } from "./features/topbar";
@@ -57,7 +57,6 @@ import {
5757
import { CloneProjectDialog } from "~/shared/clone-project";
5858
import type { TokenPermissions } from "@webstudio-is/authorization-token";
5959
import { useToastErrors } from "~/shared/error/toast-error";
60-
import { loadBuilderData, setBuilderData } from "~/shared/builder-data";
6160
import { initBuilderApi } from "~/shared/builder-api";
6261
import { updateWebstudioData } from "~/shared/instance-utils";
6362
import { migrateWebstudioDataMutable } from "~/shared/webstudio-data-migrator";
@@ -71,6 +70,7 @@ import {
7170
} from "~/shared/copy-paste/init-copy-paste";
7271
import { useInertHandlers } from "./shared/inert-handlers";
7372
import { TextToolbar } from "./features/workspace/canvas-tools/text-toolbar";
73+
import { SyncClient } from "~/shared/sync-client";
7474

7575
registerContainers();
7676

@@ -206,6 +206,12 @@ const ChromeWrapper = ({
206206
);
207207
};
208208

209+
const builderClient = new SyncClient({
210+
role: "leader",
211+
object: createObjectPool(),
212+
storages: [new ServerSyncStorage()],
213+
});
214+
209215
export type BuilderProps = {
210216
project: Project;
211217
publisherHost: string;
@@ -242,9 +248,9 @@ export const Builder = ({
242248
const controller = new AbortController();
243249

244250
$dataLoadingState.set("loading");
245-
loadBuilderData({ projectId: project.id, signal: controller.signal })
246-
.then((data) => {
247-
setBuilderData(data);
251+
builderClient.connect({
252+
signal: controller.signal,
253+
onReady() {
248254
startProjectSync({
249255
projectId: project.id,
250256
buildId: build.id,
@@ -260,12 +266,10 @@ export const Builder = ({
260266
// so builder is started listening for connect event
261267
// when canvas is rendered
262268
$dataLoadingState.set("loaded");
263-
})
264-
.catch((error) => {
265-
console.error(error);
269+
266270
// @todo make needs error handling and error state? e.g. a toast
267-
$dataLoadingState.set("idle");
268-
});
271+
},
272+
});
269273
return () => {
270274
$dataLoadingState.set("idle");
271275
controller.abort("unmount");
@@ -287,7 +291,6 @@ export const Builder = ({
287291
$publisher.set({ publish });
288292
}, [publish]);
289293

290-
useBuilderStore();
291294
useSyncServer({
292295
projectId: project.id,
293296
authPermit,

apps/builder/app/builder/shared/sync/command-queue.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { Project } from "@webstudio-is/project";
22
import type { Build } from "@webstudio-is/project-build";
3-
import type { SyncItem } from "immerhin";
3+
import type { Change } from "immerhin";
4+
import type { Transaction } from "~/shared/sync-client";
45

56
type Command =
67
| {
78
type: "transactions";
89
projectId: Project["id"];
9-
transactions: SyncItem[];
10+
transactions: Transaction<Change[]>[];
1011
}
1112
| {
1213
type: "setDetails";

apps/builder/app/builder/shared/sync/sync-server.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { useEffect } from "react";
22
import { atom } from "nanostores";
3+
import type { Change } from "immerhin";
34
import type { Project } from "@webstudio-is/project";
45
import type { Build } from "@webstudio-is/project-build";
56
import type { AuthPermit } from "@webstudio-is/trpc-interface/index.server";
67
import * as commandQueue from "./command-queue";
78
import { restPatchPath } from "~/shared/router-utils";
89
import { toast } from "@webstudio-is/design-system";
9-
import { serverSyncStore } from "~/shared/sync";
1010
import { fetch } from "~/shared/fetch.client";
11+
import type { SyncStorage, Transaction } from "~/shared/sync-client";
12+
import { $project } from "~/shared/nano-states";
13+
import { loadBuilderData } from "~/shared/builder-data";
1114

1215
// Periodic check for new entries to group them into one job/call in sync queue.
1316
const NEW_ENTRIES_INTERVAL = 1000;
@@ -284,27 +287,30 @@ const useSyncProject = ({
284287
return;
285288
}
286289
syncServer();
287-
288-
const updateProjectTransactions = () => {
289-
const transactions = serverSyncStore.popAll();
290-
if (transactions.length === 0) {
291-
return;
292-
}
293-
commandQueue.enqueue({ type: "transactions", transactions, projectId });
294-
};
295-
296-
const intervalHandle = setInterval(
297-
updateProjectTransactions,
298-
NEW_ENTRIES_INTERVAL
299-
);
300-
301-
return () => {
302-
updateProjectTransactions();
303-
clearInterval(intervalHandle);
304-
};
305290
}, [projectId, authPermit]);
306291
};
307292

293+
export class ServerSyncStorage implements SyncStorage {
294+
name = "server";
295+
sendTransaction(transaction: Transaction<Change[]>) {
296+
if (transaction.object === "server") {
297+
const projectId = $project.get()?.id ?? "";
298+
commandQueue.enqueue({
299+
type: "transactions",
300+
transactions: [transaction],
301+
projectId,
302+
});
303+
}
304+
}
305+
subscribe(setState: (state: unknown) => void, signal: AbortSignal) {
306+
const projectId = $project.get()?.id ?? "";
307+
loadBuilderData({ projectId, signal }).then((data) => {
308+
const serverData = new Map(Object.entries(data));
309+
setState(new Map([["server", serverData]]));
310+
});
311+
}
312+
}
313+
308314
type SyncServerProps = {
309315
projectId: Project["id"];
310316
authPermit: AuthPermit;

apps/builder/app/routes/rest.patch.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { applyPatches, enableMapSet, enablePatches } from "immer";
22
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
3-
import type { SyncItem } from "immerhin";
3+
import type { Change } from "immerhin";
44
import {
55
Breakpoints,
66
Breakpoint,
@@ -49,9 +49,10 @@ import type { Database } from "@webstudio-is/postrest/index.server";
4949
import { publicStaticEnv } from "~/env/env.static";
5050
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
5151
import { checkCsrf } from "~/services/csrf-session.server";
52+
import type { Transaction } from "~/shared/sync-client";
5253

5354
type PatchData = {
54-
transactions: Array<SyncItem>;
55+
transactions: Transaction<Change[]>[];
5556
buildId: Build["id"];
5657
projectId: Project["id"];
5758
version: number;
@@ -96,7 +97,7 @@ export const action = async ({
9697
return { status: "error", errors: "Project id required" };
9798
}
9899

99-
const lastTransactionId = transactions.at(-1)?.transactionId;
100+
const lastTransactionId = transactions.at(-1)?.id;
100101

101102
if (lastTransactionId === undefined) {
102103
return {
@@ -175,7 +176,7 @@ export const action = async ({
175176
const patchedStyleDeclKeysSet = new Set<string>();
176177

177178
for await (const transaction of transactions) {
178-
for await (const change of transaction.changes) {
179+
for await (const change of transaction.payload) {
179180
const { namespace, patches } = change;
180181
if (patches.length === 0) {
181182
continue;

apps/builder/app/shared/builder-data.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,6 @@ export const getBuilderData = (): BuilderData => {
4040
};
4141
};
4242

43-
export const setBuilderData = (data: BuilderData) => {
44-
$assets.set(data.assets);
45-
$instances.set(data.instances);
46-
$dataSources.set(data.dataSources);
47-
$resources.set(data.resources);
48-
// props should be after data sources to compute logic
49-
$props.set(data.props);
50-
$pages.set(data.pages);
51-
$styleSources.set(data.styleSources);
52-
$styleSourceSelections.set(data.styleSourceSelections);
53-
$breakpoints.set(data.breakpoints);
54-
$styles.set(data.styles);
55-
$marketplaceProduct.set(data.marketplaceProduct);
56-
};
57-
5843
const getPair = <Item extends { id: string }>(item: Item) =>
5944
[item.id, item] as const;
6045

apps/builder/app/shared/sync-client.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
NanostoresSyncObject,
99
SyncClient,
1010
SyncObjectPool,
11+
type SyncStorage,
1112
} from "./sync-client";
1213

1314
enableMapSet();
@@ -217,6 +218,18 @@ test("support pool of objects", () => {
217218
);
218219
});
219220

221+
test("merge state in pool object to partially restore from storage", () => {
222+
const store1 = atom(0);
223+
const store2 = atom(1);
224+
const objectPool = new SyncObjectPool([
225+
new NanostoresSyncObject("store1", store1),
226+
new NanostoresSyncObject("store2", store2),
227+
]);
228+
objectPool.setState(new Map([["store1", 2]]));
229+
expect(store1.get()).toEqual(2);
230+
expect(store2.get()).toEqual(1);
231+
});
232+
220233
describe("nanostores sync object", () => {
221234
test("sync initial state and exchange transactions", () => {
222235
const emitter = createNanoEvents();
@@ -290,3 +303,79 @@ describe("nanostores sync object", () => {
290303
expect(sendTransaction).toBeCalledTimes(0);
291304
});
292305
});
306+
307+
describe("storages", () => {
308+
class TestStorage implements SyncStorage {
309+
name = "TestStorage";
310+
value: undefined | number;
311+
constructor(value: undefined | number) {
312+
this.value = value;
313+
}
314+
sendTransaction = vi.fn();
315+
subscribe(setState: (state: unknown) => void) {
316+
setState(this.value);
317+
}
318+
}
319+
320+
test("get initial state from storage", () => {
321+
const $store = atom(0);
322+
const client = new SyncClient({
323+
role: "leader",
324+
object: new NanostoresSyncObject("nanostores", $store),
325+
storages: [new TestStorage(1)],
326+
});
327+
client.connect({ signal: new AbortController().signal });
328+
expect($store.get()).toEqual(1);
329+
});
330+
331+
test("fallback to current state when storage is empty", () => {
332+
const $store = atom(0);
333+
const client = new SyncClient({
334+
role: "leader",
335+
object: new NanostoresSyncObject("nanostores", $store),
336+
storages: [new TestStorage(undefined)],
337+
});
338+
client.connect({ signal: new AbortController().signal });
339+
expect($store.get()).toEqual(0);
340+
});
341+
342+
test("get state from the first non-empty storage", () => {
343+
const $store = atom(0);
344+
const client = new SyncClient({
345+
role: "leader",
346+
object: new NanostoresSyncObject("nanostores", $store),
347+
storages: [
348+
new TestStorage(undefined),
349+
new TestStorage(1),
350+
new TestStorage(2),
351+
],
352+
});
353+
client.connect({ signal: new AbortController().signal });
354+
expect($store.get()).toEqual(1);
355+
});
356+
357+
test("send transactions to all provided storages", () => {
358+
const $store = atom(0);
359+
const storage1 = new TestStorage(undefined);
360+
const storage2 = new TestStorage(undefined);
361+
const client = new SyncClient({
362+
role: "leader",
363+
object: new NanostoresSyncObject("nanostores", $store),
364+
storages: [storage1, storage2],
365+
});
366+
client.connect({ signal: new AbortController().signal });
367+
$store.set(1);
368+
expect(storage1.sendTransaction).toBeCalledTimes(1);
369+
expect(storage1.sendTransaction).toHaveBeenCalledWith({
370+
id: expect.any(String),
371+
object: "nanostores",
372+
payload: 1,
373+
});
374+
expect(storage2.sendTransaction).toBeCalledTimes(1);
375+
expect(storage2.sendTransaction).toHaveBeenCalledWith({
376+
id: expect.any(String),
377+
object: "nanostores",
378+
payload: 1,
379+
});
380+
});
381+
});

0 commit comments

Comments
 (0)