Improve settings #168

Merged
schmelczer merged 14 commits from asch/explore into main 2025-11-19 19:53:10 +00:00
19 changed files with 301 additions and 127 deletions

View file

@ -16,10 +16,14 @@ LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.authors="andras@schmelczer.dev" LABEL org.opencontainers.image.authors="andras@schmelczer.dev"
COPY --from=builder /build/local-client-cli/dist/cli.js /app/cli.js COPY --from=builder /build/local-client-cli/dist/cli.js /app/cli.js
COPY --from=builder /build/local-client-cli/dist/healthcheck.js /app/healthcheck.js
HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=1 \
CMD node /app/healthcheck.js /tmp/vaultlink-health.json
WORKDIR /vault WORKDIR /vault
VOLUME ["/vault"] VOLUME ["/vault"]
ENTRYPOINT ["node", "/app/cli.js"] ENTRYPOINT ["node", "/app/cli.js", "--health", "/tmp/vaultlink-health.json"]
CMD ["--help"] CMD ["--help"]

View file

@ -12,6 +12,8 @@ export interface CliArgs {
ignorePatterns?: string[]; ignorePatterns?: string[];
webSocketRetryIntervalMs?: number; webSocketRetryIntervalMs?: number;
logLevel: LogLevel; logLevel: LogLevel;
health?: string;
enableTelemetry?: boolean;
} }
export function parseArgs(argv: string[]): CliArgs { export function parseArgs(argv: string[]): CliArgs {
@ -51,6 +53,14 @@ export function parseArgs(argv: string[]): CliArgs {
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
"INFO" "INFO"
) )
.option(
"--health <path>",
"[OPTIONAL] Path to health status file for Docker healthcheck"
)
.option(
"--enable-telemetry",
"[OPTIONAL] Enable telemetry (disabled by default)"
)
.addHelpText( .addHelpText(
"after", "after",
` `
@ -78,6 +88,8 @@ Examples:
| number | number
| undefined; | undefined;
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
const health = opts.health as string | undefined;
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
if (localPath === undefined) { if (localPath === undefined) {
@ -117,6 +129,8 @@ Examples:
maxFileSizeMB: maxFileSizeMb, maxFileSizeMB: maxFileSizeMb,
ignorePatterns: ignorePattern, ignorePatterns: ignorePattern,
webSocketRetryIntervalMs: websocketRetryIntervalMs, webSocketRetryIntervalMs: websocketRetryIntervalMs,
logLevel logLevel,
health,
enableTelemetry
}; };
} }

View file

@ -1,5 +1,7 @@
import * as path from "path"; import * as path from "path";
import * as fs from "fs/promises"; import * as fs from "fs/promises";
import * as fsSync from "fs";
import type { NetworkConnectionStatus } from "sync-client";
import { import {
SyncClient, SyncClient,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
@ -13,6 +15,19 @@ import { FileWatcher } from "./file-watcher";
import { formatLogLine, colorize, styleText } from "./logger-formatter"; import { formatLogLine, colorize, styleText } from "./logger-formatter";
import packageJson from "../package.json"; import packageJson from "../package.json";
function writeHealthStatus(
filePath: string,
connectionStatus: NetworkConnectionStatus
): void {
try {
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
} catch (error) {
console.error(
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
const LOG_LEVEL_ORDER = { const LOG_LEVEL_ORDER = {
[LogLevel.DEBUG]: 0, [LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1, [LogLevel.INFO]: 1,
@ -78,11 +93,13 @@ async function main(): Promise<void> {
syncConcurrency: syncConcurrency:
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency, args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
diffCacheSizeMB: DEFAULT_SETTINGS.diffCacheSizeMB,
ignorePatterns, ignorePatterns,
webSocketRetryIntervalMs: webSocketRetryIntervalMs:
args.webSocketRetryIntervalMs ?? args.webSocketRetryIntervalMs ??
DEFAULT_SETTINGS.webSocketRetryIntervalMs, DEFAULT_SETTINGS.webSocketRetryIntervalMs,
isSyncEnabled: true isSyncEnabled: true,
enableTelemetry: args.enableTelemetry ?? false
}; };
const client = await SyncClient.create({ const client = await SyncClient.create({
@ -119,6 +136,21 @@ async function main(): Promise<void> {
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
copilot-pull-request-reviewer[bot] commented 2025-11-18 22:19:54 +00:00 (Migrated from github.com)

The setInterval creates an untracked timer that will continue running indefinitely. Consider storing the timer reference and clearing it during cleanup or process termination to prevent potential resource leaks.

		const healthInterval = setInterval(() => {
			void client.checkConnection().then((status) => {
				writeHealthStatus(healthFile, status);
			});
		}, 30 * 1000); // every 30 seconds
		const clearHealthInterval = () => clearInterval(healthInterval);
		process.on("SIGINT", clearHealthInterval);
		process.on("SIGTERM", clearHealthInterval);
		process.on("exit", clearHealthInterval);
The setInterval creates an untracked timer that will continue running indefinitely. Consider storing the timer reference and clearing it during cleanup or process termination to prevent potential resource leaks. ```suggestion const healthInterval = setInterval(() => { void client.checkConnection().then((status) => { writeHealthStatus(healthFile, status); }); }, 30 * 1000); // every 30 seconds const clearHealthInterval = () => clearInterval(healthInterval); process.on("SIGINT", clearHealthInterval); process.on("SIGTERM", clearHealthInterval); process.on("exit", clearHealthInterval); ```
}); });
if (args.health !== undefined) {
const healthFile = args.health;
const healthInterval = setInterval(() => {
void client.checkConnection().then((status) => {
writeHealthStatus(healthFile, status);
});
}, 30 * 1000); // every 30 seconds
const clearHealthInterval = (): void => {
clearInterval(healthInterval);
};
process.on("SIGINT", clearHealthInterval);
process.on("SIGTERM", clearHealthInterval);
process.on("exit", clearHealthInterval);
}
// Add colored log formatter with level filtering // Add colored log formatter with level filtering
client.logger.addOnMessageListener((logLine) => { client.logger.addOnMessageListener((logLine) => {
// Only show messages at or above the configured log level // Only show messages at or above the configured log level
@ -132,7 +164,10 @@ async function main(): Promise<void> {
const fileWatcher = new FileWatcher(absolutePath, client); const fileWatcher = new FileWatcher(absolutePath, client);
client.addWebSocketStatusChangeListener(() => { client.addWebSocketStatusChangeListener(() => {
client.logger.info("WebSocket status changed"); const isConnected = client.isWebSocketConnected;
client.logger.info(
`WebSocket status changed: ${isConnected ? "connected" : "disconnected"}`
);
}); });
client.addRemainingSyncOperationsListener((remaining) => { client.addRemainingSyncOperationsListener((remaining) => {

View file

@ -0,0 +1,66 @@
#!/usr/bin/env node
copilot-pull-request-reviewer[bot] commented 2025-11-18 22:19:54 +00:00 (Migrated from github.com)

The type guard always returns true for any non-null object, failing to validate the actual structure of NetworkConnectionStatus. This will pass invalid objects that lack required properties like isSuccessful, isWebSocketConnected, or serverMessage, leading to runtime errors when those properties are accessed on line 44.

	const obj = value as Record<string, unknown>;
	return (
		typeof obj.isSuccessful === "boolean" &&
		typeof obj.isWebSocketConnected === "boolean" &&
		typeof obj.serverMessage === "string"
	);
The type guard always returns true for any non-null object, failing to validate the actual structure of NetworkConnectionStatus. This will pass invalid objects that lack required properties like isSuccessful, isWebSocketConnected, or serverMessage, leading to runtime errors when those properties are accessed on line 44. ```suggestion const obj = value as Record<string, unknown>; return ( typeof obj.isSuccessful === "boolean" && typeof obj.isWebSocketConnected === "boolean" && typeof obj.serverMessage === "string" ); ```
/**
* Healthcheck script for Docker container
* Checks if the sync client is connected to the server
*/
import * as fs from "fs";
import type { NetworkConnectionStatus } from "sync-client";
function isHealthStatus(value: unknown): value is NetworkConnectionStatus {
if (typeof value !== "object" || value === null) {
return false;
}
return (
"isSuccessful" in value &&
typeof value.isSuccessful === "boolean" &&
"isWebSocketConnected" in value &&
typeof value.isWebSocketConnected === "boolean" &&
"serverMessage" in value &&
typeof value.serverMessage === "string"
);
}
function main(): void {
if (process.argv.length < 3) {
console.error("Usage: healthcheck <path-to-health-file>");
process.exit(1);
}
const [, , healthFile] = process.argv;
try {
// Check if health file exists
if (!fs.existsSync(healthFile)) {
console.error(`Health file does not exist: ${healthFile}`);
process.exit(1);
}
// Read and parse health status
const content = fs.readFileSync(healthFile, "utf-8");
const parsed: unknown = JSON.parse(content);
// Validate the parsed object using type guard
if (!isHealthStatus(parsed)) {
throw new Error("Invalid health status format");
}
const status = parsed;
if (!status.isSuccessful || !status.isWebSocketConnected) {
console.error("Not connected to server: " + status.serverMessage);
process.exit(1);
}
console.log("Healthy: Connected to server");
process.exit(0);
} catch (error) {
console.error(
`Health check failed: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
}
main();

View file

@ -2,7 +2,10 @@ const path = require("path");
const webpack = require("webpack"); const webpack = require("webpack");
module.exports = { module.exports = {
entry: "./src/cli.ts", entry: {
cli: "./src/cli.ts",
healthcheck: "./src/healthcheck.ts"
},
target: "node", target: "node",
mode: "production", mode: "production",
optimization: { optimization: {
@ -21,7 +24,7 @@ module.exports = {
}, },
output: { output: {
globalObject: "this", globalObject: "this",
filename: "cli.js", filename: "[name].js",
path: path.resolve(__dirname, "dist") path: path.resolve(__dirname, "dist")
}, },
plugins: [ plugins: [

View file

@ -13,8 +13,6 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@plausible-analytics/tracker": "^0.4.3",
"@sentry/browser": "^10.8.0",
"@types/node": "^24.8.1", "@types/node": "^24.8.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -22,7 +20,7 @@
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"mini-css-extract-plugin": "^2.9.2", "mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.10.2", "obsidian": "1.10.2",
"reconcile-text": "^0.5.0", "reconcile-text": "^0.7.1",
"resolve-url-loader": "^5.0.0", "resolve-url-loader": "^5.0.0",
"sass": "^1.91.0", "sass": "^1.91.0",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
@ -33,7 +31,6 @@
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "5.8.3", "typescript": "5.8.3",
"url": "^0.11.4", "url": "^0.11.4",
"virtual-scroller": "^1.13.1",
"webpack": "^5.99.9", "webpack": "^5.99.9",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
} }

View file

@ -11,8 +11,6 @@ import { HistoryView } from "./views/history/history-view";
import { StatusBar } from "./views/status-bar/status-bar"; import { StatusBar } from "./views/status-bar/status-bar";
import { LogsView } from "./views/logs/logs-view"; import { LogsView } from "./views/logs/logs-view";
import { StatusDescription } from "./views/status-description/status-description"; import { StatusDescription } from "./views/status-description/status-description";
import * as Sentry from "@sentry/browser";
import { init as plausibleInit } from "@plausible-analytics/tracker";
import { import {
SyncClient, SyncClient,
rateLimit, rateLimit,
@ -50,45 +48,6 @@ export default class VaultLinkPlugin extends Plugin {
".trash/**" ".trash/**"
); );
plausibleInit({
domain: "vault-link",
endpoint: "https://stats.schmelczer.dev/status",
autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true
});
Sentry.init({
dsn: "https://56accd39d92442e788a457a04623cf57@bugs.schmelczer.dev/1",
skipBrowserExtensionCheck: false
});
const onError = (event: ErrorEvent): void => {
Sentry.captureException(event.error, {
extra: {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno
}
});
};
window.addEventListener("error", onError);
this.disposables.push(() => {
window.removeEventListener("error", onError);
});
const onUnhandledRejection = (event: PromiseRejectionEvent): void => {
Sentry.captureException(event.reason);
};
window.addEventListener("unhandledrejection", onUnhandledRejection);
this.disposables.push(() => {
window.removeEventListener(
"unhandledrejection",
onUnhandledRejection
);
});
const isDebugBuild = process.env.NODE_ENV === "development"; const isDebugBuild = process.env.NODE_ENV === "development";
const debugOptions = isDebugBuild const debugOptions = isDebugBuild
? { ? {

View file

@ -33,7 +33,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
isOutdated: boolean; isOutdated: boolean;
}[] = []; }[] = [];
private static app: App; private static app?: App;
public decorations: DecorationSet = RangeSet.of([]); public decorations: DecorationSet = RangeSet.of([]);
public static setCursors( public static setCursors(
@ -88,7 +88,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
private static findFileForEditor( private static findFileForEditor(
editor: EditorView editor: EditorView
): RelativePath | undefined { ): RelativePath | undefined {
return RemoteCursorsPluginValue.app.workspace return RemoteCursorsPluginValue.app?.workspace
.getLeavesOfType("markdown") .getLeavesOfType("markdown")
.map((leaf) => leaf.view) .map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView) .filter((view) => view instanceof MarkdownView)

View file

@ -72,6 +72,7 @@ export class SyncSettingsTab extends PluginSettingTab {
this.renderSettingsHeader(containerEl); this.renderSettingsHeader(containerEl);
this.renderConnectionSettings(containerEl); this.renderConnectionSettings(containerEl);
this.renderSyncSettings(containerEl); this.renderSyncSettings(containerEl);
this.renderMiscSettings(containerEl);
} }
public hide(): void { public hide(): void {
@ -193,38 +194,28 @@ export class SyncSettingsTab extends PluginSettingTab {
}) })
); );
new Setting(containerEl) new Setting(containerEl).addButton((button) =>
.addButton((button) => button
button.setButtonText("Apply").onClick(async () => { .setButtonText("Apply & test connection")
.onClick(async () => {
if (this.areThereUnsavedChanges()) { if (this.areThereUnsavedChanges()) {
await this.syncClient.setSettings({ await this.syncClient.setSettings({
vaultName: this.editedVaultName, vaultName: this.editedVaultName,
remoteUri: this.editedServerUri, remoteUri: this.editedServerUri,
token: this.editedToken token: this.editedToken
}); });
new Notice("Checking connection to the server...");
new Notice( new Notice(
"The changes have been applied successfully!" (
await this.syncClient.checkConnection()
).serverMessage
); );
await this.statusDescription.updateConnectionState(); await this.statusDescription.updateConnectionState();
} else { } else {
new Notice("No changes to apply"); new Notice("No changes to apply");
} }
}) })
) );
.addButton((button) =>
button.setButtonText("Test connection").onClick(async () => {
if (this.areThereUnsavedChanges()) {
new Notice(
"There are unsaved changes, testing with the currently saved settings"
);
}
new Notice(
(await this.syncClient.checkConnection()).serverMessage
);
await this.statusDescription.updateConnectionState();
})
);
} }
private areThereUnsavedChanges(): boolean { private areThereUnsavedChanges(): boolean {
@ -339,6 +330,26 @@ export class SyncSettingsTab extends PluginSettingTab {
); );
} }
private renderMiscSettings(containerEl: HTMLElement): void {
containerEl.createEl("h3", { text: "Other" });
new Setting(containerEl)
.setName("Enable telemetry")
.setDesc(
"Allow sending anonymous usage data & error reports to help improve the plugin. The data collected is never shared with third parties."
)
.setTooltip(
"Allow sending anonymous usage data & error reports to help improve the plugin. The data collected is never shared with third parties."
)
.addToggle((toggle) =>
toggle
.setValue(this.syncClient.getSettings().enableTelemetry)
.onChange(async (value) =>
this.syncClient.setSetting("enableTelemetry", value)
)
);
}
private setStatusDescriptionSubscription( private setStatusDescriptionSubscription(
newSubscription?: () => unknown newSubscription?: () => unknown
): void { ): void {

View file

@ -871,13 +871,6 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@plausible-analytics/tracker": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.3.tgz",
"integrity": "sha512-RKTgH5xu7Pa77VS4OEnS4woPhDxRgWLJlt9f6JhwgBC9ilknCfJIVEN2A1D8OR7hzgxMQF/hPyls9iN9ReAm3Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": { "node_modules/@sentry-internal/browser-utils": {
"version": "10.8.0", "version": "10.8.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.8.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.8.0.tgz",
@ -3488,10 +3481,9 @@
} }
}, },
"node_modules/reconcile-text": { "node_modules/reconcile-text": {
"version": "0.5.0", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.5.0.tgz", "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.7.1.tgz",
"integrity": "sha512-zki3lqw9Oxdhm9ZvDN17VyYoL1Isc8BEL07ILVDE2yGfNEI7thrkczoNCUr+hkFU2rzZtfxECTG0b7p61AJ6wg==", "integrity": "sha512-khedcYvAKs7ELKh5Z8mz2vyomMY5TqznV1dB+k/7qUAX9cheMNN5/EPJVQYZepOMunYbnQitvhFJX3kD4IMcNw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/regex-parser": { "node_modules/regex-parser": {
@ -3499,11 +3491,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/request-animation-frame-timeout": {
"version": "2.0.4",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"dev": true, "dev": true,
@ -4329,14 +4316,6 @@
"resolved": "obsidian-plugin", "resolved": "obsidian-plugin",
"link": true "link": true
}, },
"node_modules/virtual-scroller": {
"version": "1.13.1",
"dev": true,
"license": "MIT",
"dependencies": {
"request-animation-frame-timeout": "^2.0.3"
}
},
"node_modules/w3c-keyname": { "node_modules/w3c-keyname": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@ -4661,8 +4640,6 @@
"version": "0.10.0", "version": "0.10.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@plausible-analytics/tracker": "^0.4.3",
"@sentry/browser": "^10.8.0",
"@types/node": "^24.8.1", "@types/node": "^24.8.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -4670,7 +4647,7 @@
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"mini-css-extract-plugin": "^2.9.2", "mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.10.2", "obsidian": "1.10.2",
"reconcile-text": "^0.5.0", "reconcile-text": "^0.7.1",
"resolve-url-loader": "^5.0.0", "resolve-url-loader": "^5.0.0",
"sass": "^1.91.0", "sass": "^1.91.0",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
@ -4681,7 +4658,6 @@
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "5.8.3", "typescript": "5.8.3",
"url": "^0.11.4", "url": "^0.11.4",
"virtual-scroller": "^1.13.1",
"webpack": "^5.99.9", "webpack": "^5.99.9",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
} }
@ -4696,6 +4672,7 @@
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@sentry/browser": "^10.8.0",
"@types/node": "^24.8.1", "@types/node": "^24.8.1",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"tslib": "2.8.1", "tslib": "2.8.1",
@ -4729,12 +4706,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"sync-client/node_modules/reconcile-text": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.7.1.tgz",
"integrity": "sha512-khedcYvAKs7ELKh5Z8mz2vyomMY5TqznV1dB+k/7qUAX9cheMNN5/EPJVQYZepOMunYbnQitvhFJX3kD4IMcNw==",
"license": "MIT"
},
"test-client": { "test-client": {
"version": "0.10.0", "version": "0.10.0",
"bin": { "bin": {

View file

@ -28,6 +28,7 @@
"webpack": "^5.99.9", "webpack": "^5.99.9",
"webpack-cli": "^6.0.1", "webpack-cli": "^6.0.1",
"webpack-merge": "^6.0.1", "webpack-merge": "^6.0.1",
"@sentry/browser": "^10.8.0",
"ws": "^8.18.3" "ws": "^8.18.3"
} }
} }

View file

@ -9,6 +9,8 @@ export interface SyncSettings {
maxFileSizeMB: number; maxFileSizeMB: number;
ignorePatterns: string[]; ignorePatterns: string[];
webSocketRetryIntervalMs: number; webSocketRetryIntervalMs: number;
diffCacheSizeMB: number;
enableTelemetry: boolean;
} }
export const DEFAULT_SETTINGS: SyncSettings = { export const DEFAULT_SETTINGS: SyncSettings = {
@ -19,7 +21,9 @@ export const DEFAULT_SETTINGS: SyncSettings = {
isSyncEnabled: false, isSyncEnabled: false,
maxFileSizeMB: 10, maxFileSizeMB: 10,
ignorePatterns: [], ignorePatterns: [],
webSocketRetryIntervalMs: 3500 webSocketRetryIntervalMs: 3500,
diffCacheSizeMB: 4,
enableTelemetry: false
}; };
export class Settings { export class Settings {

View file

@ -22,11 +22,13 @@ import type { CursorSpan } from "./services/types/CursorSpan";
import type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors"; import type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
import { FileChangeNotifier } from "./sync-operations/file-change-notifier"; import { FileChangeNotifier } from "./sync-operations/file-change-notifier";
import { FixedSizeDocumentCache } from "./utils/fix-sized-cache"; import { FixedSizeDocumentCache } from "./utils/fix-sized-cache";
import { setUpTelemetry } from "./utils/set-up-telemetry";
export class SyncClient { export class SyncClient {
private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000;
private hasStartedOfflineSync = false; private hasStartedOfflineSync = false;
private hasFinishedOfflineSync = false; private hasFinishedOfflineSync = false;
private unloadTelemetry?: () => void;
private constructor( private constructor(
private readonly history: SyncHistory, private readonly history: SyncHistory,
@ -38,8 +40,13 @@ export class SyncClient {
private readonly _logger: Logger, private readonly _logger: Logger,
private readonly connectionStatus: ConnectionStatus, private readonly connectionStatus: ConnectionStatus,
private readonly cursorTracker: CursorTracker, private readonly cursorTracker: CursorTracker,
private readonly fileChangeNotifier: FileChangeNotifier private readonly fileChangeNotifier: FileChangeNotifier,
private readonly contentCache: FixedSizeDocumentCache
) { ) {
if (settings.getSettings().enableTelemetry) {
this.unloadTelemetry = setUpTelemetry();
}
this.settings.addOnSettingsChangeListener( this.settings.addOnSettingsChangeListener(
async (newSettings, oldSettings) => { async (newSettings, oldSettings) => {
if (newSettings.vaultName !== oldSettings.vaultName) { if (newSettings.vaultName !== oldSettings.vaultName) {
@ -53,6 +60,24 @@ export class SyncClient {
this.stop(); this.stop();
} }
} }
if (
newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB
) {
this.contentCache.resize(
newSettings.diffCacheSizeMB * 1024 * 1024
);
}
if (
newSettings.enableTelemetry !== oldSettings.enableTelemetry
) {
if (newSettings.enableTelemetry) {
this.unloadTelemetry = setUpTelemetry();
} else {
this.unloadTelemetry?.();
}
}
} }
); );
} }
@ -65,6 +90,10 @@ export class SyncClient {
return this.database.length; return this.database.length;
} }
public get isWebSocketConnected(): boolean {
return this.webSocketManager.isWebSocketConnected;
}
public static async create({ public static async create({
fs, fs,
persistence, persistence,
@ -152,8 +181,7 @@ export class SyncClient {
settings, settings,
syncService, syncService,
fileOperations, fileOperations,
unrestrictedSyncer, unrestrictedSyncer
contentCache
); );
const webSocketManager = new WebSocketManager( const webSocketManager = new WebSocketManager(
@ -182,7 +210,8 @@ export class SyncClient {
logger, logger,
connectionStatus, connectionStatus,
cursorTracker, cursorTracker,
fileChangeNotifier fileChangeNotifier,
contentCache
); );
logger.info("SyncClient initialised"); logger.info("SyncClient initialised");
@ -235,6 +264,7 @@ export class SyncClient {
public async reset(): Promise<void> { public async reset(): Promise<void> {
this.stop(); this.stop();
this.connectionStatus.startReset(); this.connectionStatus.startReset();
this.contentCache.clear();
await this.syncer.reset(); await this.syncer.reset();
this.history.reset(); this.history.reset();
this.database.reset(); this.database.reset();

View file

@ -34,8 +34,7 @@ export class Syncer {
settings: Settings, settings: Settings,
private readonly syncService: SyncService, private readonly syncService: SyncService,
private readonly operations: FileOperations, private readonly operations: FileOperations,
private readonly internalSyncer: UnrestrictedSyncer, private readonly internalSyncer: UnrestrictedSyncer
private readonly contentCache: FixedSizeDocumentCache
) { ) {
this.syncQueue = new PQueue({ this.syncQueue = new PQueue({
concurrency: settings.getSettings().syncConcurrency concurrency: settings.getSettings().syncConcurrency
@ -252,7 +251,6 @@ export class Syncer {
public async reset(): Promise<void> { public async reset(): Promise<void> {
await this.waitUntilFinished(); await this.waitUntilFinished();
this.contentCache.clear();
} }
public async syncRemotelyUpdatedFile( public async syncRemotelyUpdatedFile(

View file

@ -236,4 +236,40 @@ describe("fixedSizeDocumentCache", () => {
assert.equal(cache.get(2), doc2); assert.equal(cache.get(2), doc2);
assert.equal(cache.get(3), doc3); assert.equal(cache.get(3), doc3);
}); });
it("resizeToLargerSizeNoEviction", async () => {
const cache = new FixedSizeDocumentCache(4);
const doc1 = new Uint8Array([1, 2]);
const doc2 = new Uint8Array([3, 4]);
cache.put(1, doc1);
cache.put(2, doc2);
cache.resize(10);
assert.equal(cache.get(1), doc1);
assert.equal(cache.get(2), doc2);
});
it("resizeCausesMultipleEvictions", async () => {
const cache = new FixedSizeDocumentCache(10);
const doc1 = new Uint8Array([1, 2]);
const doc2 = new Uint8Array([3, 4]);
const doc3 = new Uint8Array([5, 6]);
const doc4 = new Uint8Array([7, 8]);
cache.put(1, doc1);
cache.put(2, doc2);
cache.put(3, doc3);
cache.put(4, doc4);
// Cache has 8 bytes total
cache.resize(2);
// Should evict doc1, doc2, doc3 to get down to 2 bytes
assert.equal(cache.get(1), undefined);
assert.equal(cache.get(2), undefined);
assert.equal(cache.get(3), undefined);
assert.equal(cache.get(4), doc4);
});
}); });

View file

@ -14,14 +14,12 @@ class LRUNode {
// evicting the least recently used documents when the size limit is exceeded. // evicting the least recently used documents when the size limit is exceeded.
export class FixedSizeDocumentCache { export class FixedSizeDocumentCache {
private readonly maxSizeInBytes: number;
private currentSizeInBytes: number; private currentSizeInBytes: number;
private readonly cache: Map<VaultUpdateId, LRUNode>; private readonly cache: Map<VaultUpdateId, LRUNode>;
private head: LRUNode | null; // Least recently used private head: LRUNode | null; // Least recently used
private tail: LRUNode | null; // Most recently used private tail: LRUNode | null; // Most recently used
public constructor(maxSizeInBytes: number) { public constructor(private maxSizeInBytes: number) {
this.maxSizeInBytes = maxSizeInBytes;
this.currentSizeInBytes = 0; this.currentSizeInBytes = 0;
this.cache = new Map(); this.cache = new Map();
this.head = null; this.head = null;
@ -56,14 +54,7 @@ export class FixedSizeDocumentCache {
this.cache.set(updateId, newNode); this.cache.set(updateId, newNode);
this.addToTail(newNode); this.addToTail(newNode);
this.currentSizeInBytes += content.byteLength; this.currentSizeInBytes += content.byteLength;
this.fitBelowMaxSize();
// Evict least recently used documents if over size limit
while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) {
const lruNode = this.head;
this.removeNode(lruNode);
this.cache.delete(lruNode.key);
this.currentSizeInBytes -= lruNode.value.byteLength;
}
} }
public clear(): void { public clear(): void {
@ -73,6 +64,21 @@ export class FixedSizeDocumentCache {
this.currentSizeInBytes = 0; this.currentSizeInBytes = 0;
} }
public resize(newMaxSizeInBytes: number): void {
this.maxSizeInBytes = newMaxSizeInBytes;
this.fitBelowMaxSize();
}
private fitBelowMaxSize(): void {
// Evict least recently used documents if over size limit
while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) {
const lruNode = this.head;
this.removeNode(lruNode);
this.cache.delete(lruNode.key);
this.currentSizeInBytes -= lruNode.value.byteLength;
}
}
private removeNode(node: LRUNode): void { private removeNode(node: LRUNode): void {
if (node.prev) { if (node.prev) {
node.prev.next = node.next; node.prev.next = node.next;

View file

@ -0,0 +1,33 @@
import * as Sentry from "@sentry/browser";
export const setUpTelemetry = (): (() => void) => {
Sentry.init({
dsn: "https://56accd39d92442e788a457a04623cf57@bugs.schmelczer.dev/1",
skipBrowserExtensionCheck: false
});
const onError = (event: ErrorEvent): void => {
Sentry.captureException(event.error, {
extra: {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno
}
});
};
window.addEventListener("error", onError);
const onUnhandledRejection = (event: PromiseRejectionEvent): void => {
Sentry.captureException(event.reason);
};
window.addEventListener("unhandledrejection", onUnhandledRejection);
return (): void => {
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onUnhandledRejection);
Sentry.close(5000).catch(() => {
// Ignore errors during shutdown
});
};
};

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "vault-link",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -92,9 +92,9 @@ fn set_up_logging(
.with_line_number(is_debug_mode) .with_line_number(is_debug_mode)
.compact(); .compact();
let stdout_layer = tracing_subscriber::fmt::layer() let stderr_layer = tracing_subscriber::fmt::layer()
.with_ansi(use_colors) .with_ansi(use_colors)
.with_writer(std::io::stdout) .with_writer(std::io::stderr)
.event_format(format.clone()); .event_format(format.clone());
let file_layer = tracing_subscriber::fmt::layer() let file_layer = tracing_subscriber::fmt::layer()
@ -104,8 +104,8 @@ fn set_up_logging(
tracing_subscriber::registry() tracing_subscriber::registry()
.with(env_filter) .with(env_filter)
.with(stdout_layer)
.with(file_layer) .with(file_layer)
.with(stderr_layer)
.try_init() .try_init()
.context("Failed to initialise tracing") .context("Failed to initialise tracing")
.map_err(init_error)?; .map_err(init_error)?;