This commit is contained in:
Andras Schmelczer 2025-12-14 23:30:04 +00:00
commit c4f992c9d6
21 changed files with 233 additions and 193 deletions

View file

@ -2,5 +2,5 @@ export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
export const DIFF_CACHE_SIZE_MB = 2; export const DIFF_CACHE_SIZE_MB = 2;
export const MAX_LOG_MESSAGE_COUNT = 100000; 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 = 2; export const SUPPORTED_API_VERSION = 3;
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10; export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10;

View file

@ -169,9 +169,9 @@ export class FileOperations {
} }
await this.ensureClearPath(newPath); await this.ensureClearPath(newPath);
this.database.move(oldPath, newPath); this.database.move(oldPath, newPath);
await this.fs.rename(oldPath, newPath); await this.fs.rename(oldPath, newPath);
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
} }

View file

@ -9,6 +9,7 @@ export type DocumentId = string;
export type RelativePath = string; export type RelativePath = string;
export interface DocumentMetadata { export interface DocumentMetadata {
documentId: DocumentId;
parentVersionId: VaultUpdateId; parentVersionId: VaultUpdateId;
hash: string; hash: string;
remoteRelativePath?: RelativePath; remoteRelativePath?: RelativePath;
@ -36,7 +37,6 @@ export interface StoredDatabase {
*/ */
export interface DocumentRecord { export interface DocumentRecord {
relativePath: RelativePath; relativePath: RelativePath;
documentId: DocumentId;
metadata: DocumentMetadata | undefined; metadata: DocumentMetadata | undefined;
isDeleted: boolean; isDeleted: boolean;
updates: Promise<unknown>[]; updates: Promise<unknown>[];
@ -57,9 +57,8 @@ export class Database {
this.documents = this.documents =
initialState.documents?.map( initialState.documents?.map(
({ relativePath, documentId, ...metadata }) => ({ ({ relativePath, ...metadata }) => ({
relativePath, relativePath,
documentId,
metadata, metadata,
isDeleted: false, isDeleted: false,
updates: [], updates: [],
@ -114,7 +113,7 @@ export class Database {
i === 0 i === 0
? false ? false
: records[i - 1].parallelVersion === : records[i - 1].parallelVersion ===
current.parallelVersion current.parallelVersion
) )
) { ) {
throw new Error( throw new Error(
@ -127,6 +126,7 @@ export class Database {
public updateDocumentMetadata( public updateDocumentMetadata(
metadata: { metadata: {
documentId: DocumentId;
parentVersionId: VaultUpdateId; parentVersionId: VaultUpdateId;
hash: string; hash: string;
remoteRelativePath: RelativePath; remoteRelativePath: RelativePath;
@ -196,19 +196,18 @@ export class Database {
} }
public createNewPendingDocument( public createNewPendingDocument(
documentId: DocumentId,
relativePath: RelativePath, relativePath: RelativePath,
promise: Promise<unknown> promise: Promise<unknown>
): DocumentRecord { ): DocumentRecord {
this.logger.debug( this.logger.debug(
`Creating new pending document: ${relativePath} (${documentId})` `Creating new pending document: ${relativePath}`
); );
const previousEntry = const previousEntry =
this.getLatestDocumentByRelativePath(relativePath); this.getLatestDocumentByRelativePath(relativePath);
const entry = { const entry = {
relativePath, relativePath,
documentId, documentId: undefined,
metadata: undefined, metadata: undefined,
isDeleted: false, isDeleted: false,
updates: [promise], updates: [promise],
@ -231,8 +230,8 @@ export class Database {
): DocumentRecord { ): DocumentRecord {
const entry = { const entry = {
relativePath, relativePath,
documentId,
metadata: { metadata: {
documentId,
parentVersionId, parentVersionId,
hash: EMPTY_HASH, hash: EMPTY_HASH,
remoteRelativePath: relativePath remoteRelativePath: relativePath
@ -251,7 +250,7 @@ export class Database {
public getDocumentByDocumentId( public getDocumentByDocumentId(
find: DocumentId find: DocumentId
): DocumentRecord | undefined { ): DocumentRecord | undefined {
return this.documents.find(({ documentId }) => documentId === find); return this.documents.find(({ metadata }) => metadata?.documentId === find);
} }
public move( public move(
@ -331,8 +330,7 @@ export class Database {
public async save(): Promise<void> { public async save(): Promise<void> {
return this.saveData({ return this.saveData({
documents: this.resolvedDocuments.map( documents: this.resolvedDocuments.map(
({ relativePath, documentId, metadata }) => ({ ({ relativePath, metadata }) => ({
documentId,
relativePath, relativePath,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...metadata! // `resolvedDocuments` only returns docs with metadata set ...metadata! // `resolvedDocuments` only returns docs with metadata set
@ -346,9 +344,12 @@ export class Database {
private ensureConsistency(): void { private ensureConsistency(): void {
const idToPath = new Map<string, string[]>(); const idToPath = new Map<string, string[]>();
this.resolvedDocuments.forEach(({ relativePath, documentId }) => { this.resolvedDocuments.forEach(({ relativePath, metadata }) => {
idToPath.set(documentId, [ if (metadata === undefined) {
...(idToPath.get(documentId) ?? []), return;
}
idToPath.set(metadata.documentId, [
...(idToPath.get(metadata.documentId) ?? []),
relativePath relativePath
]); ]);
}); });
@ -360,7 +361,7 @@ export class Database {
if (duplicates.length > 0) { if (duplicates.length > 0) {
throw new Error( throw new Error(
"Document IDs are not unique, found duplicates: " + "Document IDs are not unique, found duplicates: " +
duplicates.join("; ") duplicates.join("; ")
); );
} }
} }

View file

@ -14,15 +14,14 @@ export class ServerConfig {
private response: Promise<PingResponse> | undefined; private response: Promise<PingResponse> | undefined;
private config: ServerConfigData | undefined; private config: ServerConfigData | undefined;
public constructor(private readonly syncService: SyncService) {} public constructor(private readonly syncService: SyncService) { }
private static validateConfig(config: ServerConfigData): void { private static validateConfig(config: ServerConfigData): void {
if (config.supportedApiVersion !== SUPPORTED_API_VERSION) { if (config.supportedApiVersion !== SUPPORTED_API_VERSION) {
const shouldUpgradeClient = const shouldUpgradeClient =
config.supportedApiVersion > SUPPORTED_API_VERSION; config.supportedApiVersion > SUPPORTED_API_VERSION;
throw new ServerVersionMismatchError( throw new ServerVersionMismatchError(
`Unsupported API version: ${config.supportedApiVersion}. Consider upgrading the ${ `Unsupported API version: ${config.supportedApiVersion}. Consider upgrading the ${shouldUpgradeClient ? "client" : "sync-server"
shouldUpgradeClient ? "client" : "sync-server"
} to ensure compatibility` } to ensure compatibility`
); );
} }
@ -34,11 +33,6 @@ export class ServerConfig {
} }
} }
// warm the cache
public async initialize(): Promise<void> {
await this.getConfig();
}
public async checkConnection(forceUpdate = false): Promise<{ public async checkConnection(forceUpdate = false): Promise<{
isSuccessful: boolean; isSuccessful: boolean;
message: string; message: string;

View file

@ -66,27 +66,29 @@ export class SyncService {
} }
public async create({ public async create({
documentId,
relativePath, relativePath,
contentBytes contentBytes,
forceMerge
}: { }: {
documentId?: DocumentId;
relativePath: RelativePath; relativePath: RelativePath;
contentBytes: Uint8Array; contentBytes: Uint8Array;
forceMerge?: boolean;
}): Promise<DocumentVersionWithoutContent> { }): Promise<DocumentVersionWithoutContent> {
return this.retryForever(async () => { return this.retryForever(async () => {
const formData = new FormData(); const formData = new FormData();
if (documentId !== undefined) {
formData.append("document_id", documentId);
}
formData.append("relative_path", relativePath); formData.append("relative_path", relativePath);
if (forceMerge === true) {
formData.append("force_merge", "true");
}
formData.append( formData.append(
"content", "content",
new Blob([new Uint8Array(contentBytes)]) new Blob([new Uint8Array(contentBytes)])
); );
this.logger.debug( this.logger.debug(
`Creating document with id ${documentId} and relative path ${relativePath}` `Creating document with relative path ${relativePath} (forceMerge: ${forceMerge})`
); );
const response = await this.client(this.getUrl("/documents"), { const response = await this.client(this.getUrl("/documents"), {
@ -155,8 +157,7 @@ export class SyncService {
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${ `Updated document ${JSON.stringify(result)} with id ${result.documentId
result.documentId
}}` }}`
); );
@ -208,8 +209,7 @@ export class SyncService {
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${ `Updated document ${JSON.stringify(result)} with id ${result.documentId
result.documentId
}}` }}`
); );
@ -336,7 +336,7 @@ export class SyncService {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug( this.logger.debug(
"Getting all documents" + "Getting all documents" +
(since != null ? ` since ${since}` : "") (since != null ? ` since ${since}` : "")
); );
const url = new URL(this.getUrl("/documents")); const url = new URL(this.getUrl("/documents"));

View file

@ -6,7 +6,7 @@ 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_S } from "../consts"; import { WEBSOCKET_DISCONNECT_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";
@ -36,7 +36,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 (
@ -69,10 +69,10 @@ export class WebSocketManager {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
reject( reject(
new Error( new Error(
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds` `Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS} seconds`
) )
); );
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000); }, WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS * 1000);
}); });
try { try {

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;
@ -472,7 +472,8 @@ export class SyncClient {
this.checkIfDestroyed("startSyncing"); this.checkIfDestroyed("startSyncing");
this.fetchController.finishReset(); this.fetchController.finishReset();
await this.serverConfig.initialize(); // warm the cache
await this.serverConfig.getConfig();
this.webSocketManager.start(); this.webSocketManager.start();
if (!this.hasStartedOfflineSync) { if (!this.hasStartedOfflineSync) {

View file

@ -113,7 +113,7 @@ export class CursorTracker {
documentsWithCursors.push({ documentsWithCursors.push({
relative_path: relativePath, relative_path: relativePath,
document_id: record.documentId, document_id: record.metadata.documentId,
vault_update_id: record.metadata.parentVersionId, vault_update_id: record.metadata.parentVersionId,
cursors: cursors.map(({ start, end }) => ({ cursors: cursors.map(({ start, end }) => ({
start: Math.min(start, end), start: Math.min(start, end),

View file

@ -8,13 +8,12 @@ import type { SyncService } from "../services/sync-service";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import PQueue from "p-queue"; import PQueue from "p-queue";
import { hash } from "../utils/hash"; import { hash } from "../utils/hash";
import { v4 as uuidv4 } from "uuid";
import type { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations"; import type { FileOperations } from "../file-operations/file-operations";
import { findMatchingFile } from "../utils/find-matching-file"; import { findMatchingFile } from "../utils/find-matching-file";
import type { UnrestrictedSyncer } from "./unrestricted-syncer"; import type { UnrestrictedSyncer } from "./unrestricted-syncer";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "../services/sync-reset-error"; import { SyncResetError } from "../errors/sync-reset-error";
import { Locks } from "../utils/data-structures/locks"; import { Locks } from "../utils/data-structures/locks";
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate"; import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate";
@ -98,9 +97,7 @@ export class Syncer {
const [promise, resolve, reject] = createPromise(); const [promise, resolve, reject] = createPromise();
const id = uuidv4();
const document = this.database.createNewPendingDocument( const document = this.database.createNewPendingDocument(
id,
relativePath, relativePath,
promise promise
); );
@ -171,7 +168,7 @@ export class Syncer {
// in that case, we mustn't move it again. // in that case, we mustn't move it again.
if ( if (
this.database.getLatestDocumentByRelativePath(relativePath) === this.database.getLatestDocumentByRelativePath(relativePath) ===
undefined || undefined ||
this.database.getLatestDocumentByRelativePath(relativePath) this.database.getLatestDocumentByRelativePath(relativePath)
?.isDeleted === true ?.isDeleted === true
) { ) {
@ -391,8 +388,6 @@ export class Syncer {
} }
private async internalScheduleSyncForOfflineChanges(): Promise<void> { private async internalScheduleSyncForOfflineChanges(): Promise<void> {
await this.createFakeDocumentsFromRemoteState();
const allLocalFiles = await this.operations.listFilesRecursively(); const allLocalFiles = await this.operations.listFilesRecursively();
this.logger.info( this.logger.info(
`Scheduling sync for ${allLocalFiles.length} local files` `Scheduling sync for ${allLocalFiles.length} local files`
@ -426,9 +421,19 @@ export class Syncer {
// Perhaps the file has been moved; let's check by looking at the deleted files // Perhaps the file has been moved; let's check by looking at the deleted files
const contentHash = await this.syncQueue.add(async () => { const contentHash = await this.syncQueue.add(async () => {
const contentBytes = try {
await this.operations.read(relativePath); // this can throw FileNotFoundError const contentBytes =
return hash(contentBytes); await this.operations.read(relativePath); // this can throw FileNotFoundError
return hash(contentBytes);
} catch (e) {
if (
e instanceof Error &&
e.name === "FileNotFoundError"
) {
return undefined;
}
throw e;
}
}); });
if (contentHash == undefined) { if (contentHash == undefined) {
@ -481,42 +486,9 @@ export class Syncer {
return this.syncLocallyDeletedFile(relativePath); return this.syncLocallyDeletedFile(relativePath);
}) })
); );
}
/**
* Create fake documents in the database for all files that are present locally
* and also exist remotely. This will stop the subequent syncs from duplicating
* the documents by creating the same documents from multiple clients.
*/
private async createFakeDocumentsFromRemoteState(): Promise<void> {
if (this.database.getHasInitialSyncCompleted()) {
return;
}
const [allLocalFiles, remote] = await awaitAll([
this.operations.listFilesRecursively(),
this.syncQueue.add(async () => this.syncService.getAll())
]);
if (remote !== undefined) {
remote.latestDocuments
.filter(
(remoteDocument) =>
allLocalFiles.includes(remoteDocument.relativePath) &&
!remoteDocument.isDeleted &&
this.database.getDocumentByDocumentId(
remoteDocument.documentId
) === undefined
)
.forEach((remoteDocument) => {
this.database.createNewEmptyDocument(
remoteDocument.documentId,
remoteDocument.vaultUpdateId,
remoteDocument.relativePath
);
});
}
this.database.setHasInitialSyncCompleted(true); this.database.setHasInitialSyncCompleted(true);
} }
} }

View file

@ -82,9 +82,9 @@ export class UnrestrictedSyncer {
const contentHash = hash(contentBytes); const contentHash = hash(contentBytes);
const response = await this.syncService.create({ const response = await this.syncService.create({
documentId: document.documentId,
relativePath: originalRelativePath, relativePath: originalRelativePath,
contentBytes contentBytes,
forceMerge: !this.database.getHasInitialSyncCompleted() // don't duplicate files on first sync
}); });
// In case a document with the same name (but different ID) had existed remotely that we haven't known about // In case a document with the same name (but different ID) had existed remotely that we haven't known about
@ -100,6 +100,7 @@ export class UnrestrictedSyncer {
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(
{ {
documentId: response.documentId,
parentVersionId: response.vaultUpdateId, parentVersionId: response.vaultUpdateId,
hash: contentHash, hash: contentHash,
remoteRelativePath: response.relativePath remoteRelativePath: response.relativePath
@ -131,13 +132,21 @@ export class UnrestrictedSyncer {
}; };
await this.executeSync(updateDetails, async () => { await this.executeSync(updateDetails, async () => {
if (document.metadata === undefined) {
this.logger.debug(
`Document ${document.relativePath} has no metadata, so it was never synced remotely`
);
return;
}
const response = await this.syncService.delete({ const response = await this.syncService.delete({
documentId: document.documentId, documentId: document.metadata.documentId,
relativePath: document.relativePath relativePath: document.relativePath
}); });
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(
{ {
...document.metadata,
parentVersionId: response.vaultUpdateId, parentVersionId: response.vaultUpdateId,
hash: EMPTY_HASH, hash: EMPTY_HASH,
remoteRelativePath: document.relativePath remoteRelativePath: document.relativePath
@ -170,14 +179,14 @@ export class UnrestrictedSyncer {
const updateDetails: SyncUpdateDetails | SyncMovedDetails = const updateDetails: SyncUpdateDetails | SyncMovedDetails =
oldPath !== undefined oldPath !== undefined
? { ? {
type: SyncType.MOVE, type: SyncType.MOVE,
relativePath: document.relativePath, relativePath: document.relativePath,
movedFrom: oldPath movedFrom: oldPath
} }
: { : {
type: SyncType.UPDATE, type: SyncType.UPDATE,
relativePath: document.relativePath relativePath: document.relativePath
}; };
await this.executeSync(updateDetails, async () => { await this.executeSync(updateDetails, async () => {
const originalRelativePath = document.relativePath; const originalRelativePath = document.relativePath;
@ -216,22 +225,22 @@ export class UnrestrictedSyncer {
response = response =
isText && cachedVersion !== undefined isText && cachedVersion !== undefined
? await this.syncService.putText({ ? await this.syncService.putText({
documentId: document.documentId, documentId: document.metadata.documentId,
parentVersionId: parentVersionId:
document.metadata.parentVersionId, document.metadata.parentVersionId,
relativePath: document.relativePath, relativePath: document.relativePath,
content: diff( content: diff(
new TextDecoder().decode(cachedVersion), new TextDecoder().decode(cachedVersion),
new TextDecoder().decode(contentBytes) new TextDecoder().decode(contentBytes)
) )
}) })
: await this.syncService.putBinary({ : await this.syncService.putBinary({
documentId: document.documentId, documentId: document.metadata.documentId,
parentVersionId: parentVersionId:
document.metadata.parentVersionId, document.metadata.parentVersionId,
relativePath: document.relativePath, relativePath: document.relativePath,
contentBytes contentBytes
}); });
} else { } else {
if (!force) { if (!force) {
this.logger.debug( this.logger.debug(
@ -241,7 +250,7 @@ export class UnrestrictedSyncer {
} }
response = await this.syncService.get({ response = await this.syncService.get({
documentId: document.documentId documentId: document.metadata.documentId
}); });
} }
@ -290,6 +299,7 @@ export class UnrestrictedSyncer {
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(
{ {
...document.metadata,
parentVersionId: response.vaultUpdateId, parentVersionId: response.vaultUpdateId,
hash: contentHash, hash: contentHash,
remoteRelativePath: response.relativePath remoteRelativePath: response.relativePath
@ -317,6 +327,7 @@ export class UnrestrictedSyncer {
} else { } else {
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(
{ {
...document.metadata,
parentVersionId: response.vaultUpdateId, parentVersionId: response.vaultUpdateId,
hash: contentHash, hash: contentHash,
remoteRelativePath: response.relativePath remoteRelativePath: response.relativePath
@ -334,16 +345,16 @@ export class UnrestrictedSyncer {
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: originalRelativePath
} }
: { : {
type: SyncType.UPDATE, type: SyncType.UPDATE,
relativePath: response.relativePath relativePath: response.relativePath
}; };
if (areThereLocalChanges) { if (areThereLocalChanges) {
this.history.addHistoryEntry({ this.history.addHistoryEntry({
@ -437,12 +448,12 @@ export class UnrestrictedSyncer {
const [promise, resolve] = createPromise(); const [promise, resolve] = createPromise();
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(
{ {
documentId: remoteVersion.documentId,
parentVersionId: remoteVersion.vaultUpdateId, parentVersionId: remoteVersion.vaultUpdateId,
hash: hash(contentBytes), hash: hash(contentBytes),
remoteRelativePath: remoteVersion.relativePath remoteRelativePath: remoteVersion.relativePath
}, },
this.database.createNewPendingDocument( this.database.createNewPendingDocument(
remoteVersion.documentId,
remoteVersion.relativePath, remoteVersion.relativePath,
promise promise
) )
@ -541,9 +552,8 @@ export class UnrestrictedSyncer {
type: SyncType.SKIPPED, type: SyncType.SKIPPED,
relativePath relativePath
}, },
message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${ message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB
maxFileSizeMB } MB`
} MB`
}; };
} }
} }
@ -582,6 +592,7 @@ export class UnrestrictedSyncer {
this.database.delete(document.relativePath); this.database.delete(document.relativePath);
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(
{ {
documentId: response.documentId,
parentVersionId: response.vaultUpdateId, parentVersionId: response.vaultUpdateId,
hash: EMPTY_HASH, hash: EMPTY_HASH,
remoteRelativePath: response.relativePath remoteRelativePath: response.relativePath

View file

@ -9,7 +9,7 @@ type ResolvedTuple<T extends readonly unknown[]> = {
export const awaitAll = async <T extends readonly unknown[]>( export const awaitAll = async <T extends readonly unknown[]>(
promises: PromiseTuple<T> promises: PromiseTuple<T>
): Promise<ResolvedTuple<T>> => { ): Promise<ResolvedTuple<T>> => {
// eslint-disable-next-line no-restricted-properties // eslint-disable-next-line no-restricted-properties, @typescript-eslint/await-thenable
const result = await Promise.allSettled(promises); const result = await Promise.allSettled(promises);
for (const res of result) { for (const res of result) {
if (res.status === "rejected") { if (res.status === "rejected") {

11
rustfmt.toml Normal file
View file

@ -0,0 +1,11 @@
# Rustfmt configuration
# This should match the .editorconfig settings
# Use spaces for indentation (matches .editorconfig indent_style = space)
hard_tabs = false
# Use 4 spaces for indentation (matches .editorconfig indent_size = 4)
tab_spaces = 4
# Use Unix line endings (matches .editorconfig end_of_line = lf)
newline_style = "Unix"

View file

@ -35,7 +35,8 @@ cd ..
cp frontend/obsidian-plugin/manifest.json manifest.json # for BRAT, otherwise it wouldn't update cp frontend/obsidian-plugin/manifest.json manifest.json # for BRAT, otherwise it wouldn't update
git ls-files | xargs npx eclint fix # Format all files across the project (frontend and backend)
npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}"
# Commit and tag # Commit and tag
git add . git add .

View file

@ -45,10 +45,11 @@ cd frontend
npm run build npm run build
npm run test npm run test
npm run lint npm run lint
cd ..
# Use git ls-files to only check tracked files, respecting .gitignore # Format all files across the project (frontend and backend)
# We always run in fix mode and then check with git status # Prettier respects .gitignore by default
git ls-files | xargs npx eclint fix npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}"
if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then
git status --porcelain git status --porcelain
@ -56,6 +57,4 @@ if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then
exit 1 exit 1
fi fi
cd ..
echo "Success" echo "Success"

View file

@ -12,5 +12,7 @@ cp -r sync-server/bindings/* frontend/sync-client/src/services/types/
cd frontend cd frontend
npm run lint npm run lint
git ls-files | xargs npx eclint fix cd ..
cd -
# Format all files across the project (frontend and backend)
npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}"

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

@ -20,4 +20,4 @@ pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Info;
pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"]; pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"];
pub const SUPPORTED_API_VERSION: u32 = 2; pub const SUPPORTED_API_VERSION: u32 = 3;

View file

@ -5,7 +5,7 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use log::{debug, error}; use log::debug;
use serde::Serialize; use serde::Serialize;
use thiserror::Error; use thiserror::Error;
use ts_rs::TS; use ts_rs::TS;

View file

@ -11,10 +11,11 @@ use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion};
use crate::{ use crate::{
app_state::{ app_state::{
AppState, AppState,
database::models::{DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, database::models::{StoredDocumentVersion, VaultId},
}, },
config::user_config::User, config::user_config::User,
errors::{SyncServerError, client_error, server_error}, errors::{SyncServerError, server_error},
server::{responses::DocumentUpdateResponse, update_document::merge_with_stored_version},
utils::{ utils::{
find_first_available_path::find_first_available_path, normalize::normalize, find_first_available_path::find_first_available_path, normalize::normalize,
sanitize_path::sanitize_path, sanitize_path::sanitize_path,
@ -37,7 +38,7 @@ pub async fn create_document(
TypedHeader(device_id): TypedHeader<DeviceIdHeader>, TypedHeader(device_id): TypedHeader<DeviceIdHeader>,
State(state): State<AppState>, State(state): State<AppState>,
TypedMultipart(request): TypedMultipart<CreateDocumentVersion>, TypedMultipart(request): TypedMultipart<CreateDocumentVersion>,
) -> Result<Json<DocumentVersionWithoutContent>, SyncServerError> { ) -> Result<Json<DocumentUpdateResponse>, SyncServerError> {
debug!("Creating document in vault `{vault_id}`"); debug!("Creating document in vault `{vault_id}`");
let mut transaction = state let mut transaction = state
@ -46,24 +47,39 @@ pub async fn create_document(
.await .await
.map_err(server_error)?; .map_err(server_error)?;
let document_id = match request.document_id { let sanitized_relative_path = sanitize_path(&request.relative_path);
Some(document_id) => {
let existing_version = state
.database
.get_latest_document(&vault_id, &document_id, Some(&mut transaction))
.await
.map_err(server_error)?;
if existing_version.is_some() { if request.force_merge.unwrap_or_default() {
return Err(client_error(anyhow::anyhow!( let latest_version = state
"Document with the same ID `{document_id}` already exists" .database
))); .get_latest_document_by_path(
} &vault_id,
&sanitized_relative_path,
Some(&mut transaction),
)
.await
.map_err(server_error)?;
if let Some(latest_version) = latest_version {
info!(
"Document already exists at new location: `{sanitized_relative_path}` when trying to create it in vault `{vault_id}`, merging into existing document"
);
document_id return merge_with_stored_version(
latest_version.clone(),
latest_version,
vault_id,
user,
device_id,
state,
&sanitized_relative_path,
request.content.contents.to_vec(),
transaction,
)
.await;
} }
None => uuid::Uuid::new_v4(), }
};
let document_id = uuid::Uuid::new_v4();
let last_update_id = state let last_update_id = state
.database .database
@ -71,7 +87,6 @@ pub async fn create_document(
.await .await
.map_err(server_error)?; .map_err(server_error)?;
let sanitized_relative_path = sanitize_path(&request.relative_path);
let deduped_path = find_first_available_path( let deduped_path = find_first_available_path(
&vault_id, &vault_id,
&sanitized_relative_path, &sanitized_relative_path,
@ -105,5 +120,7 @@ pub async fn create_document(
.await .await
.map_err(server_error)?; .map_err(server_error)?;
Ok(Json(new_version.into())) Ok(Json(DocumentUpdateResponse::FastForwardUpdate(
new_version.into(),
)))
} }

View file

@ -4,18 +4,16 @@ use reconcile_text::NumberOrText;
use serde::{self, Deserialize}; use serde::{self, Deserialize};
use ts_rs::TS; use ts_rs::TS;
use crate::app_state::database::models::{DocumentId, VaultUpdateId}; use crate::app_state::database::models::VaultUpdateId;
#[derive(TS, Debug, TryFromMultipart)] #[derive(TS, Debug, TryFromMultipart)]
#[ts(export)] #[ts(export)]
pub struct CreateDocumentVersion { pub struct CreateDocumentVersion {
/// The client can decide the document id (if it wishes to) in order
/// to help with syncing. If the client does not provide a document id,
/// the server will generate one. If the client provides a document id
/// it must not already exist in the database.
pub document_id: Option<DocumentId>,
pub relative_path: String, pub relative_path: String,
// whether to merge with existing document at the same path if it exists
pub force_merge: Option<bool>,
#[ts(as = "Vec<u8>")] #[ts(as = "Vec<u8>")]
#[form_data(limit = "unlimited")] #[form_data(limit = "unlimited")]
pub content: FieldData<Bytes>, pub content: FieldData<Bytes>,

View file

@ -16,7 +16,10 @@ use super::{
use crate::{ use crate::{
app_state::{ app_state::{
AppState, AppState,
database::models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, database::{
Transaction,
models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId},
},
}, },
config::user_config::User, config::user_config::User,
errors::{SyncServerError, client_error, not_found_error, server_error}, errors::{SyncServerError, client_error, not_found_error, server_error},
@ -141,12 +144,6 @@ async fn update_document(
.await .await
.map_err(server_error)?; .map_err(server_error)?;
let last_update_id = state
.database
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
.await
.map_err(server_error)?;
let latest_version = state let latest_version = state
.database .database
.get_latest_document(&vault_id, &document_id, Some(&mut transaction)) .get_latest_document(&vault_id, &document_id, Some(&mut transaction))
@ -174,12 +171,39 @@ async fn update_document(
))); )));
} }
merge_with_stored_version(
parent_document,
latest_version,
vault_id,
user,
device_id,
state,
&sanitized_relative_path,
content,
transaction,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn merge_with_stored_version(
parent_document: StoredDocumentVersion,
latest_version: StoredDocumentVersion,
vault_id: VaultId,
user: User,
device_id: DeviceIdHeader,
state: AppState,
sanitized_relative_path: &str,
content: Vec<u8>,
mut transaction: Transaction<'_>,
) -> Result<Json<DocumentUpdateResponse>, SyncServerError> {
// Return the latest version if the content and path are the same as the latest // Return the latest version if the content and path are the same as the latest
// version // version
if content == latest_version.content && sanitized_relative_path == latest_version.relative_path if content == latest_version.content && sanitized_relative_path == latest_version.relative_path
{ {
info!( info!(
"Document content is the same as the latest version for `{document_id}`, skipping update" "Document content is the same as the latest version for `{}`, skipping update",
parent_document.document_id
); );
transaction transaction
.rollback() .rollback()
@ -193,14 +217,17 @@ async fn update_document(
} }
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!("Merging changes for document `{document_id}` in vault `{vault_id}`"); info!(
"Merging changes for document `{}` in vault `{vault_id}`",
parent_document.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"),
@ -227,7 +254,7 @@ async fn update_document(
{ {
let new_path = find_first_available_path( let new_path = find_first_available_path(
&vault_id, &vault_id,
&sanitized_relative_path, sanitized_relative_path,
&state.database, &state.database,
&mut transaction, &mut transaction,
) )
@ -245,8 +272,14 @@ async fn update_document(
latest_version.relative_path.clone() latest_version.relative_path.clone()
}; };
let last_update_id = state
.database
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
.await
.map_err(server_error)?;
let new_version = StoredDocumentVersion { let new_version = StoredDocumentVersion {
document_id, document_id: parent_document.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,