This commit is contained in:
Andras Schmelczer 2025-03-15 14:12:04 +00:00
commit 9ad54eff7a
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C
21 changed files with 76 additions and 75 deletions

View file

@ -9,10 +9,7 @@ export class ObsidianFileEventHandler {
if (file instanceof TFile) { if (file instanceof TFile) {
this.client.logger.info(`File created: ${file.path}`); this.client.logger.info(`File created: ${file.path}`);
await this.client.syncer.syncLocallyCreatedFile( await this.client.syncer.syncLocallyCreatedFile(file.path);
file.path,
new Date(file.stat.ctime)
);
} else { } else {
this.client.logger.debug(`Folder created: ${file.path}, ignored`); this.client.logger.debug(`Folder created: ${file.path}, ignored`);
} }
@ -34,8 +31,7 @@ export class ObsidianFileEventHandler {
await this.client.syncer.syncLocallyUpdatedFile({ await this.client.syncer.syncLocallyUpdatedFile({
oldPath, oldPath,
relativePath: file.path, relativePath: file.path
updateTime: new Date(file.stat.ctime)
}); });
} else { } else {
this.client.logger.debug( this.client.logger.debug(
@ -53,8 +49,7 @@ export class ObsidianFileEventHandler {
this.client.logger.info(`File modified: ${file.path}`); this.client.logger.info(`File modified: ${file.path}`);
await this.client.syncer.syncLocallyUpdatedFile({ await this.client.syncer.syncLocallyUpdatedFile({
relativePath: file.path, relativePath: file.path
updateTime: new Date(file.stat.ctime)
}); });
} else { } else {
this.client.logger.debug(`Folder modified: ${file.path}, ignored`); this.client.logger.debug(`Folder modified: ${file.path}, ignored`);

View file

@ -60,7 +60,7 @@ export class HistoryView extends ItemView {
} }
element.createEl("span", { element.createEl("span", {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
text: entry.relativePath text: entry.relativePath
}); });

View file

@ -1,6 +1,6 @@
import type { WorkspaceLeaf } from "obsidian"; import type { WorkspaceLeaf } from "obsidian";
import { ItemView } from "obsidian"; import { ItemView } from "obsidian";
import type VaultLinkPlugin from "src/vault-link-plugin"; import type VaultLinkPlugin from "../vault-link-plugin";
import type { SyncClient } from "sync-client"; import type { SyncClient } from "sync-client";
export class LogsView extends ItemView { export class LogsView extends ItemView {

View file

@ -1,7 +1,7 @@
import type { App } from "obsidian"; import type { App } from "obsidian";
import { Notice, PluginSettingTab, Setting } from "obsidian"; import { Notice, PluginSettingTab, Setting } from "obsidian";
import type VaultLinkPlugin from "src/vault-link-plugin"; import type VaultLinkPlugin from "../vault-link-plugin";
import type { StatusDescription } from "./status-description"; import type { StatusDescription } from "./status-description";
import { LogsView } from "./logs-view"; import { LogsView } from "./logs-view";
import { HistoryView } from "./history-view"; import { HistoryView } from "./history-view";

View file

@ -1,5 +1,5 @@
import type { HistoryStats, SyncClient } from "sync-client"; import type { HistoryStats, SyncClient } from "sync-client";
import type VaultLinkPlugin from "src/vault-link-plugin"; import type VaultLinkPlugin from "../vault-link-plugin";
export class StatusBar { export class StatusBar {
private readonly statusBarItem: HTMLElement; private readonly statusBarItem: HTMLElement;

View file

@ -1,6 +1,9 @@
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import type { RelativePath } from "../persistence/database"; import type { RelativePath } from "../persistence/database";
// Manages locks on documents to prevent concurrent modifications
// allowing the client's FileOperations implementation to be simpler.
// Locks are granted in a first-in-first-out order.
export class DocumentLocks { export class DocumentLocks {
private readonly locked = new Set<RelativePath>(); private readonly locked = new Set<RelativePath>();
private readonly waiters = new Map<RelativePath, (() => void)[]>(); private readonly waiters = new Map<RelativePath, (() => void)[]>();

View file

@ -1,4 +1,3 @@
import type { FileSystemOperations } from "sync-client";
import type { import type {
Database, Database,
DocumentRecord, DocumentRecord,
@ -7,6 +6,7 @@ import type {
import { FileOperations } from "./file-operations"; import { FileOperations } from "./file-operations";
import { Logger } from "../tracing/logger"; import { Logger } from "../tracing/logger";
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
import type { FileSystemOperations } from "./filesystem-operations";
describe("File operations", () => { describe("File operations", () => {
class MockDatabase { class MockDatabase {

View file

@ -1,6 +1,6 @@
import type { Logger } from "src/tracing/logger"; import type { Logger } from "../tracing/logger";
import type { FileSystemOperations } from "./filesystem-operations"; import type { FileSystemOperations } from "./filesystem-operations";
import type { Database, RelativePath } from "src/persistence/database"; import type { Database, RelativePath } from "../persistence/database";
import { isBinary, isFileTypeMergable, mergeText } from "sync_lib"; import { isBinary, isFileTypeMergable, mergeText } from "sync_lib";
import { import {
FileNotFoundError, FileNotFoundError,

View file

@ -1,4 +1,4 @@
import type { RelativePath } from "src/persistence/database"; import type { RelativePath } from "../persistence/database";
export interface FileSystemOperations { export interface FileSystemOperations {
listAllFiles: () => Promise<RelativePath[]>; listAllFiles: () => Promise<RelativePath[]>;

View file

@ -12,7 +12,8 @@ export class FileNotFoundError extends Error {
// Decorate FileSystemOperations replacing errors with FileNotFoundError // Decorate FileSystemOperations replacing errors with FileNotFoundError
// if the accessed file doesn't exist. It also ensures that there's only // if the accessed file doesn't exist. It also ensures that there's only
// ever a single request in-flight for any one file. // ever a single request in-flight for any one file through the use of
// DocumentLocks.
export class SafeFileSystemOperations implements FileSystemOperations { export class SafeFileSystemOperations implements FileSystemOperations {
private readonly locks: DocumentLocks; private readonly locks: DocumentLocks;
@ -24,7 +25,6 @@ export class SafeFileSystemOperations implements FileSystemOperations {
} }
public async listAllFiles(): Promise<RelativePath[]> { public async listAllFiles(): Promise<RelativePath[]> {
this.logger.debug("Listing all files");
return this.fs.listAllFiles(); return this.fs.listAllFiles();
} }

View file

@ -216,7 +216,7 @@ export class Database {
relativePath: RelativePath, relativePath: RelativePath,
promise: Promise<void> promise: Promise<void>
): Promise<void> { ): Promise<void> {
let entry = this.getLatestDocumentByRelativePath(relativePath); const entry = this.getLatestDocumentByRelativePath(relativePath);
if (entry === undefined) { if (entry === undefined) {
throw new Error( throw new Error(
@ -238,7 +238,7 @@ export class Database {
relativePath: RelativePath, relativePath: RelativePath,
promise: Promise<void> promise: Promise<void>
): void { ): void {
let previousEntry = this.getLatestDocumentByRelativePath(relativePath); const previousEntry = this.getLatestDocumentByRelativePath(relativePath);
const entry = { const entry = {
relativePath, relativePath,
@ -300,7 +300,7 @@ export class Database {
({ identity }) => identity !== oldDocument.identity ({ identity }) => identity !== oldDocument.identity
); );
let newDocument = this.getLatestDocumentByRelativePath(newRelativePath); const newDocument = this.getLatestDocumentByRelativePath(newRelativePath);
if (newDocument !== undefined && !newDocument.isDeleted) { if (newDocument !== undefined && !newDocument.isDeleted) {
throw new Error( throw new Error(
`Document already exists at new location: ${newRelativePath}` `Document already exists at new location: ${newRelativePath}`

View file

@ -1,5 +1,5 @@
import type { Logger } from "src/tracing/logger"; import type { Logger } from "../tracing/logger";
import { LogLevel } from "src/tracing/logger"; import { LogLevel } from "../tracing/logger";
export interface SyncSettings { export interface SyncSettings {
remoteUri: string; remoteUri: string;

View file

@ -1,5 +1,5 @@
import { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
import { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import { retriedFetchFactory } from "../utils/retried-fetch"; import { retriedFetchFactory } from "../utils/retried-fetch";

View file

@ -8,7 +8,7 @@ import type {
} from "../persistence/database"; } from "../persistence/database";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
import { ConnectedState } from "./connected-state"; import type { ConnectedState } from "./connected-state";
export interface CheckConnectionResult { export interface CheckConnectionResult {
isSuccessful: boolean; isSuccessful: boolean;

View file

@ -1,17 +1,17 @@
import type { Database, RelativePath } from "../persistence/database"; import type { Database, RelativePath } from "../persistence/database";
import type { SyncService } from "src/services/sync-service"; import type { SyncService } from "../services/sync-service";
import type { Logger } from "src/tracing/logger"; import type { Logger } from "../tracing/logger";
import type { SyncHistory } from "src/tracing/sync-history"; import type { SyncHistory } from "../tracing/sync-history";
import PQueue from "p-queue"; import PQueue from "p-queue";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { hash } from "src/utils/hash"; import { hash } from "../utils/hash";
import type { components } from "src/services/types"; import type { components } from "../services/types";
import type { Settings } from "src/persistence/settings"; import type { Settings } from "../persistence/settings";
import type { FileOperations } from "src/file-operations/file-operations"; import type { FileOperations } from "../file-operations/file-operations";
import { findMatchingFile } from "src/utils/find-matching-file"; import { findMatchingFile } from "../utils/find-matching-file";
import { UnrestrictedSyncer } from "./unrestricted-syncer"; import { UnrestrictedSyncer } from "./unrestricted-syncer";
import { FileNotFoundError } from "src/file-operations/safe-filesystem-operations"; import { FileNotFoundError } from "../file-operations/safe-filesystem-operations";
import { createPromise } from "src/utils/create-promise"; import { createPromise } from "../utils/create-promise";
export class Syncer { export class Syncer {
private readonly remainingOperationsListeners: (( private readonly remainingOperationsListeners: ((
@ -45,9 +45,9 @@ export class Syncer {
}); });
this.syncQueue.on("active", () => this.syncQueue.on("active", () =>
this.remainingOperationsListeners.forEach((listener) => { this.remainingOperationsListeners.forEach((listener) =>
listener(this.syncQueue.size) { listener(this.syncQueue.size); }
) ); }
); );
this.internalSyncer = new UnrestrictedSyncer( this.internalSyncer = new UnrestrictedSyncer(
@ -107,7 +107,7 @@ export class Syncer {
); );
try { try {
await this.syncQueue.add(() => await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncLocallyCreatedFile( this.internalSyncer.unrestrictedSyncLocallyCreatedFile(
proposedDocumentId, proposedDocumentId,
() => this.database.getDocumentByUpdatePromise(promise) () => this.database.getDocumentByUpdatePromise(promise)
@ -261,7 +261,7 @@ export class Syncer {
public async reset(): Promise<void> { public async reset(): Promise<void> {
this.syncQueue.clear(); this.syncQueue.clear();
await this.syncQueue.onEmpty(); await this.syncQueue.onEmpty();
this.remainingOperationsListeners.forEach((listener) => listener(0)); this.remainingOperationsListeners.forEach((listener) => { listener(0); });
this.internalSyncer.reset(); this.internalSyncer.reset();
} }
@ -297,7 +297,7 @@ export class Syncer {
private async syncRemotelyUpdatedFile( private async syncRemotelyUpdatedFile(
remoteVersion: components["schemas"]["DocumentVersionWithoutContent"] remoteVersion: components["schemas"]["DocumentVersionWithoutContent"]
): Promise<void> { ): Promise<void> {
let document = this.database.getDocumentByDocumentId( const document = this.database.getDocumentByDocumentId(
remoteVersion.documentId remoteVersion.documentId
); );

View file

@ -5,18 +5,18 @@ import type {
RelativePath RelativePath
} from "../persistence/database"; } from "../persistence/database";
import type { SyncService } from "src/services/sync-service"; import type { SyncService } from "../services/sync-service";
import { Logger } from "src/tracing/logger"; import type { Logger } from "../tracing/logger";
import type { SyncHistory } from "src/tracing/sync-history"; import type { SyncHistory } from "../tracing/sync-history";
import { SyncSource, SyncStatus, SyncType } from "src/tracing/sync-history"; import { SyncSource, SyncStatus, SyncType } from "../tracing/sync-history";
import { EMPTY_HASH, hash } from "src/utils/hash"; import { EMPTY_HASH, hash } from "../utils/hash";
import type { components } from "src/services/types"; import type { components } from "../services/types";
import { deserialize } from "src/utils/deserialize"; import { deserialize } from "../utils/deserialize";
import type { Settings } from "src/persistence/settings"; import type { Settings } from "../persistence/settings";
import type { FileOperations } from "src/file-operations/file-operations"; import type { FileOperations } from "../file-operations/file-operations";
import { FileNotFoundError } from "src/file-operations/safe-filesystem-operations"; import { FileNotFoundError } from "../file-operations/safe-filesystem-operations";
import { DocumentLocks } from "../file-operations/document-locks"; import { DocumentLocks } from "../file-operations/document-locks";
import { createPromise } from "src/utils/create-promise"; import { createPromise } from "../utils/create-promise";
export class UnrestrictedSyncer { export class UnrestrictedSyncer {
private readonly locks: DocumentLocks; private readonly locks: DocumentLocks;
@ -289,7 +289,6 @@ export class UnrestrictedSyncer {
let localMetadata = getLatestDocument(); let localMetadata = getLatestDocument();
if ( if (
localMetadata !== undefined &&
localMetadata?.metadata !== undefined localMetadata?.metadata !== undefined
) { ) {
// If the file exists locally, let's pretend the user has updated it // If the file exists locally, let's pretend the user has updated it
@ -352,11 +351,11 @@ export class UnrestrictedSyncer {
remoteVersion.relativePath, remoteVersion.relativePath,
contentBytes, contentBytes,
() => () =>
this.database.getNewResolvedDocumentByRelativePath( { this.database.getNewResolvedDocumentByRelativePath(
remoteVersion.documentId, remoteVersion.documentId,
remoteVersion.relativePath, remoteVersion.relativePath,
promise promise
) ); }
); );
const document = const document =

View file

@ -1,4 +1,4 @@
import type { RelativePath } from "src/persistence/database"; import type { RelativePath } from "../persistence/database";
import type { Logger } from "./logger"; import type { Logger } from "./logger";
export interface CommonHistoryEntry { export interface CommonHistoryEntry {

View file

@ -1,6 +1,6 @@
import * as fetchRetryFactory from "fetch-retry"; import * as fetchRetryFactory from "fetch-retry";
import type { RequestInitRetryParams } from "fetch-retry"; import type { RequestInitRetryParams } from "fetch-retry";
import type { Logger } from "src/tracing/logger"; import type { Logger } from "../tracing/logger";
function getUrlFromInput(input: RequestInfo | URL): string { function getUrlFromInput(input: RequestInfo | URL): string {
if (input instanceof URL) { if (input instanceof URL) {

View file

@ -1,12 +1,17 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"module": "ESNext", "module": "ESNext",
"target": "ESNext", "target": "ESNext",
"strict": true, "strict": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"lib": ["DOM", "ESNext"] "moduleResolution": "bundler",
"lib": [
"DOM", // to get "fetch"
],
"declaration": true,
"declarationDir": "./dist/types"
}, },
"exclude": ["./dist"] "exclude": [
"./dist"
]
} }

View file

@ -1,10 +1,10 @@
import { assert } from "../utils/assert"; import { assert } from "../utils/assert";
import type { import {
RelativePath, type RelativePath,
FileSystemOperations, type FileSystemOperations,
SyncSettings type SyncSettings,
SyncClient
} from "sync-client"; } from "sync-client";
import { SyncClient } from "sync-client";
export class MockClient implements FileSystemOperations { export class MockClient implements FileSystemOperations {
protected readonly localFiles = new Map<string, Uint8Array>(); protected readonly localFiles = new Map<string, Uint8Array>();
@ -24,8 +24,8 @@ export class MockClient implements FileSystemOperations {
await Promise.all( await Promise.all(
Object.keys(this.initialSettings).map(async (key) => { Object.keys(this.initialSettings).map(async (key) => {
return this.client.settings.setSetting( return this.client.settings.setSetting(
key as keyof SyncSettings, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion key as keyof SyncSettings,
this.initialSettings[key as keyof SyncSettings] // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion this.initialSettings[key as keyof SyncSettings]!
); );
}) })
); );
@ -88,10 +88,10 @@ export class MockClient implements FileSystemOperations {
const newParts = newContent.split(" ").map((part) => part.trim()); const newParts = newContent.split(" ").map((part) => part.trim());
existingParts.forEach((part) => existingParts.forEach((part) =>
// all changes should be additive // all changes should be additive
assert( { assert(
newParts.includes(part), newParts.includes(part),
`Part ${part} not found in new content` `Part ${part} not found in new content`
) ); }
); );
this.client.logger.info( this.client.logger.info(

View file

@ -92,7 +92,7 @@ async function runTest({
async function runTests(): Promise<void> { async function runTests(): Promise<void> {
const agentCounts = [2, 8]; const agentCounts = [2, 8];
const jitterScaleInSeconds = [0.5, 0, 2]; const jitterScaleInSeconds = [0.5, 0, 2];
const concurrencies = [1]; const concurrencies = [16, 1];
const iterations = [50, 200]; const iterations = [50, 200];
const doDeletes = [true, false]; const doDeletes = [true, false];
@ -101,7 +101,7 @@ async function runTests(): Promise<void> {
for (const jitter of jitterScaleInSeconds) { for (const jitter of jitterScaleInSeconds) {
for (const iteration of iterations) { for (const iteration of iterations) {
for (const deleteFiles of doDeletes) { for (const deleteFiles of doDeletes) {
for (let i = 0; i < 10; i++) { for (let i = 0; i < 20; i++) {
await runTest({ await runTest({
agentCount, agentCount,
concurrency, concurrency,
@ -110,7 +110,6 @@ async function runTests(): Promise<void> {
jitterScaleInSeconds: jitter jitterScaleInSeconds: jitter
}); });
} }
return;
} }
} }
} }