mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Server snapshot WIP
This commit is contained in:
parent
49925038fd
commit
7b72406824
10 changed files with 97 additions and 45 deletions
|
@ -140,7 +140,6 @@ import DebugCanvas, {
|
||||||
import { AIComponents } from "./components/AI";
|
import { AIComponents } from "./components/AI";
|
||||||
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||||
import { isElementLink } from "../packages/excalidraw/element/elementLink";
|
import { isElementLink } from "../packages/excalidraw/element/elementLink";
|
||||||
import type { ElementsChange } from "../packages/excalidraw/change";
|
|
||||||
|
|
||||||
import Slider from "rc-slider";
|
import Slider from "rc-slider";
|
||||||
import "rc-slider/assets/index.css";
|
import "rc-slider/assets/index.css";
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { Network } from "../sync/utils";
|
||||||
// CFDO II: add senderId, possibly roomId as well
|
// CFDO II: add senderId, possibly roomId as well
|
||||||
export class DurableDeltasRepository implements DeltasRepository {
|
export class DurableDeltasRepository implements DeltasRepository {
|
||||||
// there is a 2MB row limit, hence working with max payload size of 1.5 MB
|
// there is a 2MB row limit, hence working with max payload size of 1.5 MB
|
||||||
// and leaving a buffer for other row metadata
|
// and leaving a ~500kB buffer for other row metadata
|
||||||
private static readonly MAX_PAYLOAD_SIZE = 1_500_000;
|
private static readonly MAX_PAYLOAD_SIZE = 1_500_000;
|
||||||
|
|
||||||
constructor(private storage: DurableObjectStorage) {
|
constructor(private storage: DurableObjectStorage) {
|
||||||
|
|
|
@ -2,8 +2,6 @@ import { DurableObject } from "cloudflare:workers";
|
||||||
import { DurableDeltasRepository } from "./repository";
|
import { DurableDeltasRepository } from "./repository";
|
||||||
import { ExcalidrawSyncServer } from "../sync/server";
|
import { ExcalidrawSyncServer } from "../sync/server";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Durable Object impl. of Excalidraw room.
|
* Durable Object impl. of Excalidraw room.
|
||||||
*/
|
*/
|
||||||
|
@ -11,26 +9,10 @@ export class DurableRoom extends DurableObject {
|
||||||
private roomId: string | null = null;
|
private roomId: string | null = null;
|
||||||
private sync: ExcalidrawSyncServer;
|
private sync: ExcalidrawSyncServer;
|
||||||
|
|
||||||
private snapshot!: {
|
|
||||||
appState: Record<string, any>;
|
|
||||||
elements: Map<string, ExcalidrawElement>;
|
|
||||||
version: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(ctx: DurableObjectState, env: Env) {
|
constructor(ctx: DurableObjectState, env: Env) {
|
||||||
super(ctx, env);
|
super(ctx, env);
|
||||||
|
|
||||||
this.ctx.blockConcurrencyWhile(async () => {
|
this.ctx.blockConcurrencyWhile(async () => {
|
||||||
// CFDO I: snapshot should likely be a transient store
|
|
||||||
// CFDO II: loaded the latest state from the db
|
|
||||||
this.snapshot = {
|
|
||||||
// CFDO: start persisting acknowledged version (not a scene version!)
|
|
||||||
// CFDO: we don't persist appState, should we?
|
|
||||||
appState: {},
|
|
||||||
elements: new Map(),
|
|
||||||
version: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.roomId = (await this.ctx.storage.get("roomId")) || null;
|
this.roomId = (await this.ctx.storage.get("roomId")) || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -5367,7 +5367,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
if (midPoint && midPoint > -1) {
|
if (midPoint && midPoint > -1) {
|
||||||
this.store.shouldCaptureIncrement();
|
this.store.scheduleCapture();
|
||||||
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
|
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
|
||||||
|
|
||||||
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
|
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||||
|
|
|
@ -32,7 +32,7 @@ import type {
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||||
import { getNonDeletedGroupIds } from "./groups";
|
import { getNonDeletedGroupIds } from "./groups";
|
||||||
import { getObservedAppState } from "./store";
|
import { getObservedAppState, StoreSnapshot } from "./store";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
ObservedAppState,
|
ObservedAppState,
|
||||||
|
@ -1036,7 +1036,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||||
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
||||||
* @returns new instance with modified delta/s
|
* @returns new instance with modified delta/s
|
||||||
*/
|
*/
|
||||||
public applyLatestChanges(elements: SceneElementsMap): ElementsDelta {
|
public applyLatestChanges(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
modifierOptions: "deleted" | "inserted",
|
||||||
|
): ElementsDelta {
|
||||||
const modifier =
|
const modifier =
|
||||||
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
||||||
const latestPartial: { [key: string]: unknown } = {};
|
const latestPartial: { [key: string]: unknown } = {};
|
||||||
|
@ -1069,7 +1072,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||||
delta.deleted,
|
delta.deleted,
|
||||||
delta.inserted,
|
delta.inserted,
|
||||||
modifier(existingElement),
|
modifier(existingElement),
|
||||||
"inserted",
|
modifierOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
modifiedDeltas[id] = modifiedDelta;
|
modifiedDeltas[id] = modifiedDelta;
|
||||||
|
@ -1092,7 +1095,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||||
|
|
||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
elementsSnapshot: Map<
|
||||||
|
string,
|
||||||
|
OrderedExcalidrawElement
|
||||||
|
> = StoreSnapshot.empty().elements,
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
|
@ -1106,7 +1112,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsDelta.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
elementsSnapshot,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -159,7 +159,13 @@ export class History {
|
||||||
entry: HistoryEntry,
|
entry: HistoryEntry,
|
||||||
prevElements: SceneElementsMap,
|
prevElements: SceneElementsMap,
|
||||||
) {
|
) {
|
||||||
const updatedEntry = HistoryEntry.applyLatestChanges(entry, prevElements);
|
const inversedEntry = HistoryEntry.inverse(entry);
|
||||||
|
const updatedEntry = HistoryEntry.applyLatestChanges(
|
||||||
|
inversedEntry,
|
||||||
|
prevElements,
|
||||||
|
"inserted",
|
||||||
|
);
|
||||||
|
|
||||||
return stack.push(updatedEntry);
|
return stack.push(updatedEntry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -474,14 +474,13 @@ export class StoreDelta {
|
||||||
public static applyLatestChanges(
|
public static applyLatestChanges(
|
||||||
delta: StoreDelta,
|
delta: StoreDelta,
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
|
modifierOptions: "deleted" | "inserted",
|
||||||
): StoreDelta {
|
): StoreDelta {
|
||||||
const inversedDelta = this.inverse(delta);
|
|
||||||
|
|
||||||
return this.create(
|
return this.create(
|
||||||
inversedDelta.elements.applyLatestChanges(elements),
|
delta.elements.applyLatestChanges(elements, modifierOptions),
|
||||||
inversedDelta.appState,
|
delta.appState,
|
||||||
{
|
{
|
||||||
id: inversedDelta.id,
|
id: delta.id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { DTO } from "../utility-types";
|
||||||
export type CLIENT_DELTA = DTO<StoreDelta>;
|
export type CLIENT_DELTA = DTO<StoreDelta>;
|
||||||
export type CLIENT_CHANGE = DTO<StoreChange>;
|
export type CLIENT_CHANGE = DTO<StoreChange>;
|
||||||
|
|
||||||
|
export type RESTORE_PAYLOAD = {};
|
||||||
export type RELAY_PAYLOAD = CLIENT_CHANGE;
|
export type RELAY_PAYLOAD = CLIENT_CHANGE;
|
||||||
export type PUSH_PAYLOAD = CLIENT_DELTA;
|
export type PUSH_PAYLOAD = CLIENT_DELTA;
|
||||||
export type PULL_PAYLOAD = { lastAcknowledgedVersion: number };
|
export type PULL_PAYLOAD = { lastAcknowledgedVersion: number };
|
||||||
|
@ -15,6 +16,7 @@ export type CHUNK_INFO = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CLIENT_MESSAGE = (
|
export type CLIENT_MESSAGE = (
|
||||||
|
| { type: "restore"; payload: RESTORE_PAYLOAD }
|
||||||
| { type: "relay"; payload: RELAY_PAYLOAD }
|
| { type: "relay"; payload: RELAY_PAYLOAD }
|
||||||
| { type: "pull"; payload: PULL_PAYLOAD }
|
| { type: "pull"; payload: PULL_PAYLOAD }
|
||||||
| { type: "push"; payload: PUSH_PAYLOAD }
|
| { type: "push"; payload: PUSH_PAYLOAD }
|
||||||
|
@ -48,7 +50,8 @@ export type SERVER_MESSAGE =
|
||||||
| {
|
| {
|
||||||
type: "rejected";
|
type: "rejected";
|
||||||
payload: { deltas: Array<CLIENT_DELTA>; message: string };
|
payload: { deltas: Array<CLIENT_DELTA>; message: string };
|
||||||
};
|
}
|
||||||
|
| { type: "restored"; payload: { elements: Array<ExcalidrawElement> } };
|
||||||
|
|
||||||
export interface DeltasRepository {
|
export interface DeltasRepository {
|
||||||
save(delta: CLIENT_DELTA): SERVER_DELTA | null;
|
save(delta: CLIENT_DELTA): SERVER_DELTA | null;
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { Network, Utils } from "./utils";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DeltasRepository,
|
DeltasRepository,
|
||||||
CLIENT_MESSAGE,
|
|
||||||
PULL_PAYLOAD,
|
PULL_PAYLOAD,
|
||||||
PUSH_PAYLOAD,
|
PUSH_PAYLOAD,
|
||||||
SERVER_MESSAGE,
|
SERVER_MESSAGE,
|
||||||
|
@ -11,7 +10,10 @@ import type {
|
||||||
CHUNK_INFO,
|
CHUNK_INFO,
|
||||||
RELAY_PAYLOAD,
|
RELAY_PAYLOAD,
|
||||||
CLIENT_MESSAGE_BINARY,
|
CLIENT_MESSAGE_BINARY,
|
||||||
|
CLIENT_MESSAGE,
|
||||||
|
ExcalidrawElement,
|
||||||
} from "./protocol";
|
} from "./protocol";
|
||||||
|
import { StoreDelta } from "../store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core excalidraw sync logic.
|
* Core excalidraw sync logic.
|
||||||
|
@ -24,7 +26,22 @@ export class ExcalidrawSyncServer {
|
||||||
Map<CHUNK_INFO["position"], CLIENT_MESSAGE_BINARY["payload"]>
|
Map<CHUNK_INFO["position"], CLIENT_MESSAGE_BINARY["payload"]>
|
||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(private readonly repository: DeltasRepository) {}
|
// CFDO II: load from the db
|
||||||
|
private elements = new Map<string, ExcalidrawElement>();
|
||||||
|
|
||||||
|
constructor(private readonly repository: DeltasRepository) {
|
||||||
|
// CFDO II: load from the db
|
||||||
|
const deltas = this.repository.getAllSinceVersion(0);
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
const storeDelta = StoreDelta.load(delta.payload);
|
||||||
|
|
||||||
|
// CFDO II: fix types (everywhere)
|
||||||
|
const [nextElements] = storeDelta.elements.applyTo(this.elements as any);
|
||||||
|
|
||||||
|
this.elements = nextElements;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CFDO: optimize, should send a message about collaborators (no collaborators => no need to send ephemerals)
|
// CFDO: optimize, should send a message about collaborators (no collaborators => no need to send ephemerals)
|
||||||
public onConnect(client: WebSocket) {
|
public onConnect(client: WebSocket) {
|
||||||
|
@ -48,11 +65,12 @@ export class ExcalidrawSyncServer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, payload, chunkInfo } = rawMessage;
|
|
||||||
|
|
||||||
// if there is chunkInfo, there are more than 1 chunks => process them first
|
// if there is chunkInfo, there are more than 1 chunks => process them first
|
||||||
if (chunkInfo) {
|
if (rawMessage.chunkInfo) {
|
||||||
return this.processChunks(client, { type, payload, chunkInfo });
|
return this.processChunks(client, {
|
||||||
|
...rawMessage,
|
||||||
|
chunkInfo: rawMessage.chunkInfo,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.processMessage(client, rawMessage);
|
return this.processMessage(client, rawMessage);
|
||||||
|
@ -132,6 +150,8 @@ export class ExcalidrawSyncServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case "restore":
|
||||||
|
return this.restore(client);
|
||||||
case "relay":
|
case "relay":
|
||||||
return this.relay(client, parsedPayload as RELAY_PAYLOAD);
|
return this.relay(client, parsedPayload as RELAY_PAYLOAD);
|
||||||
case "pull":
|
case "pull":
|
||||||
|
@ -147,6 +167,15 @@ export class ExcalidrawSyncServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private restore(client: WebSocket) {
|
||||||
|
return this.send(client, {
|
||||||
|
type: "restored",
|
||||||
|
payload: {
|
||||||
|
elements: Array.from(this.elements.values()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private relay(client: WebSocket, payload: RELAY_PAYLOAD) {
|
private relay(client: WebSocket, payload: RELAY_PAYLOAD) {
|
||||||
// CFDO I: we should likely apply these to the snapshot
|
// CFDO I: we should likely apply these to the snapshot
|
||||||
return this.broadcast(
|
return this.broadcast(
|
||||||
|
@ -191,10 +220,38 @@ export class ExcalidrawSyncServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private push(client: WebSocket, delta: PUSH_PAYLOAD) {
|
private push(client: WebSocket, delta: PUSH_PAYLOAD) {
|
||||||
// CFDO I: apply latest changes to delt & apply the deltas to the snapshot
|
const [storeDelta, applyingError] = Utils.try(() => {
|
||||||
const [acknowledged, savingError] = Utils.try(() =>
|
// update the "deleted" delta according to the latest changes (in case of concurrent changes)
|
||||||
this.repository.save(delta),
|
const storeDelta = StoreDelta.applyLatestChanges(
|
||||||
);
|
StoreDelta.load(delta),
|
||||||
|
this.elements as any,
|
||||||
|
"deleted",
|
||||||
|
);
|
||||||
|
|
||||||
|
// apply the delta to the elements snapshot
|
||||||
|
const [nextElements] = storeDelta.elements.applyTo(this.elements as any);
|
||||||
|
|
||||||
|
this.elements = nextElements;
|
||||||
|
|
||||||
|
return storeDelta;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (applyingError) {
|
||||||
|
// CFDO: everything should be automatically rolled-back in the db -> double-check
|
||||||
|
return this.send(client, {
|
||||||
|
type: "rejected",
|
||||||
|
payload: {
|
||||||
|
message: applyingError
|
||||||
|
? applyingError.message
|
||||||
|
: "Couldn't apply the delta.",
|
||||||
|
deltas: [delta],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [acknowledged, savingError] = Utils.try(() => {
|
||||||
|
return this.repository.save(storeDelta);
|
||||||
|
});
|
||||||
|
|
||||||
if (savingError || !acknowledged) {
|
if (savingError || !acknowledged) {
|
||||||
// CFDO: everything should be automatically rolled-back in the db -> double-check
|
// CFDO: everything should be automatically rolled-back in the db -> double-check
|
||||||
|
@ -204,7 +261,7 @@ export class ExcalidrawSyncServer {
|
||||||
message: savingError
|
message: savingError
|
||||||
? savingError.message
|
? savingError.message
|
||||||
: "Coudn't persist the delta.",
|
: "Coudn't persist the delta.",
|
||||||
deltas: [delta],
|
deltas: [storeDelta],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -7986,7 +7986,7 @@ lodash.camelcase@^4.3.0:
|
||||||
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
|
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
|
||||||
integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
|
integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
|
||||||
|
|
||||||
lodash.debounce@^4.0.8:
|
lodash.debounce@4.0.8, lodash.debounce@^4.0.8:
|
||||||
version "4.0.8"
|
version "4.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||||
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
|
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue