actually works
This commit is contained in:
parent
fb71622e40
commit
f2337dbbd0
7 changed files with 238 additions and 8 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/**`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue