no remote path chacnge
This commit is contained in:
parent
19d5dc1999
commit
17a1f4d060
16 changed files with 93 additions and 314 deletions
|
|
@ -1,6 +1,5 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
||||
import type { WebSocketVaultPathChange } from "./WebSocketVaultPathChange";
|
||||
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
||||
|
||||
export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "pathChange" } & WebSocketVaultPathChange | { "type": "cursorPositions" } & CursorPositionFromServer;
|
||||
export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer;
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface WebSocketVaultPathChange { vaultUpdateId: number, documentId: string, relativePath: string, }
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
export interface WebSocketVaultUpdate { documents: DocumentVersionWithoutContent[], isInitialSync: boolean, }
|
||||
export interface WebSocketVaultUpdate { document: DocumentVersionWithoutContent, }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type { WebSocketClientMessage } from "./types/WebSocketClientMessage";
|
|||
import type { CursorPositionFromClient } from "./types/CursorPositionFromClient";
|
||||
import type { ClientCursors } from "./types/ClientCursors";
|
||||
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
|
||||
import type { WebSocketVaultPathChange } from "./types/WebSocketVaultPathChange";
|
||||
import {
|
||||
WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS,
|
||||
WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS
|
||||
|
|
@ -23,10 +22,6 @@ export class WebSocketManager {
|
|||
(update: WebSocketVaultUpdate) => Promise<void>
|
||||
>();
|
||||
|
||||
public readonly onRemotePathChangeReceived = new EventListeners<
|
||||
(pathChange: WebSocketVaultPathChange) => Promise<void>
|
||||
>();
|
||||
|
||||
public readonly onRemoteCursorsUpdateReceived = new EventListeners<
|
||||
(cursors: ClientCursors[]) => Promise<void>
|
||||
>();
|
||||
|
|
@ -295,12 +290,6 @@ export class WebSocketManager {
|
|||
case "vaultUpdate":
|
||||
await this.onRemoteVaultUpdateReceived.triggerAsync(message);
|
||||
return;
|
||||
case "pathChange":
|
||||
this.logger.debug(
|
||||
`Received path change for document ${message.documentId} → ${message.relativePath}`
|
||||
);
|
||||
await this.onRemotePathChangeReceived.triggerAsync(message);
|
||||
return;
|
||||
case "cursorPositions":
|
||||
this.logger.debug(
|
||||
`Received cursor positions for ${JSON.stringify(message.clients)}`
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export class SyncEventQueue {
|
|||
private savePending = false;
|
||||
|
||||
|
||||
private lastSeenUpdateId: VaultUpdateId;
|
||||
private readonly lastSeenUpdateId: VaultUpdateId;
|
||||
|
||||
public constructor(
|
||||
private readonly settings: Settings,
|
||||
|
|
@ -250,9 +250,7 @@ export class SyncEventQueue {
|
|||
e.documentId === docId) ||
|
||||
(e.type === SyncEventType.RemoteUpdate &&
|
||||
// we care about the local path not the remote
|
||||
this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path) ||
|
||||
(e.type === SyncEventType.RemotePathChange &&
|
||||
this.getDocumentByDocumentId(e.pathChange.documentId)?.path === path)
|
||||
this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -280,10 +278,7 @@ export class SyncEventQueue {
|
|||
}
|
||||
|
||||
public enqueue(input: FileSyncEvent): void {
|
||||
if (
|
||||
input.type === SyncEventType.RemoteUpdate ||
|
||||
input.type === SyncEventType.RemotePathChange
|
||||
) {
|
||||
if (input.type === SyncEventType.RemoteUpdate) {
|
||||
this.events.push(input);
|
||||
return;
|
||||
}
|
||||
|
|
@ -414,31 +409,15 @@ export class SyncEventQueue {
|
|||
return result;
|
||||
}
|
||||
|
||||
// Coalesce multiple events of the same remote kind for the same
|
||||
// documentId to the last one. Kinds are coalesced independently so
|
||||
// that an interleaved content+path stream (e.g. VaultUpdate →
|
||||
// PathChange) still preserves the VaultUpdate-before-PathChange
|
||||
// ordering invariant the syncer relies on.
|
||||
if (first.type === SyncEventType.RemoteUpdate) {
|
||||
const { documentId } = first.remoteVersion;
|
||||
const matching = this.events.filter(
|
||||
(e) =>
|
||||
e.type === SyncEventType.RemoteUpdate &&
|
||||
e.remoteVersion.documentId === documentId
|
||||
);
|
||||
const result = matching[matching.length - 1];
|
||||
for (const item of matching) {
|
||||
removeFromArray(this.events, item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// SyncRemotePath
|
||||
const { documentId } = first.pathChange;
|
||||
// Coalesce multiple RemoteUpdate events for the same documentId
|
||||
// down to the last one — the `.next` walk already short-circuits
|
||||
// on obsolete versions via `parentVersionId` checks, but compacting
|
||||
// here keeps the queue bounded under burst remote activity.
|
||||
const { documentId } = first.remoteVersion;
|
||||
const matching = this.events.filter(
|
||||
(e) =>
|
||||
e.type === SyncEventType.RemotePathChange &&
|
||||
e.pathChange.documentId === documentId
|
||||
e.type === SyncEventType.RemoteUpdate &&
|
||||
e.remoteVersion.documentId === documentId
|
||||
);
|
||||
const result = matching[matching.length - 1];
|
||||
for (const item of matching) {
|
||||
|
|
@ -463,8 +442,6 @@ export class SyncEventQueue {
|
|||
e.documentId === documentId) ||
|
||||
(e.type === SyncEventType.RemoteUpdate &&
|
||||
e.remoteVersion.documentId === documentId) ||
|
||||
(e.type === SyncEventType.RemotePathChange &&
|
||||
e.pathChange.documentId === documentId) ||
|
||||
(e.type === SyncEventType.LocalDelete &&
|
||||
e.documentId === documentId)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { scheduleOfflineChanges } from "./offline-change-detector";
|
|||
import { SyncResetError } from "../errors/sync-reset-error";
|
||||
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
|
||||
import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate";
|
||||
import type { WebSocketVaultPathChange } from "../services/types/WebSocketVaultPathChange";
|
||||
import type { WebSocketManager } from "../services/websocket-manager";
|
||||
import type { WebSocketClientMessage } from "../services/types/WebSocketClientMessage";
|
||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||
|
|
@ -67,14 +66,19 @@ export class Syncer {
|
|||
this.webSocketManager.onWebSocketStatusChanged.add((isConnected) => {
|
||||
if (isConnected) {
|
||||
this.sendHandshakeMessage();
|
||||
// The server no longer carries an `is_initial_sync`
|
||||
// terminator: it streams missed versions as individual
|
||||
// VaultUpdates and then behaves like a live subscription.
|
||||
// Mark first-sync as complete once we've observed the
|
||||
// transition to "connected" — per-path sync status still
|
||||
// relies on `hasPendingEventsForPath`, which correctly
|
||||
// shows SYNCING while catch-up events are in flight.
|
||||
this._isFirstSyncComplete = true;
|
||||
}
|
||||
});
|
||||
this.webSocketManager.onRemoteVaultUpdateReceived.add(
|
||||
this.syncRemotelyUpdatedFile.bind(this)
|
||||
);
|
||||
this.webSocketManager.onRemotePathChangeReceived.add(
|
||||
this.syncRemotelyChangedPath.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
public get isFirstSyncComplete(): boolean {
|
||||
|
|
@ -106,63 +110,22 @@ export class Syncer {
|
|||
}
|
||||
|
||||
|
||||
// Handler for every `WebSocketVaultUpdate` the server emits. The
|
||||
// server filters out messages authored by this device, so every
|
||||
// update here comes from a peer (or is part of the catch-up stream
|
||||
// the server replays on connect for versions we missed while
|
||||
// offline).
|
||||
public async syncRemotelyUpdatedFile(
|
||||
message: WebSocketVaultUpdate
|
||||
): Promise<void> {
|
||||
await this.scheduleSyncForOfflineChanges();
|
||||
|
||||
for (const remoteVersion of message.documents) {
|
||||
this.queue.enqueue({
|
||||
type: SyncEventType.RemoteUpdate,
|
||||
remoteVersion
|
||||
});
|
||||
}
|
||||
|
||||
if (message.isInitialSync) {
|
||||
this._isFirstSyncComplete = true;
|
||||
}
|
||||
this.queue.enqueue({
|
||||
type: SyncEventType.RemoteUpdate,
|
||||
remoteVersion: message.document
|
||||
});
|
||||
|
||||
this.ensureDraining();
|
||||
|
||||
}
|
||||
|
||||
// A PathChange notifies us that a document now lives at a new server-
|
||||
// canonical path. It's delivered to every client (origin included)
|
||||
// because the create/update HTTP response no longer carries the path,
|
||||
// so the only way the origin learns about dedupe or first-rename-wins
|
||||
// is via this event.
|
||||
//
|
||||
// Algorithmic assumptions:
|
||||
// (1) Per-vault broadcast ordering is preserved by the server, so if
|
||||
// the same write produced a `VaultUpdate` (content change) and a
|
||||
// `PathChange` (path change), the `VaultUpdate` is handled first
|
||||
// — that's what lets us skip advancing `parentVersionId` here
|
||||
// without risking a stuck "already up-to-date" check later.
|
||||
// (2) On a lag-induced disconnect (`broadcast::error::Lagged`) the
|
||||
// server disconnects the client for a full resync, so out-of-
|
||||
// order delivery across a reconnect boundary can't leave us with
|
||||
// a stale PathChange overwriting a newer one.
|
||||
public async syncRemotelyChangedPath(
|
||||
pathChange: WebSocketVaultPathChange
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.scheduleSyncForOfflineChanges();
|
||||
|
||||
this.queue.enqueue({
|
||||
type: SyncEventType.RemotePathChange,
|
||||
pathChange
|
||||
});
|
||||
|
||||
await this.scheduleDrain();
|
||||
} catch (e) {
|
||||
if (e instanceof SyncResetError) {
|
||||
this.logger.info(
|
||||
"Failed to apply remote path change due to a reset"
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.error(`Failed to apply remote path change: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async scheduleSyncForOfflineChanges(): Promise<void> {
|
||||
|
|
@ -332,9 +295,6 @@ export class Syncer {
|
|||
case SyncEventType.RemoteUpdate:
|
||||
await this.processSyncRemoteContent(event);
|
||||
break;
|
||||
case SyncEventType.RemotePathChange:
|
||||
await this.processSyncRemotePath(event);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof FileNotFoundError) {
|
||||
|
|
@ -594,51 +554,6 @@ export class Syncer {
|
|||
await this.processRemoteUpdateForNewDocument(remoteVersion);
|
||||
}
|
||||
|
||||
private async processSyncRemotePath(
|
||||
event: Extract<SyncEvent, { type: SyncEventType.RemotePathChange }>
|
||||
): Promise<void> {
|
||||
const { pathChange } = event;
|
||||
const existing = this.queue.getDocumentByDocumentId(
|
||||
pathChange.documentId
|
||||
);
|
||||
if (existing === undefined) {
|
||||
throw new Error(
|
||||
`Received path change for unknown document ${pathChange.documentId}`
|
||||
);
|
||||
}
|
||||
|
||||
const { path: currentPath, record } = existing;
|
||||
const newPath = pathChange.relativePath;
|
||||
|
||||
if (currentPath !== newPath) {
|
||||
await this.operations.move(currentPath, newPath);
|
||||
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: {
|
||||
type: SyncType.MOVE,
|
||||
relativePath: newPath,
|
||||
movedFrom: currentPath
|
||||
},
|
||||
message: "Applied remote path change",
|
||||
author: pathChange.userId,
|
||||
timestamp: new Date(pathChange.updatedDate)
|
||||
});
|
||||
}
|
||||
|
||||
// `operations.move` updates the queue's path index, but doesn't
|
||||
// touch `remoteRelativePath`. Refresh it so offline change
|
||||
// detection compares against the server's path. parentVersionId
|
||||
// intentionally stays at its prior value: if the write also
|
||||
// changed content, the corresponding VaultUpdate handles that;
|
||||
// advancing it here would make us skip fetching content we don't
|
||||
// yet have.
|
||||
this.queue.setDocument(newPath, {
|
||||
...record,
|
||||
remoteRelativePath: newPath
|
||||
});
|
||||
}
|
||||
|
||||
private async processRemoteUpdateForExistingDocument(
|
||||
currentPath: RelativePath,
|
||||
record: DocumentRecord,
|
||||
|
|
@ -734,14 +649,14 @@ export class Syncer {
|
|||
|
||||
// Path reconciliation fallback for the reconnect case.
|
||||
//
|
||||
// In steady-state streaming, server-initiated renames arrive as
|
||||
// dedicated `PathChange` WebSocket events and are handled by
|
||||
// `syncRemotelyChangedPath`. But the reconnect catch-up path
|
||||
// (`get_unseen_documents` → `VaultUpdate(is_initial_sync=…)`)
|
||||
// replays *versions* from the DB — `PathChange` is emission-
|
||||
// only and not replayed. Without this branch, a pure rename
|
||||
// that happened while we were disconnected would leave our
|
||||
// local file stuck at its old path forever.
|
||||
// In steady-state streaming, server-initiated renames arrive
|
||||
// as `VaultUpdate` events with `originatesFromSelf=true` for
|
||||
// the author and drive `processSyncRemotePath`. The reconnect
|
||||
// catch-up (`get_unseen_documents` → `is_initial_sync=true`)
|
||||
// replays versions authored by any device with
|
||||
// `originatesFromSelf=false`, so those take the full remote-
|
||||
// sync branch and we need this in-branch path reconciliation
|
||||
// to avoid leaving the local file stuck at its old path.
|
||||
//
|
||||
// Only apply the server's path when the record's
|
||||
// `remoteRelativePath` still matches `currentPath` — that means
|
||||
|
|
@ -1107,8 +1022,8 @@ export class Syncer {
|
|||
}
|
||||
}
|
||||
// Only delete on disk if the record at `path` is still the one
|
||||
// we expected — if a PathChange moved another doc here, we
|
||||
// shouldn't delete its file.
|
||||
// we expected — if a self-origin path-change moved another doc
|
||||
// here, we shouldn't delete its file.
|
||||
const finalRecord = this.queue.getSettledDocumentByPath(path);
|
||||
if (
|
||||
finalRecord === undefined ||
|
||||
|
|
@ -1121,9 +1036,10 @@ export class Syncer {
|
|||
}
|
||||
|
||||
// The response carries content only — path reconciliation is the
|
||||
// sole responsibility of the `PathChange` WebSocket event, which
|
||||
// fires independently for renames/dedupes. We therefore always
|
||||
// record the current local `path` here; an in-flight `PathChange`
|
||||
// sole responsibility of the self-origin `VaultUpdate` echo (the
|
||||
// `originatesFromSelf=true` branch of `syncRemoteVaultUpdate`),
|
||||
// which fires independently for renames/dedupes. We therefore
|
||||
// always record the current local `path` here; an in-flight echo
|
||||
// will move the file and fix `remoteRelativePath` if the server
|
||||
// placed the document somewhere else.
|
||||
const existingRecord = this.queue.getSettledDocumentByPath(path);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
|
||||
import type { WebSocketVaultPathChange } from "../services/types/WebSocketVaultPathChange";
|
||||
|
||||
export type VaultUpdateId = number;
|
||||
export type DocumentId = string;
|
||||
|
|
@ -25,16 +24,14 @@ export enum SyncEventType {
|
|||
LocalCreate = "local-create",
|
||||
LocalUpdate = "local-update", // includes both content and path changes
|
||||
LocalDelete = "local-delete",
|
||||
RemoteUpdate = "remote-update",
|
||||
RemotePathChange = "remote-path-change",
|
||||
RemoteUpdate = "remote-update", // includes every type of update coming from the server
|
||||
}
|
||||
|
||||
export type FileSyncEvent =
|
||||
| { type: SyncEventType.LocalCreate; path: RelativePath }
|
||||
| { type: SyncEventType.LocalUpdate; path: RelativePath; oldPath?: RelativePath }
|
||||
| { type: SyncEventType.LocalDelete; path: RelativePath }
|
||||
| { type: SyncEventType.RemoteUpdate; remoteVersion: DocumentVersionWithoutContent }
|
||||
| { type: SyncEventType.RemotePathChange; pathChange: WebSocketVaultPathChange };
|
||||
| { type: SyncEventType.RemoteUpdate; remoteVersion: DocumentVersionWithoutContent };
|
||||
|
||||
export type SyncEvent =
|
||||
| {
|
||||
|
|
@ -57,8 +54,4 @@ export type SyncEvent =
|
|||
| {
|
||||
type: SyncEventType.RemoteUpdate;
|
||||
remoteVersion: DocumentVersionWithoutContent;
|
||||
}
|
||||
| {
|
||||
type: SyncEventType.RemotePathChange;
|
||||
pathChange: WebSocketVaultPathChange;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue