diff --git a/.gitignore b/.gitignore index 40c35c64..8fd58813 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ data.json .igitignore # developer harness +.mcp.json AGENTS.md CLAUDE.md AGENTS.md diff --git a/__tests__/ObservableMap.test.ts b/__tests__/ObservableMap.test.ts index 8174debf..df197883 100644 --- a/__tests__/ObservableMap.test.ts +++ b/__tests__/ObservableMap.test.ts @@ -12,7 +12,7 @@ class TestTimeProvider implements TimeProvider { private pendingCallbacks: Array<{ id: number; callback: () => void }> = []; private nextId = 1; - getTime(): number { + now(): number { return Date.now(); } diff --git a/__tests__/TestSettingsStorage.ts b/__tests__/TestSettingsStorage.ts index 0aa47651..f7d98eaa 100644 --- a/__tests__/TestSettingsStorage.ts +++ b/__tests__/TestSettingsStorage.ts @@ -63,7 +63,7 @@ describe("NamespacedSettings", () => { */ const nested = new NamespacedSettings(settings, "deeply/nested/path"); await nested.set({ foo: "test", count: 1 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(settings.get()).toEqual({ deeply: { @@ -86,7 +86,7 @@ describe("NamespacedSettings", () => { }; await namespaced.set(testData); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(namespaced.get()).toEqual(testData); expect(settings.get()).toEqual({ @@ -105,10 +105,10 @@ describe("NamespacedSettings", () => { const path2 = new NamespacedSettings(settings, "a/b/d"); await path1.set({ foo: "path1", count: 1 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); await path2.set({ foo: "path2", count: 2 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(settings.get()).toEqual({ a: { @@ -127,13 +127,13 @@ describe("NamespacedSettings", () => { */ const namespaced = new NamespacedSettings(settings, "test/path"); await namespaced.set({ foo: "initial", count: 1 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); await namespaced.update((current) => ({ ...current, count: current.count + 1, })); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(namespaced.get()).toEqual({ foo: "initial", @@ -150,7 +150,7 @@ describe("NamespacedSettings", () => { const parent = child.getParent(); await child.set({ foo: "test", count: 1 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(parent.get()).toEqual({ child: { foo: "test", count: 1 }, @@ -166,7 +166,7 @@ describe("NamespacedSettings", () => { const child = parent.getChild("child"); await child.set({ foo: "test", count: 1 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(settings.get()).toEqual({ parent: { @@ -181,7 +181,7 @@ describe("NamespacedSettings", () => { */ const namespaced = new NamespacedSettings(settings, "test/path"); await namespaced.set({ foo: "test", count: 1 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(namespaced.exists()).toBe(true); }); @@ -202,10 +202,10 @@ describe("NamespacedSettings", () => { */ const namespaced = new NamespacedSettings(settings, "test/path"); await namespaced.set({ foo: "test", count: 1 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); await namespaced.delete(); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(namespaced.exists()).toBe(false); expect(namespaced.get()).toEqual({}); @@ -228,7 +228,7 @@ describe("NamespacedSettings", () => { foo: "new", count: 2, }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); // Called once upon subscription and once after setting a new value expect(listener).toHaveBeenCalledTimes(2); @@ -250,12 +250,12 @@ describe("NamespacedSettings", () => { await namespaced.set({ debugging: true, }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); await namespaced.set({ debugging: false, }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); // Called once upon subscription and once after setting a new value expect(listener).toHaveBeenCalledTimes(3); expect(namespaced.get()).toEqual({ @@ -276,7 +276,7 @@ describe("NamespacedSettings", () => { foo: "new", count: 42, }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); // Only called once upon initial subscription expect(listener).toHaveBeenCalledTimes(1); @@ -303,7 +303,7 @@ describe("NamespacedSettings", () => { // Force update notification await settings.update((current) => ({ ...current })); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); // Now check both the raw settings and the namespaced view expect(settings.get()).toEqual({ @@ -343,7 +343,7 @@ describe("NamespacedSettings", () => { ], })); await settings.save(); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); const listItem = new NamespacedSettings( settings, @@ -356,7 +356,7 @@ describe("NamespacedSettings", () => { // Force update notification await settings.update((current) => ({ ...current })); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); // Check both raw settings and namespaced view expect(settings.get()).toEqual({ @@ -385,12 +385,12 @@ describe("NamespacedSettings", () => { { guid: "456", foo: "other", count: 2 }, ], })); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); const listItem = new NamespacedSettings(settings, "folders/[guid=123]"); await listItem.delete(); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(settings.get()).toEqual({ folders: [{ guid: "456", foo: "other", count: 2 }], @@ -408,7 +408,7 @@ describe("NamespacedSettings", () => { "test-2": 4, other: 5, })); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); const testSettings = new NamespacedSettings(settings, "(test-*)"); expect(testSettings.get()).toEqual({ @@ -428,7 +428,7 @@ describe("NamespacedSettings", () => { "feature-2": false, "not-matching": "should be ignored", }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(settings.get()).toEqual({ "feature-1": true, @@ -445,7 +445,7 @@ describe("NamespacedSettings", () => { const child = parent.getChild("child"); await child.set({ foo: "value", count: 10 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(settings.get()).toEqual({ parent: { @@ -503,7 +503,7 @@ describe("NamespacedSettings", () => { namespaced.subscribe(listener); await namespaced.flush(); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); // Called once upon subscription and once after flush expect(listener).toHaveBeenCalledTimes(2); @@ -526,11 +526,11 @@ describe("NamespacedSettings", () => { data: true, }, })); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); const namespaced = new NamespacedSettings(settings, "new/namespace"); await namespaced.set({ foo: "test", count: 1 }); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(settings.get()).toEqual({ existing: { @@ -558,7 +558,7 @@ describe("NamespacedSettings", () => { path: { foo: "external", count: 99 }, }, })); - mockTime.setTime(mockTime.getTime() + 30); + mockTime.setTime(mockTime.now() + 30); expect(listener).toHaveBeenCalledTimes(2); expect(namespaced.get()).toEqual({ diff --git a/__tests__/TestTimeProvider.ts b/__tests__/TestTimeProvider.ts index f506e280..ad96cb28 100644 --- a/__tests__/TestTimeProvider.ts +++ b/__tests__/TestTimeProvider.ts @@ -14,11 +14,11 @@ describe("MockTimeProvider debounce", () => { expect(mockFn).not.toHaveBeenCalled(); // Advance time by 500ms - mockTime.setTime(mockTime.getTime() + 500); + mockTime.setTime(mockTime.now() + 500); expect(mockFn).not.toHaveBeenCalled(); // Advance time to just after the delay - mockTime.setTime(mockTime.getTime() + 501); + mockTime.setTime(mockTime.now() + 501); expect(mockFn).toHaveBeenCalledTimes(1); }); @@ -31,15 +31,15 @@ describe("MockTimeProvider debounce", () => { debouncedFn(); // Advance 300ms and call again - mockTime.setTime(mockTime.getTime() + 300); + mockTime.setTime(mockTime.now() + 300); debouncedFn(); // Advance another 300ms and call again - mockTime.setTime(mockTime.getTime() + 300); + mockTime.setTime(mockTime.now() + 300); debouncedFn(); // Advance time to trigger the last debounced call - mockTime.setTime(mockTime.getTime() + 1000); + mockTime.setTime(mockTime.now() + 1000); expect(mockFn).toHaveBeenCalledTimes(1); }); @@ -53,7 +53,7 @@ describe("MockTimeProvider debounce", () => { debouncedFn("test", 123); // Advance time past delay - mockTime.setTime(mockTime.getTime() + 1001); + mockTime.setTime(mockTime.now() + 1001); // Check if function was called with correct arguments expect(mockFn).toHaveBeenCalledWith("test", 123); @@ -68,17 +68,17 @@ describe("MockTimeProvider debounce", () => { debouncedFn(); // Advance time almost to delay - mockTime.setTime(mockTime.getTime() + 900); + mockTime.setTime(mockTime.now() + 900); // Second call - should reset timer debouncedFn(); // Advance time past first delay but not second - mockTime.setTime(mockTime.getTime() + 200); + mockTime.setTime(mockTime.now() + 200); expect(mockFn).not.toHaveBeenCalled(); // Advance time past second delay - mockTime.setTime(mockTime.getTime() + 801); + mockTime.setTime(mockTime.now() + 801); expect(mockFn).toHaveBeenCalledTimes(1); }); @@ -90,11 +90,11 @@ describe("MockTimeProvider debounce", () => { debouncedFn(); // Advance time by 400ms - mockTime.setTime(mockTime.getTime() + 400); + mockTime.setTime(mockTime.now() + 400); expect(mockFn).not.toHaveBeenCalled(); // Advance time past default delay - mockTime.setTime(mockTime.getTime() + 101); + mockTime.setTime(mockTime.now() + 101); expect(mockFn).toHaveBeenCalledTimes(1); }); }); diff --git a/__tests__/TestTokenStore.ts b/__tests__/TestTokenStore.ts index d9dc4553..6943f4e9 100644 --- a/__tests__/TestTokenStore.ts +++ b/__tests__/TestTokenStore.ts @@ -17,7 +17,7 @@ async function _testTokenStore() { ) => { testTimeProvider.setTimeout(() => { callback({ - token: (testTimeProvider.getTime() + 30 * 60 * 1000).toString(), + token: (testTimeProvider.now() + 30 * 60 * 1000).toString(), }); }, 100); }; @@ -49,23 +49,23 @@ async function _testTokenStore() { ]); // Advance time for response to happen - testTimeProvider.setTime(testTimeProvider.getTime() + 1000); // Advance time by 1 second + testTimeProvider.setTime(testTimeProvider.now() + 1000); // Advance time by 1 second await tokenPromise; tokenStore.log(tokenStore.report()); // Advance time to trigger refresh of tokens close to expiry - testTimeProvider.setTime(testTimeProvider.getTime() + 5 * 60 * 1000); // Advance time by 5 minutes + testTimeProvider.setTime(testTimeProvider.now() + 5 * 60 * 1000); // Advance time by 5 minutes tokenStore.log(tokenStore.report()); - testTimeProvider.setTime(testTimeProvider.getTime() + 20 * 60 * 1000); // Advance time by 20 minutes + testTimeProvider.setTime(testTimeProvider.now() + 20 * 60 * 1000); // Advance time by 20 minutes tokenStore.log(tokenStore.report()); // Stop the TokenStore processing to clean up tokenStore.stop(); - testTimeProvider.setTime(testTimeProvider.getTime() + 1000); // Advance time by 1 second - testTimeProvider.setTime(testTimeProvider.getTime() + 1000); // Advance time by 1 second + testTimeProvider.setTime(testTimeProvider.now() + 1000); // Advance time by 1 second + testTimeProvider.setTime(testTimeProvider.now() + 1000); // Advance time by 1 second tokenStore.log(tokenStore.report()); tokenStore.clearState(); @@ -88,7 +88,7 @@ describe("token store", () => { log: () => undefined, refresh: failingRefresh, getTimeProvider: () => tp, - getJwtExpiry: () => tp.getTime() + 1000, + getJwtExpiry: () => tp.now() + 1000, }, 1, ); diff --git a/__tests__/mocks/MockTimeProvider.ts b/__tests__/mocks/MockTimeProvider.ts index 2cd2d6f4..42261531 100644 --- a/__tests__/mocks/MockTimeProvider.ts +++ b/__tests__/mocks/MockTimeProvider.ts @@ -13,13 +13,11 @@ export class MockTimeProvider implements TimeProvider { this.currentTime = Date.now(); } - getTime(): number { + now(): number { return this.currentTime; } setTime(newTime: number): void { - const diff = (newTime - this.currentTime) / 1000; - console.log(`setting time to ${newTime} (+${diff}s)`); this.currentTime = newTime; this.checkTimers(); } @@ -97,10 +95,8 @@ export class MockTimeProvider implements TimeProvider { } private checkTimers(): void { - console.log(this.timers); this.timers.forEach((timer) => { if (this.currentTime >= timer.triggerTime) { - console.log("timer triggered"); timer.callback(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const id = timer.id; diff --git a/debug-tools/unused.mjs b/debug-tools/unused.mjs deleted file mode 100644 index ebe281f9..00000000 --- a/debug-tools/unused.mjs +++ /dev/null @@ -1,114 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const projectRoot = path.join(__dirname, "..", 'src'); // Adjust this to your project's source directory -const fileExtensions = ['.ts', '.tsx', '.svelte']; // Including .svelte files - -async function getAllFiles(dir) { - let files = []; - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files = files.concat(await getAllFiles(fullPath)); - } else if (fileExtensions.includes(path.extname(entry.name))) { - files.push(fullPath); - } - } - return files; -} - -function resolveImportPath(importPath, currentFile, allFiles) { - const currentDir = path.dirname(currentFile); - let resolvedPath = path.resolve(currentDir, importPath); - - // If the resolved path doesn't have an extension, try adding extensions - if (!path.extname(resolvedPath)) { - for (const ext of fileExtensions) { - const pathWithExt = resolvedPath + ext; - if (allFiles.includes(pathWithExt)) { - return pathWithExt; - } - } - // If no exact match, check if it's a directory with an index file - for (const ext of fileExtensions) { - const indexPath = path.join(resolvedPath, `index${ext}`); - if (allFiles.includes(indexPath)) { - return indexPath; - } - } - } - - return resolvedPath; -} - -async function findReferences(filePath, allFiles) { - const content = await fs.readFile(filePath, 'utf-8'); - const references = new Set(); - - // Regex to match import statements - const importRegex = /import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"]([^'"]+)['"]/g; - - let match; - while ((match = importRegex.exec(content)) !== null) { - const importPath = match[1]; - const resolvedPath = resolveImportPath(importPath, filePath, allFiles); - if (allFiles.includes(resolvedPath)) { - references.add(resolvedPath); - } - } - - // Check for Svelte component usage - allFiles.forEach(file => { - const fileName = path.basename(file, path.extname(file)); - if (content.includes(`<${fileName}`)) { - references.add(file); - } - }); - - return Array.from(references); -} - -async function analyzeFiles() { - const allFiles = await getAllFiles(projectRoot); - const referenceMap = new Map(); - - for (const file of allFiles) { - const references = await findReferences(file, allFiles); - referenceMap.set(file, references); - } - - return { allFiles, referenceMap }; -} - -try { - const { allFiles, referenceMap } = await analyzeFiles(); - console.log('File analysis:'); - allFiles.forEach(file => { - const relativePath = path.relative(projectRoot, file); - const references = referenceMap.get(file); - const isReferenced = Array.from(referenceMap.values()).some(refs => refs.includes(file)); - const isImported = references.length > 0 || isReferenced; - - console.log(`[${isImported ? 'IMPORTED' : 'NOT IMPORTED'}] ${relativePath}`); - if (references.length > 0) { - console.log(' References:'); - references.forEach(ref => console.log(` - ${path.relative(projectRoot, ref)}`)); - } - if (isReferenced) { - console.log(' Referenced in:'); - allFiles.forEach(f => { - if (referenceMap.get(f).includes(file)) { - console.log(` - ${path.relative(projectRoot, f)}`); - } - }); - } - console.log(''); // Empty line for readability - }); -} catch (error) { - console.error('An error occurred:', error); -} diff --git a/debug-tools/watch.py b/debug-tools/watch.py deleted file mode 100644 index 0f4e2109..00000000 --- a/debug-tools/watch.py +++ /dev/null @@ -1,26 +0,0 @@ -import sys -import signal -from inotify.adapters import InotifyTree - -def main(directory): - inotify = InotifyTree(directory) - - def signal_handler(sig, frame): - print('Stopping...') - sys.exit(0) - - signal.signal(signal.SIGINT, signal_handler) - print(f"Listening for changes in: {directory}") - - for event in inotify.event_gen(yield_nones=False): - (_, type_names, path, filename) = event - if 'IN_MODIFY' in type_names: - print(f"Modified file: {path}/{filename}") - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python watch.py ") - sys.exit(1) - - main(sys.argv[1]) - diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 5fa392cc..89f4a12f 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"; @@ -14,9 +15,14 @@ if you want to view the source, please visit the github repository of this plugi */ `; -const gitTag = execSync("git describe --tags --always", { - encoding: "utf8", -}).trim(); +const getGitTag = () => { + try { + return execSync("git describe --tags --always", { encoding: "utf8" }).trim(); + } catch (e) { + return "dev"; + } +}; +const gitTag = getGitTag(); const develop = process.argv[2] === "develop"; const staging = process.argv[2] === "staging"; @@ -35,7 +41,15 @@ const NotifyPlugin = { name: "on-end", setup(build) { build.onEnd((result) => { - if (result.errors.length > 0) execSync(`notify-send "Build Failed"`); + if (result.errors.length > 0) { + execSync(`notify-send "Build Failed"`); + } else if (watch) { + const tag = getGitTag(); + const outfile = build.initialOptions.outfile; + const content = fs.readFileSync(outfile, "utf8"); + fs.writeFileSync(outfile, content.replace(/__GIT_TAG__/g, tag)); + console.log(`GIT_TAG: ${tag}`); + } }); }, }; @@ -80,6 +94,7 @@ const context = await esbuild.context({ compilerOptions: { css: true }, preprocess: sveltePreprocess(), }), + inlineWorkerPlugin(), YjsInternalsPlugin, NotifyPlugin, ], @@ -89,7 +104,7 @@ const context = await esbuild.context({ sourcemap: debug ? "inline" : false, define: { BUILD_TYPE: debug ? '"debug"' : '"prod"', - GIT_TAG: `"${gitTag}"`, + GIT_TAG: watch ? '"__GIT_TAG__"' : `"${gitTag}"`, HEALTH_URL: `"${healthUrl}"`, API_URL: `"${apiUrl}"`, AUTH_URL: `"${authUrl}"`, @@ -110,7 +125,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/jest.config.js b/jest.config.js index 9dd81529..8b055d12 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,13 +10,16 @@ module.exports = { moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1", "^src/(.*)$": "/src/$1", + "^yjs$": "/node_modules/yjs/src/index.js", + "^yjs/dist/src/internals$": "/node_modules/yjs/src/internals.js", }, testPathIgnorePatterns: ["/__tests__/mocks/"], globals: { "BUILD_TYPE": "production", }, + transformIgnorePatterns: ["/node_modules/(?!(yjs|lib0)/)"], transform: { - ".ts": [ + "\\.ts$": [ "ts-jest", { // Note: We shouldn't need to include `isolatedModules` here because it's a deprecated config option in TS 5, @@ -26,5 +29,19 @@ module.exports = { useESM: true, }, ], + "src/.+\\.js$": [ + "ts-jest", + { + isolatedModules: true, + useESM: true, + }, + ], + "node_modules/(yjs|lib0)/.+\\.js$": [ + "ts-jest", + { + isolatedModules: true, + useESM: true, + }, + ], }, }; diff --git a/package-lock.json b/package-lock.json index a694c217..22deb380 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "cbor-x": "^1.6.0", "diff": "^5.2.0", "diff-match-patch": "^1.0.5", "eventsource": "^2.0.2", @@ -16,13 +17,13 @@ "jose": "^5.3.0", "lucide-svelte": "^0.377.0", "monkey-around": "^3.0.0", + "node-diff3": "^3.2.0", "obsidian-daily-notes-interface": "^0.9.4", "path-browserify": "^1.0.1", "pocketbase": "^0.20.3", "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 +42,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", @@ -565,6 +567,84 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz", + "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz", + "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz", + "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz", + "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz", + "integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz", + "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@codemirror/state": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", @@ -2487,6 +2567,37 @@ } ] }, + "node_modules/cbor-extract": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz", + "integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.0", + "@cbor-extract/cbor-extract-linux-x64": "2.2.0", + "@cbor-extract/cbor-extract-win32-x64": "2.2.0" + } + }, + "node_modules/cbor-x": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz", + "integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2629,6 +2740,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", @@ -2764,6 +2882,16 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2926,6 +3054,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 +3430,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", @@ -4886,6 +5069,15 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-diff3": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/node-diff3/-/node-diff3-3.2.0.tgz", + "integrity": "sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==", + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, "node_modules/node-gyp-build": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz", @@ -4896,6 +5088,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6215,25 +6422,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", @@ -6748,6 +6936,42 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz", + "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==", + "optional": true + }, + "@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz", + "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==", + "optional": true + }, + "@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz", + "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==", + "optional": true + }, + "@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz", + "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==", + "optional": true + }, + "@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz", + "integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==", + "optional": true + }, + "@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz", + "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==", + "optional": true + }, "@codemirror/state": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", @@ -8031,6 +8255,29 @@ "integrity": "sha512-UWM1zlo3cZfkpBysd7AS+z+v007q9G1+fLTUU42rQnY6t2axoogPW/xol6T7juU5EUoOhML4WgBIdG+9yYqAjQ==", "dev": true }, + "cbor-extract": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz", + "integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==", + "optional": true, + "requires": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.0", + "@cbor-extract/cbor-extract-linux-x64": "2.2.0", + "@cbor-extract/cbor-extract-win32-x64": "2.2.0", + "node-gyp-build-optional-packages": "5.1.1" + } + }, + "cbor-x": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz", + "integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==", + "requires": { + "cbor-extract": "^2.2.0" + } + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8136,6 +8383,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", @@ -8232,6 +8485,12 @@ "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", "dev": true }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true + }, "detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -8359,6 +8618,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 +8903,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", @@ -9831,11 +10128,25 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node-diff3": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/node-diff3/-/node-diff3-3.2.0.tgz", + "integrity": "sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==" + }, "node-gyp-build": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz", "integrity": "sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==" }, + "node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "optional": true, + "requires": { + "detect-libc": "^2.0.1" + } + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10735,14 +11046,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..19c8c54e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs watch", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs develop", + "build": "bash scripts/guard-build.sh", + "build:force": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs develop", "release": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "beta": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs debug", "staging": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs staging", @@ -30,6 +31,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", @@ -40,6 +42,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "cbor-x": "^1.6.0", "diff": "^5.2.0", "diff-match-patch": "^1.0.5", "eventsource": "^2.0.2", @@ -47,6 +50,7 @@ "jose": "^5.3.0", "lucide-svelte": "^0.377.0", "monkey-around": "^3.0.0", + "node-diff3": "^3.2.0", "obsidian-daily-notes-interface": "^0.9.4", "path-browserify": "^1.0.1", "pocketbase": "^0.20.3", diff --git a/scripts/guard-build.sh b/scripts/guard-build.sh new file mode 100755 index 00000000..eb235ecc --- /dev/null +++ b/scripts/guard-build.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Guard against running `npm run build` in a dev environment. +# The build command produces a production bundle (api.system3.md). +# For local development, use the staging build instead. +# +# Staging build (continuous watcher, points to dev servers): +# npm run staging vaults/live1/.obsidian/plugins/system3-relay/ & +# npm run staging vaults/live2/.obsidian/plugins/system3-relay/ & +# +# See MEMORY.md "Staging Build" section for details. + +set -e + +# Only guard when .staging-only marker file is present. +# Without it, run the normal build. +if [ ! -f ".staging-only" ]; then + tsc -noEmit -skipLibCheck && node esbuild.config.mjs develop + exit $? +fi + +echo "" +echo "==========================================================" +echo " DO NOT use 'npm run build' for local development." +echo "" +echo " 'npm run build' produces a PRODUCTION bundle that" +echo " connects to api.system3.md (production servers)." +echo " It will break your local auth session." +echo "" +echo " For local development, use the staging build:" +echo "" +echo " npm run staging vaults/live1/.obsidian/plugins/system3-relay/ &" +echo " npm run staging vaults/live2/.obsidian/plugins/system3-relay/ &" +echo "" + +# Show staging process status +STAGING_PIDS=$(pgrep -f "esbuild.config.mjs staging" 2>/dev/null | head -10) +if [ -n "$STAGING_PIDS" ]; then + echo " Staging watchers are already running (PIDs: $(echo $STAGING_PIDS | tr '\n' ' '))." + echo " To trigger a rebuild, touch a source file:" + echo "" + echo " touch src/main.ts" + echo "" +else + echo " No staging watchers running. Start them with the commands above." + echo "" +fi + +echo " To force a production build anyway, run directly:" +echo " npx tsc -noEmit -skipLibCheck && node esbuild.config.mjs develop" +echo "==========================================================" +echo "" +exit 1 diff --git a/src/BackgroundSync.ts b/src/BackgroundSync.ts index 921843e3..fa5abd44 100644 --- a/src/BackgroundSync.ts +++ b/src/BackgroundSync.ts @@ -78,7 +78,7 @@ export class BackgroundSync extends HasLogging { private downloadCompletionCallbacks = new Map< string, { - resolve: () => void; + resolve: (result?: Uint8Array) => void; reject: (error: Error) => void; } >(); @@ -100,6 +100,13 @@ export class BackgroundSync extends HasLogging { this.processSyncQueue(); this.processDownloadQueue(); }, 1000); + + // Add polling timer for disk changes (poll all folders) + this.timeProvider.setInterval(() => { + this.sharedFolders.forEach((folder) => { + folder.pollDiskState(); + }); + }, 5000); // Poll every 5 seconds } /** @@ -338,12 +345,12 @@ export class BackgroundSync extends HasLogging { } downloadPromise - .then(() => { + .then((result) => { item.status = "completed"; const callback = this.downloadCompletionCallbacks.get(item.guid); if (callback) { - callback.resolve(); + callback.resolve(result as Uint8Array | undefined); this.downloadCompletionCallbacks.delete(item.guid); } @@ -490,7 +497,9 @@ export class BackgroundSync extends HasLogging { * @param item The document to download * @returns A promise that resolves when the download completes */ - enqueueDownload(item: SyncFile | Document | Canvas): Promise { + enqueueDownload( + item: SyncFile | Document | Canvas, + ): Promise { // Skip if already in progress if (this.inProgressDownloads.has(item.guid)) { this.debug( @@ -501,13 +510,13 @@ export class BackgroundSync extends HasLogging { const existingCallback = this.downloadCompletionCallbacks.get(item.guid); if (existingCallback) { this.processDownloadQueue(); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { existingCallback.resolve = resolve; existingCallback.reject = reject; }); } this.processDownloadQueue(); - return Promise.resolve(); + return Promise.resolve(undefined); } const sharedFolder = item.sharedFolder; @@ -545,9 +554,11 @@ export class BackgroundSync extends HasLogging { this.inProgressDownloads.add(item.guid); // Create a promise that will resolve when the download completes - const downloadPromise = new Promise((resolve, reject) => { - this.downloadCompletionCallbacks.set(item.guid, { resolve, reject }); - }); + const downloadPromise = new Promise( + (resolve, reject) => { + this.downloadCompletionCallbacks.set(item.guid, { resolve, reject }); + }, + ); // Add to the queue and start processing this.downloadQueue.push(queueItem); @@ -726,45 +737,31 @@ export class BackgroundSync extends HasLogging { async syncDocumentWebsocket(doc: Document | Canvas): Promise { // if the local file is synced, then we do the two step process - // check if file is tracking - let currentFileContents = ""; - - // Handle different document types - let currentTextStr = ""; - let currentCanvasData: CanvasData | null = null; - if (isCanvas(doc)) { // Store the exported canvas data rather than a stringified version - currentCanvasData = Canvas.exportCanvasData(doc.ydoc); - currentTextStr = JSON.stringify(currentCanvasData); - } else if (isDocument(doc)) { - currentTextStr = doc.text; - } - try { - currentFileContents = await doc.sharedFolder.read(doc); - } catch (e) { - // File does not exist - } - - // Only proceed with update if file matches current ydoc state - let contentsMatch = false; - if (isCanvas(doc) && currentCanvasData) { - // For canvas, use deep object comparison instead of string equality - const currentFileJson = currentFileContents - ? JSON.parse(currentFileContents) - : { nodes: [], edges: [] }; - contentsMatch = areObjectsEqual(currentCanvasData, currentFileJson); - } else { - contentsMatch = currentTextStr === currentFileContents; - } - - if (!contentsMatch && currentFileContents) { - this.log( - "file is not tracking local disk. resolve merge conflicts before syncing.", - ); - return false; + const currentCanvasData = Canvas.exportCanvasData(doc.ydoc); + try { + const currentFileContents = await doc.sharedFolder.read(doc); + + // Only proceed with update if file matches current ydoc state + let contentsMatch = false; + if (isCanvas(doc) && currentCanvasData) { + // For canvas, use deep object comparison instead of string equality + const currentFileJson = currentFileContents + ? JSON.parse(currentFileContents) + : { nodes: [], edges: [] }; + contentsMatch = areObjectsEqual(currentCanvasData, currentFileJson); + if (!contentsMatch && currentFileContents) { + this.log( + "file is not tracking local disk. resolve merge conflicts before syncing.", + ); + return false; + } + } + } catch (e) { + // File does not exist + } } - const promise = doc.onceProviderSynced(); const intent = doc.intent; doc.connect(); @@ -773,7 +770,8 @@ export class BackgroundSync extends HasLogging { } // promise can take some time - if (intent === "disconnected" && !doc.userLock) { + const isActive = doc.userLock || doc.sharedFolder?.mergeManager?.isActive(doc.guid); + if (intent === "disconnected" && !isActive) { doc.disconnect(); doc.sharedFolder.tokenStore.removeFromRefreshQueue(S3RN.encode(doc.s3rn)); } @@ -785,7 +783,7 @@ export class BackgroundSync extends HasLogging { * @param canvas The canvas to download * @returns A promise that resolves when the download completes */ - enqueueCanvasDownload(canvas: Canvas): Promise { + enqueueCanvasDownload(canvas: Canvas): Promise { return this.enqueueDownload(canvas); } @@ -828,26 +826,17 @@ export class BackgroundSync extends HasLogging { } } - private async getDocument(doc: Document, retry = 3, wait = 3000) { + private async getDocument( + doc: Document, + retry = 3, + wait = 3000, + ): Promise { try { - // Get the current contents before applying the update - const currentText = doc.text; - let currentFileContents = ""; - try { - currentFileContents = await doc.sharedFolder.read(doc); - } catch (e) { - // File doesn't exist - } - - // Only proceed with update if file matches current ydoc state - const contentsMatch = currentText === currentFileContents; - const hasContents = currentFileContents !== ""; - const response = await this.downloadItem(doc); const rawUpdate = response.arrayBuffer; const updateBytes = new Uint8Array(rawUpdate); - // Check for newly created documents without content, and reject them + // Validate: reject uninitialized documents const newDoc = new Y.Doc(); Y.applyUpdate(newDoc, updateBytes); const users = newDoc.getMap("users"); @@ -855,7 +844,6 @@ export class BackgroundSync extends HasLogging { if (contents === "") { if (users.size === 0) { - // Hack for better compat with < 0.4.2. this.log( "[getDocument] Server contains uninitialized document. Waiting for peer to upload.", users.size, @@ -867,28 +855,20 @@ export class BackgroundSync extends HasLogging { this.getDocument(doc, retry - 1, wait * 2); }, wait); } - return; + return undefined; } if (doc.text) { this.log( "[getDocument] local crdt has contents, but remote is empty", ); this.enqueueSync(doc); - return; + return undefined; } } this.log("[getDocument] applying content from server"); Y.applyUpdate(doc.ydoc, updateBytes); - - if (hasContents && !contentsMatch) { - this.log("Skipping flush - file requires merge conflict resolution."); - return; - } - if (doc.sharedFolder.syncStore.has(doc.path)) { - doc.sharedFolder.flush(doc, doc.text); - this.log("[getDocument] flushed"); - } + return updateBytes; } catch (e) { this.error(e); throw e; diff --git a/src/Canvas.ts b/src/Canvas.ts index 004f62c4..5915d6fa 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -182,8 +182,8 @@ export class Canvas extends HasProvider implements IFile, HasMimeType { async awaitingUpdates(): Promise { await this.whenSynced(); await this.getServerSynced(); - if (!this._awaitingUpdates) { - return false; + if (this._awaitingUpdates !== undefined) { + return this._awaitingUpdates; } this._awaitingUpdates = !this.hasLocalDB(); return this._awaitingUpdates; @@ -240,6 +240,20 @@ export class Canvas extends HasProvider implements IFile, HasMimeType { return this.whenSyncedPromise.getPromise(); } + /** + * Release lock on this canvas. + * Transitions HSM from active back to idle mode. + * Call this when editor closes (replaces userLock = false). + */ + releaseLock(): void { + this.userLock = false; + + const mergeManager = this.sharedFolder.mergeManager; + if (mergeManager) { + mergeManager.unload(this.guid); + } + } + public get sharedFolder(): SharedFolder { return this._parent; } diff --git a/src/CanvasPlugin.ts b/src/CanvasPlugin.ts index 83042f55..a42209f6 100644 --- a/src/CanvasPlugin.ts +++ b/src/CanvasPlugin.ts @@ -109,9 +109,10 @@ export class CanvasPlugin extends HasLogging { this.trackedEmbedViews.add(embedView); this.unsubscribes.push( (() => { + const document = this.relayCanvas.sharedFolder.proxy.getDoc(embedView.file.path); const plugin = new ViewHookPlugin( embedView, - this.relayCanvas.sharedFolder.proxy.getDoc(embedView.file.path), + document, ); plugin.initialize().catch((error) => { this.error( @@ -119,9 +120,23 @@ export class CanvasPlugin extends HasLogging { error, ); }); + + // Acquire HSM lock for the embedded document so its HSM + // transitions to active mode and can process CM6 events. + // Read editor content: the embed's CM6 editor may start empty, + // so use the child view's data (which holds the disk content). + const editorContent = embedView.data ?? ""; + document.acquireLock(editorContent).catch((error: unknown) => { + this.error( + "Error acquiring lock for canvas embed:", + error, + ); + }); + return () => { this.trackedEmbedViews.delete(embedView); plugin.destroy(); + document.releaseLock(); }; })(), ); diff --git a/src/ConnectionPool.ts b/src/ConnectionPool.ts index c1c5b220..62025b86 100644 --- a/src/ConnectionPool.ts +++ b/src/ConnectionPool.ts @@ -110,7 +110,7 @@ export class ConnectionPool extends HasLogging { } private processQueue(): void { - const currentTime = this.timeProvider.getTime(); + const currentTime = this.timeProvider.now(); //this.logConnectionStatus(); this.enforceConnectionLimits(); @@ -147,7 +147,7 @@ export class ConnectionPool extends HasLogging { this.connections.set(request.uuid, { uuid: request.uuid, disconnect: request.disconnect, - leaseExpiryTime: this.timeProvider.getTime() + request.lease_s * 1000, + leaseExpiryTime: this.timeProvider.now() + request.lease_s * 1000, }); this.log(`Created queued temporary connection for ${request.uuid}`); @@ -211,7 +211,7 @@ export class ConnectionPool extends HasLogging { this.connections.set(uuid, { uuid, disconnect, - leaseExpiryTime: this.timeProvider.getTime() + lease_s * 1000, + leaseExpiryTime: this.timeProvider.now() + lease_s * 1000, }); this.log(`Created temporary connection for ${uuid}`); return true; diff --git a/src/DeviceManager.ts b/src/DeviceManager.ts new file mode 100644 index 00000000..4d252429 --- /dev/null +++ b/src/DeviceManager.ts @@ -0,0 +1,180 @@ +"use strict"; + +import { Platform } from "obsidian"; +import type { LoginManager } from "./LoginManager"; +import { Observable } from "./observable/Observable"; + +const DEVICE_ID_KEY = "relay-device-id"; + +/** + * Generate a PocketBase-compatible ID. + * Format: 15 characters, lowercase alphanumeric only. + */ +function generatePocketBaseId(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + const array = new Uint8Array(15); + crypto.getRandomValues(array); + return Array.from(array, (byte) => chars[byte % chars.length]).join(""); +} + +/** + * Get platform string for device registration. + */ +function getPlatform(): string { + if (Platform.isIosApp) return "Phone (iOS)"; + if (Platform.isAndroidApp) return "Phone (Android)"; + if (Platform.isMacOS) return "Desktop (macOS)"; + if (Platform.isWin) return "Desktop (Windows)"; + if (Platform.isLinux) return "Desktop (Linux)"; + if (Platform.isMobile) return "Mobile"; + return "Desktop"; +} + +export class DeviceManager extends Observable { + private deviceId: string | null = null; + private registered = false; + + constructor( + private appId: string, + private vaultName: string, + private loginManager: LoginManager, + ) { + super("DeviceManager"); + } + + /** + * Get or create the device ID from localStorage. + */ + getDeviceId(): string { + if (this.deviceId) return this.deviceId; + + let id = localStorage.getItem(DEVICE_ID_KEY); + if (!id) { + id = generatePocketBaseId(); + localStorage.setItem(DEVICE_ID_KEY, id); + this.log("Generated new device ID:", id); + } + this.deviceId = id; + return id; + } + + /** + * Get platform string. + */ + getPlatform(): string { + return getPlatform(); + } + + /** + * Register device and vault with PocketBase. + * Creates records if they don't exist, updates if they do. + */ + async register(): Promise { + if (this.registered) { + this.debug("Already registered this session"); + return; + } + + if (!this.loginManager.loggedIn) { + this.debug("Not logged in, skipping registration"); + return; + } + + const deviceId = this.getDeviceId(); + const platform = this.getPlatform(); + const userId = this.loginManager.user?.id; + + if (!userId) { + this.warn("No user ID available"); + return; + } + + try { + // Register device + await this.registerDevice(deviceId, platform, userId); + + // Register vault + await this.registerVault(this.appId, this.vaultName, deviceId, userId); + + this.registered = true; + this.log("Device and vault registered successfully"); + } catch (error) { + this.error("Failed to register device/vault:", error); + } + } + + private async registerDevice( + deviceId: string, + platform: string, + userId: string, + ): Promise { + const pb = this.loginManager.pb; + + try { + // Try to create new device record + await pb.collection("devices").create({ + id: deviceId, + name: platform, + platform: platform, + user: userId, + }); + this.log("Created new device record:", deviceId); + } catch (e: any) { + // Record may already exist, try to update + if (e.status === 400 || e.status === 409) { + try { + await pb.collection("devices").update(deviceId, { + platform: platform, + user: userId, + }); + this.log("Updated existing device record:", deviceId); + } catch (updateError) { + this.error("Failed to update device:", updateError); + throw updateError; + } + } else { + throw e; + } + } + } + + private async registerVault( + vaultId: string, + vaultName: string, + deviceId: string, + userId: string, + ): Promise { + const pb = this.loginManager.pb; + + try { + // Try to create new vault record + await pb.collection("vaults").create({ + id: vaultId, + name: vaultName, + device: deviceId, + user: userId, + }); + this.log("Created new vault record:", vaultId); + } catch (e: any) { + // Record may already exist, try to update + if (e.status === 400 || e.status === 409) { + try { + await pb.collection("vaults").update(vaultId, { + name: vaultName, + device: deviceId, + }); + this.log("Updated existing vault record:", vaultId); + } catch (updateError) { + this.error("Failed to update vault:", updateError); + throw updateError; + } + } else { + throw e; + } + } + } + + override destroy(): void { + super.destroy(); + } +} diff --git a/src/Document.ts b/src/Document.ts index 028fd381..da3aa892 100644 --- a/src/Document.ts +++ b/src/Document.ts @@ -8,7 +8,6 @@ import { S3Document, S3Folder, S3RN, S3RemoteDocument } from "./S3RN"; import { SharedFolder } from "./SharedFolder"; import type { TFile, Vault, TFolder } from "obsidian"; import { debounce } from "obsidian"; -import { DiskBuffer, DiskBufferStore } from "./DiskBuffer"; import type { Unsubscriber } from "./observable/Observable"; import { Dependency } from "./promiseUtils"; import { flags, withFlag } from "./flagManager"; @@ -16,6 +15,10 @@ import { flag } from "./flags"; import type { HasMimeType, IFile } from "./IFile"; import { getMimeType } from "./mimetypes"; import { diffMatchPatch } from "./y-diffMatchPatch"; +import type { MergeHSM } from "./merge-hsm/MergeHSM"; +import type { EditorViewRef } from "./merge-hsm/types"; +import { ProviderIntegration, type YjsProvider } from "./merge-hsm/integration/ProviderIntegration"; +import { generateHash } from "./hashing"; export function isDocument(file?: IFile): file is Document { return file instanceof Document; @@ -23,7 +26,7 @@ export function isDocument(file?: IFile): file is Document { export class Document extends HasProvider implements IFile, HasMimeType { private _parent: SharedFolder; - private _persistence: IndexeddbPersistence; + private _persistence: IndexeddbPersistence | null = null; whenSyncedPromise: Dependency | null = null; persistenceSynced: boolean = false; _awaitingUpdates?: boolean; @@ -40,11 +43,28 @@ export class Document extends HasProvider implements IFile, HasMimeType { mtime: number; size: number; }; - _diskBuffer?: DiskBuffer; - _diskBufferStore?: DiskBufferStore; unsubscribes: Unsubscriber[] = []; pendingOps: ((data: string) => string)[] = []; + /** + * MergeHSM instance for this document. + * Only available when HSM active mode is enabled. + * Use acquireLock() to get/create the HSM. + */ + private _hsm: MergeHSM | null = null; + + /** + * ProviderIntegration instance for bridging HSM with the provider. + * Created when lock is acquired, destroyed when released. + */ + private _providerIntegration: ProviderIntegration | null = null; + + /** + * Flag to track when we're in the middle of our own save operation. + * Used to distinguish our writes from external modifications. + */ + private _isSaving: boolean = false; + constructor( path: string, guid: string, @@ -67,7 +87,10 @@ export class Document extends HasProvider implements IFile, HasMimeType { mtime: Date.now(), size: 0, }; - this._diskBufferStore = this.sharedFolder.diskBufferStore; + // Initialize HSM immediately so it's always available for filtering disk changes. + // The HSM starts in loading state and transitions to idle once persistence loads. + // Document owns the HSM - use ensureHSM() which uses MergeManager as a factory. + this.ensureHSM(); this.unsubscribes.push( this._parent.subscribe(this.path, (state) => { @@ -78,46 +101,41 @@ export class Document extends HasProvider implements IFile, HasMimeType { ); this.setLoggers(`[SharedDoc](${this.path})`); - try { - const key = `${this.sharedFolder.appId}-relay-doc-${this.guid}`; - this._persistence = new IndexeddbPersistence(key, this.ydoc); - } catch (e) { - this.warn("Unable to open persistence.", this.guid); - console.error(e); - throw e; - } - this.whenSynced().then(() => { - const statsObserver = (event: Y.YTextEvent) => { - const origin = event.transaction.origin; - if (event.changes.keys.size === 0) return; - if (origin == this) return; - this.updateStats(); - }; - this.ytext.observe(statsObserver); - this.unsubscribes.push(() => { - this.ytext?.unobserve(statsObserver); - }); - this.updateStats(); - try { - this._persistence.set("path", this.path); - this._persistence.set("relay", this.sharedFolder.relayId || ""); - this._persistence.set("appId", this.sharedFolder.appId); - this._persistence.set("s3rn", S3RN.encode(this.s3rn)); - } catch (e) { - // pass - } - - (async () => { - const serverSynced = await this.getServerSynced(); - if (!serverSynced) { - await this.onceProviderSynced(); - await this.markSynced(); - } - })(); - }); + // need to port this to the HSM + // this.whenSynced().then(() => { + // const statsObserver = (event: Y.YTextEvent) => { + // const origin = event.transaction.origin; + // if (event.changes.keys.size === 0) return; + // if (origin == this) return; + // this.updateStats(); + // }; + // this.ytext.observe(statsObserver); + // this.unsubscribes.push(() => { + // this.ytext?.unobserve(statsObserver); + // }); + // this.updateStats(); + // try { + // this._persistence!.set("path", this.path); + // this._persistence!.set("relay", this.sharedFolder.relayId || ""); + // this._persistence!.set("appId", this.sharedFolder.appId); + // this._persistence!.set("s3rn", S3RN.encode(this.s3rn)); + // } catch (e) { + // // pass + // } + + // (async () => { + // const serverSynced = await this.getServerSynced(); + // if (!serverSynced) { + // await this.onceProviderSynced(); + // await this.markSynced(); + // } + // })(); + // }); withFlag(flag.enableDeltaLogging, () => { + // Only attach observer when remoteDoc is loaded (avoid triggering lazy creation) + if (!this.isRemoteDocLoaded) return; const logObserver = (event: Y.YTextEvent) => { let log = ""; log += `Transaction origin: ${event.transaction.origin} ${event.transaction.origin?.constructor?.name}\n`; @@ -144,10 +162,9 @@ export class Document extends HasProvider implements IFile, HasMimeType { this.updateStats(); } - async process(fn: (data: string) => string) { - if (this.tfile && flags().enableAutomaticDiffResolution) { - this.pendingOps.push(fn); - } + async process(_fn: (data: string) => string) { + // Automatic diff resolution removed due to data loss issues (BUG-020) + // This method is intentionally a no-op } public get parent(): TFolder | null { @@ -157,6 +174,200 @@ export class Document extends HasProvider implements IFile, HasMimeType { public get sharedFolder(): SharedFolder { return this._parent; } + + /** + * Get the MergeHSM instance for this document. + * Returns null if HSM active mode is not enabled or lock not acquired. + */ + public get hsm(): MergeHSM | null { + return this._hsm; + } + + /** + * Ensure an HSM exists for this document, creating one if needed. + * Document owns the HSM - MergeManager is just a factory. + * @returns The MergeHSM instance, or null if MergeManager not available + */ + ensureHSM(): MergeHSM | null { + if (this._hsm) { + return this._hsm; + } + + const mergeManager = this.sharedFolder?.mergeManager; + if (!mergeManager) { + return null; + } + + // Create HSM using factory + this._hsm = mergeManager.createHSM({ + guid: this.guid, + getPath: () => this.path, + remoteDoc: this.isRemoteDocLoaded ? this.ydoc : null, + getDiskContent: () => this.readDiskContent(), + getPersistenceMetadata: () => ({ + path: this.path, + relay: this.sharedFolder.relayId || "", + appId: this.sharedFolder.appId, + s3rn: this.s3rn ? S3RN.encode(this.s3rn) : "", + }), + }); + + // Subscribe to effects + this._hsm.subscribe((effect) => { + this.handleEffect(effect); + }); + + // Subscribe to state changes for sync status updates + this._hsm.onStateChange(() => { + mergeManager.updateSyncStatus(this.guid, this._hsm!.getSyncStatus()); + }); + + // Notify MergeManager for hibernation tracking + mergeManager.notifyHSMCreated(this.guid); + + return this._hsm; + } + + /** + * Create the remote YDoc, populating it from localDoc if available. + * This ensures the remoteDoc has content for provider sync even when + * content was enrolled into localDoc (e.g., via initializeWithContent). + */ + ensureRemoteDoc(): Y.Doc { + const isNew = !this.isRemoteDocLoaded; + const doc = super.ensureRemoteDoc(); + if (isNew && this._hsm) { + const localDoc = this._hsm.getLocalDoc(); + if (localDoc) { + const update = Y.encodeStateAsUpdate(localDoc); + Y.applyUpdate(doc, update); + } + } + return doc; + } + + /** + * Acquire lock on this document for active editing. + * Transitions HSM from idle to active mode. + * Call this when editor opens (replaces userLock = true). + * + * @param editorContent - The current editor/disk content (required in v6). + * Since the editor content equals the disk content when a file is first + * opened (before CRDT loads), this provides accurate disk content for + * merge operations. Pass the content from the editor or read from disk. + * @returns The MergeHSM instance, or null if HSM is not enabled + */ + async acquireLock(editorContent?: string, editorViewRef?: EditorViewRef): Promise { + const mergeManager = this.sharedFolder.mergeManager; + if (!mergeManager || !this._hsm) { + this.userLock = true; // Fallback if MergeManager/HSM not available + return null; + } + + try { + // v6: If editorContent not provided, read from disk (fallback for backward compatibility) + let content = editorContent; + if (content === undefined) { + const tfile = this.tfile; + if (tfile) { + content = await this.vault.read(tfile); + } else { + content = ""; // New file, no content yet + } + } + + // Ensure remoteDoc and provider exist before entering active mode. + // This wakes the document from hibernation if needed. + const remoteDoc = this.ensureRemoteDoc(); + this._hsm.setRemoteDoc(remoteDoc); + + // Send ACQUIRE_LOCK with editorContent to transition from idle to active. + // Always send (don't guard with isLoaded) because releaseLock() doesn't await + // unload(), so activeDocs may not be cleared when file is quickly reopened. + // The HSM handles duplicate ACQUIRE_LOCK gracefully (no-op if already active). + this._hsm.send({ type: "ACQUIRE_LOCK", editorContent: content, editorViewRef }); + mergeManager.markActive(this.guid); + + // Create ProviderIntegration BEFORE awaiting so it can deliver + // PROVIDER_SYNCED during the entering phase (needed for empty-IDB flow). + if (!this._providerIntegration) { + this._providerIntegration = new ProviderIntegration( + this._hsm, + remoteDoc, + this._provider! as YjsProvider + ); + } + + // Wait for HSM to reach a post-entering active state + await this._hsm.awaitActive(); + + // Guard against race: releaseLock() may have been called while we + // were awaiting. If so, the HSM has already transitioned back to idle + // and we must not keep a ProviderIntegration (it would leak). + if (!this.userLock && !mergeManager.isActive(this.guid)) { + this._providerIntegration.destroy(); + this._providerIntegration = null; + return null; + } + + this.userLock = true; // Keep for compatibility + + return this._hsm; + } catch (e) { + this.warn("[acquireLock] Failed to acquire HSM lock:", e); + this.userLock = true; // Fallback + return null; + } + } + + /** + * Release lock on this document. + * Transitions HSM from active back to idle mode. + * Call this when editor closes (replaces userLock = false). + */ + releaseLock(): void { + this.userLock = false; // Keep for compatibility + + // Destroy ProviderIntegration (unsubscribes from events) + if (this._providerIntegration) { + this._providerIntegration.destroy(); + this._providerIntegration = null; + } + + // Guard: sharedFolder may be null if document was orphaned (file moved out of folder) + const mergeManager = this.sharedFolder?.mergeManager; + if (mergeManager) { + // MergeManager.unload() sends RELEASE_LOCK + mergeManager.unload(this.guid); + } + } + + /** + * Get the HSM sync status for this document. + * Returns the status if HSM is available, or null otherwise. + * This can be used instead of checkStale() when HSM is enabled. + */ + getHSMSyncStatus(): import("./merge-hsm/types").SyncStatus | null { + const mergeManager = this.sharedFolder?.mergeManager; + if (!mergeManager) { + return null; + } + return mergeManager.syncStatus.get(this.guid) ?? null; + } + + /** + * Check if the document has a conflict according to HSM. + * Returns true if HSM indicates a conflict, false if synced/pending, + * or null if HSM is not available. + */ + hasHSMConflict(): boolean | null { + const status = this.getHSMSyncStatus(); + if (!status) { + return null; + } + return status.status === "conflict"; + } + public get tfile(): TFile | null { if (!this._tfile) { this._tfile = this.getTFile(); @@ -179,98 +390,68 @@ export class Document extends HasProvider implements IFile, HasMimeType { return this.ytext.toString(); } - public async diskBuffer(read = false): Promise { - if (read || this._diskBuffer === undefined) { - let fileContents: string; - try { - const storedContents = await this._parent.diskBufferStore - .loadDiskBuffer(this.guid) - .catch((e) => { - return null; - }); - if (storedContents !== null && storedContents !== "") { - fileContents = storedContents; - } else { - fileContents = await this._parent.read(this); - } - return this.setDiskBuffer(fileContents.replace(/\r\n/g, "\n")); - } catch (e) { - console.error(e); - throw e; - } - } - return this._diskBuffer; - } - - setDiskBuffer(contents: string): TFile { - if (this._diskBuffer) { - this._diskBuffer.contents = contents; - } else { - this._diskBuffer = new DiskBuffer( - this._parent.vault, - "local disk", - contents, + // =========================================================================== + // HSM-aware accessors (localDoc only - no fallback to remoteDoc) + // =========================================================================== + + /** + * Get the HSM's localDoc when available (active mode only). + * Returns null when HSM is not in active mode or not available. + * + * IMPORTANT: All editor operations should use localDoc, not ydoc (remoteDoc). + * Writing to ydoc directly causes corruption. + */ + public get localDoc(): Y.Doc | null { + return this._hsm?.getLocalDoc() ?? null; + } + + /** + * Get the Y.Text from HSM's localDoc. + * @throws Error if HSM is not in active mode (no localDoc available) + */ + public get localYText(): Y.Text { + const doc = this.localDoc; + if (!doc) { + throw new Error( + `Document ${this.path}: Cannot access localYText - HSM not in active mode.` ); } - this._parent.diskBufferStore - .saveDiskBuffer(this.guid, contents) - .catch((e) => {}); - return this._diskBuffer; - } - - async clearDiskBuffer(): Promise { - if (this._diskBuffer) { - this._diskBuffer.contents = ""; - this._diskBuffer = undefined; + return doc.getText("contents"); + } + + /** + * Get text content from HSM's localDoc. + * @throws Error if HSM is not in active mode (no localDoc available) + */ + public get localText(): string { + return this.localYText.toString(); + } + + /** + * Get the YDoc that should be used for write operations. + * Returns localDoc when in active mode, throws when HSM not in active mode. + * + * IMPORTANT: Writing to ydoc (remoteDoc) directly causes corruption. + * All write operations must go through this method or the HSM. + * + * @throws Error if HSM is not in active mode (no localDoc available) + */ + public getWritableDoc(): Y.Doc { + const localDoc = this.localDoc; + if (!localDoc) { + throw new Error( + `Document ${this.path}: Cannot write - HSM not in active mode. ` + + `Writing to ydoc (remoteDoc) directly causes corruption.` + ); } - await this._parent.diskBufferStore - .removeDiskBuffer(this.guid) - .catch((e) => {}); + return localDoc; } - public async checkStale(): Promise { - await this.whenSynced(); - const diskBuffer = await this.diskBuffer(true); - const contents = (diskBuffer as DiskBuffer).contents; - const response = await this.sharedFolder.backgroundSync.downloadItem(this); - const updateBytes = new Uint8Array(response.arrayBuffer); - - Y.applyUpdate(this.ydoc, updateBytes); - const stale = this.text !== contents; - - const og = this.text; - let text = og; - - const applied: ((data: string) => string)[] = []; - for (const fn of this.pendingOps) { - text = fn(text); - applied.push(fn); - - if (text == contents) { - this.clearDiskBuffer(); - if (og == this.text) { - diffMatchPatch(this.ydoc, text, this); - } else { - if (flags().enableDeltaLogging) { - this.warn( - "diffMatchPatch solution is stale an cannot be applied", - text, - this.text, - ); - } else { - this.log("diffMatchPatch solution is stale an cannot be applied"); - } - return true; - } - this.pendingOps = []; - return true; - } - } - this.pendingOps = []; - if (!stale) { - this.clearDiskBuffer(); - } - return stale; + /** + * Check if the document is in a writable state (HSM active mode). + */ + public get isWritable(): boolean { + return this.localDoc !== null; } async connect(): Promise { @@ -298,17 +479,25 @@ export class Document extends HasProvider implements IFile, HasMimeType { } public get ready(): boolean { + if (!this._persistence) return this.synced; return this._persistence.isReady(this.synced); } hasLocalDB(): boolean { + if (!this._persistence) return false; return this._persistence.hasServerSync || this._persistence.hasUserData(); } async awaitingUpdates(): Promise { await this.whenSynced(); await this.getServerSynced(); - if (!this._awaitingUpdates) { + if (this._awaitingUpdates !== undefined) { + return this._awaitingUpdates; + } + // If folder has synced with server (or is authoritative, which sets serverSynced), we don't need to wait + const folderServerSynced = await this.sharedFolder.getServerSynced(); + if (folderServerSynced) { + this._awaitingUpdates = false; return false; } this._awaitingUpdates = !this.hasLocalDB(); @@ -348,6 +537,12 @@ export class Document extends HasProvider implements IFile, HasMimeType { return; } + if (!this._persistence) { + this.persistenceSynced = true; + resolve(); + return; + } + this._persistence.once("synced", () => { this.persistenceSynced = true; resolve(); @@ -372,7 +567,7 @@ export class Document extends HasProvider implements IFile, HasMimeType { return getMimeType(this.path); } - save() { + async save() { if (!this.tfile) { return; } @@ -380,25 +575,43 @@ export class Document extends HasProvider implements IFile, HasMimeType { this.warn("skipping save for pending delete", this.path); return; } - this.vault.modify(this.tfile, this.text); - this.warn("file saved", this.path); - } - requestSave = debounce(this.save, 2000); - - async markOrigin(origin: "local" | "remote"): Promise { - await this._persistence.setOrigin(origin); + // Mark that we're saving to distinguish from external modifications + this._isSaving = true; + try { + const contents = this.text; + await this.vault.modify(this.tfile, contents); + this.warn("file saved", this.path); + + // Notify HSM of save completion with new mtime and hash + if (this._hsm && this.tfile) { + const mtime = this.tfile.stat.mtime; + const encoder = new TextEncoder(); + const hash = await generateHash(encoder.encode(contents).buffer); + this._hsm.send({ type: "SAVE_COMPLETE", mtime, hash }); + } + } finally { + this._isSaving = false; + } } - async getOrigin(): Promise<"local" | "remote" | undefined> { - return this._persistence.getOrigin(); + /** + * Check if the document is currently being saved by us. + * Used to distinguish our writes from external modifications. + */ + get isSaving(): boolean { + return this._isSaving; } + requestSave = debounce(this.save, 2000); + async markSynced(): Promise { + if (!this._persistence) return; await this._persistence.markServerSynced(); } async getServerSynced(): Promise { + if (!this._persistence) return false; return this._persistence.getServerSynced(); } @@ -410,13 +623,15 @@ export class Document extends HasProvider implements IFile, HasMimeType { this.unsubscribes.forEach((unsubscribe) => { unsubscribe(); }); - super.destroy(); - this.ydoc.destroy(); - if (this._diskBuffer) { - this._diskBuffer.contents = ""; - this._diskBuffer = undefined; + + // Release HSM lock if held + if (this._hsm) { + this.releaseLock(); } - this._diskBufferStore = null as any; + + super.destroy(); + // Note: super.destroy() calls destroyRemoteDoc() which handles ydoc cleanup. + // Do NOT call this.ydoc.destroy() here — it would trigger lazy creation. this.whenSyncedPromise?.destroy(); this.whenSyncedPromise = null as any; this.readyPromise?.destroy(); @@ -429,13 +644,16 @@ export class Document extends HasProvider implements IFile, HasMimeType { } public async cleanup(): Promise { - this._diskBufferStore?.removeDiskBuffer(this.guid); + this.sharedFolder?.mergeManager?.notifyHSMDestroyed(this.guid); } // Helper method to update file stats private updateStats(): void { this.stat.mtime = Date.now(); - this.stat.size = this.text.length; + // Only access text if remoteDoc is loaded (avoid triggering lazy creation) + if (this.isRemoteDocLoaded) { + this.stat.size = this.text.length; + } } // Additional methods that might be useful @@ -449,4 +667,97 @@ export class Document extends HasProvider implements IFile, HasMimeType { this.ytext.insert(this.ytext.length, content); this.updateStats(); } + + // =========================================================================== + // HSM Effect Handling + // =========================================================================== + + /** + * Read current disk content for the HSM. + * Used as diskLoader callback when creating HSM. + */ + async readDiskContent(): Promise<{ content: string; hash: string; mtime: number }> { + const tfile = this.tfile; + if (!tfile) { + throw new Error(`[Document] Cannot read disk content for ${this.path}: TFile not found`); + } + const content = await this.vault.read(tfile); + const encoder = new TextEncoder(); + const hash = await generateHash(encoder.encode(content).buffer); + return { content, hash, mtime: tfile.stat.mtime }; + } + + /** + * Handle effects emitted by the HSM. + * Called by HSM subscriber in ensureHSM(). + */ + async handleEffect(effect: import("./merge-hsm/types").MergeEffect): Promise { + switch (effect.type) { + case "WRITE_DISK": + await this.handleWriteDisk(effect.contents); + break; + case "PERSIST_STATE": + await this.handlePersistState(effect.state); + break; + case "SYNC_TO_REMOTE": + await this.handleSyncToRemote(effect.update); + break; + // Other effects (DISPATCH_CM6, STATUS_CHANGED, etc.) are handled elsewhere + } + } + + private async handleWriteDisk(contents: string): Promise { + const tfile = this.tfile; + if (!tfile) { + this.warn("[handleEffect:WRITE_DISK] TFile not found, cannot write"); + return; + } + if (this.sharedFolder.isPendingDelete(this.path)) { + this.warn("[handleEffect:WRITE_DISK] Skipping write for pending delete", this.path); + return; + } + + this._isSaving = true; + try { + await this.vault.modify(tfile, contents); + this.debug?.("[handleEffect:WRITE_DISK] Wrote to disk", this.path); + + // Notify HSM of save completion with new mtime and hash + if (this._hsm) { + const encoder = new TextEncoder(); + const hash = await generateHash(encoder.encode(contents).buffer); + this._hsm.send({ type: "SAVE_COMPLETE", mtime: tfile.stat.mtime, hash }); + } + } finally { + this._isSaving = false; + } + } + + private async handlePersistState(state: import("./merge-hsm/types").PersistedMergeState): Promise { + const mergeManager = this.sharedFolder?.mergeManager; + if (!mergeManager) return; + + // Update LCA cache in MergeManager + if (state.lca) { + mergeManager.setLCA(this.guid, { + contents: state.lca.contents, + meta: { hash: state.lca.hash, mtime: state.lca.mtime }, + stateVector: state.lca.stateVector, + }); + } else { + mergeManager.setLCA(this.guid, null); + } + } + + private async handleSyncToRemote(update: Uint8Array): Promise { + // Skip if document is in active mode - ProviderIntegration handles it + if (this.userLock || this.sharedFolder?.mergeManager?.isActive(this.guid)) { + return; + } + + // Apply update to remoteDoc (intentionally triggers lazy creation / wake from hibernation) + const remoteDoc = this.ensureRemoteDoc(); + Y.applyUpdate(remoteDoc, update, "idle-sync"); + } + } diff --git a/src/HasProvider.ts b/src/HasProvider.ts index 3cef93e0..0d692204 100644 --- a/src/HasProvider.ts +++ b/src/HasProvider.ts @@ -51,15 +51,23 @@ function makeProvider( return provider; } +/** Disconnected state returned when no provider exists */ +const DISCONNECTED_STATE: ConnectionState = { + status: "disconnected", +} as ConnectionState; + type Listener = (state: ConnectionState) => void; export class HasProvider extends HasLogging { - _provider: YSweetProvider; + _provider: YSweetProvider | null = null; path?: string; - ydoc: Y.Doc; + private _ydoc: Y.Doc | null = null; clientToken: ClientToken; - private _offConnectionError: () => void; - private _offState: () => void; + // 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) | null = null; + private _offState: (() => void) | null = null; listeners: Map; constructor( @@ -71,29 +79,61 @@ export class HasProvider extends HasLogging { super(); this.listeners = new Map(); this.loginManager = loginManager; - const user = this.loginManager?.user; - this.ydoc = new Y.Doc(); - - if (flags().enableDocumentHistory) { - this.ydoc.gc = false; - } - - if (user) { - const permanentUserData = new Y.PermanentUserData(this.ydoc); - permanentUserData.setUserMapping(this.ydoc, this.ydoc.clientID, user.id); - } this.tokenStore = tokenStore; this.clientToken = this.tokenStore.getTokenSync(S3RN.encode(this.s3rn)) || ({ token: "", url: "", docId: "-", expiryTime: 0 } as ClientToken); + } + + /** + * Get the remote YDoc. Lazily creates it on first access. + * Most callers should use this property for backward compatibility. + */ + public get ydoc(): Y.Doc { + if (!this._ydoc) { + this.ensureRemoteDoc(); + } + return this._ydoc!; + } + + /** + * Get the remote YDoc without creating it. + * Returns null if the remoteDoc has not been created yet. + */ + public get remoteDocOrNull(): Y.Doc | null { + return this._ydoc; + } - this._provider = makeProvider(this.clientToken, this.ydoc, user); + /** + * Check if the remote YDoc and provider are currently loaded. + */ + public get isRemoteDocLoaded(): boolean { + return this._ydoc !== null; + } + + /** + * Create the remote YDoc and provider if they don't exist. + * Returns the YDoc for convenience. + */ + ensureRemoteDoc(): Y.Doc { + if (this._ydoc) { + return this._ydoc; + } + + const user = this.loginManager?.user; + this._ydoc = new Y.Doc(); + + if (flags().enableDocumentHistory) { + this._ydoc.gc = false; + } + + this._provider = makeProvider(this.clientToken, this._ydoc, user); const connectionErrorSub = this.providerConnectionErrorSubscription( (event) => { this.log(`[${this.path}] disconnection event`, event); - const shouldConnect = this._provider.canReconnect(); + const shouldConnect = this._provider?.canReconnect() ?? false; this.disconnect(); if (shouldConnect) { this.connect(); @@ -110,6 +150,32 @@ export class HasProvider extends HasLogging { ); stateSub.on(); this._offState = stateSub.off; + + return this._ydoc; + } + + /** + * Destroy the remote YDoc and provider, freeing memory. + * The document can be re-created later via ensureRemoteDoc(). + */ + destroyRemoteDoc(): void { + if (this._offConnectionError) { + this._offConnectionError(); + this._offConnectionError = null; + } + if (this._offState) { + this._offState(); + this._offState = null; + } + if (this._provider) { + this._provider.destroy(); + this._provider = null; + } + if (this._ydoc) { + this._ydoc.destroy(); + this._ydoc = null; + } + this._providerSynced = false; } public get s3rn(): S3RNType { @@ -118,7 +184,9 @@ export class HasProvider extends HasLogging { public set s3rn(value: S3RNType) { this._s3rn = value; - this.refreshProvider(this.clientToken); + if (this._provider) { + this.refreshProvider(this.clientToken); + } } public get debuggerUrl(): string { @@ -156,7 +224,7 @@ export class HasProvider extends HasLogging { } providerActive() { - if (this.clientToken) { + if (this.clientToken && this._provider) { const tokenIsSet = this._provider.hasUrl(this.clientToken.url); const expired = Date.now() > (this.clientToken?.expiryTime || 0); return tokenIsSet && !expired; @@ -169,7 +237,8 @@ export class HasProvider extends HasLogging { this.clientToken = clientToken; if (!this._provider) { - throw new Error("missing provider!"); + // No provider yet - token will be used when ensureRemoteDoc() is called + return; } const result = this._provider.refreshToken( @@ -195,10 +264,12 @@ export class HasProvider extends HasLogging { if (this.connected) { return Promise.resolve(true); } + // Ensure remoteDoc exists before connecting + this.ensureRemoteDoc(); return this.getProviderToken() .then((clientToken) => { this.refreshProvider(clientToken); // XXX is this still needed? - this._provider.connect(); + this._provider!.connect(); this.notifyListeners(); return true; }) @@ -208,19 +279,27 @@ export class HasProvider extends HasLogging { } public get state(): ConnectionState { + if (!this._provider) { + return DISCONNECTED_STATE; + } return this._provider.connectionState; } get intent(): ConnectionIntent { + if (!this._provider) { + return "disconnected" as ConnectionIntent; + } return this._provider.intent; } public get synced(): boolean { - return this._provider.synced; + return this._providerSynced; } disconnect() { - this._provider.disconnect(); + if (this._provider) { + this._provider.disconnect(); + } this.tokenStore.removeFromRefreshQueue(this.guid); this.notifyListeners(); } @@ -237,23 +316,26 @@ export class HasProvider extends HasLogging { } onceConnected(): Promise { + this.ensureRemoteDoc(); return new Promise((resolve) => { const resolveOnConnect = (state: ConnectionState) => { if (state.status === "connected") { + this._provider!.off("status", resolveOnConnect); resolve(); } }; - // provider observers are manually cleared in destroy() - this._provider.on("status", resolveOnConnect); + this._provider!.on("status", resolveOnConnect); }); } onceProviderSynced(): Promise { - if (this._provider.synced) { + if (this._providerSynced) { return Promise.resolve(); } + this.ensureRemoteDoc(); return new Promise((resolve) => { - this._provider.once("synced", () => { + this._provider!.once("synced", () => { + this._providerSynced = true; resolve(); }); }); @@ -274,10 +356,10 @@ export class HasProvider extends HasLogging { f: (event: Event) => void, ): Subscription { const on = () => { - this._provider.on("connection-error", f); + this._provider?.on("connection-error", f); }; const off = () => { - this._provider.off("connection-error", f); + this._provider?.off("connection-error", f); }; return { on, off } as Subscription; } @@ -286,24 +368,16 @@ export class HasProvider extends HasLogging { f: (state: ConnectionState) => void, ): Subscription { const on = () => { - this._provider.on("status", f); + this._provider?.on("status", f); }; const off = () => { - this._provider.off("status", f); + this._provider?.off("status", f); }; return { on, off } as Subscription; } destroy() { - if (this._offConnectionError) { - this._offConnectionError(); - } - if (this._offState) { - this._offState(); - } - if (this._provider) { - this._provider.destroy(); - } + this.destroyRemoteDoc(); this.loginManager = null as any; } } diff --git a/src/LiveViews.ts b/src/LiveViews.ts index 5ce09795..80c9d57f 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -1,5 +1,4 @@ import type { Extension } from "@codemirror/state"; -import { StateField, EditorState, Compartment } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { App, @@ -9,23 +8,29 @@ import { TFile, TextFileView, Workspace, + editorInfoField, moment, type CachedMetadata, } from "obsidian"; import ViewActions from "src/components/ViewActions.svelte"; import * as Y from "yjs"; import { Document } from "./Document"; +import type { EditorViewRef } from "./merge-hsm/types"; import type { ConnectionState } from "./HasProvider"; import { LoginManager } from "./LoginManager"; import NetworkStatus from "./NetworkStatus"; import { SharedFolder, SharedFolders } from "./SharedFolder"; import { curryLog, HasLogging, RelayInstances } from "./debug"; import { Banner } from "./ui/Banner"; -import { LiveEdit } from "./y-codemirror.next/LiveEditPlugin"; +import { HSMEditorPlugin } from "./merge-hsm/integration/HSMEditorPlugin"; import { yRemoteSelections, yRemoteSelectionsTheme, } from "./y-codemirror.next/RemoteSelections"; +import { + conflictDecorationPlugin, + conflictDecorationTheme, +} from "./y-codemirror.next/ConflictDecorationPlugin"; import { InvalidLinkPlugin } from "./markdownView/InvalidLinkExtension"; import * as Differ from "./differ/differencesView"; import type { CanvasView } from "./CanvasView"; @@ -35,6 +40,18 @@ import { LiveNode } from "./y-codemirror.next/LiveNodePlugin"; import { flags } from "./flagManager"; import { AwarenessViewPlugin } from "./AwarenessViewPlugin"; import { TextFileViewPlugin } from "./TextViewPlugin"; +import { ViewHookPlugin } from "./plugins/ViewHookPlugin"; +import { DiskBuffer } from "./DiskBuffer"; + +/** + * Access the LiveViewManager singleton via the Obsidian plugin registry. + * Replaces ConnectionManagerStateField — no CM6 state field needed since + * the plugin is a singleton reachable from any EditorView. + */ +export function getConnectionManager(editor: EditorView): LiveViewManager | null { + const fileInfo = editor.state.field(editorInfoField, false); + return (fileInfo as any)?.app?.plugins?.plugins?.["system3-relay"]?._liveViews ?? null; +} const BACKGROUND_CONNECTIONS = 3; @@ -124,65 +141,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 +236,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(); @@ -291,6 +267,7 @@ export class RelayCanvasView implements S3View { view: this, state: this.canvas.state, remote: this.canvas.sharedFolder.remote, + tracking: this.tracking, }, }); this.offConnectionStatusSubscription = this.canvas.subscribe( @@ -300,6 +277,7 @@ export class RelayCanvasView implements S3View { view: this, state: state, remote: this.canvas.sharedFolder.remote, + tracking: this.tracking, }); }, ); @@ -308,6 +286,7 @@ export class RelayCanvasView implements S3View { view: this, state: this.canvas.state, remote: this.canvas.sharedFolder.remote, + tracking: this.tracking, }); } } @@ -383,7 +362,7 @@ export class RelayCanvasView implements S3View { this.offConnectionStatusSubscription = undefined; } this.canvas.disconnect(); - this.canvas.userLock = false; + this.canvas.releaseLock(); } destroy() { @@ -407,6 +386,7 @@ export class LiveView shouldConnect: boolean; canConnect: boolean; private _plugin?: TextFileViewPlugin; + private _viewHookPlugin?: ViewHookPlugin; private _viewActions?: ViewActions; private offConnectionStatusSubscription?: () => void; @@ -414,6 +394,7 @@ export class LiveView private _banner?: Banner; _tracking: boolean; private _awarenessPlugin?: AwarenessViewPlugin; + private _hsmStateUnsubscribe?: () => void; constructor( connectionManager: LiveViewManager, @@ -450,13 +431,17 @@ export class LiveView } public get tracking() { + if (this.document?.hsm) { + return this.document.hsm.state.statePath === "active.tracking"; + } return this._tracking; } public set tracking(value: boolean) { const old = this._tracking; this._tracking = value; - if (this._tracking !== old) { + // Only call attach for non-HSM mode (fallback for views without HSM) + if (this._tracking !== old && !this.document?.hsm) { this.attach(); } } @@ -475,93 +460,95 @@ 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 () => { - const diskBuffer = await this.document.diskBuffer(); - const stale = await this.document.checkStale(); - if (!stale) { - this.clearMergeButton(); - return; - } - this._parent.openDiffView({ - file1: this.document, - file2: diskBuffer, - showMergeOption: true, - onResolve: async () => { - this.document.clearDiskBuffer(); - this.clearMergeButton(); - // Force view to sync to CRDT state after differ resolution - if ( - this._plugin && - typeof this._plugin.syncViewToCRDT === "function" - ) { - await this._plugin.syncViewToCRDT(); + mergeBanner(): () => void { + this._banner = new Banner( + this.view, + { short: "Merge conflict", long: "Merge conflict -- click to resolve" }, + async () => { + // HSM-aware conflict resolution path + const hsm = this.document.hsm; + if (hsm) { + const conflictData = hsm.getConflictData(); + const localDoc = hsm.getLocalDoc(); + if ( + conflictData && + localDoc && + hsm.state.statePath.includes("conflict") + ) { + this.log("[mergeBanner] Opening diff view for conflict resolution"); + + // Check if there are inline conflict regions (new flow) + const hasInlineConflicts = + conflictData.conflictRegions && + conflictData.conflictRegions.length > 0; + + if (hasInlineConflicts) { + // With inline conflicts, clicking banner opens diff view as alternative + this.log( + "[mergeBanner] Inline conflicts present, opening diff view as alternative", + ); } - }, - }); - }); - - // Insert after view-header-left - viewHeaderLeftElement.insertAdjacentElement("afterend", mergeButton); - } - } - clearMergeButton() { - const existingButton = this.view.containerEl.querySelector(".system3-merge-button"); - if (existingButton) { - existingButton.remove(); - } - } + // Get CURRENT localDoc content (not stale conflictData.local) + const currentLocalContent = localDoc.getText("contents").toString(); + const diskContent = conflictData.remote; + + this.log( + `[mergeBanner] localDoc: ${currentLocalContent.length} chars, disk: ${diskContent.length} chars`, + ); + + // Create DiskBuffer wrappers (differ expects TFile-like objects) + // Use DiskBuffer for BOTH sides to ensure we show correct content + const localFile = new DiskBuffer( + this._parent.app.vault, + this.document.path + " (Local)", + currentLocalContent, + ); + const diskFile = new DiskBuffer( + this._parent.app.vault, + this.document.path + " (Disk)", + diskContent, + ); + + // Transition HSM to resolving state + hsm.send({ type: "OPEN_DIFF_VIEW" }); + + // Open diff view: localDoc (left) vs disk (right) + this._parent.openDiffView({ + file1: localFile, // Current localDoc content + file2: diskFile, // Disk content + showMergeOption: true, + onResolve: async () => { + this.log("[mergeBanner] HSM conflict resolved via diff view"); + + // The differ modifies file1 (localFile) in-place via its contents. + // Get the resolved content and apply it to HSM's localDoc. + const resolvedContent = localFile.contents; + + if (resolvedContent === currentLocalContent) { + // User kept local - just update LCA + hsm.send({ type: "RESOLVE_ACCEPT_LOCAL" }); + } else if (resolvedContent === diskContent) { + // User chose disk + hsm.send({ type: "RESOLVE_ACCEPT_DISK" }); + } else { + // User merged - send merged content + hsm.send({ + type: "RESOLVE_ACCEPT_MERGED", + contents: resolvedContent, + }); + } - 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._banner?.destroy(); + this._banner = undefined; + }, + }); + return false; // Don't destroy banner yet - wait for resolution } - 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 false; + }, + ); return () => {}; } @@ -569,7 +556,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(); @@ -600,6 +587,7 @@ export class LiveView view: this, state: this.document.state, remote: this.document.sharedFolder.remote, + tracking: this.tracking, }, }); this.offConnectionStatusSubscription = this.document.subscribe( @@ -609,6 +597,7 @@ export class LiveView view: this, state: state, remote: this.document.sharedFolder.remote, + tracking: this.tracking, }); }, ); @@ -617,6 +606,7 @@ export class LiveView view: this, state: this.document.state, remote: this.document.sharedFolder.remote, + tracking: this.tracking, }); } } @@ -641,25 +631,86 @@ export class LiveView this.view instanceof MarkdownView && this.view.getMode() === "preview" ) { + this.log("[LiveView.checkStale] skipping - preview mode"); return false; } - const stale = await this.document.checkStale(); - if (stale && this.document._diskBuffer?.contents) { + + // Use HSM conflict detection + const hsmConflict = this.document.hasHSMConflict(); + this.log(`[LiveView.checkStale] HSM conflict detection: ${hsmConflict}`); + if (hsmConflict === true) { + this.log( + "[LiveView.checkStale] HSM reports conflict, showing merge banner", + ); this.mergeBanner(); + return true; } else { this._banner?.destroy(); this._banner = undefined; + return false; } - return stale; } attach(): Promise { // can be called multiple times, whereas release is only ever called once - this.document.userLock = true; + // Use HSM acquireLock if available, otherwise falls back to userLock internally + // Pass view as EditorViewRef so HSM can read the live dirty flag + const viewRef: EditorViewRef = this.view as unknown as EditorViewRef; + this.document + .acquireLock(undefined, viewRef) + .then((hsm) => { + // Subscribe to HSM state changes for automatic conflict banner handling + // Must happen AFTER acquireLock completes so hsm is available + if (hsm && !this._hsmStateUnsubscribe) { + let lastStatePath: string | null = null; + this._hsmStateUnsubscribe = hsm.stateChanges.subscribe((state) => { + const isConflict = state.statePath.includes("conflict"); + if (state.statePath !== lastStatePath) { + this.log( + `[LiveView.attach] HSM state changed: ${state.statePath}, isConflict: ${isConflict}`, + ); + lastStatePath = state.statePath; + } + + // Update ViewActions to reflect tracking state change + this._viewActions?.$set({ + view: this, + state: this.document.state, + remote: this.document.sharedFolder.remote, + tracking: this.tracking, + }); + + if (isConflict && !this._banner) { + this.log( + "[LiveView.attach] HSM entered conflict state, showing merge banner", + ); + this.mergeBanner(); + } else if (!isConflict && this._banner) { + this.log( + "[LiveView.attach] HSM exited conflict state, hiding merge banner", + ); + this._banner.destroy(); + this._banner = undefined; + } + }); + } + }) + .catch((e) => { + this.warn("[LiveView.attach] acquireLock failed:", e); + }); // Add CSS class to indicate this view should have live editing if (this.view instanceof MarkdownView) { this.view.containerEl.addClass("relay-live-editor"); + + // Initialize ViewHookPlugin to capture non-CM6 edit paths + // (preview mode checkbox toggles, frontmatter saves via Properties panel) + if (!this._viewHookPlugin) { + this._viewHookPlugin = new ViewHookPlugin(this.view, this.document); + this._viewHookPlugin.initialize().catch((error) => { + this.error("Error initializing ViewHookPlugin:", error); + }); + } } if (!(this.view instanceof MarkdownView)) { @@ -725,23 +776,28 @@ export class LiveView this._viewActions = undefined; this._banner?.destroy(); this._banner = undefined; - this.clearMergeButton(); if (this.offConnectionStatusSubscription) { this.offConnectionStatusSubscription(); this.offConnectionStatusSubscription = undefined; } + // Clean up HSM state subscription + if (this._hsmStateUnsubscribe) { + this._hsmStateUnsubscribe(); + this._hsmStateUnsubscribe = undefined; + } this._awarenessPlugin?.destroy(); this._awarenessPlugin = undefined; + this._viewHookPlugin?.destroy(); + this._viewHookPlugin = undefined; this._plugin?.destroy(); this._plugin = undefined; this.document.disconnect(); - this.document.userLock = false; + this.document.releaseLock(); } destroy() { this.release(); this.clearViewActions(); - this.clearMergeButton(); (this.view.leaf as any).rebuildView?.(); this._parent = null as any; this.view = null as any; @@ -755,7 +811,6 @@ export class LiveViewManager { workspace: Workspace; views: S3View[]; private _activePromise?: Promise | null; - _compartment: Compartment; private loginManager: LoginManager; private offListeners: (() => void)[] = []; private folderListeners: Map void> = new Map(); @@ -784,7 +839,6 @@ export class LiveViewManager { this.loginManager = loginManager; this.networkStatus = networkStatus; this.refreshQueue = []; - this._compartment = new Compartment(); this.log = curryLog("[LiveViews]", "log"); this.warn = curryLog("[LiveViews]", "warn"); @@ -848,16 +902,6 @@ export class LiveViewManager { RelayInstances.set(this, "LiveViewManager"); } - reconfigure(editorView: EditorView) { - editorView.dispatch({ - effects: this._compartment.reconfigure([ - ConnectionManagerStateField.init(() => { - return this; - }), - ]), - }); - } - onMeta(tfile: TFile, cb: (data: string, cache: CachedMetadata) => void) { this.metadataListeners.set(tfile, cb); } @@ -889,6 +933,75 @@ export class LiveViewManager { return this.views.some((view) => view.document === doc); } + /** + * Notify MergeManagers which documents have open editors. + * Groups views by their shared folder and calls setActiveDocuments() on each. + * This transitions HSMs from 'loading' to the appropriate mode (idle or active). + * + * Per spec (Gap 8): LiveViews sends bulk update to MergeManager indicating which + * documents have open editors. MergeManager fans out SET_MODE_ACTIVE to those HSMs, + * and SET_MODE_IDLE to all others. + */ + private async updateMergeManagerActiveDocuments(views: S3View[]): Promise { + // Group document GUIDs by their shared folder + const folderToGuids = new Map>(); + + for (const view of views) { + const doc = view.document; + if (!doc) continue; + + const folder = doc.sharedFolder; + if (!folder?.mergeManager) continue; + + if (!folderToGuids.has(folder)) { + folderToGuids.set(folder, new Set()); + } + folderToGuids.get(folder)!.add(doc.guid); + } + + // Also discover embedded markdown files inside canvas views. + // Canvas nodes with type='file' have a child TextFileView whose file + // may belong to a shared folder. Their HSMs need active mode too. + for (const view of views) { + if (!(view instanceof RelayCanvasView)) continue; + const canvas = view.view.canvas; + if (!canvas?.nodes) continue; + + for (const [, node] of canvas.nodes) { + const nodeData = node.getData?.(); + // @ts-ignore — child is not typed on CanvasNode, only on CanvasNodeData + const child = (node as any).child ?? nodeData?.child; + if (!child?.file) continue; + + const filePath = child.file.path; + const folder = this.sharedFolders.lookup(filePath); + if (!folder?.mergeManager || !folder.ready) continue; + + const embeddedDoc = folder.proxy.getDoc(filePath); + if (!embeddedDoc) continue; + + if (!folderToGuids.has(folder)) { + folderToGuids.set(folder, new Set()); + } + folderToGuids.get(folder)!.add(embeddedDoc.guid); + } + } + + // Call setActiveDocuments on each folder's MergeManager + for (const [folder, guids] of folderToGuids) { + const allGuids = Array.from(folder.files.keys()); + folder.mergeManager.setActiveDocuments(guids, allGuids); + } + + // Also notify folders with no active views (all HSMs should be idle) + for (const folder of this.sharedFolders.items()) { + if (!folderToGuids.has(folder) && folder.mergeManager) { + const allGuids = Array.from(folder.files.keys()); + folder.mergeManager.setActiveDocuments(new Set(), allGuids); + } + } + } + private releaseViews(views: S3View[]) { views.forEach((view) => { view.release(); @@ -991,21 +1104,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}`); } } }); @@ -1143,11 +1256,12 @@ export class LiveViewManager { return false; } const activeDocumentFolders = this.findFolders(); + + // Notify MergeManagers which documents have open editors (Gap 8: mode determination) + // This transitions HSMs from 'loading' to the appropriate mode before attach() calls acquireLock() + await this.updateMergeManagerActiveDocuments(views); + if (activeDocumentFolders.length === 0 && views.length === 0) { - if (this.extensions.length !== 0) { - log("Unexpected plugins loaded."); - this.wipe(); - } logViews("Releasing Views", this.views); this.releaseViews(this.views); this.views = []; @@ -1219,22 +1333,17 @@ export class LiveViewManager { } load() { - this.wipe(); - if (this.views.length > 0) { - this.extensions.push([ - this._compartment.of( - ConnectionManagerStateField.init(() => { - return this; - }), - ), - LiveEdit, - LiveNode, - yRemoteSelectionsTheme, - yRemoteSelections, - InvalidLinkPlugin, - ]); - this.workspace.updateOptions(); - } + if (this.extensions.length > 0) return; // already registered + this.extensions.push([ + HSMEditorPlugin, + LiveNode, + yRemoteSelectionsTheme, + yRemoteSelections, + InvalidLinkPlugin, + conflictDecorationPlugin, + conflictDecorationTheme, + ]); + this.workspace.updateOptions(); } public destroy() { @@ -1260,13 +1369,3 @@ export class LiveViewManager { } } -export const ConnectionManagerStateField = StateField.define< - LiveViewManager | undefined ->({ - create(state: EditorState) { - return undefined; - }, - update(currentManager, transaction) { - return currentManager; - }, -}); diff --git a/src/LoginManager.ts b/src/LoginManager.ts index 6b4e5837..15667f12 100644 --- a/src/LoginManager.ts +++ b/src/LoginManager.ts @@ -1,6 +1,6 @@ "use strict"; -import { requestUrl, type RequestUrlResponsePromise } from "obsidian"; +import { apiVersion, requestUrl, type RequestUrlResponsePromise } from "obsidian"; import { User } from "./User"; import PocketBase, { BaseAuthStore, @@ -13,7 +13,7 @@ import { Observable } from "./observable/Observable"; declare const GIT_TAG: string; -import { customFetch } from "./customFetch"; +import { customFetch, getDeviceManagementHeaders } from "./customFetch"; import { LocalAuthStore } from "./pocketbase/LocalAuthStore"; import type { TimeProvider } from "./TimeProvider"; import { FeatureFlagManager } from "./flagManager"; @@ -192,6 +192,7 @@ export class LoginManager extends Observable { options.fetch = customFetch; options.headers = Object.assign({}, options.headers, { "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, }); return { url, options }; }; @@ -283,6 +284,8 @@ export class LoginManager extends Observable { const headers = { Authorization: `Bearer ${this.pb.authStore.token}`, "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, + ...getDeviceManagementHeaders(), }; return requestUrl({ url: `${this.endpointManager.getApiUrl()}/relay/${relay_guid}/check-host`, @@ -295,6 +298,8 @@ export class LoginManager extends Observable { const headers = { Authorization: `Bearer ${this.pb.authStore.token}`, "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, + ...getDeviceManagementHeaders(), }; requestUrl({ url: `${this.endpointManager.getApiUrl()}/flags`, @@ -315,6 +320,7 @@ export class LoginManager extends Observable { whoami() { const headers = { Authorization: `Bearer ${this.pb.authStore.token}`, + ...getDeviceManagementHeaders(), }; requestUrl({ url: `${this.endpointManager.getApiUrl()}/whoami`, @@ -351,6 +357,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); @@ -362,6 +372,7 @@ export class LoginManager extends Observable { options.fetch = customFetch; options.headers = Object.assign({}, options.headers, { "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, }); return { url, options }; }; @@ -389,6 +400,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 +561,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() { diff --git a/src/NetworkStatus.ts b/src/NetworkStatus.ts index 36fdd600..090c1a30 100644 --- a/src/NetworkStatus.ts +++ b/src/NetworkStatus.ts @@ -1,6 +1,7 @@ -import { requestUrl } from "obsidian"; +import { apiVersion, requestUrl } from "obsidian"; import { curryLog } from "./debug"; import type { TimeProvider } from "./TimeProvider"; +import { getDeviceManagementHeaders } from "./customFetch"; declare const GIT_TAG: string; @@ -77,7 +78,11 @@ class NetworkStatus { return requestUrl({ url: this.url, method: "GET", - headers: { "Relay-Version": GIT_TAG }, + headers: { + "Relay-Version": GIT_TAG, + "Obsidian-Version": apiVersion, + ...getDeviceManagementHeaders(), + }, }) .then((response) => { if (response.status === 200) { diff --git a/src/RelayDebugAPI.ts b/src/RelayDebugAPI.ts new file mode 100644 index 00000000..dc31e919 --- /dev/null +++ b/src/RelayDebugAPI.ts @@ -0,0 +1,196 @@ +/** + * RelayDebugAPI — Plugin-level debug surface exposed as `window.__relayDebug`. + * + * Aggregates per-folder recording bridges and provides CDP-accessible + * utilities for E2E tests, live debugging, and diagnostics. + * + * Lifecycle: created in plugin onload(), destroyed in onunload(). + */ + +import * as Y from 'yjs'; +import { IndexeddbPersistence } from './storage/y-indexeddb'; +import type { E2ERecordingBridge, E2ERecordingState } from './merge-hsm/recording'; +import { getHSMBootId, getHSMBootEntries, getRecentEntries } from './debug'; + +// ============================================================================= +// Global interface exposed via CDP +// ============================================================================= + +export interface RelayDebugGlobal { + /** Start recording all HSM activity */ + startRecording: (name?: string) => E2ERecordingState; + /** Stop recording and return lightweight summary JSON */ + stopRecording: () => string; + /** Get current recording state */ + getState: () => E2ERecordingState; + /** Check if recording is active */ + isRecording: () => boolean; + /** Get list of active document GUIDs */ + getActiveDocuments: () => string[]; + /** Get the current boot ID (for disk recording) */ + getBootId: () => string | null; + /** Get entries from current boot (reads disk file, filters by boot ID) */ + getBootEntries: () => Promise; + /** Get last N entries for a specific document (buffer + disk, newest files first) */ + getRecentEntries: (guid: string, limit?: number) => Promise; + /** Read Y.Doc text content from IndexedDB without waking the HSM */ + readIdbContent: (guid: string, appId: string) => Promise<{ content: string; stateVector: Uint8Array } | null>; +} + +// ============================================================================= +// RelayDebugAPI +// ============================================================================= + +export class RelayDebugAPI { + private bridges = new Map(); + private activeRecordingName: string | null = null; + + constructor() { + this.installGlobal(); + } + + /** + * Register a per-folder recording bridge. + * Returns a cleanup function to call when the folder is destroyed. + */ + registerBridge(folderPath: string, bridge: E2ERecordingBridge): () => void { + this.bridges.set(folderPath, bridge); + + // Auto-start recording if one is currently active + if (this.activeRecordingName !== null) { + try { + bridge.startRecording(this.activeRecordingName); + } catch { /* already recording */ } + } + + this.installGlobal(); + + return () => { + bridge.dispose(); + this.bridges.delete(folderPath); + this.installGlobal(); + }; + } + + /** + * Install the `window.__relayDebug` global. + */ + private installGlobal(): void { + const g = typeof window !== 'undefined' ? window : globalThis; + + const api: RelayDebugGlobal = { + startRecording: (name) => { + this.activeRecordingName = name ?? 'E2E Recording'; + const results: E2ERecordingState[] = []; + for (const bridge of this.bridges.values()) { + try { results.push(bridge.startRecording(name)); } + catch { /* already recording */ } + } + return { + recording: results.some(r => r.recording), + name: name ?? null, + id: results[0]?.id ?? null, + startedAt: results[0]?.startedAt ?? null, + documentCount: results.reduce((sum, r) => sum + r.documentCount, 0), + totalEntries: results.reduce((sum, r) => sum + r.totalEntries, 0), + }; + }, + + stopRecording: () => { + this.activeRecordingName = null; + const recordings: string[] = []; + for (const bridge of this.bridges.values()) { + try { recordings.push(bridge.stopRecording()); } + catch { /* not recording */ } + } + const combined = recordings.flatMap(r => { + try { return JSON.parse(r); } catch { return []; } + }); + return JSON.stringify(combined, null, 2); + }, + + getState: () => { + let totalDocs = 0; + let totalEntries = 0; + let recording = false; + let name: string | null = null; + let id: string | null = null; + let startedAt: string | null = null; + + for (const bridge of this.bridges.values()) { + const state = bridge.getState(); + if (state.recording) { + recording = true; + name = name ?? state.name; + id = id ?? state.id; + startedAt = startedAt ?? state.startedAt; + } + totalDocs += state.documentCount; + totalEntries += state.totalEntries; + } + + return { recording, name, id, startedAt, documentCount: totalDocs, totalEntries }; + }, + + isRecording: () => { + for (const bridge of this.bridges.values()) { + if (bridge.isRecording()) return true; + } + return false; + }, + + getActiveDocuments: () => { + const docs: string[] = []; + for (const bridge of this.bridges.values()) { + docs.push(...bridge.getActiveDocuments()); + } + return docs; + }, + + getBootId: () => getHSMBootId(), + getBootEntries: () => getHSMBootEntries(), + getRecentEntries: (guid, limit) => getRecentEntries(guid, limit), + readIdbContent: readIdbContent, + }; + + (g as any).__relayDebug = api; + } + + /** + * Remove globals and dispose all bridges. + * Call in plugin onunload(). + */ + destroy(): void { + for (const bridge of this.bridges.values()) { + bridge.dispose(); + } + this.bridges.clear(); + this.activeRecordingName = null; + + const g = typeof window !== 'undefined' ? window : globalThis; + delete (g as any).__relayDebug; + } +} + +// ============================================================================= +// IDB Utility +// ============================================================================= + +async function readIdbContent( + guid: string, + appId: string, +): Promise<{ content: string; stateVector: Uint8Array } | null> { + const dbName = `${appId}-relay-doc-${guid}`; + const tempDoc = new Y.Doc(); + try { + const persistence = new IndexeddbPersistence(dbName, tempDoc); + await persistence.whenSynced; + const content = tempDoc.getText('contents').toString(); + const stateVector = Y.encodeStateVector(tempDoc); + await persistence.destroy(); + return { content, stateVector }; + } catch { + tempDoc.destroy(); + return null; + } +} diff --git a/src/RelayManager.ts b/src/RelayManager.ts index e3671530..71e259d5 100644 --- a/src/RelayManager.ts +++ b/src/RelayManager.ts @@ -2041,7 +2041,7 @@ export class RelayManager extends HasLogging { } else { this.warn("No role found to leave relay"); } - this.store?.cascade("relay", relay.id); + this.store?.cascade("relays", relay.id); } async kick(relay_role: RelayRole) { diff --git a/src/SettingsStorage.ts b/src/SettingsStorage.ts index 5d34051f..fda058de 100644 --- a/src/SettingsStorage.ts +++ b/src/SettingsStorage.ts @@ -113,6 +113,7 @@ */ import { Observable, type Unsubscriber } from "./observable/Observable"; +import { PostOffice } from "./observable/Postie"; export type PathSegment = string | number; export type Path = PathSegment[]; @@ -696,6 +697,7 @@ export class NamespacedSettings< run(this.get()); return () => { this._listeners.delete(run); + PostOffice.getInstance().cancel(run); }; } diff --git a/src/ShareLinkPlugin.ts b/src/ShareLinkPlugin.ts index a3bf204d..024bac3b 100644 --- a/src/ShareLinkPlugin.ts +++ b/src/ShareLinkPlugin.ts @@ -2,8 +2,7 @@ import { Annotation, ChangeSet } from "@codemirror/state"; import { EditorView, ViewPlugin } from "@codemirror/view"; import type { PluginValue } from "@codemirror/view"; import { TextFileView } from "obsidian"; -import { LiveView, LiveViewManager } from "./LiveViews"; -import { connectionManagerFacet } from "./y-codemirror.next/LiveEditPlugin"; +import { LiveView, LiveViewManager, getConnectionManager } from "./LiveViews"; import { hasKey, updateFrontMatter } from "./Frontmatter"; import { diffChars } from "diff"; @@ -59,7 +58,7 @@ export class ShareLinkPluginValue implements PluginValue { constructor(editor: EditorView) { this.editor = editor; - this.connectionManager = this.editor.state.facet(connectionManagerFacet); + this.connectionManager = getConnectionManager(this.editor)!; this.view = this.connectionManager.findView(editor); this.editor = editor; if (this.view) { @@ -79,7 +78,7 @@ export class ShareLinkPluginValue implements PluginValue { if (!this.view || !this.view.shouldConnect) { return; } - if (this.view.document.text != this.editor.state.doc.toString()) { + if (this.view.document.localText != this.editor.state.doc.toString()) { return; } const text = this.editor.state.doc.toString(); @@ -87,7 +86,7 @@ export class ShareLinkPluginValue implements PluginValue { const withShareLink = updateFrontMatter(text, { shareLink: shareLink, }); - if (!(text || this.view.document.text)) { + if (!(text || this.view.document.localText)) { // document is empty this.editor.dispatch({ changes: { from: 0, insert: withShareLink }, diff --git a/src/SharedFolder.ts b/src/SharedFolder.ts index 77e3feb8..158819c7 100644 --- a/src/SharedFolder.ts +++ b/src/SharedFolder.ts @@ -8,21 +8,23 @@ import { debounce, normalizePath, } from "obsidian"; -import { IndexeddbPersistence } from "./storage/y-indexeddb"; +import { + IndexeddbPersistence, +} from "./storage/y-indexeddb"; import * as idb from "lib0/indexeddb"; import { dirname, join, sep } from "path-browserify"; import { HasProvider, type ConnectionIntent } from "./HasProvider"; +import type { EventMessage } from "./client/provider"; import { Document } from "./Document"; import { ObservableSet } from "./observable/ObservableSet"; import { LoginManager } from "./LoginManager"; import { LiveTokenStore } from "./LiveTokenStore"; import { SharedPromise, Dependency, withTimeoutWarning } from "./promiseUtils"; -import { S3Folder, S3RN, S3RemoteFolder } from "./S3RN"; +import { S3Folder, S3RN, S3RemoteFolder, S3RemoteDocument } from "./S3RN"; import type { RemoteSharedFolder } from "./Relay"; import { RelayManager } from "./RelayManager"; import type { Unsubscriber } from "svelte/store"; -import { DiskBufferStore } from "./DiskBuffer"; import { BackgroundSync } from "./BackgroundSync"; import type { NamespacedSettings } from "./SettingsStorage"; import { RelayInstances } from "./debug"; @@ -49,6 +51,19 @@ import { SyncSettingsManager, type SyncFlags } from "./SyncSettings"; import { ContentAddressedFileStore, SyncFile, isSyncFile } from "./SyncFile"; import { Canvas, isCanvas } from "./Canvas"; import { flags } from "./flagManager"; +import { MergeManager } from "./merge-hsm/MergeManager"; +import { + E2ERecordingBridge, + type HSMLogEntry, +} from "./merge-hsm/recording"; +import { recordHSMEntry } from "./debug"; +import { generateHash } from "./hashing"; +import { + saveState as saveMergeState, + openDatabase as openMergeHSMDatabase, + getAllStates, +} from "./merge-hsm/persistence"; +import * as Y from "yjs"; export interface SharedFolderSettings { guid: string; @@ -152,10 +167,11 @@ export class SharedFolder extends HasProvider { private pendingDeletes: Set = new Set(); private _persistence: IndexeddbPersistence; - diskBufferStore: DiskBufferStore; proxy: SharedFolder; cas: ContentAddressedStore; syncSettingsManager: SyncSettingsManager; + mergeManager: MergeManager; + private recordingBridge: E2ERecordingBridge; constructor( public appId: string, @@ -198,7 +214,6 @@ export class SharedFolder extends HasProvider { }); this.relayManager = relayManager; this.relayId = relayId; - this.diskBufferStore = new DiskBufferStore(); this._shouldConnect = this.settings.connect ?? true; this.authoritative = !awaitingUpdates; @@ -215,7 +230,7 @@ export class SharedFolder extends HasProvider { this.syncSettingsManager, ); this.syncStore.on(async () => { - await this.syncFileTree(this.syncStore); + await this.syncFileTree(); }); this.unsubscribes.push( @@ -263,16 +278,131 @@ export class SharedFolder extends HasProvider { throw e; } + // If folder is authoritative (local-only, not awaiting server updates), + // mark it as server synced so it's considered "ready" even after reload + if (this.authoritative) { + this._persistence.markServerSynced(); + } + if (loginManager.loggedIn) { this.connect(); } this.cas = new ContentAddressedStore(this); - this.whenReady().then(() => { + // Create MergeManager for this SharedFolder (per-folder instance) + const folderPath = this.path; // Capture for closure + this.mergeManager = new MergeManager({ + getVaultId: (guid: string) => `${this.appId}-relay-doc-${guid}`, + getDocument: (guid: string) => { + const file = this.files.get(guid); + if (!file || !isDocument(file)) return undefined; + return file; + }, + timeProvider: undefined, // Use default + createPersistence: (vaultId, doc, userId, captureOpts) => + new IndexeddbPersistence(vaultId, doc, userId, captureOpts), + getDiskState: async (docPath: string) => { + // docPath is SharedFolder-relative (e.g., "/note.md") + const vaultPath = this.getPath(docPath); + const tfile = this.vault.getAbstractFileByPath(vaultPath); + if (!(tfile instanceof TFile)) return null; + const contents = await this.vault.read(tfile); + const encoder = new TextEncoder(); + const hash = await generateHash(encoder.encode(contents).buffer); + return { contents, mtime: tfile.stat.mtime, hash }; + }, + loadAllStates: async () => { + try { + const db = await openMergeHSMDatabase(); + try { + return await getAllStates(db); + } finally { + db.close(); + } + } catch { + return []; + } + }, + onEffect: async (guid, effect) => { + this.debug?.(`[MergeManager] Effect for ${guid}:`, effect.type); + if (effect.type === "PERSIST_STATE") { + try { + const db = await openMergeHSMDatabase(); + try { + await saveMergeState(db, effect.state); + } finally { + db.close(); + } + } catch (e) { + this.warn(`[MergeManager] Failed to persist state for ${guid}:`, e); + } + } else if (effect.type === "SYNC_TO_REMOTE") { + // BUG-033 fix: Handle SYNC_TO_REMOTE in idle mode + // When a file is closed, ProviderIntegration is destroyed so no one + // listens for these effects. Handle them at the SharedFolder level. + await this.handleIdleSyncToRemote(guid, effect.update); + } else if (effect.type === "WRITE_DISK") { + // BUG-033 fix: Handle WRITE_DISK in idle mode + // This is emitted when remote changes need to be written to disk + // without an editor open. + await this.handleIdleWriteDisk(effect.guid, effect.contents); + } else if (effect.type === "REQUEST_PROVIDER_SYNC") { + // Fork reconciliation: download remote state and signal provider synced + await this.handleRequestProviderSync(effect.guid); + } + }, + getPersistenceMetadata: (guid: string, path: string) => { + const s3rn = this.relayId + ? new S3RemoteDocument(this.relayId, this.guid, guid) + : null; + return { + path, + relay: this.relayId || "", + appId: this.appId, + s3rn: s3rn ? S3RN.encode(s3rn) : "", + }; + }, + userId: loginManager?.user?.id, + }); + + // Create per-folder recording bridge and register with the debug API. + this.recordingBridge = new E2ERecordingBridge({ + onEntry: flags().enableHSMRecording + ? (entry: HSMLogEntry) => recordHSMEntry(entry) + : undefined, + getFullPath: (guid: string) => { + const file = this.files.get(guid); + if (!file || !isDocument(file)) return undefined; + return join(this.path, file.path); + }, + }); + const debugAPI = (globalThis as any).__relayDebug; + if (debugAPI?.registerBridge) { + const unregister = debugAPI.registerBridge(this.path, this.recordingBridge); + this.unsubscribes.push(unregister); + } + this.mergeManager.setOnTransition((guid, path, info) => { + this.recordingBridge.recordTransition(guid, path, info); + }); + + // Wire folder-level event subscriptions for idle mode remote updates + this.setupEventSubscriptions(); + + this.whenReady().then(async () => { if (!this.destroyed) { + // Bulk-load LCA cache before registering HSMs + await this.mergeManager.initialize(); + this.addLocalDocs(); - this.syncFileTree(this.syncStore); + this.syncFileTree(); + + // Transition all HSMs to idle mode since no editor is open yet. + // HSMs start in 'loading', then receive SET_MODE_IDLE from MergeManager. + if (this.mergeManager) { + const allGuids = Array.from(this.files.keys()); + this.mergeManager.setActiveDocuments(new Set(), allGuids); + } } }); @@ -288,17 +418,300 @@ export class SharedFolder extends HasProvider { } }); + const authoritative = this.authoritative; (async () => { const serverSynced = await this.getServerSynced(); if (!serverSynced) { + if (authoritative) { + await this.markSynced(); + } else { + await this.onceProviderSynced(); + await this.markSynced(); + } + } else if (!authoritative) { + // Even when IDB already has serverSync, we still need the + // provider to sync so _providerSynced is set. Without this, + // the folder's `synced` getter stays false and downstream + // flows (syncFileTree downloads) can fail. await this.onceProviderSynced(); - await this.markSynced(); } })(); RelayInstances.set(this, this.path); } + private setupEventSubscriptions() { + if (!this._provider || !this.mergeManager) return; + + this._provider.subscribeToEvents( + ["document.updated"], + (event: EventMessage) => { + this.handleDocumentUpdateEvent(event); + }, + ); + } + + private handleDocumentUpdateEvent(event: EventMessage) { + if (!this.mergeManager) return; + + const docId = event.doc_id; + if (!docId) return; + + // Skip events from our own user (server echo of our own updates) + if (event.user && event.user === this.loginManager?.user?.id) { + return; + } + + // Extract the guid from the doc_id + // The doc_id format is "{relayId}-{guid}" where both are UUIDs + const uuidPattern = + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + const match = docId.match( + new RegExp(`^${uuidPattern}-(${uuidPattern})$`, "i"), + ); + if (!match) return; + const guid = match[1]; + + if (!this.files.has(guid)) return; + + const file = this.files.get(guid); + if (!file || !isDocument(file)) return; + + // Skip direct update injection when active — ProviderIntegration handles + // it through the y-protocols sync channel with proper origin filtering. + // The enqueueDownload path is safe in all cases (fetches server state), + // so only skip when direct injection is enabled. + if (this.mergeManager.isActive(guid) && flags().enableDirectRemoteUpdates) { + return; + } + + // Forward remote updates to MergeManager for idle mode documents. + // This ensures updates are received even when the file is closed. + // CBOR decoding may return Buffer or plain object — ensure Uint8Array. + if (event.update) { + if (flags().enableDirectRemoteUpdates) { + // Direct update application (can cause PermanentUserData issues) + const update = + event.update instanceof Uint8Array + ? event.update + : new Uint8Array(event.update); + this.mergeManager.handleRemoteUpdate(guid, update); + } else { + // Safer path: enqueue for background sync polling + // Then forward the downloaded bytes to HSM for merge + disk write + this.backgroundSync.enqueueDownload(file).then((updateBytes) => { + if (updateBytes) { + this.mergeManager.handleRemoteUpdate(guid, updateBytes); + } + }); + } + } + } + + /** + * Handle SYNC_TO_REMOTE effect in idle mode (BUG-033 fix). + * + * When a document is in idle mode (file closed), the HSM may still need + * to sync local disk changes to the remote server. This happens when: + * 1. External process modifies the file on disk + * 2. HSM detects the change via polling + * 3. HSM performs idle auto-merge (disk → local CRDT) + * 4. HSM emits SYNC_TO_REMOTE effect + * + * Without this handler, the effect is dropped because ProviderIntegration + * is destroyed when the file is closed. + */ + private async handleIdleSyncToRemote( + guid: string, + update: Uint8Array, + ): Promise { + const file = this.files.get(guid); + if (!file || !isDocument(file)) { + this.warn( + `[handleIdleSyncToRemote] Document not found for guid: ${guid}`, + ); + return; + } + + // Check if this document is in active mode. userLock is set after + // acquireLock() completes, but the HSM may already be active (and + // emitting SYNC_TO_REMOTE) during the await in acquireLock(). + // Check both userLock and MergeManager.isActive() to cover the window. + if (file.userLock || this.mergeManager?.isActive(guid)) { + this.debug?.( + `[handleIdleSyncToRemote] Document ${guid} is in active mode, skipping`, + ); + return; + } + + try { + // Apply update to the document's remoteDoc (which is file.ydoc). + // This intentionally triggers lazy creation (wake from hibernation). + const remoteDoc = file.ensureRemoteDoc(); + Y.applyUpdate(remoteDoc, update, "local"); + + // Also update the HSM's remoteDoc reference so it stays in sync + if (file.hsm) { + file.hsm.setRemoteDoc(remoteDoc); + } + + // The per-document provider is not connected in idle mode, so we + // must explicitly sync via backgroundSync to push the update to + // the server. + await this.backgroundSync.enqueueSync(file); + this.log(`[handleIdleSyncToRemote] Synced idle mode update for ${guid}`); + } catch (e) { + this.warn( + `[handleIdleSyncToRemote] Failed to sync update for ${guid}:`, + e, + ); + } + } + + /** + * Handle WRITE_DISK effect in idle mode (BUG-033 fix). + * + * When a document is in idle mode and receives remote updates, the HSM + * may need to write merged content to disk. This happens when: + * 1. Remote update arrives (from server) + * 2. HSM performs idle auto-merge (remote → local CRDT) + * 3. HSM emits WRITE_DISK effect to update the file on disk + * + * Without this handler, the effect is dropped. + */ + private async handleIdleWriteDisk( + guid: string, + contents: string, + ): Promise { + try { + // Look up document by guid to get current path (handles renames) + const file = this.files.get(guid); + if (!file || !isDocument(file)) { + this.warn(`[handleIdleWriteDisk] Document not found for guid: ${guid}`); + return; + } + + const vaultPath = this.getPath(file.path); + const tfile = this.vault.getAbstractFileByPath(vaultPath); + if (!(tfile instanceof TFile)) { + this.warn(`[handleIdleWriteDisk] File not found at path: ${vaultPath}`); + return; + } + + await this.vault.modify(tfile, contents); + this.log(`[handleIdleWriteDisk] Wrote merged content to ${vaultPath}`); + } catch (e) { + this.warn(`[handleIdleWriteDisk] Failed to write for guid ${guid}:`, e); + } + } + + /** + * Handle REQUEST_PROVIDER_SYNC effect for fork reconciliation. + * + * When a fork is created (disk edit in idle mode), the HSM needs remote + * state to reconcile. This handler: + * 1. Downloads latest state from server via backgroundSync + * 2. Applies updates to remoteDoc + * 3. Sends CONNECTED + PROVIDER_SYNCED to HSM + * 4. HSM then runs fork reconciliation + */ + private async handleRequestProviderSync(guid: string): Promise { + const file = this.files.get(guid); + if (!file || !isDocument(file)) { + this.warn(`[handleRequestProviderSync] Document not found for guid: ${guid}`); + return; + } + + // Skip if document is in active mode - ProviderIntegration handles it + if (file.userLock || this.mergeManager?.isActive(guid)) { + this.debug?.(`[handleRequestProviderSync] Document ${guid} is in active mode, skipping`); + return; + } + + try { + // Download latest state from server + const updateBytes = await this.backgroundSync.enqueueDownload(file); + + // Apply to remoteDoc + const remoteDoc = file.ensureRemoteDoc(); + if (updateBytes) { + Y.applyUpdate(remoteDoc, updateBytes, "server"); + } + + // Update HSM's remoteDoc reference + if (file.hsm) { + file.hsm.setRemoteDoc(remoteDoc); + + // Signal provider is connected and synced + file.hsm.send({ type: "CONNECTED" }); + file.hsm.send({ type: "PROVIDER_SYNCED" }); + } + + this.log(`[handleRequestProviderSync] Synced provider for ${guid}`); + } catch (e) { + this.warn(`[handleRequestProviderSync] Failed for guid ${guid}:`, e); + } + } + + /** + * Poll for disk changes on all documents in this SharedFolder. + * Only sends DISK_CHANGED if the disk state actually differs from HSM's knowledge. + * Works for all documents regardless of hibernation state. + * + * @param guids - Optional set of GUIDs to poll. If not provided, polls all documents. + */ + async pollDiskState(guids?: string[]): Promise { + const targetGuids = guids ?? Array.from(this.files.keys()); + + for (const guid of targetGuids) { + const file = this.files.get(guid); + if (!file || !isDocument(file)) continue; + + const hsm = file.hsm; + if (!hsm) continue; + + try { + const diskState = await file.readDiskContent(); + const currentDisk = hsm.state.disk; + + if (this.shouldSendDiskChanged(currentDisk, diskState)) { + hsm.send({ + type: "DISK_CHANGED", + contents: diskState.content, + mtime: diskState.mtime, + hash: diskState.hash, + }); + } + } catch (e) { + // File might have been deleted - ignore + this.debug?.( + `[pollDiskState] Failed to read disk state for ${guid}:`, + e, + ); + } + } + } + + /** + * Determine if DISK_CHANGED event should be sent based on current vs new disk state. + * Returns true if disk state has changed, false if unchanged. + */ + private shouldSendDiskChanged( + currentDisk: { hash: string; mtime: number } | null, + newDiskState: { mtime: number; hash: string }, + ): boolean { + // No current disk state - always send + if (!currentDisk) return true; + + // Compare mtime first (fast check) + if (currentDisk.mtime !== newDiskState.mtime) return true; + + // Compare hash as fallback (handles clock skew edge cases) + if (currentDisk.hash !== newDiskState.hash) return true; + + return false; + } + private addLocalDocs = () => { const syncTFiles = this.getSyncFiles(); const files: IFile[] = []; @@ -405,7 +818,7 @@ export class SharedFolder extends HasProvider { async netSync() { await this.whenReady(); this.addLocalDocs(); - await this.syncFileTree(this.syncStore); + await this.syncFileTree(); this.backgroundSync.enqueueSharedFolderSync(this); } @@ -414,16 +827,20 @@ export class SharedFolder extends HasProvider { } async sync() { - await this.syncFileTree(this.syncStore); + await this.syncFileTree(); } - connect(): Promise { + async connect(): Promise { if (this.s3rn instanceof S3RemoteFolder) { if (this.connected || this.shouldConnect) { - return super.connect(); + const result = await super.connect(); + if (result && this.mergeManager) { + this.setupEventSubscriptions(); + } + return result; } } - return Promise.resolve(false); + return false; } public get name(): string { @@ -799,7 +1216,7 @@ export class SharedFolder extends HasProvider { }); } - syncFileTree(syncStore: SyncStore): Promise { + syncFileTree(): Promise { // If a sync is already running, mark that we want another sync after if (this.syncFileTreePromise) { this.syncRequestedDuringSync = true; @@ -807,7 +1224,7 @@ export class SharedFolder extends HasProvider { promise.then(() => { if (this.syncRequestedDuringSync) { this.syncRequestedDuringSync = false; - return this.syncFileTree(syncStore); + return this.syncFileTree(); } }); return promise; @@ -821,12 +1238,12 @@ export class SharedFolder extends HasProvider { this.ydoc.transact(async () => { // Sync folder operations first because renames/moves also affect files this.syncStore.migrateUp(); - this.syncByType(syncStore, diffLog, ops, [SyncType.Folder]); + this.syncByType(this.syncStore, diffLog, ops, [SyncType.Folder]); }, this); await Promise.all(ops.map((op) => op.promise)); this.ydoc.transact(async () => { this.syncByType( - syncStore, + this.syncStore, diffLog, ops, this.syncStore.typeRegistry.getEnabledFileSyncTypes(), @@ -1268,6 +1685,11 @@ export class SharedFolder extends HasProvider { throw new Error("unexpected ifile type"); } doc.move(vpath, this); + + // Document creates its own HSM via ensureHSM() - no need to register separately. + // Just ensure the HSM exists after the move. + doc.ensureHSM(); + return doc; } @@ -1283,9 +1705,18 @@ export class SharedFolder extends HasProvider { throw new Error(`called download on item that is not in ids ${vpath}`); } const doc = this.getOrCreateDoc(guid, vpath); - doc.markOrigin("remote"); - this.backgroundSync.enqueueDownload(doc); + // Download via queue — returns raw CRDT bytes applied to remoteDoc + const updateBytes = await this.backgroundSync.enqueueDownload(doc); + + if (updateBytes) { + await doc.hsm?.initializeFromRemote(updateBytes, Date.now()); + + // Flush remoteDoc content to disk + if (this.syncStore.has(doc.path)) { + await this.flush(doc, doc.text); + } + } this.files.set(guid, doc); this.fset.add(doc, update); @@ -1306,29 +1737,24 @@ export class SharedFolder extends HasProvider { } const doc = this.getOrCreateDoc(guid, vpath); - const originPromise = doc.getOrigin(); - const awaitingUpdatesPromise = this.awaitingUpdates(); - (async () => { - const exists = await this.exists(doc); + const [exists, awaitingUpdates] = await Promise.all([ + this.exists(doc), + this.awaitingUpdates(), + ]); if (!exists) { throw new Error(`Upload failed, doc does not exist at ${vpath}`); } - const [contents, origin, awaitingUpdates] = await Promise.all([ - this.read(doc), - originPromise, - awaitingUpdatesPromise, - ]); - const text = doc.ydoc.getText("contents"); - if (!awaitingUpdates && origin === undefined) { - this.log(`[${doc.path}] No Known Peers: Syncing file into ytext.`); - this.ydoc.transact(() => { - text.insert(0, contents); - }, this._persistence); - doc.markOrigin("local"); - this.log(`[${doc.path}] Uploading file`); - await this.backgroundSync.enqueueSync(doc); - this.markUploaded(doc); + if (!awaitingUpdates) { + // HSM handles enrollment check and lazy disk loading internally. + // initializeWithContent() checks origin in one IDB session, only reads + // disk if not already enrolled, and sets origin atomically. + const enrolled = await doc.hsm?.initializeWithContent(); + if (enrolled) { + this.log(`[${doc.path}] Uploading file`); + await this.backgroundSync.enqueueSync(doc); + this.markUploaded(doc); + } } })(); @@ -1557,11 +1983,24 @@ export class SharedFolder extends HasProvider { this.syncStore.delete(vpath); const doc = this.files.get(guid); if (doc) { - doc.cleanup(); this.fset.delete(doc); + this.files.delete(guid); + doc.cleanup(); + doc.destroy(); } - this.files.delete(guid); }, this); + indexedDB.deleteDatabase(`${this.appId}-relay-doc-${guid}`); + } else { + // syncStore entry already gone (remote delete) - find by path + const doc = this.fset.find((f) => f.path === vpath); + if (doc) { + const docGuid = doc.guid; + this.fset.delete(doc); + this.files.delete(docGuid); + doc.cleanup(); + doc.destroy(); + indexedDB.deleteDatabase(`${this.appId}-relay-doc-${docGuid}`); + } } } @@ -1649,18 +2088,21 @@ export class SharedFolder extends HasProvider { this.unsubscribes.forEach((unsub) => { unsub(); }); + this.files.forEach((doc: IFile) => { doc.destroy(); this.files.delete(doc.guid); }); + + this.recordingBridge?.dispose(); this.syncStore.destroy(); this.syncSettingsManager.destroy(); + this.mergeManager?.destroy(); super.destroy(); this.ydoc.destroy(); this.fset.clear(); this._settings.destroy(); this._settings = null as any; - this.diskBufferStore = null as any; this.relayManager = null as any; this.backgroundSync = null as any; this.loginManager = null as any; @@ -1668,12 +2110,14 @@ export class SharedFolder extends HasProvider { this.fileManager = null as any; this.syncStore = null as any; this.syncSettingsManager = null as any; + this.mergeManager = null as any; this.whenSyncedPromise?.destroy(); this.whenSyncedPromise = null as any; this.readyPromise?.destroy(); this.readyPromise = null as any; this.syncFileTreePromise?.destroy(); this.syncFileTreePromise = null as any; + } } @@ -1720,11 +2164,23 @@ export class SharedFolders extends ObservableSet { } public delete(item: SharedFolder): boolean { + // Collect IDB database names before destroy nulls references + const dbNames: string[] = []; + if (item) { + item.files.forEach((doc: IFile) => { + dbNames.push(`${item.appId}-relay-doc-${doc.guid}`); + }); + dbNames.push(item.guid); + } item?.destroy(); const deleted = super.delete(item); this.settings.update((current) => { return current.filter((settings) => settings.guid !== item.guid); }); + // Delete IDB databases after in-memory objects are destroyed + for (const name of dbNames) { + indexedDB.deleteDatabase(name); + } return deleted; } @@ -1753,9 +2209,7 @@ export class SharedFolders extends ObservableSet { if (this._offRemoteUpdates) { this._offRemoteUpdates(); } - this.unsubscribes.forEach((unsub) => { - unsub(); - }); + super.destroy(); this.relayManager = null as any; this.folderBuilder = null as any; } @@ -1767,13 +2221,30 @@ export class SharedFolders extends ObservableSet { private _load(folders: SharedFolderSettings[]) { let updated = false; folders.forEach((folder: SharedFolderSettings) => { + // Validate required fields + if (!folder.path) { + this.warn(`Invalid settings: folder missing path, skipping`); + return; + } + if (!folder.guid || !S3RN.validateUUID(folder.guid)) { + this.warn( + `Invalid settings: folder "${folder.path}" has invalid guid "${folder.guid}", skipping`, + ); + return; + } const tFolder = this.vault.getFolderByPath(folder.path); if (!tFolder) { this.warn(`Invalid settings, ${folder.path} does not exist`); return; } - this._new(folder.path, folder.guid, folder?.relay); - updated = true; + try { + this._new(folder.path, folder.guid, folder?.relay); + updated = true; + } catch (e) { + this.warn( + `Failed to load folder "${folder.path}": ${e instanceof Error ? e.message : String(e)}`, + ); + } }); if (updated) { @@ -1787,6 +2258,19 @@ export class SharedFolders extends ObservableSet { relayId?: string, awaitingUpdates?: boolean, ): SharedFolder { + // Validate inputs + if (!path) { + throw new Error("Cannot create shared folder: path is required"); + } + if (!guid || !S3RN.validateUUID(guid)) { + throw new Error(`Cannot create shared folder: invalid guid "${guid}"`); + } + if (relayId && !S3RN.validateUUID(relayId)) { + throw new Error( + `Cannot create shared folder: invalid relayId "${relayId}"`, + ); + } + const existing = this.find( (folder) => folder.path == path && folder.guid == guid, ); diff --git a/src/SyncStore.ts b/src/SyncStore.ts index 50d84386..41d7229a 100644 --- a/src/SyncStore.ts +++ b/src/SyncStore.ts @@ -252,10 +252,10 @@ export class SyncStore extends Observable { this.legacyIds.observe(logObserver); this.meta.observe(logObserver); this.unsubscribes.push(() => { - this.legacyIds.unobserve(logObserver); + this.legacyIds?.unobserve(logObserver); }); this.unsubscribes.push(() => { - this.meta.unobserve(logObserver); + this.meta?.unobserve(logObserver); }); }); @@ -278,10 +278,10 @@ export class SyncStore extends Observable { this.legacyIds.observe(legacyListener); this.meta.observe(syncFileObserver); this.unsubscribes.push(() => { - this.legacyIds.unobserve(legacyListener); + this.legacyIds?.unobserve(legacyListener); }); this.unsubscribes.push(() => { - this.meta.unobserve(syncFileObserver); + this.meta?.unobserve(syncFileObserver); }); this.unsubscribes.push( this.typeRegistry.subscribe(() => { diff --git a/src/TextViewPlugin.ts b/src/TextViewPlugin.ts index 219c9522..52fd9694 100644 --- a/src/TextViewPlugin.ts +++ b/src/TextViewPlugin.ts @@ -105,7 +105,8 @@ export class TextFileViewPlugin extends HasLogging { } await this.doc.whenSynced(); - if (this.doc.text === this.view.view.getViewData()) { + const docText = this.doc.localText; + if (docText === this.view.view.getViewData()) { // Document and view content already match - set tracking immediately this.view.tracking = true; this.warn("resync() - content matches, setting tracking=true"); @@ -115,27 +116,23 @@ export class TextFileViewPlugin extends HasLogging { documentPath: this.doc.path, documentTFilePath: this.doc._tfile?.path, viewFilePath: this.view.view.file?.path, - documentText: this.doc.text, + documentText: docText, viewData: this.view.view.getViewData(), documentGuid: this.doc.guid, tFilesMatching: this.doc._tfile === this.view.view.file, - documentTextLength: this.doc.text?.length || 0, + documentTextLength: docText?.length || 0, viewDataLength: this.view.view.getViewData()?.length || 0, }); } - if (!this.doc.hasLocalDB() && this.doc.text === "") { + if (!this.doc.hasLocalDB() && docText === "") { this.warn("local db missing, not setting buffer"); return; } - // Check if document is stale before overwriting view content - const stale = await this.doc.checkStale(); - if (stale && this.view) { - this.warn("Document is stale - showing merge banner"); - this.view.checkStale().then(async (stale) => { - if (!stale) { - await this.syncViewToCRDT(); - } - }); // This will show the merge banner + // Check if document has HSM conflict before overwriting view content + const hasConflict = this.doc.hasHSMConflict(); + if (hasConflict && this.view) { + this.warn("Document has HSM conflict - showing merge banner"); + this.view.checkStale(); // This will show the merge banner via HSM } else { // Document is authoritative, force view to match CRDT state (like getKeyFrame in LiveEditPlugin) this.warn("Document is authoritative - syncing view to CRDT state"); @@ -154,7 +151,7 @@ export class TextFileViewPlugin extends HasLogging { ) { this.warn("Syncing view to CRDT - setViewData"); this.saving = true; - this.view.view.setViewData(this.doc.text, false); + this.view.view.setViewData(this.doc.localText, false); this.doc.save(); this.saving = false; this.view.tracking = true; @@ -205,7 +202,7 @@ export class TextFileViewPlugin extends HasLogging { that.doc && that.view.view.file === that.doc.tfile ) { - if (that.view.document.text === data) { + if (that.doc.localText === data) { that.view.tracking = true; } } @@ -228,7 +225,7 @@ export class TextFileViewPlugin extends HasLogging { if (that.view.tracking && !that.saving) { that.warn("tracking - applying diff"); diffMatchPatch( - that.doc.ydoc, + that.doc.getWritableDoc(), that.view.view.getViewData(), that.doc, ); @@ -270,7 +267,7 @@ export class TextFileViewPlugin extends HasLogging { } this.warn("setting view data"); this.saving = true; - this.view.view.setViewData(this.doc.text, false); + this.view.view.setViewData(this.doc.localText, false); this.view.view.requestSave(); this.saving = false; this.view.tracking = true; diff --git a/src/TimeProvider.ts b/src/TimeProvider.ts index 7ddca69d..c9b9b55c 100644 --- a/src/TimeProvider.ts +++ b/src/TimeProvider.ts @@ -8,7 +8,7 @@ export class DefaultTimeProvider implements TimeProvider { this.intervals = []; } - getTime(): number { + now(): number { return Date.now(); } @@ -63,7 +63,7 @@ export class DefaultTimeProvider implements TimeProvider { } export interface TimeProvider { - getTime: () => number; + now: () => number; setInterval: (callback: () => void, ms: number) => number; clearInterval: (timerId: number) => void; setTimeout: (callback: () => void, ms: number) => number; diff --git a/src/TokenStore.ts b/src/TokenStore.ts index 6b925fcf..6161e0b5 100644 --- a/src/TokenStore.ts +++ b/src/TokenStore.ts @@ -3,6 +3,7 @@ import { decodeJwt } from "jose"; import type { TimeProvider } from "./TimeProvider"; import { RelayInstances } from "./debug"; +import { awaitOnReload } from "./reloadUtils"; interface TokenStoreConfig { log: (message: string) => void; @@ -238,12 +239,12 @@ export class TokenStore { } isTokenValid(token: TokenInfo): boolean { - const currentTime = this.timeProvider.getTime(); + const currentTime = this.timeProvider.now(); return currentTime < token.expiryTime; } shouldRefresh(token: TokenInfo): boolean { - const currentTime = this.timeProvider.getTime(); + const currentTime = this.timeProvider.now(); return currentTime + this.expiryMargin > token.expiryTime; } @@ -313,7 +314,7 @@ export class TokenStore { _reportWithFilter(filter: (documentId: string) => boolean) { const reportLines: string[] = []; - const currentTime = this.timeProvider.getTime(); + const currentTime = this.timeProvider.now(); const tokens = Array.from(this.tokenMap.entries()).sort((a, b) => { return a[1].expiryTime - b[1].expiryTime; }); @@ -393,6 +394,11 @@ export class TokenStore { } destroy() { + // Track active token refresh promises before clearing + if (this._activePromises.size > 0) { + awaitOnReload(Promise.all(this._activePromises.values()).then(() => {})); + } + this.clear(); this.timeProvider.destroy(); this.timeProvider = null as any; diff --git a/src/client/provider.ts b/src/client/provider.ts index 29ce2df7..6a729e37 100644 --- a/src/client/provider.ts +++ b/src/client/provider.ts @@ -15,11 +15,15 @@ import * as awarenessProtocol from "y-protocols/awareness"; import { Observable } from "lib0/observable"; import * as math from "lib0/math"; import * as url from "lib0/url"; +import { decode as decodeCBOR } from "cbor-x"; export const messageSync = 0; export const messageQueryAwareness = 3; export const messageAwareness = 1; export const messageAuth = 2; +export const messageEvent = 4; +export const messageEventSubscribe = 5; +export const messageEventUnsubscribe = 6; export type HandlerFunction = ( encoder: encoding.Encoder, @@ -97,6 +101,28 @@ messageHandlers[messageAuth] = ( ); }; +messageHandlers[messageEvent] = ( + _encoder, + decoder, + provider, + _emitSynced, + _messageType, +) => { + const cborLength = decoding.readVarUint(decoder); + const cborData = decoding.readUint8Array(decoder, cborLength); + + try { + const eventMessage = decodeCBOR(cborData); + + // Only process if we're subscribed to this event type + if (provider.eventSubscriptions.has(eventMessage.event_type)) { + provider.processEvent(eventMessage); + } + } catch (error) { + console.error('Failed to decode event message:', error); + } +}; + // @todo - this should depend on awareness.outdatedTime const messageReconnectTimeout = 30000; @@ -192,6 +218,11 @@ const setupWS = (provider: YSweetProvider) => { encoding.writeVarUint(encoder, messageSync); syncProtocol.writeSyncStep1(encoder, provider.doc); websocket.send(encoding.toUint8Array(encoder)); + // Re-subscribe to events after reconnection + if (provider.eventSubscriptions.size > 0) { + const eventTypes = Array.from(provider.eventSubscriptions); + provider.sendEventSubscribe(eventTypes); + } // broadcast local awareness state if (provider.awareness.getLocalState() !== null) { const encoderAwarenessState = encoding.createEncoder(); @@ -258,6 +289,18 @@ export interface ConnectionState { intent: ConnectionIntent; } +export interface EventMessage { + event_id: string; + event_type: string; + doc_id: string; + timestamp: number; + user?: string; + metadata?: Record; + update?: Uint8Array; +} + +export type EventCallback = (event: EventMessage) => void; + /** * Websocket Provider for Yjs. Creates a websocket connection to sync the shared document. * The document name is attached to the provided url. I.e. the following example @@ -300,6 +343,8 @@ export class YSweetProvider extends Observable { _unloadHandler: Function; _checkInterval: ReturnType | number; maxConnectionErrors: number; + eventSubscriptions: Set; + eventCallbacks: Map; /** * @param serverUrl - server url @@ -357,6 +402,8 @@ export class YSweetProvider extends Observable { this.wsLastMessageReceived = 0; this.shouldConnect = connect; this.maxConnectionErrors = maxConnectionErrors; + this.eventSubscriptions = new Set(); + this.eventCallbacks = new Map(); this._resyncInterval = 0; if (resyncInterval > 0) { @@ -662,4 +709,83 @@ export class YSweetProvider extends Observable { return this.url === expectedUrl; } + subscribeToEvents(eventTypes: string[], callback: EventCallback) { + eventTypes.forEach(type => { + this.eventSubscriptions.add(type); + + if (!this.eventCallbacks.has(type)) { + this.eventCallbacks.set(type, []); + } + this.eventCallbacks.get(type)!.push(callback); + }); + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.sendEventSubscribe(eventTypes); + } + } + + unsubscribeFromEvents(eventTypes: string[], callback?: EventCallback) { + eventTypes.forEach(type => { + if (callback && this.eventCallbacks.has(type)) { + const callbacks = this.eventCallbacks.get(type)!; + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + if (callbacks.length === 0) { + this.eventSubscriptions.delete(type); + this.eventCallbacks.delete(type); + } + } else { + this.eventSubscriptions.delete(type); + this.eventCallbacks.delete(type); + } + }); + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.sendEventUnsubscribe(eventTypes); + } + } + + sendEventSubscribe(eventTypes: string[]) { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageEventSubscribe); + encoding.writeVarUint(encoder, eventTypes.length); + + eventTypes.forEach(type => { + encoding.writeVarString(encoder, type); + }); + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(encoding.toUint8Array(encoder)); + } + } + + sendEventUnsubscribe(eventTypes: string[]) { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageEventUnsubscribe); + encoding.writeVarUint(encoder, eventTypes.length); + + eventTypes.forEach(type => { + encoding.writeVarString(encoder, type); + }); + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(encoding.toUint8Array(encoder)); + } + } + + processEvent(eventMessage: EventMessage) { + this.emit('event', [eventMessage]); + + const callbacks = this.eventCallbacks.get(eventMessage.event_type) || []; + callbacks.forEach(callback => { + try { + callback(eventMessage); + } catch (error) { + console.error('Event callback error:', error); + } + }); + } + } diff --git a/src/components/Relays.svelte b/src/components/Relays.svelte index e3704f92..944d8e9a 100644 --- a/src/components/Relays.svelte +++ b/src/components/Relays.svelte @@ -301,14 +301,14 @@ -{#if subscriptions.values().length > 0} +{#if $subscriptions.values().filter((s) => $relays.has(s.relayId)).length > 0}
- {#each $subscriptions.values() as subscription} + {#each $subscriptions.values().filter((s) => $relays.has(s.relayId)) as subscription} 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"} - - -