More fixes

This commit is contained in:
Andras Schmelczer 2026-04-26 13:59:44 +01:00
commit 039affff09
10 changed files with 91 additions and 135 deletions

View file

@ -29,8 +29,7 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md") s
.assertFileNotExists("B.md")
.assertFileCount(0); .assertFileCount(0);
} }
} }

View file

@ -9,8 +9,7 @@ export const renameCreateConflictTest: TestDefinition = {
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "A.md", content: "hi" }, { type: "create", client: 0, path: "A.md", content: "hi" },
{ type: "sync", client: 0 }, { type: "barrier" },
{ type: "sync", client: 1 },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
@ -26,7 +25,7 @@ export const renameCreateConflictTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md").assertContent("B.md", "hi"); s.assertFileCount(2).assertContent("B.md", "hi").assertContent("B (1).md", "hi");
} }
} }
] ]

View file

@ -31,15 +31,8 @@ export const simultaneousCreateDeleteSamePathTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.ifFileExists("A.md", (inner) =>
inner
.assertFileCount(1)
.assertContent("A.md", "modified by 1 while offline")
);
if (!s.files.has("A.md")) {
s.assertFileCount(0); s.assertFileCount(0);
} }
} }
}
] ]
}; };

View file

@ -373,17 +373,6 @@ export class SyncClient {
this.syncer.syncLocallyCreatedFile(relativePath); this.syncer.syncLocallyCreatedFile(relativePath);
} }
public syncLocallyDeletedFile(relativePath: RelativePath): void {
this.checkIfDestroyed("syncLocallyDeletedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
if (this.expectedFsEvents.matchDelete(relativePath)) {
return;
}
this.syncer.syncLocallyDeletedFile(relativePath);
}
public syncLocallyUpdatedFile({ public syncLocallyUpdatedFile({
oldPath, oldPath,
relativePath relativePath
@ -404,6 +393,19 @@ export class SyncClient {
}); });
} }
public syncLocallyDeletedFile(relativePath: RelativePath): void {
this.checkIfDestroyed("syncLocallyDeletedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
if (this.expectedFsEvents.matchDelete(relativePath)) {
return;
}
this.syncer.syncLocallyDeletedFile(relativePath);
}
public getDocumentSyncingStatus( public getDocumentSyncingStatus(
relativePath: RelativePath relativePath: RelativePath
): DocumentSyncStatus { ): DocumentSyncStatus {

View file

@ -17,7 +17,7 @@ function truncateFileNameToByteLimit(
maxBytes: number maxBytes: number
): string { ): string {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
if (encoder.encode(fileName).byteLength <= maxBytes) {return fileName;} if (encoder.encode(fileName).byteLength <= maxBytes) { return fileName; }
const dotIndex = fileName.lastIndexOf("."); const dotIndex = fileName.lastIndexOf(".");
// Dotfile (starts with "." and nothing else) → no extension to preserve. // Dotfile (starts with "." and nothing else) → no extension to preserve.
@ -35,7 +35,7 @@ function truncateFileNameToByteLimit(
let usedBytes = 0; let usedBytes = 0;
for (const { segment } of segmenter.segment(stem)) { for (const { segment } of segmenter.segment(stem)) {
const segmentBytes = encoder.encode(segment).byteLength; const segmentBytes = encoder.encode(segment).byteLength;
if (usedBytes + segmentBytes > stemBudget) {break;} if (usedBytes + segmentBytes > stemBudget) { break; }
truncatedStem += segment; truncatedStem += segment;
usedBytes += segmentBytes; usedBytes += segmentBytes;
} }

View file

@ -38,14 +38,6 @@ export class SyncEventQueue {
// It maps pending changes onto the local filesystem. // It maps pending changes onto the local filesystem.
private readonly events: SyncEvent[] = []; private readonly events: SyncEvent[] = [];
// Tombstones: documents we deleted along with the vaultUpdateId at
// which the delete committed. After we delete, the server may still
// send us older broadcasts for that document (e.g. a backlog update
// committed before the delete from another client). Without these
// entries, the syncer would resurrect the doc by treating an old
// update as a brand-new create.
private readonly deletedDocuments = new Map<DocumentId, VaultUpdateId>();
// file creations for paths matching any of these patterns are ignored // file creations for paths matching any of these patterns are ignored
// because the user explicitly told us to ignore them. // because the user explicitly told us to ignore them.
private userIgnorePatterns: RegExp[]; private userIgnorePatterns: RegExp[];
@ -302,42 +294,6 @@ export class SyncEventQueue {
return this.save(); return this.save();
} }
/**
* Mark a document as deleted at a given vault-update version. Used by
* the syncer after a successful local or remote delete so future
* obsolete broadcasts for that doc (older parents that arrive late)
* don't resurrect it as a brand-new create.
*/
public recordDeletion(
documentId: DocumentId,
deletedAtVaultUpdateId: VaultUpdateId
): void {
const existing = this.deletedDocuments.get(documentId);
if (existing !== undefined && existing >= deletedAtVaultUpdateId) {
return;
}
this.deletedDocuments.set(documentId, deletedAtVaultUpdateId);
}
/**
* Returns the vault-update version at which we last saw this document
* deleted, or `undefined` if we have no record of its deletion.
*/
public getDeletionVersion(
documentId: DocumentId
): VaultUpdateId | undefined {
return this.deletedDocuments.get(documentId);
}
/**
* Forget a doc's tombstone used when a doc with the same id is
* re-introduced (e.g. via a remote create whose server-side state
* surpasses the previous delete).
*/
public clearDeletion(documentId: DocumentId): void {
this.deletedDocuments.delete(documentId);
}
public getDocumentByDocumentId( public getDocumentByDocumentId(
target: DocumentId target: DocumentId
): DocumentWithPath | undefined { ): DocumentWithPath | undefined {
@ -436,7 +392,7 @@ export class SyncEventQueue {
return undefined; return undefined;
} }
private updatePendingCreatePath( public updatePendingCreatePath(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
): void { ): void {

View file

@ -217,8 +217,8 @@ export class Syncer {
} }
private ensureDraining(): void { private ensureDraining(): void {
if (this.drainPromise !== undefined) {return;} if (this.drainPromise !== undefined) { return; }
if (this.isScanning) {return;} if (this.isScanning) { return; }
this.drainPromise = this.drain().finally(() => { this.drainPromise = this.drain().finally(() => {
this.drainPromise = undefined; this.drainPromise = undefined;
}); });
@ -318,7 +318,7 @@ export class Syncer {
private async skipIfOversized(event: SyncEvent): Promise<boolean> { private async skipIfOversized(event: SyncEvent): Promise<boolean> {
let sizeInBytes = 0; let sizeInBytes = 0;
let relativePath: RelativePath = ""; let relativePath: RelativePath;
switch (event.type) { switch (event.type) {
case SyncEventType.LocalDelete: case SyncEventType.LocalDelete:
@ -329,7 +329,7 @@ export class Syncer {
relativePath = event.path; relativePath = event.path;
break; break;
case SyncEventType.RemoteChange: case SyncEventType.RemoteChange:
if (event.remoteVersion.isDeleted) {return false;} if (event.remoteVersion.isDeleted) { return false; }
sizeInBytes = event.remoteVersion.contentSize; sizeInBytes = event.remoteVersion.contentSize;
({ relativePath } = event.remoteVersion); ({ relativePath } = event.remoteVersion);
break; break;
@ -339,7 +339,7 @@ export class Syncer {
sizeInBytes, sizeInBytes,
relativePath relativePath
); );
if (oversizedEntry === undefined) {return false;} if (oversizedEntry === undefined) { return false; }
this.history.addHistoryEntry(oversizedEntry); this.history.addHistoryEntry(oversizedEntry);
@ -417,22 +417,24 @@ export class Syncer {
); );
return; return;
} }
const relativePath = doc.path;
const response = await this.syncService.delete({ const response = await this.syncService.delete({
documentId, documentId,
relativePath relativePath: doc.path
}); });
await this.queue.removeDocument(doc.path); // Don't remove the doc from the queue or advance lastSeenUpdateId
this.queue.recordDeletion(documentId, response.vaultUpdateId); // here. The server broadcasts the delete back to us over the
this.queue.lastSeenUpdateId = response.vaultUpdateId; // WebSocket; that receipt drives `processRemoteDelete`'s cleanup
// and history entry. Keeping the entry in the map until then lets
// late remote updates be recognised as "file is missing" and
// skipped, instead of resurrecting the doc.
//
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
details: { details: {
type: SyncType.DELETE, type: SyncType.DELETE,
relativePath relativePath: doc.path
}, },
message: "Successfully deleted file on the server", message: "Successfully deleted file on the server",
author: response.userId author: response.userId
@ -520,7 +522,7 @@ export class Syncer {
parentVersionId: response.vaultUpdateId, parentVersionId: response.vaultUpdateId,
remoteRelativePath: response.relativePath remoteRelativePath: response.relativePath
}; };
let remoteHash = ""; let remoteHash: string;
if ("type" in response && response.type === "MergingUpdate") { if ("type" in response && response.type === "MergingUpdate") {
const responseBytes = base64ToBytes(response.contentBase64); const responseBytes = base64ToBytes(response.contentBase64);
@ -565,16 +567,16 @@ export class Syncer {
`Document ${response.documentId} is no longer tracked after update; cannot reconcile potential rename` `Document ${response.documentId} is no longer tracked after update; cannot reconcile potential rename`
); );
} else { } else {
const currentPath = tracked.path ?? path; const currentPath = tracked.path;
if (currentPath === path) { if (currentPath === path) {
// a http response will always be more up-to-date than any queued remote update // a http response will always be more up-to-date than any queued remote update
// move will always move to the relative path when MoveOnConflict.EXISTING is given // move will always move to the relative path when MoveOnConflict.EXISTING is given
await this.operations.move( await this.operations.move(
path, currentPath,
response.relativePath, response.relativePath,
MoveOnConflict.EXISTING MoveOnConflict.EXISTING
); );
this.queue.updatePendingCreatePath(currentPath, response.relativePath);
await this.queue.setDocument(response.relativePath, { await this.queue.setDocument(response.relativePath, {
...record, ...record,
remoteHash remoteHash
@ -597,13 +599,13 @@ export class Syncer {
// consistent. Without this, a later remote create at the // consistent. Without this, a later remote create at the
// originally-requested path would see a phantom local conflict // originally-requested path would see a phantom local conflict
// and stash the new file under a `conflict-<uuid>-` path. // and stash the new file under a `conflict-<uuid>-` path.
if (response.relativePath !== createEvent.path) { if (response.relativePath !== createEvent.originalPath) {
await this.operations.move( await this.operations.move(
createEvent.path, createEvent.path,
response.relativePath, response.relativePath,
MoveOnConflict.EXISTING MoveOnConflict.EXISTING
); );
createEvent.path = response.relativePath; this.queue.updatePendingCreatePath(createEvent.path, response.relativePath);
} }
await this.queue.resolveCreate(createEvent, { await this.queue.resolveCreate(createEvent, {
...record, ...record,
@ -624,7 +626,12 @@ export class Syncer {
if (remoteVersion.isDeleted) { if (remoteVersion.isDeleted) {
if (documentWithPath === undefined) { if (documentWithPath === undefined) {
// trying to delete a document we've already scheduled for deletion locally // The doc isn't tracked locally — either we never had
// it (joined the vault after the delete) or a previous
// delete already cleaned it up. Just advance
// `lastSeenUpdateId` so we don't replay this on the
// next reconnect.
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
return; return;
} }
return this.processRemoteDelete( return this.processRemoteDelete(
@ -633,24 +640,6 @@ export class Syncer {
); );
} }
// The doc was deleted at-or-after the version this broadcast
// describes (e.g. another client's update committed before our
// local delete; the server's backlog is replaying it now). Apply
// would resurrect a doc we deliberately removed.
const deletedAt = this.queue.getDeletionVersion(
remoteVersion.documentId
);
if (
deletedAt !== undefined &&
deletedAt >= remoteVersion.vaultUpdateId
) {
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
this.logger.debug(
`Skipping obsolete remote update for already-deleted document ${remoteVersion.documentId} (V=${remoteVersion.vaultUpdateId} <= deleted V=${deletedAt})`
);
return;
}
if ( if (
(documentWithPath?.record.parentVersionId ?? 0) >= (documentWithPath?.record.parentVersionId ?? 0) >=
remoteVersion.vaultUpdateId remoteVersion.vaultUpdateId
@ -663,7 +652,22 @@ export class Syncer {
} }
if (documentWithPath !== undefined) { if (documentWithPath !== undefined) {
// must be the update to an existing doc // The doc is tracked. If the local file backing it has
// gone missing — e.g. the user deleted it and the
// LocalDelete hasn't drained yet, or our HTTP DELETE just
// landed and we're still waiting on the WebSocket receipt
// — ignore the update. Otherwise we'd try to operate on a
// vanished file (or recreate one we're tearing down).
const fileExists = await this.operations.exists(
documentWithPath.path
);
if (!fileExists) {
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
this.logger.debug(
`Ignoring remote update for ${remoteVersion.documentId}: local file at ${documentWithPath.path} is missing`
);
return;
}
return this.processRemoteUpdate( return this.processRemoteUpdate(
documentWithPath.path, documentWithPath.path,
documentWithPath.record, documentWithPath.record,
@ -671,10 +675,6 @@ export class Syncer {
); );
} }
const pendingCreate = this.queue.findLatestCreateForPath(
remoteVersion.relativePath
);
return this.processRemoteCreateForNewDocument(remoteVersion); return this.processRemoteCreateForNewDocument(remoteVersion);
} }
@ -684,10 +684,6 @@ export class Syncer {
): Promise<void> { ): Promise<void> {
await this.operations.delete(path); await this.operations.delete(path);
await this.queue.removeDocument(path); await this.queue.removeDocument(path);
this.queue.recordDeletion(
remoteVersion.documentId,
remoteVersion.vaultUpdateId
);
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;

View file

@ -717,19 +717,23 @@ impl Database {
.await .await
.context("Failed to commit transaction")?; .context("Failed to commit transaction")?;
// The broadcast is delivered to every connected client except the // For non-delete writes the originating device already has
// author — the send task filters on `origin_device_id` (see // authoritative state from its HTTP response, so we tag the
// `websocket.rs`). The origin already has authoritative state // broadcast with `origin_device_id` and the send task in
// from the HTTP response that triggered this write. // `websocket.rs` filters it out for that device. Deletes are
self.broadcasts.send_document_update( // delivered to *every* connected client including the author —
vault_id.clone(), // the originator only removes the document from its sync queue
WebSocketServerMessageWithOrigin::with_origin( // once it receives this receipt.
version.device_id.clone(), let envelope = WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate {
WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate {
document: version.clone().into(), document: version.clone().into(),
}), });
), let with_origin = if version.is_deleted {
); WebSocketServerMessageWithOrigin::new(envelope)
} else {
WebSocketServerMessageWithOrigin::with_origin(version.device_id.clone(), envelope)
};
self.broadcasts
.send_document_update(vault_id.clone(), with_origin);
Ok(()) Ok(())
} }

View file

@ -58,11 +58,15 @@ pub struct CursorPositionFromServer {
pub clients: Vec<ClientCursors>, pub clients: Vec<ClientCursors>,
} }
// One committed version, broadcast to every connected client *except* // One committed version. Non-delete updates are broadcast to every
// the device that authored it — that device already has the new state // connected client *except* the device that authored them — that
// via its HTTP response. The server also emits these one-at-a-time to // device already has the new state via its HTTP response. Deletes are
// catch up a freshly-connected client on versions committed while it // broadcast to every client including the author: the author keeps
// was offline, in ascending `vault_update_id` order. // the document in its sync queue until this receipt arrives so a late
// remote update can't sneak in between the HTTP response and the
// queue cleanup. The server also emits these one-at-a-time to catch
// up a freshly-connected client on versions committed while it was
// offline, in ascending `vault_update_id` order.
#[derive(TS, Serialize, Clone, Debug)] #[derive(TS, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct WebSocketVaultUpdate { pub struct WebSocketVaultUpdate {

View file

@ -165,6 +165,9 @@ async fn websocket(
Ok(update) => { Ok(update) => {
// Drop messages this device authored because the HTTP // Drop messages this device authored because the HTTP
// response already carried authoritative state back. // response already carried authoritative state back.
// Delete broadcasts are sent without an origin so the
// author also receives them — that's the receipt the
// client needs to drop the doc from its sync queue.
if Some(&device_id) == update.origin_device_id.as_ref() { if Some(&device_id) == update.origin_device_id.as_ref() {
continue; continue;
} }