From 7b27dcd1b52d01c619fbb56f21e03f8c18f0f94b Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Fri, 16 Jan 2026 10:09:42 -0800 Subject: [PATCH 001/139] fix: mark HasProvider as synced if it has ever synced this session --- src/HasProvider.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/HasProvider.ts b/src/HasProvider.ts index 3cef93e0..6ce9449a 100644 --- a/src/HasProvider.ts +++ b/src/HasProvider.ts @@ -58,6 +58,9 @@ export class HasProvider extends HasLogging { path?: string; ydoc: Y.Doc; clientToken: ClientToken; + // Track if provider has ever synced. We use our own flag because + // _provider.synced can be reset to false on reconnection. + _providerSynced: boolean = false; private _offConnectionError: () => void; private _offState: () => void; listeners: Map; @@ -216,7 +219,7 @@ export class HasProvider extends HasLogging { } public get synced(): boolean { - return this._provider.synced; + return this._providerSynced; } disconnect() { @@ -249,11 +252,12 @@ export class HasProvider extends HasLogging { } onceProviderSynced(): Promise { - if (this._provider.synced) { + if (this._providerSynced) { return Promise.resolve(); } return new Promise((resolve) => { this._provider.once("synced", () => { + this._providerSynced = true; resolve(); }); }); From 28b788ad680ce6bb29f70ede55e668517bef6331 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Fri, 16 Jan 2026 10:23:13 -0800 Subject: [PATCH 002/139] fix: don't block LiveView creation on folder.ready --- src/LiveViews.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/LiveViews.ts b/src/LiveViews.ts index 5ce09795..c2b4afb8 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -991,21 +991,21 @@ export class LiveViewManager { } const folder = this.sharedFolders.lookup(viewFilePath); if (folder && canvasView.file) { - const canvas = folder.getFile(canvasView.file); - if (isCanvas(canvas)) { - if (!this.loginManager.loggedIn) { - const view = new LoggedOutView(this, canvasView, () => { - return this.loginManager.openLoginPage(); - }); - views.push(view); - } else if (folder.ready) { + if (!this.loginManager.loggedIn) { + const view = new LoggedOutView(this, canvasView, () => { + return this.loginManager.openLoginPage(); + }); + views.push(view); + } else if (folder.ready) { + const canvas = folder.getFile(canvasView.file); + if (isCanvas(canvas)) { const view = new RelayCanvasView(this, canvasView, canvas); views.push(view); } else { - this.log(`Folder not ready, skipping views. folder=${folder.path}`); + this.log(`Skipping canvas view connection for ${viewFilePath}`); } } else { - this.log(`Skipping canvas view connection for ${viewFilePath}`); + this.log(`Folder not ready, skipping views. folder=${folder.path}`); } } }); From 841a1c6a7a9429f1ae02d96a56f6461d3fa0e677 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Fri, 16 Jan 2026 11:05:40 -0800 Subject: [PATCH 003/139] feat: add metrics infrastructure for obsidian-metrics integration Adds RelayMetrics class that integrates with the obsidian-metrics plugin when available, with graceful no-op fallback when disabled. --- src/debug.ts | 79 +++++++++++++++++ src/main.ts | 2 + src/types/obsidian-metrics.d.ts | 149 ++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 src/types/obsidian-metrics.d.ts diff --git a/src/debug.ts b/src/debug.ts index 494f1aa2..b5ee2518 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -277,3 +277,82 @@ const debug = BUILD_TYPE === "debug"; export function createToast(notifier: INotifier) { return createToastFunction(notifier, debug); } + +// ============================================================================ +// Metrics Integration (for obsidian-metrics plugin) +// ============================================================================ + +import type { + IObsidianMetricsAPI, + MetricInstance, + ObsidianMetricsPlugin, +} from "./types/obsidian-metrics"; + +/** + * Metrics for Relay - uses obsidian-metrics plugin if available, no-ops otherwise. + * + * Uses event-based initialization to handle plugin load order. The obsidian-metrics + * plugin emits 'obsidian-metrics:ready' when loaded, and metric creation is idempotent. + */ +class RelayMetrics { + private dbSize: MetricInstance | null = null; + private compactions: MetricInstance | null = null; + private compactionDuration: MetricInstance | null = null; + + /** + * Initialize metrics from the API. Called when obsidian-metrics becomes available. + * Safe to call multiple times - metric creation is idempotent. + */ + initializeFromAPI(api: IObsidianMetricsAPI): void { + this.dbSize = api.createGauge({ + name: "relay_db_size", + help: "Number of updates stored in IndexedDB per document", + labelNames: ["document"], + }); + this.compactions = api.createCounter({ + name: "relay_compactions_total", + help: "Total compaction operations", + labelNames: ["document"], + }); + this.compactionDuration = api.createHistogram({ + name: "relay_compaction_duration_seconds", + help: "Compaction duration in seconds", + labelNames: ["document"], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10], + }); + } + + setDbSize(document: string, count: number): void { + this.dbSize?.labels({ document }).set(count); + } + + recordCompaction(document: string, durationSeconds: number): void { + this.compactions?.labels({ document }).inc(); + this.compactionDuration?.labels({ document }).observe(durationSeconds); + } +} + +/** + * Initialize metrics integration with Obsidian app. + * Sets up event listener for obsidian-metrics:ready and checks if already available. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function initializeMetrics(app: any, registerEvent: (eventRef: any) => void): void { + // Listen for metrics API becoming available (or re-initializing after reload) + registerEvent( + app.workspace.on("obsidian-metrics:ready", (api: IObsidianMetricsAPI) => { + metrics.initializeFromAPI(api); + }) + ); + + // Also try to get it immediately in case metrics plugin loaded first + const metricsPlugin = app.plugins?.plugins?.["obsidian-metrics"] as + | ObsidianMetricsPlugin + | undefined; + if (metricsPlugin?.api) { + metrics.initializeFromAPI(metricsPlugin.api); + } +} + +/** Singleton metrics instance */ +export const metrics = new RelayMetrics(); diff --git a/src/main.ts b/src/main.ts index 386ac662..68c8e930 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,7 @@ import { RelayInstances, initializeLogger, flushLogs, + initializeMetrics, } from "./debug"; import { getPatcher, Patcher } from "./Patcher"; import { LiveTokenStore } from "./LiveTokenStore"; @@ -329,6 +330,7 @@ export default class Live extends Plugin { disableConsole: false, // Disable console logging }, ); + initializeMetrics(this.app, (ref) => this.registerEvent(ref)); this.notifier = new ObsidianNotifier(); this.debug = curryLog("[System 3][Relay]", "debug"); diff --git a/src/types/obsidian-metrics.d.ts b/src/types/obsidian-metrics.d.ts new file mode 100644 index 00000000..254d5c27 --- /dev/null +++ b/src/types/obsidian-metrics.d.ts @@ -0,0 +1,149 @@ +/** + * Type declarations for the Obsidian Metrics API + * + * Copy this file into your plugin to get type-safe access to the metrics API. + * + * ## Accessing the API + * + * Access via the plugin instance: + * ```typescript + * const metricsPlugin = this.app.plugins.plugins['obsidian-metrics'] as ObsidianMetricsPlugin | undefined; + * const api = metricsPlugin?.api; + * ``` + * + * ## Handling Plugin Load Order + * + * The metrics plugin emits 'obsidian-metrics:ready' when loaded. Listen for this + * event to handle cases where your plugin loads before obsidian-metrics: + * + * ```typescript + * class MyPlugin extends Plugin { + * private metricsApi: IObsidianMetricsAPI | undefined; + * private documentGauge: MetricInstance | undefined; + * + * async onload() { + * // Listen for metrics API becoming available (or re-initializing after reload) + * this.registerEvent( + * this.app.workspace.on('obsidian-metrics:ready', (api: IObsidianMetricsAPI) => { + * this.initializeMetrics(api); + * }) + * ); + * + * // Also try to get it immediately in case metrics plugin loaded first + * const metricsPlugin = this.app.plugins.plugins['obsidian-metrics'] as ObsidianMetricsPlugin | undefined; + * if (metricsPlugin?.api) { + * this.initializeMetrics(metricsPlugin.api); + * } + * } + * + * private initializeMetrics(api: IObsidianMetricsAPI) { + * this.metricsApi = api; + * // Metric creation is idempotent - safe to call multiple times + * this.documentGauge = api.createGauge({ + * name: 'my_document_size_bytes', + * help: 'Size of documents in bytes', + * labelNames: ['document'] + * }); + * } + * + * updateDocumentSize(doc: string, bytes: number) { + * this.documentGauge?.labels({ document: doc }).set(bytes); + * } + * } + * ``` + * + * ## Key Points + * + * - **Do NOT cache the API or metrics long-term** - they become stale if obsidian-metrics reloads + * - Listen for 'obsidian-metrics:ready' and re-initialize your metrics each time it fires + * - Metric creation is idempotent: calling createGauge() with the same name returns the existing metric + * - It's safe to store metric references within an initialization cycle, but always re-create them + * when 'obsidian-metrics:ready' fires + */ + +export interface MetricLabels { + [key: string]: string; +} + +export interface CounterOptions { + name: string; + help: string; + labelNames?: string[]; +} + +export interface GaugeOptions { + name: string; + help: string; + labelNames?: string[]; +} + +export interface HistogramOptions { + name: string; + help: string; + labelNames?: string[]; + buckets?: number[]; +} + +export interface SummaryOptions { + name: string; + help: string; + labelNames?: string[]; + percentiles?: number[]; + maxAgeSeconds?: number; + ageBuckets?: number; +} + +export interface LabeledMetricInstance { + inc(value?: number): void; + dec(value?: number): void; + set(value: number): void; + observe(value: number): void; + startTimer(): () => void; +} + +export interface MetricInstance { + inc(value?: number, labels?: MetricLabels): void; + dec(value?: number, labels?: MetricLabels): void; + set(value: number, labels?: MetricLabels): void; + observe(value: number, labels?: MetricLabels): void; + startTimer(labels?: MetricLabels): () => void; + labels(labels: MetricLabels): LabeledMetricInstance; +} + +export interface IObsidianMetricsAPI { + // Metric retrieval + getMetric(name: string): MetricInstance | undefined; + getAllMetrics(): Promise; + clearMetric(name: string): boolean; + clearAllMetrics(): void; + + // Metric creation (idempotent - returns existing metric if name matches) + createCounter(options: CounterOptions): MetricInstance; + createGauge(options: GaugeOptions): MetricInstance; + createHistogram(options: HistogramOptions): MetricInstance; + createSummary(options: SummaryOptions): MetricInstance; + + // Convenience methods (create + optional initial value) + counter(name: string, help: string, value?: number): MetricInstance; + gauge(name: string, help: string, value?: number): MetricInstance; + histogram(name: string, help: string, buckets?: number[]): MetricInstance; + summary(name: string, help: string, percentiles?: number[]): MetricInstance; + + // Timing utilities + createTimer(metricName: string): () => number; + measureAsync(metricName: string, fn: () => Promise): Promise; + measureSync(metricName: string, fn: () => T): T; +} + +/** Type for the obsidian-metrics plugin instance */ +export interface ObsidianMetricsPlugin { + api: IObsidianMetricsAPI; +} + +/** Augment Obsidian's workspace events to include our custom event */ +declare module 'obsidian' { + interface Workspace { + on(name: 'obsidian-metrics:ready', callback: (api: IObsidianMetricsAPI) => void): EventRef; + trigger(name: 'obsidian-metrics:ready', api: IObsidianMetricsAPI): void; + } +} From 60c5bc90b67914d73238952511273f223d05e4cd Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Wed, 21 Jan 2026 10:44:38 -0800 Subject: [PATCH 004/139] feat: track IndexedDB document size and compaction metrics --- src/storage/y-indexeddb.js | 63 ++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/src/storage/y-indexeddb.js b/src/storage/y-indexeddb.js index 3ed601c1..cf724cac 100644 --- a/src/storage/y-indexeddb.js +++ b/src/storage/y-indexeddb.js @@ -2,10 +2,25 @@ import * as Y from 'yjs' import * as idb from 'lib0/indexeddb' import * as promise from 'lib0/promise' import { Observable } from 'lib0/observable' +import { metrics } from '../debug' const customStoreName = 'custom' const updatesStoreName = 'updates' +/** + * Compare two Uint8Arrays for equality + * @param {Uint8Array} a + * @param {Uint8Array} b + * @returns {boolean} + */ +const uint8ArrayEquals = (a, b) => { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + // Use a higher threshold on startup to avoid slow initial compaction // After sync, use the lower threshold to keep the database lean export const STARTUP_TRIM_SIZE = 500 @@ -27,7 +42,10 @@ export const fetchUpdates = (idbPersistence, beforeApplyUpdatesCallback = () => } }) .then(() => idb.getLastKey(updatesStore).then(lastKey => { idbPersistence._dbref = lastKey + 1 })) - .then(() => idb.count(updatesStore).then(cnt => { idbPersistence._dbsize = cnt })) + .then(() => idb.count(updatesStore).then(cnt => { + idbPersistence._dbsize = cnt + metrics.setDbSize(idbPersistence.name, cnt) + })) .then(() => { if (!idbPersistence._destroyed) { afterApplyUpdatesCallback(updatesStore) @@ -44,9 +62,18 @@ export const storeState = (idbPersistence, forceStore = true) => fetchUpdates(idbPersistence) .then(updatesStore => { if (forceStore || idbPersistence._dbsize >= RUNTIME_TRIM_SIZE) { - idb.addAutoKey(updatesStore, Y.encodeStateAsUpdate(idbPersistence.doc)) + const compactedState = Y.encodeStateAsUpdate(idbPersistence.doc) + const startTime = performance.now() + idb.addAutoKey(updatesStore, compactedState) .then(() => idb.del(updatesStore, idb.createIDBKeyRangeUpperBound(idbPersistence._dbref, true))) - .then(() => idb.count(updatesStore).then(cnt => { idbPersistence._dbsize = cnt })) + .then(() => idb.count(updatesStore).then(cnt => { + idbPersistence._dbsize = cnt + metrics.setDbSize(idbPersistence.name, cnt) + })) + .then(() => { + const durationSeconds = (performance.now() - startTime) / 1000 + metrics.recordCompaction(idbPersistence.name, durationSeconds) + }) } }) @@ -90,12 +117,29 @@ export class IndexeddbPersistence extends Observable { this._db.then(db => { this.db = db + // Capture pending state before loading from IDB + /** @type {Uint8Array|null} */ + let pendingState = null /** * @param {IDBObjectStore} updatesStore */ - const beforeApplyUpdatesCallback = (updatesStore) => idb.addAutoKey(updatesStore, Y.encodeStateAsUpdate(doc)) - const afterApplyUpdatesCallback = () => { + const beforeApplyUpdatesCallback = (updatesStore) => { + // Capture any in-memory state before loading from IDB + pendingState = Y.encodeStateAsUpdate(doc) + } + const afterApplyUpdatesCallback = (updatesStore) => { if (this._destroyed) return this + // After loading from IDB, check if pending state had anything new + if (pendingState && pendingState.length > 2) { + const vectorBeforePending = Y.encodeStateVector(doc) + Y.applyUpdate(doc, pendingState, this) + const vectorAfterPending = Y.encodeStateVector(doc) + const changed = !uint8ArrayEquals(vectorBeforePending, vectorAfterPending) + // Only write if applying pending state actually changed something + if (changed) { + idb.addAutoKey(updatesStore, pendingState) + } + } this.synced = true this.emit('synced', [this]) } @@ -115,10 +159,17 @@ export class IndexeddbPersistence extends Observable { */ this._storeUpdate = (update, origin) => { if (this.db && origin !== this) { + // Skip updates with empty state vectors (no actual content) + const stateVector = Y.encodeStateVectorFromUpdate(update) + if (stateVector.length === 0) { + return + } const [updatesStore] = idb.transact(/** @type {IDBDatabase} */ (this.db), [updatesStoreName]) idb.addAutoKey(updatesStore, update) + ++this._dbsize + metrics.setDbSize(this.name, this._dbsize) const trimSize = this.synced ? RUNTIME_TRIM_SIZE : STARTUP_TRIM_SIZE - if (++this._dbsize >= trimSize) { + if (this._dbsize >= trimSize) { // debounce store call if (this._storeTimeoutId !== null) { clearTimeout(this._storeTimeoutId) From 1af7a67ab8e07ae17ff6b10a52ce4fcece24af4a Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Tue, 20 Jan 2026 15:04:36 -0800 Subject: [PATCH 005/139] fix: don't try to reconnect to realtime continuously on auth failure --- src/LoginManager.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/LoginManager.ts b/src/LoginManager.ts index 6b4e5837..dbcb7e14 100644 --- a/src/LoginManager.ts +++ b/src/LoginManager.ts @@ -351,6 +351,10 @@ export class LoginManager extends Observable { const result = await this.endpointManager.validateAndSetEndpoints(timeoutMs); if (result.success && this.endpointManager.hasValidatedEndpoints()) { + // Clean up old PocketBase instance before creating new one + this.pb.cancelAllRequests(); + this.pb.realtime.unsubscribe(); + // Recreate PocketBase instance with new auth URL const pbLog = curryLog("[Pocketbase]", "debug"); this.pb = new PocketBase(this.endpointManager.getAuthUrl(), this.authStore); @@ -389,6 +393,7 @@ export class LoginManager extends Observable { logout() { this.pb.cancelAllRequests(); + this.pb.realtime.unsubscribe(); this.pb.authStore.clear(); this.user = undefined; this.notifyListeners(); @@ -549,10 +554,18 @@ export class LoginManager extends Observable { async login(provider: string): Promise { this.beforeLogin(); - const authData = await this.pb.collection("users").authWithOAuth2({ - provider: provider, - }); - return this.setup(authData, provider); + try { + const authData = await this.pb.collection("users").authWithOAuth2({ + provider: provider, + }); + return this.setup(authData, provider); + } catch (e) { + // Clean up realtime subscription to prevent reconnection loops + // authWithOAuth2 internally subscribes to @oauth2 via SSE, and if it fails, + // PocketBase's realtime client will keep trying to reconnect indefinitely + this.pb.realtime.unsubscribe(); + throw e; + } } async openLoginPage() { From d408ba85c33d56ebaf25e7b9c14b595ede96f926 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Tue, 20 Jan 2026 17:33:03 -0800 Subject: [PATCH 006/139] refactor: consolidate banner/button logic into Banner class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Banner now automatically detects mobile Obsidian ≥1.11.0 and renders as a header button (short text) vs traditional banner (long text) - Added BannerText type supporting string | { short, long } - Removed setLoginIcon/clearLoginButton from LoggedOutView - Removed setMergeButton/clearMergeButton from LiveView - Removed font-weight bold and text-shadow from desktop banner --- src/LiveViews.ts | 139 +++++++---------------------------------------- src/ui/Banner.ts | 68 ++++++++++++++++++++--- styles.css | 6 +- 3 files changed, 81 insertions(+), 132 deletions(-) diff --git a/src/LiveViews.ts b/src/LiveViews.ts index c2b4afb8..66ec4b69 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -124,65 +124,24 @@ export class LoggedOutView implements S3View { this.login = login; } - setLoginIcon(): void { - const viewHeaderElement = - this.view.containerEl.querySelector(".view-header"); - const viewHeaderLeftElement = - this.view.containerEl.querySelector(".view-header-left"); - - if (viewHeaderElement && viewHeaderLeftElement) { - this.clearLoginButton(); - - // Create login button element - const loginButton = document.createElement("button"); - loginButton.className = "view-header-left system3-login-button"; - loginButton.textContent = "Login to enable Live edits"; - loginButton.setAttribute("aria-label", "Login to enable Live edits"); - loginButton.setAttribute("tabindex", "0"); - - // Add click handler - loginButton.addEventListener("click", async () => { - await this.login(); - }); - - // Insert after view-header-left - viewHeaderLeftElement.insertAdjacentElement("afterend", loginButton); - } - } - - clearLoginButton() { - const existingButton = this.view.containerEl.querySelector(".system3-login-button"); - if (existingButton) { - existingButton.remove(); - } - } - attach(): Promise { - // Use header button approach on mobile for Obsidian >=1.11.0 to avoid banner positioning issues - if (Platform.isMobile && requireApiVersion("1.11.0")) { - this.setLoginIcon(); - } else { - this.banner = new Banner( - this.view, - "Login to enable Live edits", - async () => { - return await this.login(); - }, - ); - } + this.banner = new Banner( + this.view, + { short: "Login to Relay", long: "Login to enable Live edits" }, + async () => { + return await this.login(); + }, + ); return Promise.resolve(this); } release() { this.banner?.destroy(); - this.clearLoginButton(); } destroy() { - this.release(); this.banner?.destroy(); this.banner = undefined; - this.clearLoginButton(); this.view = null as any; } } @@ -260,7 +219,7 @@ export class RelayCanvasView implements S3View { if (this.shouldConnect) { const banner = new Banner( this.view, - "You're offline -- click to reconnect", + { short: "Offline", long: "You're offline -- click to reconnect" }, async () => { this._parent.networkStatus.checkStatus(); this.connect(); @@ -475,29 +434,15 @@ export class LiveView } } - setMergeButton(): void { - const viewHeaderElement = - this.view.containerEl.querySelector(".view-header"); - const viewHeaderLeftElement = - this.view.containerEl.querySelector(".view-header-left"); - - if (viewHeaderElement && viewHeaderLeftElement) { - this.clearMergeButton(); - - // Create merge button element - const mergeButton = document.createElement("button"); - mergeButton.className = "view-header-left system3-merge-button"; - mergeButton.textContent = "Merge conflict"; - mergeButton.setAttribute("aria-label", "Merge conflict -- click to resolve"); - mergeButton.setAttribute("tabindex", "0"); - - // Add click handler - mergeButton.addEventListener("click", async () => { + mergeBanner(): () => void { + this._banner = new Banner( + this.view, + { short: "Merge conflict", long: "Merge conflict -- click to resolve" }, + async () => { const diskBuffer = await this.document.diskBuffer(); const stale = await this.document.checkStale(); if (!stale) { - this.clearMergeButton(); - return; + return true; } this._parent.openDiffView({ file1: this.document, @@ -505,7 +450,7 @@ export class LiveView showMergeOption: true, onResolve: async () => { this.document.clearDiskBuffer(); - this.clearMergeButton(); + this._banner?.destroy(); // Force view to sync to CRDT state after differ resolution if ( this._plugin && @@ -515,53 +460,9 @@ export class LiveView } }, }); - }); - - // Insert after view-header-left - viewHeaderLeftElement.insertAdjacentElement("afterend", mergeButton); - } - } - - clearMergeButton() { - const existingButton = this.view.containerEl.querySelector(".system3-merge-button"); - if (existingButton) { - existingButton.remove(); - } - } - - mergeBanner(): () => void { - // Use header button approach on mobile for Obsidian >=1.11.0 to avoid banner positioning issues - if (Platform.isMobile && requireApiVersion("1.11.0")) { - this.setMergeButton(); - } else { - this._banner = new Banner( - this.view, - "Merge conflict -- click to resolve", - async () => { - const diskBuffer = await this.document.diskBuffer(); - const stale = await this.document.checkStale(); - if (!stale) { - return true; - } - this._parent.openDiffView({ - file1: this.document, - file2: diskBuffer, - showMergeOption: true, - onResolve: async () => { - this.document.clearDiskBuffer(); - // Force view to sync to CRDT state after differ resolution - if ( - this._plugin && - typeof this._plugin.syncViewToCRDT === "function" - ) { - await this._plugin.syncViewToCRDT(); - } - }, - }); - return true; - }, - ); - } + return true; + }, + ); return () => {}; } @@ -569,7 +470,7 @@ export class LiveView if (this.shouldConnect) { const banner = new Banner( this.view, - "You're offline -- click to reconnect", + { short: "Offline", long: "You're offline -- click to reconnect" }, async () => { this._parent.networkStatus.checkStatus(); this.connect(); @@ -725,7 +626,6 @@ export class LiveView this._viewActions = undefined; this._banner?.destroy(); this._banner = undefined; - this.clearMergeButton(); if (this.offConnectionStatusSubscription) { this.offConnectionStatusSubscription(); this.offConnectionStatusSubscription = undefined; @@ -741,7 +641,6 @@ export class LiveView destroy() { this.release(); this.clearViewActions(); - this.clearMergeButton(); (this.view.leaf as any).rebuildView?.(); this._parent = null as any; this.view = null as any; diff --git a/src/ui/Banner.ts b/src/ui/Banner.ts index 6dbb9b35..c1cf9664 100644 --- a/src/ui/Banner.ts +++ b/src/ui/Banner.ts @@ -1,32 +1,50 @@ "use strict"; -import { TextFileView } from "obsidian"; +import { Platform, requireApiVersion, TextFileView } from "obsidian"; import type { CanvasView } from "src/CanvasView"; +export type BannerText = string | { short: string; long: string }; + export class Banner { view: TextFileView | CanvasView; - text: string; + text: BannerText; onClick: () => Promise; + private useHeaderButton: boolean; constructor( view: TextFileView | CanvasView, - text: string, + text: BannerText, onClick: () => Promise, ) { this.view = view; this.text = text; this.onClick = onClick; + // Use header button approach on mobile for Obsidian >=1.11.0 to avoid banner positioning issues + this.useHeaderButton = Platform.isMobile && requireApiVersion("1.11.0"); this.display(); } + private get shortText(): string { + return typeof this.text === "string" ? this.text : this.text.short; + } + + private get longText(): string { + return typeof this.text === "string" ? this.text : this.text.long; + } + display() { if (!this.view) return true; const leafContentEl = this.view.containerEl; - const contentEl = this.view.containerEl.querySelector(".view-content"); if (!leafContentEl) { return; } + if (this.useHeaderButton) { + return this.displayHeaderButton(); + } + + const contentEl = this.view.containerEl.querySelector(".view-content"); + // container to enable easy removal of the banner let bannerBox = leafContentEl.querySelector(".system3-banner-box"); if (!bannerBox) { @@ -40,7 +58,7 @@ export class Banner { banner = document.createElement("div"); banner.classList.add("system3-banner"); const span = banner.createSpan(); - span.setText(this.text); + span.setText(this.longText); banner.appendChild(span); bannerBox.appendChild(banner); const onClick = async () => { @@ -54,14 +72,48 @@ export class Banner { return true; } + private displayHeaderButton() { + const leafContentEl = this.view.containerEl; + const viewHeaderLeftElement = + leafContentEl.querySelector(".view-header-left"); + + if (!viewHeaderLeftElement) { + return; + } + + // Remove existing button if any + leafContentEl.querySelector(".system3-header-button")?.remove(); + + const button = document.createElement("button"); + button.className = "view-header-left system3-header-button"; + button.textContent = this.shortText; + button.setAttribute("aria-label", this.longText); + button.setAttribute("tabindex", "0"); + + button.addEventListener("click", async () => { + const destroy = await this.onClick(); + if (destroy) { + this.destroy(); + } + }); + + viewHeaderLeftElement.insertAdjacentElement("afterend", button); + return true; + } + destroy() { const leafContentEl = this.view.containerEl; if (!leafContentEl) { return; } - const bannerBox = leafContentEl.querySelector(".system3-banner-box"); - if (bannerBox) { - bannerBox.replaceChildren(); + + if (this.useHeaderButton) { + leafContentEl.querySelector(".system3-header-button")?.remove(); + } else { + const bannerBox = leafContentEl.querySelector(".system3-banner-box"); + if (bannerBox) { + bannerBox.replaceChildren(); + } } this.onClick = async () => true; return true; diff --git a/styles.css b/styles.css index aed8e5a3..0720f004 100644 --- a/styles.css +++ b/styles.css @@ -123,17 +123,15 @@ } .system3-banner > span { - font-weight: bold; display: flex; flex-grow: 1; font-size: var(--font-ui-medium); color: var(--text-on-accent); font: var(--font-interface-theme); - text-shadow: var(--input-shadow); } -.system3-login-button, -.system3-merge-button { +.system3-header-button, +.system3-login-button { background: var(--interactive-accent) !important; color: var(--text-on-accent) !important; border: var(--modal-border-width) solid var(--pill-border-color-hover) !important; From 4f9232e752492646ff7d295b2e7ef47e897edd86 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Wed, 21 Jan 2026 21:29:33 -0800 Subject: [PATCH 007/139] feat: add compaction worker --- esbuild.config.mjs | 4 +- package-lock.json | 135 ++++++++++++++++++++++++------- package.json | 1 + src/main.ts | 4 + src/storage/y-indexeddb.js | 60 ++++++++++++-- src/workers/CompactionManager.ts | 117 +++++++++++++++++++++++++++ src/workers/compaction.worker.ts | 126 +++++++++++++++++++++++++++++ tsconfig.json | 3 +- 8 files changed, 415 insertions(+), 35 deletions(-) create mode 100644 src/workers/CompactionManager.ts create mode 100644 src/workers/compaction.worker.ts diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 5fa392cc..09f01dc5 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -3,6 +3,7 @@ import process from "process"; import esbuildSvelte from "esbuild-svelte"; import sveltePreprocess from "svelte-preprocess"; import builtins from "builtin-modules"; +import inlineWorkerPlugin from "esbuild-plugin-inline-worker"; import { execSync } from "child_process"; import chokidar from "chokidar"; import path from "path"; @@ -80,6 +81,7 @@ const context = await esbuild.context({ compilerOptions: { css: true }, preprocess: sveltePreprocess(), }), + inlineWorkerPlugin(), YjsInternalsPlugin, NotifyPlugin, ], @@ -110,7 +112,7 @@ const copyFile = (src, dest) => { const watchAndMove = (fnames, mapping) => { // only usable on top level directory const watcher = chokidar.watch(fnames, { - ignored: /(^|[\/\\])\../, // ignore dotfiles + ignored: /(^|[\/\\])\./, // ignore dotfiles persistent: true, }); diff --git a/package-lock.json b/package-lock.json index a694c217..c05de536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "svelte-step-wizard": "^0.0.2", "tslib": "2.4.0", "uuid": "^9.0.1", - "y-indexeddb": "^9.0.9", "y-leveldb": "^0.1.2", "y-protocols": "^1.0.5", "y-websocket": "^1.5.3" @@ -41,6 +40,7 @@ "builtin-modules": "3.3.0", "chokidar": "^3.6.0", "esbuild": "^0.27.0", + "esbuild-plugin-inline-worker": "^0.1.1", "esbuild-svelte": "^0.8.0", "jest": "^29.7.0", "obsidian": "^1.7.2", @@ -2629,6 +2629,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2926,6 +2933,17 @@ "@esbuild/win32-x64": "0.27.0" } }, + "node_modules/esbuild-plugin-inline-worker": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/esbuild-plugin-inline-worker/-/esbuild-plugin-inline-worker-0.1.1.tgz", + "integrity": "sha512-VmFqsQKxUlbM51C1y5bRiMeyc1x2yTdMXhKB6S//++g9aCBg8TfGsbKxl5ZDkCGquqLY+RmEk93TBNd0i35dPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "latest", + "find-cache-dir": "^3.3.1" + } + }, "node_modules/esbuild-svelte": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/esbuild-svelte/-/esbuild-svelte-0.8.0.tgz", @@ -3291,6 +3309,50 @@ "node": ">=8" } }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-cache-dir/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6215,25 +6277,6 @@ "node": ">=0.4" } }, - "node_modules/y-indexeddb": { - "version": "9.0.12", - "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", - "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", - "dependencies": { - "lib0": "^0.2.74" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "peerDependencies": { - "yjs": "^13.0.0" - } - }, "node_modules/y-leveldb": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz", @@ -8136,6 +8179,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -8359,6 +8408,16 @@ "@esbuild/win32-x64": "0.27.0" } }, + "esbuild-plugin-inline-worker": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/esbuild-plugin-inline-worker/-/esbuild-plugin-inline-worker-0.1.1.tgz", + "integrity": "sha512-VmFqsQKxUlbM51C1y5bRiMeyc1x2yTdMXhKB6S//++g9aCBg8TfGsbKxl5ZDkCGquqLY+RmEk93TBNd0i35dPA==", + "dev": true, + "requires": { + "esbuild": "latest", + "find-cache-dir": "^3.3.1" + } + }, "esbuild-svelte": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/esbuild-svelte/-/esbuild-svelte-0.8.0.tgz", @@ -8634,6 +8693,34 @@ "to-regex-range": "^5.0.1" } }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "dependencies": { + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, "find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10735,14 +10822,6 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, - "y-indexeddb": { - "version": "9.0.12", - "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", - "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", - "requires": { - "lib0": "^0.2.74" - } - }, "y-leveldb": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz", diff --git a/package.json b/package.json index 57336274..086fa02a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "builtin-modules": "3.3.0", "chokidar": "^3.6.0", "esbuild": "^0.27.0", + "esbuild-plugin-inline-worker": "^0.1.1", "esbuild-svelte": "^0.8.0", "jest": "^29.7.0", "obsidian": "^1.7.2", diff --git a/src/main.ts b/src/main.ts index 68c8e930..3309dca2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -58,6 +58,7 @@ import { IndexedDBAnalysisModal } from "./ui/IndexedDBAnalysisModal"; import { UpdateManager } from "./UpdateManager"; import type { PluginWithApp } from "./UpdateManager"; import { ReleaseManager } from "./ui/ReleaseManager"; +import { CompactionManager } from "./workers/CompactionManager"; import type { ReleaseSettings } from "./UpdateManager"; import { SyncSettingsManager } from "./SyncSettings"; import { ContentAddressedFileStore, isSyncFile } from "./SyncFile"; @@ -1151,6 +1152,9 @@ export default class Live extends Plugin { // Cleanup all monkeypatches and destroy the singleton Patcher.destroy(); + // Terminate the compaction worker + CompactionManager.destroy(); + this.timeProvider?.destroy(); this.folderNavDecorations?.destroy(); diff --git a/src/storage/y-indexeddb.js b/src/storage/y-indexeddb.js index cf724cac..fe60932f 100644 --- a/src/storage/y-indexeddb.js +++ b/src/storage/y-indexeddb.js @@ -3,6 +3,7 @@ import * as idb from 'lib0/indexeddb' import * as promise from 'lib0/promise' import { Observable } from 'lib0/observable' import { metrics } from '../debug' +import { CompactionManager } from '../workers/CompactionManager' const customStoreName = 'custom' const updatesStoreName = 'updates' @@ -26,6 +27,52 @@ const uint8ArrayEquals = (a, b) => { export const STARTUP_TRIM_SIZE = 500 export const RUNTIME_TRIM_SIZE = 50 +/** + * Check if a database needs compaction and compact it in a worker if so. + * This should be called BEFORE creating an IndexeddbPersistence instance. + * @param {string} name - The database name + * @returns {Promise} + */ +export const maybeCompactDatabase = async (name) => { + if (!CompactionManager.instance.available) { + return + } + + // Open a temporary connection just to check the count + const tempDb = await idb.openDB(name, db => + idb.createStores(db, [ + ['updates', { autoIncrement: true }], + ['custom'] + ]) + ) + + try { + const [checkStore] = idb.transact(tempDb, [updatesStoreName], 'readonly') + const count = await idb.count(checkStore) + + if (count >= STARTUP_TRIM_SIZE) { + // Close our temp connection before worker compacts + tempDb.close() + + try { + const result = await CompactionManager.instance.compact(name) + metrics.recordCompaction(name, 0) + console.log(`[y-indexeddb] Compacted ${name}: ${result.countBefore} -> ${result.countAfter}`) + } catch (err) { + console.warn(`[y-indexeddb] Background compaction failed for ${name}:`, err) + } + return + } + } finally { + // Close temp connection if still open + try { + tempDb.close() + } catch (e) { + // Already closed + } + } +} + /** * @param {IndexeddbPersistence} idbPersistence * @param {function(IDBObjectStore):void} [beforeApplyUpdatesCallback] @@ -104,11 +151,14 @@ export class IndexeddbPersistence extends Observable { this.synced = false this._serverSynced = undefined this._origin = undefined - this._db = idb.openDB(name, db => - idb.createStores(db, [ - ['updates', { autoIncrement: true }], - ['custom'] - ]) + // First check if compaction is needed, then open the DB + this._db = maybeCompactDatabase(name).then(() => + idb.openDB(name, db => + idb.createStores(db, [ + ['updates', { autoIncrement: true }], + ['custom'] + ]) + ) ) /** * @type {Promise} diff --git a/src/workers/CompactionManager.ts b/src/workers/CompactionManager.ts new file mode 100644 index 00000000..4a98dbf1 --- /dev/null +++ b/src/workers/CompactionManager.ts @@ -0,0 +1,117 @@ +/** + * CompactionManager - coordinates background compaction via Web Worker + */ +import type { CompactionRequest, CompactionResponse } from './compaction.worker'; + +// @ts-ignore - esbuild-plugin-inline-worker handles this import +import CompactionWorker from './compaction.worker.ts'; + +export class CompactionManager { + private static _instance: CompactionManager | null = null; + private worker: Worker | null = null; + private pendingRequests = new Map void; + reject: (error: Error) => void; + }>(); + private nextId = 0; + + private constructor() { + this.initWorker(); + } + + static get instance(): CompactionManager { + if (!CompactionManager._instance) { + CompactionManager._instance = new CompactionManager(); + } + return CompactionManager._instance; + } + + static destroy(): void { + if (CompactionManager._instance) { + CompactionManager._instance.terminate(); + CompactionManager._instance = null; + } + } + + private initWorker(): void { + try { + this.worker = new CompactionWorker(); + this.worker!.onmessage = this.handleMessage.bind(this); + this.worker!.onerror = this.handleError.bind(this); + } catch (error) { + console.error('[CompactionManager] Failed to create worker:', error); + this.worker = null; + } + } + + private handleMessage(evt: MessageEvent): void { + const { id, success, countBefore, countAfter, error } = evt.data; + const pending = this.pendingRequests.get(id); + + if (!pending) { + console.warn('[CompactionManager] Received response for unknown request:', id); + return; + } + + this.pendingRequests.delete(id); + + if (success && countBefore !== undefined && countAfter !== undefined) { + pending.resolve({ countBefore, countAfter }); + } else { + pending.reject(new Error(error || 'Compaction failed')); + } + } + + private handleError(evt: ErrorEvent): void { + console.error('[CompactionManager] Worker error:', evt); + // Reject all pending requests + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error('Worker error: ' + evt.message)); + } + this.pendingRequests.clear(); + } + + /** + * Compact a database in the background worker + * @param dbName The IndexedDB database name to compact + * @returns Promise resolving to compaction stats + */ + async compact(dbName: string): Promise<{ countBefore: number; countAfter: number }> { + if (!this.worker) { + throw new Error('Compaction worker not available'); + } + + const id = String(this.nextId++); + + return new Promise((resolve, reject) => { + this.pendingRequests.set(id, { resolve, reject }); + + const request: CompactionRequest = { + id, + type: 'compact', + dbName + }; + + this.worker!.postMessage(request); + }); + } + + /** + * Check if the worker is available + */ + get available(): boolean { + return this.worker !== null; + } + + private terminate(): void { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + // Reject any pending requests + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error('CompactionManager terminated')); + } + this.pendingRequests.clear(); + } +} diff --git a/src/workers/compaction.worker.ts b/src/workers/compaction.worker.ts new file mode 100644 index 00000000..60f7125f --- /dev/null +++ b/src/workers/compaction.worker.ts @@ -0,0 +1,126 @@ +/** + * Web Worker for IndexedDB Y.Doc compaction + * Compacts accumulated updates into a single state snapshot + */ +import * as Y from 'yjs'; +import * as idb from 'lib0/indexeddb'; + +const updatesStoreName = 'updates'; + +export interface CompactionRequest { + id: string; + type: 'compact'; + dbName: string; +} + +export interface CompactionResponse { + id: string; + success: boolean; + dbName: string; + countBefore?: number; + countAfter?: number; + error?: string; +} + +/** + * Compact a Y.Doc IndexedDB database + * Reads all updates, merges them into a single state, and replaces them + */ +async function compactDatabase(dbName: string): Promise<{ countBefore: number; countAfter: number }> { + // Open the IndexedDB database + const db = await idb.openDB(dbName, (db) => { + idb.createStores(db, [ + ['updates', { autoIncrement: true }], + ['custom'] + ]); + }); + + try { + // Get all updates and the last key + const [readStore] = idb.transact(db, [updatesStoreName], 'readonly'); + const updates: Uint8Array[] = await idb.getAll(readStore); + const lastKey = await idb.getLastKey(readStore); + const countBefore = updates.length; + + if (countBefore <= 1) { + // Nothing to compact + return { countBefore, countAfter: countBefore }; + } + + // Create a Y.Doc and apply all updates + const doc = new Y.Doc(); + Y.transact(doc, () => { + for (const update of updates) { + Y.applyUpdate(doc, update); + } + }, null, false); + + // Encode the compacted state + const compactedState = Y.encodeStateAsUpdate(doc); + doc.destroy(); + + // Write compacted state and delete old entries + const [writeStore] = idb.transact(db, [updatesStoreName]); + + // Add the compacted state first (gets a key > lastKey) + await idb.addAutoKey(writeStore, compactedState); + + // Delete all entries up to and including lastKey + await idb.del(writeStore, idb.createIDBKeyRangeUpperBound(lastKey, true)); + + // Verify the count + const countAfter = await idb.count(writeStore); + + return { countBefore, countAfter }; + } finally { + db.close(); + } +} + +// Message handler +self.onmessage = async function(evt: MessageEvent) { + const { id, type, dbName } = evt.data; + + if (type !== 'compact') { + const response: CompactionResponse = { + id, + success: false, + dbName: dbName || '', + error: `Unknown operation type: ${type}` + }; + self.postMessage(response); + return; + } + + if (!dbName) { + const response: CompactionResponse = { + id, + success: false, + dbName: '', + error: 'dbName is required' + }; + self.postMessage(response); + return; + } + + try { + const { countBefore, countAfter } = await compactDatabase(dbName); + + const response: CompactionResponse = { + id, + success: true, + dbName, + countBefore, + countAfter + }; + self.postMessage(response); + } catch (error) { + const response: CompactionResponse = { + id, + success: false, + dbName, + error: error instanceof Error ? error.message : String(error) + }; + self.postMessage(response); + } +}; diff --git a/tsconfig.json b/tsconfig.json index 512f51af..bdcea489 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,7 @@ "exclude": [ "**/*.test.ts", "node_modules", - "vaults" + "vaults", + "archive" ] } From c1e110a5ad8c5cfd26c364bf5dd677df066ee888 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Thu, 22 Jan 2026 16:16:44 -0800 Subject: [PATCH 008/139] fix: iOS stack trace format --- src/debug.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/debug.ts b/src/debug.ts index b5ee2518..0078c2ff 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -188,7 +188,7 @@ export function curryLog(initialText: string, level: LogLevel = "log") { if (debugging) { const timestamp = new Date().toISOString(); const stack = new Error().stack; - const callerInfo = stack ? stack.split("\n")[2].trim() : ""; + const callerInfo = stack?.split("\n")[2]?.trim() ?? ""; const serializedArgs = args.map(serializeArg).join(" "); const logEntry: LogEntry = { From 9ff6533a494b9d2fd6e588224f2b75c90b823ea6 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Thu, 22 Jan 2026 17:18:19 -0800 Subject: [PATCH 009/139] chore: add debug logging --- src/Document.ts | 46 +++++++++++++++++++++++-- src/LiveViews.ts | 6 ++++ src/y-codemirror.next/LiveEditPlugin.ts | 18 +++++++++- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/Document.ts b/src/Document.ts index 028fd381..5a417512 100644 --- a/src/Document.ts +++ b/src/Document.ts @@ -186,12 +186,19 @@ export class Document extends HasProvider implements IFile, HasMimeType { const storedContents = await this._parent.diskBufferStore .loadDiskBuffer(this.guid) .catch((e) => { + this.warn("[diskBuffer] loadDiskBuffer error:", e); return null; }); if (storedContents !== null && storedContents !== "") { fileContents = storedContents; + this.log( + `[diskBuffer] loaded from IndexedDB cache (${storedContents.length} chars)`, + ); } else { fileContents = await this._parent.read(this); + this.log( + `[diskBuffer] read from file (${fileContents.length} chars), cache was ${storedContents === null ? "null" : "empty"}`, + ); } return this.setDiskBuffer(fileContents.replace(/\r\n/g, "\n")); } catch (e) { @@ -199,44 +206,76 @@ export class Document extends HasProvider implements IFile, HasMimeType { throw e; } } + this.log("[diskBuffer] returning existing in-memory diskBuffer"); return this._diskBuffer; } setDiskBuffer(contents: string): TFile { if (this._diskBuffer) { this._diskBuffer.contents = contents; + this.log(`[setDiskBuffer] updated existing (${contents.length} chars)`); } else { this._diskBuffer = new DiskBuffer( this._parent.vault, "local disk", contents, ); + this.log(`[setDiskBuffer] created new (${contents.length} chars)`); } this._parent.diskBufferStore .saveDiskBuffer(this.guid, contents) - .catch((e) => {}); + .then(() => { + this.log("[setDiskBuffer] saved to IndexedDB"); + }) + .catch((e) => { + this.warn("[setDiskBuffer] IndexedDB save error:", e); + }); return this._diskBuffer; } async clearDiskBuffer(): Promise { + this.log("[clearDiskBuffer] called"); if (this._diskBuffer) { this._diskBuffer.contents = ""; this._diskBuffer = undefined; + this.log("[clearDiskBuffer] cleared in-memory buffer"); } await this._parent.diskBufferStore .removeDiskBuffer(this.guid) - .catch((e) => {}); + .then(() => { + this.log("[clearDiskBuffer] removed from IndexedDB"); + }) + .catch((e) => { + this.warn("[clearDiskBuffer] IndexedDB remove error:", e); + }); } public async checkStale(): Promise { + this.log("[checkStale] starting"); await this.whenSynced(); const diskBuffer = await this.diskBuffer(true); const contents = (diskBuffer as DiskBuffer).contents; + this.log( + `[checkStale] diskBuffer contents: ${contents.length} chars, preview: "${contents.slice(0, 50).replace(/\n/g, "\\n")}..."`, + ); const response = await this.sharedFolder.backgroundSync.downloadItem(this); const updateBytes = new Uint8Array(response.arrayBuffer); + const textBeforeUpdate = this.text; Y.applyUpdate(this.ydoc, updateBytes); + const textAfterUpdate = this.text; + this.log( + `[checkStale] CRDT before update: ${textBeforeUpdate.length} chars, after: ${textAfterUpdate.length} chars`, + ); + if (textBeforeUpdate !== textAfterUpdate) { + this.log( + `[checkStale] CRDT changed after server update, preview: "${textAfterUpdate.slice(0, 50).replace(/\n/g, "\\n")}..."`, + ); + } const stale = this.text !== contents; + this.log( + `[checkStale] stale=${stale} (CRDT ${this.text.length} chars vs diskBuffer ${contents.length} chars)`, + ); const og = this.text; let text = og; @@ -268,7 +307,10 @@ export class Document extends HasProvider implements IFile, HasMimeType { } this.pendingOps = []; if (!stale) { + this.log("[checkStale] not stale, clearing diskBuffer"); this.clearDiskBuffer(); + } else { + this.log("[checkStale] stale! will show differ"); } return stale; } diff --git a/src/LiveViews.ts b/src/LiveViews.ts index 66ec4b69..afc0c41b 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -542,10 +542,16 @@ export class LiveView this.view instanceof MarkdownView && this.view.getMode() === "preview" ) { + this.log("[LiveView.checkStale] skipping - preview mode"); return false; } + this.log("[LiveView.checkStale] calling document.checkStale()"); const stale = await this.document.checkStale(); + this.log( + `[LiveView.checkStale] result: stale=${stale}, hasDiskBuffer=${!!this.document._diskBuffer?.contents}`, + ); if (stale && this.document._diskBuffer?.contents) { + this.log("[LiveView.checkStale] showing merge banner"); this.mergeBanner(); } else { this._banner?.destroy(); diff --git a/src/y-codemirror.next/LiveEditPlugin.ts b/src/y-codemirror.next/LiveEditPlugin.ts index 07d617f6..1eb1386e 100644 --- a/src/y-codemirror.next/LiveEditPlugin.ts +++ b/src/y-codemirror.next/LiveEditPlugin.ts @@ -378,8 +378,15 @@ export class LiveCMPluginValue implements PluginValue { return []; } - if (this.document?.text === this.editor.state.doc.toString()) { + const crdtText = this.document?.text; + const editorText = this.editor.state.doc.toString(); + this.log( + `[getKeyFrame] CRDT: ${crdtText?.length ?? 0} chars, editor: ${editorText.length} chars`, + ); + + if (crdtText === editorText) { // disk and ytext were already the same. + this.log("[getKeyFrame] CRDT === editor, setting tracking=true"); if (isLiveMd(this.view)) { this.view.tracking = true; } @@ -396,6 +403,12 @@ export class LiveCMPluginValue implements PluginValue { } this.warn(`ytext and editor buffer need syncing`); + this.log( + `[getKeyFrame] CRDT preview: "${crdtText?.slice(0, 50).replace(/\n/g, "\\n")}..."`, + ); + this.log( + `[getKeyFrame] editor preview: "${editorText.slice(0, 50).replace(/\n/g, "\\n")}..."`, + ); if (!this.document.hasLocalDB() && this.document.text === "") { this.warn("local db missing, not setting buffer"); return []; @@ -403,10 +416,13 @@ export class LiveCMPluginValue implements PluginValue { // disk and ytext differ if (isLiveMd(this.view) && !this.view.tracking) { + this.log("[getKeyFrame] calling view.checkStale() (LiveMd path)"); this.view.checkStale(); } else if (this.document) { + this.log("[getKeyFrame] calling document.checkStale() (embed path)"); const stale = await this.document.checkStale(); if (stale && !this.destroyed && this.editor) { + this.log("[getKeyFrame] stale, showing merge banner"); this.mergeBanner(); } } From b512db14132dfc8ed5a69bb54d982eb189067a70 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Thu, 22 Jan 2026 21:19:32 -0800 Subject: [PATCH 010/139] fix: share folder modal mobile layout - Use Obsidian's native modal title via setTitle() - Remove duplicate modal-content class nesting - Use SlimSettingItem for inline toggle and button layout - Add visible background to toggle track for dark backgrounds - Fix folder name overflow with ellipsis truncation - Prevent folder icons from shrinking --- src/components/SelectedFolder.svelte | 12 +++++ src/components/ShareFolderModalContent.svelte | 47 ++++++++----------- src/ui/ShareFolderModal.ts | 2 + 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/components/SelectedFolder.svelte b/src/components/SelectedFolder.svelte index ac1e3e47..da886e75 100644 --- a/src/components/SelectedFolder.svelte +++ b/src/components/SelectedFolder.svelte @@ -105,12 +105,24 @@ display: flex; align-items: center; gap: 6px; + min-width: 0; + } + + .folder-state:last-of-type { + flex: 1; + min-width: 0; + } + + :global(.folder-icon) { + flex-shrink: 0; } .folder-name { font-weight: 500; color: var(--text-normal); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .clear-button { diff --git a/src/components/ShareFolderModalContent.svelte b/src/components/ShareFolderModalContent.svelte index 7ea59726..d2c0bf58 100644 --- a/src/components/ShareFolderModalContent.svelte +++ b/src/components/ShareFolderModalContent.svelte @@ -3,9 +3,8 @@ import type { Relay, RelayUser, Role } from "../Relay"; import type { RelayManager } from "../RelayManager"; import type { SharedFolder, SharedFolders } from "../SharedFolder"; - import SettingItem from "./SettingItem.svelte"; import SettingItemHeading from "./SettingItemHeading.svelte"; - import SettingGroup from "./SettingGroup.svelte"; + import SlimSettingItem from "./SlimSettingItem.svelte"; import SelectedFolder from "./SelectedFolder.svelte"; import { onMount, onDestroy } from "svelte"; import { derived, writable } from "svelte/store"; @@ -22,6 +21,7 @@ isPrivate: boolean, userIds: string[], ) => Promise; + export let setTitle: (title: string) => void = () => {}; let currentStep: "main" | "users" = "main"; let isPrivate = false; @@ -109,6 +109,7 @@ if (isPrivate) { currentStep = "users"; + setTitle("Add Users to Folder"); } else { handleShare(); } @@ -154,6 +155,7 @@ function goBack() { currentStep = "main"; + setTitle("Share local folder"); } function getInitials(name: string): string { @@ -231,9 +233,7 @@ {#if currentStep === "main"} - - -