This commit is contained in:
Andras Schmelczer 2026-04-27 22:50:01 +01:00
parent cc44b66fcd
commit 1163da826e
45 changed files with 192 additions and 292 deletions

View file

@ -30,6 +30,7 @@ function fakeRemoteVersion(
userId: "user",
deviceId: "device",
contentSize: 100,
isNewFile: true,
...overrides
};
}

View file

@ -446,19 +446,8 @@ export class Syncer {
): Promise<void> {
const documentId = await event.documentId;
const doc = this.queue.getDocumentByDocumentId(documentId);
if (doc === undefined) {
// Already deleted (e.g. a remote delete drained ahead of
// this redundant local one). Nothing to do.
this.logger.debug(
`Skipping local-delete for ${documentId} — doc no longer tracked`
);
return;
}
const response = await this.syncService.delete({
documentId,
relativePath: doc.path
});
// Don't remove the doc from the queue or advance lastSeenUpdateId
@ -471,7 +460,7 @@ export class Syncer {
status: SyncStatus.SUCCESS,
details: {
type: SyncType.DELETE,
relativePath: doc.path
relativePath: event.path
},
message: "Successfully deleted file on the server",
author: response.userId,
@ -499,8 +488,21 @@ export class Syncer {
const contentBytes = await this.operations.read(diskPath);
const contentHash = await hash(contentBytes);
// For a user-driven rename the user's intent is `event.originalPath`
// — that's the rename target. For a content-only edit the user is
// agnostic to the path; sending one would be wrong if a remote
// rename processed first, because the server would interpret the
// user's (now-stale) path as a rename back. So content-only PUTs
// omit the path and the server keeps the doc at its current
// server-known location.
const renameTarget = event.isUserRename
? event.originalPath
: undefined;
const hashChanged = contentHash !== record.remoteHash;
const pathChanged = record.remoteRelativePath !== event.originalPath;
const pathChanged =
renameTarget !== undefined &&
record.remoteRelativePath !== renameTarget;
if (!hashChanged && !pathChanged) {
this.logger.debug(
@ -511,12 +513,16 @@ export class Syncer {
const response = await this.sendUpdate({
record,
relativePath: event.originalPath,
relativePath: renameTarget,
contentBytes
});
if (response.isDeleted) {
await this.processRemoteDelete(diskPath, { ...response, contentSize: 0 });
await this.processRemoteDelete(diskPath, {
...response,
contentSize: 0,
isNewFile: false
});
return;
}
@ -716,6 +722,14 @@ export class Syncer {
);
}
if (!remoteVersion.isNewFile) {
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
this.logger.debug(
`Ignoring stale RemoteChange for untracked, non-new document ${remoteVersion.documentId}`
);
return;
}
return this.processRemoteCreateForNewDocument(remoteVersion);
}
@ -889,13 +903,15 @@ export class Syncer {
contentBytes
}: {
record: DocumentRecord;
relativePath: RelativePath;
// `undefined` for content-only edits; the server keeps the doc's
// current path. A string is sent only on a user-driven rename.
relativePath: RelativePath | undefined;
contentBytes: Uint8Array;
}): Promise<DocumentUpdateResponse> {
const isText =
!isBinary(contentBytes) &&
isFileTypeMergable(
relativePath,
relativePath ?? record.remoteRelativePath,
(await this.serverConfig.getConfig()).mergeableFileExtensions
);

View file

@ -35,35 +35,36 @@ export enum SyncEventType {
export type FileSyncEvent =
| { type: SyncEventType.LocalCreate; path: RelativePath }
| {
type: SyncEventType.LocalUpdate;
path: RelativePath;
oldPath?: RelativePath; // oldPath is undefined for content changes
}
type: SyncEventType.LocalUpdate;
path: RelativePath;
oldPath?: RelativePath; // oldPath is undefined for content changes
}
| { type: SyncEventType.LocalDelete; path: RelativePath }
| {
type: SyncEventType.RemoteChange;
remoteVersion: DocumentVersionWithoutContent;
};
type: SyncEventType.RemoteChange;
remoteVersion: DocumentVersionWithoutContent;
};
export type SyncEvent =
| {
type: SyncEventType.LocalCreate;
path: RelativePath; // current path on disk
originalPath: RelativePath; // original path on disk when the event was queued
resolvers: PromiseWithResolvers<DocumentId>;
}
type: SyncEventType.LocalCreate;
path: RelativePath; // current path on disk
originalPath: RelativePath; // original path on disk when the event was queued
resolvers: PromiseWithResolvers<DocumentId>;
}
| {
type: SyncEventType.LocalUpdate;
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed
path: RelativePath; // current path on disk
originalPath: RelativePath; // original path on disk when the event was queued
// no need to store the old path in case of a rename; the server will figure it out from the parent's path
}
type: SyncEventType.LocalUpdate;
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed
path: RelativePath; // current path on disk
originalPath: RelativePath; // original path on disk when the event was queued
isUserRename: boolean; // true iff this event was queued because the user renamed the file
}
| {
type: SyncEventType.LocalDelete;
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed
}
type: SyncEventType.LocalDelete;
documentId: DocumentId | Promise<DocumentId>; // if it's a promise, the promise is fulfilled once the document's create event is processed
path: RelativePath; // only used for showing on the UI
}
| {
type: SyncEventType.RemoteChange;
remoteVersion: DocumentVersionWithoutContent;
};
type: SyncEventType.RemoteChange;
remoteVersion: DocumentVersionWithoutContent;
};