actually works
Some checks failed
Check / build (pull_request) Failing after 7s
E2E tests / build (pull_request) Failing after 6s
Publish CLI / publish-docker (pull_request) Failing after 4m59s
Publish server Docker image / publish-docker (pull_request) Failing after 35m13s

This commit is contained in:
Andras Schmelczer 2026-05-08 16:40:32 +01:00
parent fb71622e40
commit f2337dbbd0
7 changed files with 238 additions and 8 deletions

View file

@ -5,6 +5,7 @@ import type { FileOperations } from "../file-operations/file-operations";
import { findMatchingFile } from "../utils/find-matching-file";
import type { SyncEventQueue } from "./sync-event-queue";
import { removeFromArray } from "../utils/remove-from-array";
import { FileNotFoundError } from "../errors/file-not-found-error";
/**
* Scans the local filesystem and the document database to determine
@ -77,8 +78,26 @@ export async function scheduleOfflineChanges(
}
const renamedPaths = new Set<RelativePath>();
// Track paths that were in `allLocalFiles` at scan-start but have
// since disappeared. The scan awaits between `listFilesRecursively`
// and each `read`, so a concurrent delete (slow file events, real
// user activity) can vacate a slot mid-scan. Throwing would abort
// the whole scan; nothing to sync for a file that's already gone.
const disappearedPaths = new Set<RelativePath>();
for (const path of locallyPossibleCreatedFiles) {
const content = await operations.read(path);
let content: Uint8Array;
try {
content = await operations.read(path);
} catch (e) {
if (e instanceof FileNotFoundError) {
logger.debug(
`File ${path} disappeared before offline-scan could read it; skipping`
);
disappearedPaths.add(path);
continue;
}
throw e;
}
const contentHash = await hash(content);
const matchingDeletedFile = await findMatchingFile(
@ -105,7 +124,7 @@ export async function scheduleOfflineChanges(
}
for (const path of locallyPossibleCreatedFiles) {
if (renamedPaths.has(path)) {
if (renamedPaths.has(path) || disappearedPaths.has(path)) {
continue;
}

View file

@ -189,6 +189,52 @@ export class SyncEventQueue {
this._lastSeenUpdateId.add(id);
}
/**
* Watermark to send with our own `POST /documents` requests.
*
* The contiguous-prefix `lastSeenUpdateId` lags behind reality whenever
* there are gaps in the vuid stream we've observed: if the server has
* committed vuids 1..N from various clients but we've only processed
* a non-contiguous subset, `min` stays at the last hole. The server's
* create handler reads this watermark to decide whether to merge a
* new POST into an existing doc at the same path:
*
* creation_vault_update_id > last_seen_vault_update_id merge
*
* That check is meant to fire only for docs the client genuinely
* couldn't have known about. But on a same-device "rename a
* pending-create away then create something else at that path" race,
* the second POST went out with `last_seen = min` while we already
* held a record for the first create at vuid=N and the server
* happily merged the second create into our own doc, aliasing two
* physically distinct local files onto a single docId.
*
* The fix is path-scoped: if we already track a doc whose
* `remoteRelativePath` matches the path we're about to POST, the
* server's existing doc at that path is exactly the one we'd alias
* into. Bumping `last_seen` to that record's `parentVersionId`
* forces the server's `creation_vuid > last_seen` check to fail and
* fall through to the deconflict path. For paths we don't yet
* track, we send the regular `min` watermark so a legitimate
* cross-device merge (two clients independently creating the same
* path) still fires when neither side holds a record for the
* collision target.
*/
public lastSeenUpdateIdForCreate(
requestPath: RelativePath
): VaultUpdateId {
let watermark = this._lastSeenUpdateId.min;
for (const record of this.byDocId.values()) {
if (
record.remoteRelativePath === requestPath &&
record.parentVersionId > watermark
) {
watermark = record.parentVersionId;
}
}
return watermark;
}
/**
* Pin an additional ignore pattern that survives setting reloads. Used
* by the Syncer to hide internal scratch paths (e.g. `.vaultlink/**`

View file

@ -500,9 +500,16 @@ export class Syncer {
// `updatePendingCreatePath` mutates queued creates when a not-yet-sent
// local file is renamed, so a renamed-away generation does not create
// a server document at a path that a newer local file has reused.
//
// `lastSeenUpdateIdForCreate(requestPath)` (rather than the contiguous
// `lastSeenUpdateId`) blocks the server from path-merging this POST
// into a doc we already track at the same path. Without that, a
// same-device rename race can alias two physically distinct local
// files onto one docId. See `SyncEventQueue.lastSeenUpdateIdForCreate`.
const response = await this.syncService.create({
relativePath: requestPath,
lastSeenVaultUpdateId: this.queue.lastSeenUpdateId,
lastSeenVaultUpdateId:
this.queue.lastSeenUpdateIdForCreate(requestPath),
contentBytes
});