Fix syncing logic

This commit is contained in:
Andras Schmelczer 2026-01-04 14:08:33 +00:00
parent 0d7d36e971
commit 7c991c3b4d
10 changed files with 223 additions and 184 deletions

View file

@ -4,3 +4,4 @@ export const MAX_LOG_MESSAGE_COUNT = 100000;
export const MAX_HISTORY_ENTRY_COUNT = 5000; export const MAX_HISTORY_ENTRY_COUNT = 5000;
export const SUPPORTED_API_VERSION = 3; export const SUPPORTED_API_VERSION = 3;
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10; export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10;
export const WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS = 10;

View file

@ -26,7 +26,6 @@ export interface StoredDocumentMetadata {
export interface StoredDatabase { export interface StoredDatabase {
documents: StoredDocumentMetadata[]; documents: StoredDocumentMetadata[];
lastSeenUpdateId: VaultUpdateId | undefined; lastSeenUpdateId: VaultUpdateId | undefined;
hasInitialSyncCompleted: boolean;
} }
/** /**
@ -46,7 +45,6 @@ export interface DocumentRecord {
export class Database { export class Database {
private documents: DocumentRecord[]; private documents: DocumentRecord[];
private lastSeenUpdateIds: CoveredValues; private lastSeenUpdateIds: CoveredValues;
private hasInitialSyncCompleted: boolean;
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
@ -56,15 +54,13 @@ export class Database {
initialState ??= {}; initialState ??= {};
this.documents = this.documents =
initialState.documents?.map( initialState.documents?.map(({ relativePath, ...metadata }) => ({
({ relativePath, ...metadata }) => ({ relativePath,
relativePath, metadata,
metadata, isDeleted: false,
isDeleted: false, updates: [],
updates: [], parallelVersion: 0
parallelVersion: 0 })) ?? [];
})
) ?? [];
this.ensureConsistency(); this.ensureConsistency();
this.logger.debug(`Loaded ${this.documents.length} documents`); this.logger.debug(`Loaded ${this.documents.length} documents`);
@ -79,11 +75,6 @@ export class Database {
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId); this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
}); });
this.hasInitialSyncCompleted =
initialState.hasInitialSyncCompleted ?? false;
this.logger.debug(
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
);
} }
public get length(): number { public get length(): number {
@ -199,15 +190,12 @@ export class Database {
relativePath: RelativePath, relativePath: RelativePath,
promise: Promise<unknown> promise: Promise<unknown>
): DocumentRecord { ): DocumentRecord {
this.logger.debug( this.logger.debug(`Creating new pending document: ${relativePath}`);
`Creating new pending document: ${relativePath}`
);
const previousEntry = const previousEntry =
this.getLatestDocumentByRelativePath(relativePath); this.getLatestDocumentByRelativePath(relativePath);
const entry = { const entry = {
relativePath, relativePath,
documentId: undefined,
metadata: undefined, metadata: undefined,
isDeleted: false, isDeleted: false,
updates: [promise], updates: [promise],
@ -250,7 +238,9 @@ export class Database {
public getDocumentByDocumentId( public getDocumentByDocumentId(
find: DocumentId find: DocumentId
): DocumentRecord | undefined { ): DocumentRecord | undefined {
return this.documents.find(({ metadata }) => metadata?.documentId === find); return this.documents.find(
({ metadata }) => metadata?.documentId === find
);
} }
public move( public move(
@ -292,14 +282,6 @@ export class Database {
candidate.isDeleted = true; candidate.isDeleted = true;
} }
public getHasInitialSyncCompleted(): boolean {
return this.hasInitialSyncCompleted;
}
public setHasInitialSyncCompleted(value: boolean): void {
this.hasInitialSyncCompleted = value;
this.saveInTheBackground();
}
public getLastSeenUpdateId(): VaultUpdateId { public getLastSeenUpdateId(): VaultUpdateId {
return this.lastSeenUpdateIds.min; return this.lastSeenUpdateIds.min;
@ -323,7 +305,6 @@ export class Database {
this.lastSeenUpdateIds = new CoveredValues( this.lastSeenUpdateIds = new CoveredValues(
0 // the first updateId will be 1 which is the first integer after -1 0 // the first updateId will be 1 which is the first integer after -1
); );
this.hasInitialSyncCompleted = false;
this.saveInTheBackground(); this.saveInTheBackground();
} }
@ -337,7 +318,6 @@ export class Database {
}) })
), ),
lastSeenUpdateId: this.lastSeenUpdateIds.min, lastSeenUpdateId: this.lastSeenUpdateIds.min,
hasInitialSyncCompleted: this.hasInitialSyncCompleted
}); });
} }

View file

@ -6,7 +6,10 @@ import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"
import type { ClientCursors } from "./types/ClientCursors"; import type { ClientCursors } from "./types/ClientCursors";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate"; import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS } from "../consts"; import {
WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS,
WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS
} from "../consts";
import { removeFromArray } from "../utils/remove-from-array"; import { removeFromArray } from "../utils/remove-from-array";
import { EventListeners } from "../utils/data-structures/event-listeners"; import { EventListeners } from "../utils/data-structures/event-listeners";
import { awaitAll } from "../utils/await-all"; import { awaitAll } from "../utils/await-all";
@ -27,6 +30,7 @@ export class WebSocketManager {
private isStopped = true; private isStopped = true;
private resolveDisconnectingPromise: null | (() => unknown) = null; private resolveDisconnectingPromise: null | (() => unknown) = null;
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined; private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
private connectionTimeoutId: ReturnType<typeof setTimeout> | undefined;
private readonly outstandingPromises: Promise<unknown>[] = []; private readonly outstandingPromises: Promise<unknown>[] = [];
@ -36,7 +40,7 @@ export class WebSocketManager {
private readonly logger: Logger, private readonly logger: Logger,
private readonly settings: Settings, private readonly settings: Settings,
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket = WebSocket private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket = WebSocket
) {} ) { }
public get isWebSocketConnected(): boolean { public get isWebSocketConnected(): boolean {
return ( return (
@ -61,6 +65,11 @@ export class WebSocketManager {
this.reconnectTimeoutId = undefined; this.reconnectTimeoutId = undefined;
} }
if (this.connectionTimeoutId !== undefined) {
clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = undefined;
}
this.webSocket?.close(1000, "WebSocketManager has been stopped"); this.webSocket?.close(1000, "WebSocketManager has been stopped");
// eslint-disable-next-line @typescript-eslint/init-declarations // eslint-disable-next-line @typescript-eslint/init-declarations
@ -171,7 +180,22 @@ export class WebSocketManager {
this.webSocket = new this.webSocketFactoryImplementation(wsUri); this.webSocket = new this.webSocketFactoryImplementation(wsUri);
// Set connection timeout to handle cases where server is down and the WebSocket connection won't open
this.connectionTimeoutId = setTimeout(() => {
this.connectionTimeoutId = undefined;
this.logger.warn(
`WebSocket connection timeout after ${WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS} seconds`
);
// Force close to trigger onclose handler which will schedule reconnection
this.webSocket?.close();
}, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000);
this.webSocket.onopen = (): void => { this.webSocket.onopen = (): void => {
if (this.connectionTimeoutId !== undefined) {
clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = undefined;
}
// Check if we've been stopped while connecting // Check if we've been stopped while connecting
if (this.isStopped) { if (this.isStopped) {
this.webSocket?.close( this.webSocket?.close(
@ -215,7 +239,18 @@ export class WebSocketManager {
} }
}; };
this.webSocket.onerror = (error): void => {
this.logger.error(
`WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}`
);
};
this.webSocket.onclose = (event): void => { this.webSocket.onclose = (event): void => {
if (this.connectionTimeoutId !== undefined) {
clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = undefined;
}
this.logger.warn( this.logger.warn(
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
); );
@ -225,10 +260,14 @@ export class WebSocketManager {
this.resolveDisconnectingPromise?.(); this.resolveDisconnectingPromise?.();
this.resolveDisconnectingPromise = null; this.resolveDisconnectingPromise = null;
} else { } else {
const delay = this.settings.getSettings().webSocketRetryIntervalMs;
this.logger.info(
`Reconnecting to WebSocket in ${delay}ms...`
);
this.reconnectTimeoutId = setTimeout(() => { this.reconnectTimeoutId = setTimeout(() => {
this.reconnectTimeoutId = undefined; this.reconnectTimeoutId = undefined;
this.initializeWebSocket(); this.initializeWebSocket();
}, this.settings.getSettings().webSocketRetryIntervalMs); }, delay);
} }
}; };
} }

View file

@ -56,7 +56,7 @@ export class SyncClient {
database: Partial<StoredDatabase>; database: Partial<StoredDatabase>;
}> }>
> >
) {} ) { }
public get documentCount(): number { public get documentCount(): number {
return this.database.length; return this.database.length;
@ -339,7 +339,9 @@ export class SyncClient {
this.hasFinishedOfflineSync = false; this.hasFinishedOfflineSync = false;
this.serverConfig.reset(); this.serverConfig.reset();
await this.startSyncing(); if (this.settings.getSettings().isSyncEnabled) {
await this.startSyncing();
}
} }
public getSettings(): SyncSettings { public getSettings(): SyncSettings {

View file

@ -84,36 +84,16 @@ export class UnrestrictedSyncer {
const response = await this.syncService.create({ const response = await this.syncService.create({
relativePath: originalRelativePath, relativePath: originalRelativePath,
contentBytes, contentBytes,
forceMerge: !this.database.getHasInitialSyncCompleted() // don't duplicate files on first sync forceMerge: true
}); });
// In case a document with the same name (but different ID) had existed remotely that we haven't known about this.handleMaybeMergingResponse({
if (response.relativePath != originalRelativePath) { document,
this.logger.debug( response,
`Document ${originalRelativePath} has been created remotely at a different path: ${response.relativePath}, moving it locally` contentHash,
); originalRelativePath,
await this.operations.move( originalContentBytes: contentBytes
document.relativePath, });
response.relativePath
); // this can throw FileNotFoundError
}
this.database.updateDocumentMetadata(
{
documentId: response.documentId,
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
this.database.addSeenUpdateId(response.vaultUpdateId);
await this.updateCache(
response.vaultUpdateId,
contentBytes,
response.relativePath
);
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
@ -134,7 +114,7 @@ export class UnrestrictedSyncer {
await this.executeSync(updateDetails, async () => { await this.executeSync(updateDetails, async () => {
if (document.metadata === undefined) { if (document.metadata === undefined) {
this.logger.debug( this.logger.debug(
`Document ${document.relativePath} has no metadata, so it was never synced remotely` `Document ${document.relativePath} has no metadata, so it has never got synced remotely; no need to delete it remotely`
); );
return; return;
} }
@ -254,69 +234,16 @@ export class UnrestrictedSyncer {
}); });
} }
// `document` is mutable and reflects the latest state in the local database
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (document.isDeleted) {
this.logger.info(
`Document ${document.relativePath} has been deleted before we could finish updating it`
);
this.database.addSeenUpdateId(response.vaultUpdateId);
return;
}
if ( this.handleMaybeMergingResponse({
// `Syncer` creates fake local document metadata for all remote docs with invalid hashes. The parent IDs will likely match document,
// the latest versions so we still need to update the local versions to turn the fakes into real metadata. response: response!,
document.metadata.parentVersionId > response.vaultUpdateId contentHash,
) { originalRelativePath,
this.logger.debug( originalContentBytes: contentBytes
`Document ${document.relativePath} is already more up to date than the fetched version` });
);
this.database.addSeenUpdateId(response.vaultUpdateId); // in case the previous `vaultUpdateId` update hasn't made it through
return;
}
if (response.isDeleted) {
return this.applyRemoteDeleteLocally(document, response);
}
let actualPath = document.relativePath;
if (response.relativePath != originalRelativePath) {
actualPath = response.relativePath;
// Make sure to update the remote relative path to avoid uploading
// the file as a result of this filesystem event.
document.metadata.remoteRelativePath = response.relativePath;
await this.operations.move(
document.relativePath,
response.relativePath
); // this can throw FileNotFoundError
}
if (!("type" in response) || response.type === "MergingUpdate") { if (!("type" in response) || response.type === "MergingUpdate") {
const responseBytes = base64ToBytes(response.contentBase64);
contentHash = hash(responseBytes);
this.database.updateDocumentMetadata(
{
...document.metadata,
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
await this.operations.write(
actualPath,
contentBytes,
responseBytes
);
await this.updateCache(
response.vaultUpdateId,
responseBytes,
actualPath
);
if (!force) { if (!force) {
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
@ -324,32 +251,15 @@ export class UnrestrictedSyncer {
message: `The file we updated had been updated remotely, so we downloaded the merged version` message: `The file we updated had been updated remotely, so we downloaded the merged version`
}); });
} }
} else {
this.database.updateDocumentMetadata(
{
...document.metadata,
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
await this.updateCache(
response.vaultUpdateId,
contentBytes,
actualPath
);
} }
this.database.addSeenUpdateId(response.vaultUpdateId);
const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails = const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails =
oldPath !== undefined || oldPath !== undefined ||
response.relativePath != originalRelativePath response.relativePath != originalRelativePath
? { ? {
type: SyncType.MOVE, type: SyncType.MOVE,
relativePath: response.relativePath, relativePath: response.relativePath,
movedFrom: originalRelativePath movedFrom: oldPath ?? originalRelativePath
} }
: { : {
type: SyncType.UPDATE, type: SyncType.UPDATE,
@ -363,7 +273,7 @@ export class UnrestrictedSyncer {
message: `Successfully uploaded locally updated file to the server`, message: `Successfully uploaded locally updated file to the server`,
author: response.userId author: response.userId
}); });
} else { } else if (!response.isDeleted) {
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
details: actualUpdateDetails, details: actualUpdateDetails,
@ -371,6 +281,17 @@ export class UnrestrictedSyncer {
author: response.userId, author: response.userId,
timestamp: new Date(response.updatedDate) timestamp: new Date(response.updatedDate)
}); });
} else {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: {
type: SyncType.DELETE,
relativePath: document.relativePath
},
message: "File has been deleted remotely, so we deleted it locally",
author: response.userId,
timestamp: new Date(response.updatedDate)
});
} }
}); });
} }
@ -539,6 +460,105 @@ export class UnrestrictedSyncer {
} }
} }
private async handleMaybeMergingResponse(
{
document,
response,
contentHash,
originalRelativePath,
originalContentBytes
}: {
document: DocumentRecord;
response: DocumentVersion | DocumentUpdateResponse,
contentHash: string,
originalRelativePath: string,
originalContentBytes: Uint8Array
}
): Promise<void> {
// `document` is mutable and reflects the latest state in the local database
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (document.isDeleted) {
this.logger.info(
`Document ${document.relativePath} has been deleted before we could finish updating it`
);
this.database.addSeenUpdateId(response.vaultUpdateId);
return;
}
if (
(document.metadata?.parentVersionId ?? 0) > response.vaultUpdateId
) {
this.logger.debug(
`Document ${document.relativePath} is already more up to date than the fetched version`
);
this.database.addSeenUpdateId(response.vaultUpdateId); // in case the previous `vaultUpdateId` update hasn't made it through
return;
}
if (response.isDeleted) {
return this.applyRemoteDeleteLocally(document, response);
}
let actualPath = document.relativePath;
// this can't happen on the creation path as we can only get a merging response if a document already exists remotely on the same path
if (response.relativePath != originalRelativePath) {
actualPath = response.relativePath;
// Make sure to update the remote relative path to avoid uploading
// the file as a result of this filesystem event.
if (document.metadata !== undefined) {
document.metadata.remoteRelativePath = response.relativePath;
}
await this.operations.move(
document.relativePath,
response.relativePath
); // this can throw FileNotFoundError
}
if (!("type" in response) || response.type === "MergingUpdate") {
const responseBytes = base64ToBytes(response.contentBase64);
contentHash = hash(responseBytes);
this.database.updateDocumentMetadata(
{
documentId: response.documentId,
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
await this.operations.write(
actualPath,
originalContentBytes,
responseBytes
);
await this.updateCache(
response.vaultUpdateId,
responseBytes,
actualPath
);
} else {
this.database.updateDocumentMetadata(
{
documentId: response.documentId,
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
await this.updateCache(
response.vaultUpdateId,
originalContentBytes,
actualPath
);
}
this.database.addSeenUpdateId(response.vaultUpdateId);
}
private getHistoryEntryForSkippedOversizedFile( private getHistoryEntryForSkippedOversizedFile(
sizeInBytes: number, sizeInBytes: number,
relativePath: RelativePath relativePath: RelativePath
@ -578,16 +598,7 @@ export class UnrestrictedSyncer {
document: DocumentRecord, document: DocumentRecord,
response: DocumentVersion | DocumentUpdateResponse response: DocumentVersion | DocumentUpdateResponse
): Promise<void> { ): Promise<void> {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: {
type: SyncType.DELETE,
relativePath: document.relativePath
},
message: "File has been deleted remotely, so we deleted it locally",
author: response.userId,
timestamp: new Date(response.updatedDate)
});
this.database.delete(document.relativePath); this.database.delete(document.relativePath);
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(

View file

@ -9,24 +9,24 @@ server:
max_clients_per_vault: 256 max_clients_per_vault: 256
response_timeout: 30m response_timeout: 30m
mergeable_file_extensions: mergeable_file_extensions:
- md - md
- txt - txt
users: users:
user_configs: user_configs:
- name: admin - name: admin
token: test-token-change-me token: test-token-change-me
vault_access: vault_access:
type: allow_access_to_all type: allow_access_to_all
- name: other-admin - name: other-admin
token: test-token-change-me2 token: test-token-change-me2
vault_access: vault_access:
type: allow_access_to_all type: allow_access_to_all
- name: test - name: test
token: other-test-token token: other-test-token
vault_access: vault_access:
type: allow_list type: allow_list
allowed: allowed:
- default - default
logging: logging:
log_directory: logs log_directory: logs
log_rotation: 7days log_rotation: 7days

View file

@ -79,6 +79,7 @@ impl Database {
}, },
); );
} }
info!("Database migrations applied");
let database = Self { let database = Self {
config: config.clone(), config: config.clone(),
@ -301,7 +302,7 @@ impl Database {
.context("Cannot fetch max update id in vault") .context("Cannot fetch max update id in vault")
} }
pub async fn get_latest_document_by_path( pub async fn get_latest_non_deleted_document_by_path(
&self, &self,
vault: &VaultId, vault: &VaultId,
relative_path: &str, relative_path: &str,

View file

@ -52,7 +52,7 @@ pub async fn create_document(
if request.force_merge.unwrap_or_default() { if request.force_merge.unwrap_or_default() {
let latest_version = state let latest_version = state
.database .database
.get_latest_document_by_path( .get_latest_non_deleted_document_by_path(
&vault_id, &vault_id,
&sanitized_relative_path, &sanitized_relative_path,
Some(&mut transaction), Some(&mut transaction),
@ -65,7 +65,8 @@ pub async fn create_document(
); );
return merge_with_stored_version( return merge_with_stored_version(
latest_version.clone(), &sanitized_relative_path,
&Vec::new(),
latest_version, latest_version,
vault_id, vault_id,
user, user,

View file

@ -172,7 +172,8 @@ async fn update_document(
} }
merge_with_stored_version( merge_with_stored_version(
parent_document, &parent_document.relative_path,
&parent_document.content,
latest_version, latest_version,
vault_id, vault_id,
user, user,
@ -187,7 +188,8 @@ async fn update_document(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn merge_with_stored_version( pub async fn merge_with_stored_version(
parent_document: StoredDocumentVersion, parent_document_path: &str,
parent_document_content: &[u8],
latest_version: StoredDocumentVersion, latest_version: StoredDocumentVersion,
vault_id: VaultId, vault_id: VaultId,
user: User, user: User,
@ -203,7 +205,7 @@ pub async fn merge_with_stored_version(
{ {
info!( info!(
"Document content is the same as the latest version for `{}`, skipping update", "Document content is the same as the latest version for `{}`, skipping update",
parent_document.document_id latest_version.document_id
); );
transaction transaction
.rollback() .rollback()
@ -219,17 +221,17 @@ pub async fn merge_with_stored_version(
let are_all_participants_mergable = is_file_type_mergable( let are_all_participants_mergable = is_file_type_mergable(
sanitized_relative_path, sanitized_relative_path,
&state.config.server.mergeable_file_extensions, &state.config.server.mergeable_file_extensions,
) && !is_binary(&parent_document.content) ) && !is_binary(parent_document_content)
&& !is_binary(&latest_version.content) && !is_binary(&latest_version.content)
&& !is_binary(&content); && !is_binary(&content);
let merged_content = if are_all_participants_mergable { let merged_content = if are_all_participants_mergable {
info!( info!(
"Merging changes for document `{}` in vault `{vault_id}`", "Merging changes for document `{}` in vault `{vault_id}`",
parent_document.document_id latest_version.document_id
); );
reconcile( reconcile(
str::from_utf8(&parent_document.content) str::from_utf8(parent_document_content)
.expect("parent must be valid UTF-8 because it's not binary"), .expect("parent must be valid UTF-8 because it's not binary"),
&str::from_utf8(&latest_version.content) &str::from_utf8(&latest_version.content)
.expect("latest_version must be valid UTF-8 because it's not binary") .expect("latest_version must be valid UTF-8 because it's not binary")
@ -247,7 +249,7 @@ pub async fn merge_with_stored_version(
}; };
// We can only update the relative path if we're the first one to do so // We can only update the relative path if we're the first one to do so
let new_relative_path = if parent_document.relative_path == latest_version.relative_path let new_relative_path = if parent_document_path == &latest_version.relative_path
&& latest_version.relative_path != sanitized_relative_path && latest_version.relative_path != sanitized_relative_path
{ {
let new_path = find_first_available_path( let new_path = find_first_available_path(
@ -279,7 +281,7 @@ pub async fn merge_with_stored_version(
let is_different_from_request_content = merged_content != content; let is_different_from_request_content = merged_content != content;
let new_version = StoredDocumentVersion { let new_version = StoredDocumentVersion {
document_id: parent_document.document_id, document_id: latest_version.document_id,
vault_update_id: last_update_id + 1, vault_update_id: last_update_id + 1,
relative_path: new_relative_path, relative_path: new_relative_path,
content: merged_content, content: merged_content,

View file

@ -9,17 +9,19 @@ pub async fn find_first_available_path(
database: &crate::app_state::database::Database, database: &crate::app_state::database::Database,
transaction: &mut Transaction<'_>, transaction: &mut Transaction<'_>,
) -> Result<String> { ) -> Result<String> {
info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`");
for candidate in dedup_paths(sanitized_relative_path) { for candidate in dedup_paths(sanitized_relative_path) {
debug!("Checking candidate path for deconflicting names: `{candidate}`");
if database if database
.get_latest_document_by_path(vault_id, &candidate, Some(transaction)) .get_latest_non_deleted_document_by_path(vault_id, &candidate, Some(transaction))
.await? .await?
.is_none() .is_none()
{ {
info!("Selected available path: `{candidate}`"); info!("Selected available path: `{candidate}`");
return Ok(candidate); return Ok(candidate);
} }
info!(
"Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}` as `{candidate}` is already taken"
);
} }
unreachable!("dedup_paths produces infinite paths"); unreachable!("dedup_paths produces infinite paths");