From 789814a3a580a1bb2e959d3847ac20183810ecaf Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 3 Mar 2026 13:31:57 +0100 Subject: [PATCH] detect remote document URL replacements during sync Add URL replacement detection in ChangeDetector: when a peer replaces a document entirely (creating a new URL in the directory entry), the old snapshot-centric scan misses it. Now detected during the directory walk when a path's URL differs from the snapshot entry. Also add prettier for code formatting and remoteUrl field to DetectedChange. Made-with: Cursor --- .prettierrc | 9 +++++ pnpm-lock.yaml | 11 +++++- src/core/change-detection.ts | 73 ++++++++++++++++++++++++++++-------- src/types/documents.ts | 6 ++- 4 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8d8d44b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": true, + "printWidth": 80, + "semi": false, + "bracketSpacing": false, + "trailingComma": "es5", + "arrowParens": "avoid", + "objectWrap": "collapse" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9bba2e..f327f27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ devDependencies: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.19.8) + prettier: + specifier: ^3.8.1 + version: 3.8.1 tmp: specifier: ^0.2.1 version: 0.2.3 @@ -2975,7 +2978,7 @@ packages: /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -3933,6 +3936,12 @@ packages: find-up: 4.1.0 dev: true + /prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + dev: true + /pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} diff --git a/src/core/change-detection.ts b/src/core/change-detection.ts index 0fa2bf0..4c0cdb7 100644 --- a/src/core/change-detection.ts +++ b/src/core/change-detection.ts @@ -1,4 +1,9 @@ -import {AutomergeUrl, DocHandle, Repo, UrlHeads} from "@automerge/automerge-repo" +import { + AutomergeUrl, + DocHandle, + Repo, + UrlHeads, +} from "@automerge/automerge-repo" import * as A from "@automerge/automerge" import { ChangeType, @@ -107,11 +112,12 @@ export class ChangeDetector { if (localChanged || remoteChanged) { changes.push({ path: relativePath, - changeType: localChanged && remoteChanged - ? ChangeType.BOTH_CHANGED - : localChanged - ? ChangeType.LOCAL_ONLY - : ChangeType.REMOTE_ONLY, + changeType: + localChanged && remoteChanged + ? ChangeType.BOTH_CHANGED + : localChanged + ? ChangeType.LOCAL_ONLY + : ChangeType.REMOTE_ONLY, fileType: fileInfo.type, localContent: fileInfo.content, remoteContent: null, @@ -271,9 +277,10 @@ export class ChangeDetector { const localContent = await this.getLocalContent(relativePath) changes.push({ path: relativePath, - changeType: localContent !== null - ? ChangeType.BOTH_CHANGED - : ChangeType.REMOTE_ONLY, + changeType: + localContent !== null + ? ChangeType.BOTH_CHANGED + : ChangeType.REMOTE_ONLY, fileType: FileType.TEXT, localContent, remoteContent: null, @@ -412,7 +419,43 @@ export class ChangeDetector { remoteHead, }) } - // Only ignore if neither local nor remote content exists (ghost entry) + // Only ignore if neither local nor remote content exists (ghost entry) + } else if ( + getPlainUrl(entry.url) !== getPlainUrl(existingEntry.url) + ) { + // HACK: URL replacement detection bolted onto the "discover new docs" walk. + // + // A peer can replace a document entirely (creating a new URL) rather than mutating + // the existing one. This happens in several cases in updateRemoteFile(): artifact + // paths are always replaced; non-artifact docs with legacy immutable string content + // are also replaced; and recreateFailedDocuments() replaces docs that timed out + // during network sync. The two normal remote-change scans both miss this: + // - detectRemoteChanges() is snapshot-centric: it checks the old (now orphaned) + // doc's heads, which haven't changed, so it reports no change. + // - The "new doc" branch above is directory-centric: it skips paths already in + // the snapshot, assuming they're handled by detectRemoteChanges(). + // + // A cleaner fix would be to have detectRemoteChanges() also verify that the + // directory still points to the same URL for each snapshot entry, treating a + // mismatch as a first-class URL-replacement change rather than a special case here. + const localContent = await this.getLocalContent(entryPath) + const remoteContent = await this.getCurrentRemoteContent(entry.url) + const remoteHead = await this.getCurrentRemoteHead(entry.url) + + if (remoteContent !== null) { + changes.push({ + path: entryPath, + changeType: + localContent !== null + ? ChangeType.BOTH_CHANGED + : ChangeType.REMOTE_ONLY, + fileType: await this.getFileTypeFromContent(remoteContent), + localContent: localContent ?? null, + remoteContent, + remoteHead, + remoteUrl: entry.url, + }) + } } } else if (entry.type === "folder") { // Recursively process subdirectory @@ -456,10 +499,7 @@ export class ChangeDetector { const relativePath = getRelativePath(this.rootPath, entry.path) const content = await readFileContent(entry.path) - fileMap.set(relativePath, { - content, - type: entry.type, - }) + fileMap.set(relativePath, {content, type: entry.type}) }) ) } catch (error) { @@ -564,7 +604,10 @@ export class ChangeDetector { private async getCurrentRemoteHead(url: AutomergeUrl): Promise { try { const plainUrl = getPlainUrl(url) - const result = await this.findDocument(plainUrl, {maxRetries: 3, retryDelayMs: 200}) + const result = await this.findDocument(plainUrl, { + maxRetries: 3, + retryDelayMs: 200, + }) if (!result) return [] as unknown as UrlHeads return result.handle.heads() } catch { diff --git a/src/types/documents.ts b/src/types/documents.ts index ff96b3e..b675d4b 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -41,7 +41,7 @@ export interface FileDocument { export enum FileType { TEXT = "text", BINARY = "binary", - DIRECTORY = "directory", + DIRECTORY = "directory" } /** @@ -51,7 +51,7 @@ export enum ChangeType { NO_CHANGE = "no_change", LOCAL_ONLY = "local_only", REMOTE_ONLY = "remote_only", - BOTH_CHANGED = "both_changed", + BOTH_CHANGED = "both_changed" } /** @@ -86,4 +86,6 @@ export interface DetectedChange { remoteContent: string | Uint8Array | null localHead?: UrlHeads remoteHead?: UrlHeads + /** New remote URL when the remote document was replaced (artifact URL change) */ + remoteUrl?: AutomergeUrl }