Compare commits

...

32 Commits

Author SHA1 Message Date
vorotamoroz 4f8a74107c remove unnecessary package 2026-06-09 08:11:05 +01:00
vorotamoroz 42ed0d8795 update dir notation 2026-06-09 07:25:41 +01:00
vorotamoroz 54c2b1c6db reduce no-explicit-any 2026-06-09 05:59:02 +01:00
vorotamoroz 0856693aac Add instruction 2026-06-09 02:26:07 +01:00
vorotamoroz 39d78a04ac Enhance remote database management and add --vault option
Added new commands for remote database management and introduced --vault option for daemon and mirror commands.
2026-06-08 21:12:42 +09:00
vorotamoroz 0b8d73ccd8 Merge pull request #948 from vrtmrz/adjust_overwrite_prevention
Adjust overwrite prevention
2026-06-08 19:57:20 +09:00
vorotamoroz 5921a71227 Add CI 2026-06-08 10:52:00 +00:00
vorotamoroz a40929c9e4 fixed: enhance conflict handling by adding settings check for document writes 2026-06-08 10:47:34 +00:00
vorotamoroz 2d8a285201 Port new tests 2026-06-08 10:38:54 +00:00
vorotamoroz d9903bfe9e Merge pull request #947 from vrtmrz/0_25_74
Release: 0.25.74
2026-06-08 19:29:33 +09:00
vorotamoroz a6e7dddf7f Merge branch '0_25_74' into cli_test_deno 2026-06-08 11:29:06 +01:00
vorotamoroz 9a51c78011 tweak specificity 2026-06-08 11:18:35 +01:00
vorotamoroz 7b9c0b011f bump 2026-06-08 11:15:13 +01:00
vorotamoroz 26a050e6f6 Merge pull request #946 from vrtmrz/cli_vaultpath
feat:  decouple the database and vault directories
2026-06-08 19:12:11 +09:00
vorotamoroz 34162f747c fix corrupted test, update submodule 2026-06-08 11:10:37 +01:00
vorotamoroz 9e87ee4da1 Merge branch 'main' into cli_vaultpath 2026-06-08 19:07:02 +09:00
vorotamoroz ba5d4c434b Merge pull request #945 from vrtmrz/fix_941
fixed: ignore hidden files completely if hidden file sync disabled
2026-06-08 18:57:09 +09:00
vorotamoroz cf173caf88 feat: decouple the database and vault directories 2026-06-08 10:43:10 +01:00
vorotamoroz 60f21eb9d2 detect loopback and coturn option 2026-06-05 09:44:17 +01:00
vorotamoroz 6b7816d334 add coturn for test 2026-06-05 09:39:39 +01:00
vorotamoroz 369e62ee8d Improved: we can set empty for turnServer explicitly. 2026-06-05 09:27:19 +01:00
vorotamoroz 37593bbee6 Update CI to use deno 2026-06-05 09:07:38 +01:00
vorotamoroz baa51a66a7 Merge branch 'main' into cli_test_deno 2026-06-05 08:34:31 +01:00
vorotamoroz 2f10121d6c Merge remote-tracking branch 'origin/main' into cli_test_deno 2026-05-22 10:19:30 +00:00
vorotamoroz 3ab80190d6 test fix ci (Redundant test) 2026-05-22 03:48:41 +00:00
vorotamoroz 8948bf2803 test cli:p2p use nonce for peername 2026-05-22 03:48:02 +00:00
vorotamoroz 486fd15c60 fix resouce handling 2026-05-22 03:46:56 +00:00
vorotamoroz 5fd85c71ca test: chore: prettify 2026-05-22 03:20:28 +00:00
vorotamoroz c1f41910c4 test: add actions / caching 2026-05-22 03:20:11 +00:00
vorotamoroz 3693d6a6b6 test: add port ready, container cleanup 2026-05-22 03:19:48 +00:00
vorotamoroz cc3c992b1d cli: add large-file-test and benchmark between couchdb and p2p 2026-05-22 03:05:44 +00:00
vorotamoroz df390ac456 test: fix deno test helpers 2026-05-22 03:02:11 +00:00
71 changed files with 2075 additions and 933 deletions
+56 -13
View File
@@ -1,17 +1,43 @@
name: cli-deno-tests name: cli-deno-tests
on: on:
push:
branches:
- main
- beta
paths:
- '.github/workflows/cli-deno-tests.yml'
- 'src/apps/cli/**'
- 'src/lib/src/API/processSetting.ts'
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- '.github/workflows/cli-deno-tests.yml'
- 'src/apps/cli/**'
- 'src/lib/src/API/processSetting.ts'
- 'package.json'
- 'package-lock.json'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
test_task: test_task:
description: 'Deno test task to run' description: 'Deno test task to run'
type: choice type: choice
options: options:
- test - test:ci
- test:p2p
- test:all
- test:local - test:local
- test:e2e-matrix - test:e2e-matrix
- test:p2p-sync default: test:ci
default: test enable_debug:
description: 'Enable verbose and debug logging'
type: boolean
default: false
use_coturn:
description: 'Enable local coturn container for P2P tests'
type: boolean
default: false
permissions: permissions:
contents: read contents: read
@@ -27,21 +53,24 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
SELECTED_TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test' }}" SELECTED_TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test:ci' }}"
echo "[INFO] Selected task set: $SELECTED_TASK" echo "[INFO] Selected task set: $SELECTED_TASK"
case "$SELECTED_TASK" in case "$SELECTED_TASK" in
test) test:ci)
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:push-pull","test:sync-two-local","test:sync-locked-remote","test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download","test:e2e-couchdb","test:e2e-matrix"]' TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]'
;;
test:p2p)
TASK_MATRIX='["test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download"]'
;;
test:all)
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]'
;; ;;
test:local) test:local)
TASK_MATRIX='["test:setup-put-cat","test:mirror"]' TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon"]'
;; ;;
test:e2e-matrix) test:e2e-matrix)
TASK_MATRIX='["test:e2e-matrix"]' TASK_MATRIX='["test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]'
;;
test:p2p-sync)
TASK_MATRIX='["test:p2p-sync"]'
;; ;;
*) *)
echo "[ERROR] Unknown task set: $SELECTED_TASK" >&2 echo "[ERROR] Unknown task set: $SELECTED_TASK" >&2
@@ -55,6 +84,8 @@ jobs:
needs: prepare needs: prepare
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
env:
DENO_DIR: ~/.cache/deno
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -70,12 +101,21 @@ jobs:
with: with:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'
cache-dependency-path: package-lock.json
- name: Setup Deno - name: Setup Deno
uses: denoland/setup-deno@v2 uses: denoland/setup-deno@v2
with: with:
deno-version: v2.x deno-version: v2.x
- name: Cache Deno dependencies
uses: actions/cache@v4
with:
path: ~/.cache/deno
key: ${{ runner.os }}-deno-${{ hashFiles('src/apps/cli/testdeno/deno.lock', 'src/apps/cli/testdeno/deno.json') }}
restore-keys: |
${{ runner.os }}-deno-
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -102,6 +142,9 @@ jobs:
env: env:
LIVESYNC_DOCKER_MODE: native LIVESYNC_DOCKER_MODE: native
LIVESYNC_CLI_RETRY: 3 LIVESYNC_CLI_RETRY: 3
LIVESYNC_CLI_DEBUG: ${{ inputs.enable_debug == true && '1' || '0' }}
LIVESYNC_CLI_VERBOSE: ${{ inputs.enable_debug == true && '1' || '0' }}
LIVESYNC_USE_COTURN: ${{ inputs.use_coturn == true && '1' || '0' }}
run: | run: |
TASK="${{ matrix.task }}" TASK="${{ matrix.task }}"
echo "[INFO] Running Deno task: $TASK" echo "[INFO] Running Deno task: $TASK"
@@ -110,5 +153,5 @@ jobs:
- name: Stop leftover containers - name: Stop leftover containers
if: always() if: always()
run: | run: |
docker stop couchdb-test minio-test relay-test >/dev/null 2>&1 || true docker stop couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
docker rm couchdb-test minio-test relay-test >/dev/null 2>&1 || true docker rm couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
-17
View File
@@ -12,23 +12,6 @@ on:
- two-vaults-couchdb - two-vaults-couchdb
- two-vaults-minio - two-vaults-minio
default: two-vaults-matrix default: two-vaults-matrix
push:
branches:
- main
- beta
paths:
- '.github/workflows/cli-e2e.yml'
- 'src/apps/cli/**'
- 'src/lib/src/API/processSetting.ts'
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- '.github/workflows/cli-e2e.yml'
- 'src/apps/cli/**'
- 'src/lib/src/API/processSetting.ts'
- 'package.json'
- 'package-lock.json'
permissions: permissions:
contents: read contents: read
+3
View File
@@ -32,6 +32,9 @@ Always adhere to the following stylistic and spelling rules:
- Use **'dialogue'** in documentation, user-facing messages, and general text. Use **'dialog'** only inside source code (e.g. class names, methods). - Use **'dialogue'** in documentation, user-facing messages, and general text. Use **'dialog'** only inside source code (e.g. class names, methods).
- Use the hyphenated form **'plug-in'** in user-facing text. Use **'plugin'** only in codebase files, configuration settings, or technical contexts. - Use the hyphenated form **'plug-in'** in user-facing text. Use **'plugin'** only in codebase files, configuration settings, or technical contexts.
5. **User Communication Language**:
- Always reply to the user in the language in which they asked the question.
--- ---
## Technical & Architecture Rules ## Technical & Architecture Rules
+35 -2
View File
@@ -19,11 +19,15 @@ const packageJson = JSON.parse(fs.readFileSync("./package.json") + "");
const updateInfo = JSON.stringify(fs.readFileSync("./updates.md") + ""); const updateInfo = JSON.stringify(fs.readFileSync("./updates.md") + "");
const PATHS_TEST_INSTALL = process.env?.PATHS_TEST_INSTALL || ""; const PATHS_TEST_INSTALL = process.env?.PATHS_TEST_INSTALL || "";
const PATH_TEST_INSTALL = PATHS_TEST_INSTALL.split(path.delimiter).map(p => p.trim()).filter(p => p.length); const PATH_TEST_INSTALL = PATHS_TEST_INSTALL.split(path.delimiter)
.map((p) => p.trim())
.filter((p) => p.length);
if (PATH_TEST_INSTALL) { if (PATH_TEST_INSTALL) {
console.log(`Built files will be copied to ${PATH_TEST_INSTALL}`); console.log(`Built files will be copied to ${PATH_TEST_INSTALL}`);
} else { } else {
console.log("Development build: You can install the plug-in to Obsidian for testing by exporting the PATHS_TEST_INSTALL environment variable with the paths to your vault plugins directories separated by your system path delimiter (':' on Unix, ';' on Windows)."); console.log(
"Development build: You can install the plug-in to Obsidian for testing by exporting the PATHS_TEST_INSTALL environment variable with the paths to your vault plugins directories separated by your system path delimiter (':' on Unix, ';' on Windows)."
);
} }
const moduleAliasPlugin = { const moduleAliasPlugin = {
@@ -66,6 +70,34 @@ const moduleAliasPlugin = {
}, },
}; };
const removePragmaCommentsPlugin = {
name: "remove-pragma-comments",
setup(build) {
// Filter target extensions (e.g., JavaScript and TypeScript)
build.onLoad({ filter: /\.[jt]s?$/ }, async (args) => {
const source = await fs.promises.readFile(args.path, "utf8");
// Regex targeting both single-line and multi-line comments
// This regex looks for:
// - /* eslint ... */ (multi-line)
// const esLintPragmaRegexBlock = /\/\*[\s\S]*?eslint[\s\S]*?\*\/|([^\\:]|^)\/\/.*eslint.*$/gm;
// - // eslint-disable-next-line
let cleanedSource = source;
const tsIgnoreRegex = /\/\*\s*@ts-ignore\s*\*\/|([^\\:]|^)\/\/.*?@ts-ignore.*$/gm;
const esLintPragmaRegexLine = /([^\\:]|^)\/\/.*?eslint-.*$/gm;
const exps = [tsIgnoreRegex, esLintPragmaRegexLine];
for (const exp of exps) {
cleanedSource = cleanedSource.replace(exp, "$1");
}
return {
contents: cleanedSource,
loader: args.path.endsWith("ts") ? "ts" : "js",
};
});
},
};
/** @type esbuild.Plugin[] */ /** @type esbuild.Plugin[] */
const plugins = [ const plugins = [
{ {
@@ -177,6 +209,7 @@ const context = await esbuild.context({
preprocess: sveltePreprocess(), preprocess: sveltePreprocess(),
compilerOptions: { css: "injected", preserveComments: false }, compilerOptions: { css: "injected", preserveComments: false },
}), }),
removePragmaCommentsPlugin,
...plugins, ...plugins,
], ],
}); });
+6 -4
View File
@@ -18,11 +18,11 @@ export default defineConfig([
"**/*.json", "**/*.json",
"**/.eslintrc.js.bak", "**/.eslintrc.js.bak",
// Files from linked dependencies (those files should not exist for most people). // Files from linked dependencies (those files should not exist for most people).
"modules/octagonal-wheels/dist/**/*", "modules/octagonal-wheels/dist",
// Sub-projects (Exclude from root linting as they have different environments) // Sub-projects (Exclude from root linting as they have different environments)
"src/apps/**/*", "src/apps",
"utils/**/*", "utils",
// Specific exclusions from common library (src/lib) // Specific exclusions from common library (src/lib)
"src/lib/coverage", "src/lib/coverage",
@@ -73,11 +73,13 @@ export default defineConfig([
"no-unused-labels": "off", "no-unused-labels": "off",
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"require-await": "off", "require-await": "off",
// -- TypeScript specific rules (Gradual adoption of stricter rules, currently set to 'warn' for a while).
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-redundant-type-constituents": "warn",
// -- TypeScript specific rules // -- TypeScript specific rules
// @typescript-eslint/no-unsafe-* rules and @typescript-eslint/no-explicit-any: // @typescript-eslint/no-unsafe-* rules and @typescript-eslint/no-explicit-any:
// This project contains a lot of library-sh code where the use of `any` is often necessary and justified. // This project contains a lot of library-sh code where the use of `any` is often necessary and justified.
// Rules is now set to 'off' for a while. // Rules is now set to 'off' for a while.
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-call": "off", "@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off", "@typescript-eslint/no-unsafe-member-access": "off",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "0.25.73", "version": "0.25.74",
"minAppVersion": "1.7.2", "minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz", "author": "vorotamoroz",
+14 -656
View File
@@ -1,12 +1,12 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.73", "version": "0.25.74",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.73", "version": "0.25.74",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.808.0", "@aws-sdk/client-s3": "^3.808.0",
@@ -15,15 +15,14 @@
"@smithy/middleware-apply-body-checksum": "^4.3.9", "@smithy/middleware-apply-body-checksum": "^4.3.9",
"@smithy/protocol-http": "^5.3.9", "@smithy/protocol-http": "^5.3.9",
"@smithy/querystring-builder": "^4.2.9", "@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@trystero-p2p/nostr": "^0.24.0", "@trystero-p2p/nostr": "^0.24.0",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"commander": "^14.0.3",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"idb": "^8.0.3", "idb": "^8.0.3",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"micromatch": "^4.0.0",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"obsidian": "^1.12.3", "obsidian": "^1.12.3",
"octagonal-wheels": "^0.1.46", "octagonal-wheels": "^0.1.46",
@@ -33,7 +32,6 @@
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@chialab/esbuild-plugin-worker": "^0.19.0",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
@@ -50,7 +48,6 @@
"@types/pouchdb-mapreduce": "^6.1.10", "@types/pouchdb-mapreduce": "^6.1.10",
"@types/pouchdb-replication": "^6.4.7", "@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6", "@types/transform-pouch": "^1.0.6",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1", "@typescript-eslint/parser": "8.56.1",
"@vitest/browser": "^4.1.8", "@vitest/browser": "^4.1.8",
"@vitest/browser-playwright": "^4.1.8", "@vitest/browser-playwright": "^4.1.8",
@@ -66,7 +63,6 @@
"globals": "^14.0.0", "globals": "^14.0.0",
"playwright": "^1.58.2", "playwright": "^1.58.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-load-config": "^6.0.1",
"pouchdb-adapter-http": "^9.0.0", "pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-idb": "^9.0.0", "pouchdb-adapter-idb": "^9.0.0",
"pouchdb-adapter-indexeddb": "^9.0.0", "pouchdb-adapter-indexeddb": "^9.0.0",
@@ -82,11 +78,11 @@
"rollup-plugin-copy": "^3.5.0", "rollup-plugin-copy": "^3.5.0",
"svelte": "5.41.1", "svelte": "5.41.1",
"svelte-check": "^4.4.3", "svelte-check": "^4.4.3",
"svelte-eslint-parser": "^1.8.0",
"svelte-preprocess": "^6.0.3", "svelte-preprocess": "^6.0.3",
"terser": "^5.39.0", "terser": "^5.39.0",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"transform-pouch": "^2.0.0", "transform-pouch": "^2.0.0",
"tslib": "^2.8.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
@@ -1264,77 +1260,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@chialab/esbuild-plugin-meta-url": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@chialab/esbuild-plugin-meta-url/-/esbuild-plugin-meta-url-0.19.1.tgz",
"integrity": "sha512-psYdhXG5CTA16PkOc4RhWj7XJQWONXJIrRTp3xkxKW0A7d0n/B0W+TABMR3zohboyoC6Uqv1zO8jf62zx2Xh6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@chialab/esbuild-rna": "^0.19.1",
"@chialab/estransform": "^0.19.1",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@chialab/esbuild-plugin-worker": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@chialab/esbuild-plugin-worker/-/esbuild-plugin-worker-0.19.1.tgz",
"integrity": "sha512-eZeOMzPmT3LyEryS8GlUJ69NDcWEYT4JDHEYMAiAtNsN+ftFTSUkEeVFgP1zyebgmZApPc4Gdpo162nPBG2rSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@chialab/esbuild-plugin-meta-url": "^0.19.1",
"@chialab/esbuild-rna": "^0.19.1",
"@chialab/estransform": "^0.19.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@chialab/esbuild-rna": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@chialab/esbuild-rna/-/esbuild-rna-0.19.1.tgz",
"integrity": "sha512-v8dpllvqWmYsAvDkfVRRqz1jwxUZyfLYAR0MgsiifnI+C95OPdV3qhubvwebav8s8YUVz2Jr6J8bWpU+GW7TfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@chialab/estransform": "^0.19.1",
"@chialab/node-resolve": "^0.19.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@chialab/estransform": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@chialab/estransform/-/estransform-0.19.1.tgz",
"integrity": "sha512-Op0TvQxnzdcnBriFUIjgg3V3MpOB9Cfs4S7TvIuypPegFOSvuFAOcPl5V02NJ9dyGoOc8W6ORbSldc5PYKhOCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@napi-rs/magic-string": "^0.3.4",
"@parcel/source-map": "^2.0.0",
"cjs-module-lexer": "^1.2.2",
"es-module-lexer": "^1.0.0",
"oxc-parser": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@chialab/node-resolve": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@chialab/node-resolve/-/node-resolve-0.19.1.tgz",
"integrity": "sha512-J4i4YJNaFuYG6UWpum9y8XfICWsWxoCawy6HQtU2lDqp915oboxXvpZ3lBdA5Llb8VexCKQZYufY8QXPyzU62Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@codemirror/state": { "node_modules/@codemirror/state": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
@@ -2411,252 +2336,6 @@
"integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==", "integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/magic-string": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string/-/magic-string-0.3.4.tgz",
"integrity": "sha512-DEWl/B99RQsyMT3F9bvrXuhL01/eIQp/dtNSE3G1jQ4mTGRcP4iHWxoPZ577WrbjUinrNgvRA5+08g8fkPgimQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/magic-string-android-arm-eabi": "0.3.4",
"@napi-rs/magic-string-android-arm64": "0.3.4",
"@napi-rs/magic-string-darwin-arm64": "0.3.4",
"@napi-rs/magic-string-darwin-x64": "0.3.4",
"@napi-rs/magic-string-freebsd-x64": "0.3.4",
"@napi-rs/magic-string-linux-arm-gnueabihf": "0.3.4",
"@napi-rs/magic-string-linux-arm64-gnu": "0.3.4",
"@napi-rs/magic-string-linux-arm64-musl": "0.3.4",
"@napi-rs/magic-string-linux-x64-gnu": "0.3.4",
"@napi-rs/magic-string-linux-x64-musl": "0.3.4",
"@napi-rs/magic-string-win32-arm64-msvc": "0.3.4",
"@napi-rs/magic-string-win32-ia32-msvc": "0.3.4",
"@napi-rs/magic-string-win32-x64-msvc": "0.3.4"
}
},
"node_modules/@napi-rs/magic-string-android-arm-eabi": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-android-arm-eabi/-/magic-string-android-arm-eabi-0.3.4.tgz",
"integrity": "sha512-sszAYxqtzzJ4FDerDNHcqL9NhqPhj8W4DNiOanXYy50mA5oojlRtaAFPiB5ZMrWDBM32v5Q30LrmxQ4eTtu2Dg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-android-arm64": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-android-arm64/-/magic-string-android-arm64-0.3.4.tgz",
"integrity": "sha512-jdQ6HuO0X5rkX4MauTcWR4HWdgjakTOmmzqXg8L26+jOHVVG1LZE+Su5qvV4bP8vMb2h+vPE+JsnwqSmWymu3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-darwin-arm64": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-darwin-arm64/-/magic-string-darwin-arm64-0.3.4.tgz",
"integrity": "sha512-6NmMtvURce9/oq09XBZmuIeI6lPLGtEJ2ZPO/QzL3nLZa6wygiCnO/sFACKYNg5/73ET5HMMTeuogE1JI+r2Lw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-darwin-x64": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-darwin-x64/-/magic-string-darwin-x64-0.3.4.tgz",
"integrity": "sha512-f9LmfMiUAKDOtl0meOuLYeVb6OERrgGzrTg1Tn3R3fTAShM2kxRbfAuPE9ljuXxIFzOv/uqRNLSl/LqCJwpREA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-freebsd-x64": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-freebsd-x64/-/magic-string-freebsd-x64-0.3.4.tgz",
"integrity": "sha512-rqduQ4odiDK4QdM45xHWRTU4wtFIfpp8g8QGpz+3qqg7ivldDqbbNOrBaf6Oeu77uuEvWggnkyuChotfKgJdJQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-linux-arm-gnueabihf": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-linux-arm-gnueabihf/-/magic-string-linux-arm-gnueabihf-0.3.4.tgz",
"integrity": "sha512-pVaJEdEpiPqIfq3M4+yMAATS7Z9muDcWYn8H7GFH1ygh8GwgLgKfy/n/lG2M6zp18Mwd0x7E2E/qg9GgCyUzoQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-linux-arm64-gnu": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-linux-arm64-gnu/-/magic-string-linux-arm64-gnu-0.3.4.tgz",
"integrity": "sha512-9FwoAih/0tzEZx0BjYYIxWkSRMjonIn91RFM3q3MBs/evmThXUYXUqLNa1PPIkK1JoksswtDi48qWWLt8nGflQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-linux-arm64-musl": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-linux-arm64-musl/-/magic-string-linux-arm64-musl-0.3.4.tgz",
"integrity": "sha512-wCR7R+WPOcAKmVQc1s6h6HwfwW1vL9pM8BjUY9Ljkdb8wt1LmZEmV2Sgfc1SfbRQzbyl+pKeufP6adRRQVzYDA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-linux-x64-gnu": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-linux-x64-gnu/-/magic-string-linux-x64-gnu-0.3.4.tgz",
"integrity": "sha512-sbxFDpYnt5WFbxQ1xozwOvh5A7IftqSI0WnE9O7KsQIOi0ej2dvFbfOW4tmFkvH/YP8KJELo5AhP2+kEq1DpYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-linux-x64-musl": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-linux-x64-musl/-/magic-string-linux-x64-musl-0.3.4.tgz",
"integrity": "sha512-jN4h/7e2Ul8v3UK5IZu38NXLMdzVWhY4uEDlnwuUAhwRh26wBQ1/pLD97Uy/Z3dFNBQPcsv60XS9fOM1YDNT6w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-win32-arm64-msvc": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-win32-arm64-msvc/-/magic-string-win32-arm64-msvc-0.3.4.tgz",
"integrity": "sha512-gMUyTRHLWpzX2ntJFCbW2Gnla9Y/WUmbkZuW5SBAo/Jo8QojHn76Y4PNgnoXdzcsV9b/45RBxurYKAfFg9WTyg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-win32-ia32-msvc": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-win32-ia32-msvc/-/magic-string-win32-ia32-msvc-0.3.4.tgz",
"integrity": "sha512-QIMauMOvEHgL00K9np/c9CT/CRtLOz3mRTQqcZ9XGzSoAMrpxH71KSpDJrKl7h7Ro6TZ+hJ0C3T+JVuTCZNv4A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/magic-string-win32-x64-msvc": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@napi-rs/magic-string-win32-x64-msvc/-/magic-string-win32-x64-msvc-0.3.4.tgz",
"integrity": "sha512-V8FMSf828MzOI3P6/765MR7zHU6CUZqiyPhmAnwYoKFNxfv7oCviN/G6NcENeCdcYOvNgh5fYzaNLB96ndId5A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@noble/curves": { "node_modules/@noble/curves": {
"version": "1.9.7", "version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
@@ -2743,131 +2422,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@oxc-parser/binding-darwin-arm64": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.8.0.tgz",
"integrity": "sha512-3Dws5Wzj9efojjqvhS4ZF+Abh0EoiI5ciOE2kdLifMzSg4fnmYAIOktoUnPEo87TNIb4SiFJ5JgPBgEyq42Eow==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-parser/binding-darwin-x64": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.8.0.tgz",
"integrity": "sha512-DAUJ/mfq0Jn2VDYn69bhHTsIWj+aZ/viamexFwaLL7ntkIFmGpzAJZUlWofpY1IRJynKWW+P5AOLYXMllw4qUw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.8.0.tgz",
"integrity": "sha512-ZHQVey/O4K3zTIKtpfsbtJIE8MPTRHRxgY3dejaoeFQGf9C3HasgF132Yp4zN/jOUx+x8czKPVa/Af40ViyhGQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.8.0.tgz",
"integrity": "sha512-Diw+Tnf5v+zAYXzDoSKCZsMaroU6GoqZMS7smfDtFnZYTHWZrsTmPBLUQe7AFiG7O7tkhsCdcWjOYgbVkrSVOA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.8.0.tgz",
"integrity": "sha512-WloqcRrtQUVEP/Sy8ZeEgF0HgBKQjOv3zLFZqbC5ipkerKriGcVbsq3fOIMOi/55AM6/UhIAjeZGnoeco72JjQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-parser/binding-linux-x64-musl": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.8.0.tgz",
"integrity": "sha512-2j7BD9szwSXTvSj0Q8VE98UHGYvrgZzdLy4EyB0FilhQnopEfz+YV674rWGY2Il1VYxHJwGctrTJHvARolu37g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.8.0.tgz",
"integrity": "sha512-mcomr1og17yCmnwn8Q7CRzrH9Va0HccWe4Ld3/u/elBsw0SEzYGVvECRzCyRglYAbKTtusz7as9Jee0RiMOMmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.8.0.tgz",
"integrity": "sha512-nIBkc1KZOVYUaHT3+U+gM354P3byMAIXMvlmLMbs0kWVRcI4vrzL8qwWpC6QdBQxWKZGqPEqGolv8H4dDYA9nQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@parcel/source-map": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@parcel/source-map/-/source-map-2.1.1.tgz",
"integrity": "sha512-Ejx1P/mj+kMjQb8/y5XxDUn4reGdr+WyKYloBljpppUy8gs42T+BNoEOuRYqDVdgPc6NxduzIDoJS9pOFfV5Ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^1.0.3"
},
"engines": {
"node": "^12.18.3 || >=14"
}
},
"node_modules/@peculiar/asn1-cms": { "node_modules/@peculiar/asn1-cms": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz",
@@ -4749,52 +4303,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
"integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/type-utils": "8.56.1",
"@typescript-eslint/utils": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.56.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.56.1", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1", "@typescript-eslint/types": "8.56.1",
@@ -4871,31 +4385,6 @@
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/type-utils": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
"integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/utils": "8.56.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.56.1", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
@@ -6207,6 +5696,7 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@@ -6476,13 +5966,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cjs-module-lexer": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -6558,15 +6041,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/commondir": { "node_modules/commondir": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -7001,19 +6475,6 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/diff-match-patch": { "node_modules/diff-match-patch": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
@@ -7497,13 +6958,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -8788,6 +8242,7 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -9890,6 +9345,7 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@@ -10802,19 +10258,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/linkify-it": { "node_modules/linkify-it": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -11075,6 +10518,7 @@
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
@@ -11084,29 +10528,6 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.5", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -11555,26 +10976,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oxc-parser": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.8.0.tgz",
"integrity": "sha512-ObPeMkbDX7igb7NyyAC8CbVC3fY+YmlMsxsRQ2oyFBkpQtI5tjoyqSDKbS9A9EcJvt2q89C4UoC+HjVBdLYYJg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-parser/binding-darwin-arm64": "0.8.0",
"@oxc-parser/binding-darwin-x64": "0.8.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.8.0",
"@oxc-parser/binding-linux-arm64-musl": "0.8.0",
"@oxc-parser/binding-linux-x64-gnu": "0.8.0",
"@oxc-parser/binding-linux-x64-musl": "0.8.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.8.0",
"@oxc-parser/binding-win32-x64-msvc": "0.8.0"
}
},
"node_modules/p-cancelable": { "node_modules/p-cancelable": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -11844,6 +11245,7 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -12004,50 +11406,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postcss-load-config": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"lilconfig": "^3.1.1"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"jiti": ">=1.21.0",
"postcss": ">=8.0.9",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
},
"postcss": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/postcss-safe-parser": { "node_modules/postcss-safe-parser": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz",
@@ -13900,9 +13258,9 @@
} }
}, },
"node_modules/svelte-eslint-parser": { "node_modules/svelte-eslint-parser": {
"version": "1.6.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz",
"integrity": "sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw==", "integrity": "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -13916,7 +13274,7 @@
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0", "node": "^18.18.0 || ^20.9.0 || >=21.1.0",
"pnpm": "10.30.3" "pnpm": "10.34.1"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/ota-meshi" "url": "https://github.com/sponsors/ota-meshi"
@@ -14200,6 +13558,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
@@ -14312,7 +13671,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.27.0", "esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
+4 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.73", "version": "0.25.74",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js", "main": "main.js",
"type": "module", "type": "module",
@@ -61,7 +61,6 @@
"author": "vorotamoroz", "author": "vorotamoroz",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@chialab/esbuild-plugin-worker": "^0.19.0",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
@@ -78,7 +77,6 @@
"@types/pouchdb-mapreduce": "^6.1.10", "@types/pouchdb-mapreduce": "^6.1.10",
"@types/pouchdb-replication": "^6.4.7", "@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6", "@types/transform-pouch": "^1.0.6",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1", "@typescript-eslint/parser": "8.56.1",
"@vitest/browser": "^4.1.8", "@vitest/browser": "^4.1.8",
"@vitest/browser-playwright": "^4.1.8", "@vitest/browser-playwright": "^4.1.8",
@@ -94,7 +92,6 @@
"globals": "^14.0.0", "globals": "^14.0.0",
"playwright": "^1.58.2", "playwright": "^1.58.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-load-config": "^6.0.1",
"pouchdb-adapter-http": "^9.0.0", "pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-idb": "^9.0.0", "pouchdb-adapter-idb": "^9.0.0",
"pouchdb-adapter-indexeddb": "^9.0.0", "pouchdb-adapter-indexeddb": "^9.0.0",
@@ -114,14 +111,14 @@
"terser": "^5.39.0", "terser": "^5.39.0",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"transform-pouch": "^2.0.0", "transform-pouch": "^2.0.0",
"tslib": "^2.8.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-istanbul": "^8.0.0", "vite-plugin-istanbul": "^8.0.0",
"vitest": "^4.1.8", "vitest": "^4.1.8",
"webdriverio": "^9.27.0", "webdriverio": "^9.27.0",
"yaml": "^2.8.2" "yaml": "^2.8.2",
"svelte-eslint-parser": "^1.8.0"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.808.0", "@aws-sdk/client-s3": "^3.808.0",
@@ -130,15 +127,14 @@
"@smithy/middleware-apply-body-checksum": "^4.3.9", "@smithy/middleware-apply-body-checksum": "^4.3.9",
"@smithy/protocol-http": "^5.3.9", "@smithy/protocol-http": "^5.3.9",
"@smithy/querystring-builder": "^4.2.9", "@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@trystero-p2p/nostr": "^0.24.0", "@trystero-p2p/nostr": "^0.24.0",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"commander": "^14.0.3",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"idb": "^8.0.3", "idb": "^8.0.3",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"micromatch": "^4.0.0",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"obsidian": "^1.12.3", "obsidian": "^1.12.3",
"octagonal-wheels": "^0.1.46", "octagonal-wheels": "^0.1.46",
+1
View File
@@ -120,6 +120,7 @@ export class LiveSyncBaseCore<
* @param constructor * @param constructor
* @returns * @returns
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getModule<T extends AbstractModule>(constructor: new (...args: any[]) => T): T { getModule<T extends AbstractModule>(constructor: new (...args: any[]) => T): T {
for (const module of this.modules) { for (const module of this.modules) {
if (module.constructor === constructor) return module as T; if (module.constructor === constructor) return module as T;
+1
View File
@@ -6,3 +6,4 @@ node_modules
.*.json .*.json
*.env *.env
!.test.env !.test.env
bench-results
+11 -10
View File
@@ -70,6 +70,7 @@ async function verifyRemoteState(
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> { export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
const { databasePath, core, settingsPath } = context; const { databasePath, core, settingsPath } = context;
const vaultPath = context.vaultPath || databasePath;
await core.services.control.activated; await core.services.control.activated;
if (options.command === "daemon") { if (options.command === "daemon") {
@@ -235,7 +236,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("push requires two arguments: <src> <dst>"); throw new Error("push requires two arguments: <src> <dst>");
} }
const sourcePath = path.resolve(options.commandArgs[0]); const sourcePath = path.resolve(options.commandArgs[0]);
const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[1], databasePath); const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[1], vaultPath);
const sourceData = await fs.readFile(sourcePath); const sourceData = await fs.readFile(sourcePath);
const sourceStat = await fs.stat(sourcePath); const sourceStat = await fs.stat(sourcePath);
console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`); console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
@@ -253,7 +254,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.commandArgs.length < 2) { if (options.commandArgs.length < 2) {
throw new Error("pull requires two arguments: <src> <dst>"); throw new Error("pull requires two arguments: <src> <dst>");
} }
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
const destinationPath = path.resolve(options.commandArgs[1]); const destinationPath = path.resolve(options.commandArgs[1]);
console.log(`[Command] pull ${sourceDatabasePath} -> ${destinationPath}`); console.log(`[Command] pull ${sourceDatabasePath} -> ${destinationPath}`);
@@ -276,7 +277,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.commandArgs.length < 3) { if (options.commandArgs.length < 3) {
throw new Error("pull-rev requires three arguments: <src> <dst> <rev>"); throw new Error("pull-rev requires three arguments: <src> <dst> <rev>");
} }
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
const destinationPath = path.resolve(options.commandArgs[1]); const destinationPath = path.resolve(options.commandArgs[1]);
const rev = options.commandArgs[2].trim(); const rev = options.commandArgs[2].trim();
if (!rev) { if (!rev) {
@@ -333,7 +334,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.commandArgs.length < 1) { if (options.commandArgs.length < 1) {
throw new Error("put requires one argument: <dst>"); throw new Error("put requires one argument: <dst>");
} }
const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
const content = await readStdinAsUtf8(); const content = await readStdinAsUtf8();
console.log(`[Command] put stdin -> ${destinationDatabasePath}`); console.log(`[Command] put stdin -> ${destinationDatabasePath}`);
return await core.serviceModules.databaseFileAccess.storeContent( return await core.serviceModules.databaseFileAccess.storeContent(
@@ -346,7 +347,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.commandArgs.length < 1) { if (options.commandArgs.length < 1) {
throw new Error("cat requires one argument: <src>"); throw new Error("cat requires one argument: <src>");
} }
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
console.error(`[Command] cat ${sourceDatabasePath}`); console.error(`[Command] cat ${sourceDatabasePath}`);
const source = await core.serviceModules.databaseFileAccess.fetch( const source = await core.serviceModules.databaseFileAccess.fetch(
sourceDatabasePath as FilePathWithPrefix, sourceDatabasePath as FilePathWithPrefix,
@@ -370,7 +371,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.commandArgs.length < 2) { if (options.commandArgs.length < 2) {
throw new Error("cat-rev requires two arguments: <src> <rev>"); throw new Error("cat-rev requires two arguments: <src> <rev>");
} }
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath); const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
const rev = options.commandArgs[1].trim(); const rev = options.commandArgs[1].trim();
if (!rev) { if (!rev) {
throw new Error("cat-rev requires a non-empty revision"); throw new Error("cat-rev requires a non-empty revision");
@@ -397,7 +398,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.command === "ls") { if (options.command === "ls") {
const prefix = const prefix =
options.commandArgs.length > 0 && options.commandArgs[0].trim() !== "" options.commandArgs.length > 0 && options.commandArgs[0].trim() !== ""
? toDatabaseRelativePath(options.commandArgs[0], databasePath) ? toDatabaseRelativePath(options.commandArgs[0], vaultPath)
: ""; : "";
const rows: { path: string; line: string }[] = []; const rows: { path: string; line: string }[] = [];
@@ -429,7 +430,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.commandArgs.length < 1) { if (options.commandArgs.length < 1) {
throw new Error("info requires one argument: <path>"); throw new Error("info requires one argument: <path>");
} }
const targetPath = toDatabaseRelativePath(options.commandArgs[0], databasePath); const targetPath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
for await (const doc of core.services.database.localDatabase.findAllNormalDocs({ conflicts: true })) { for await (const doc of core.services.database.localDatabase.findAllNormalDocs({ conflicts: true })) {
if (doc._deleted || doc.deleted) continue; if (doc._deleted || doc.deleted) continue;
@@ -473,7 +474,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.commandArgs.length < 1) { if (options.commandArgs.length < 1) {
throw new Error("rm requires one argument: <path>"); throw new Error("rm requires one argument: <path>");
} }
const targetPath = toDatabaseRelativePath(options.commandArgs[0], databasePath); const targetPath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
console.error(`[Command] rm ${targetPath}`); console.error(`[Command] rm ${targetPath}`);
return await core.serviceModules.databaseFileAccess.delete(targetPath as FilePathWithPrefix); return await core.serviceModules.databaseFileAccess.delete(targetPath as FilePathWithPrefix);
} }
@@ -482,7 +483,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (options.commandArgs.length < 2) { if (options.commandArgs.length < 2) {
throw new Error("resolve requires two arguments: <path> <revision-to-keep>"); throw new Error("resolve requires two arguments: <path> <revision-to-keep>");
} }
const targetPath = toDatabaseRelativePath(options.commandArgs[0], databasePath) as FilePathWithPrefix; const targetPath = toDatabaseRelativePath(options.commandArgs[0], vaultPath) as FilePathWithPrefix;
const revisionToKeep = options.commandArgs[1].trim(); const revisionToKeep = options.commandArgs[1].trim();
if (revisionToKeep === "") { if (revisionToKeep === "") {
throw new Error("resolve requires a non-empty revision-to-keep"); throw new Error("resolve requires a non-empty revision-to-keep");
@@ -1,3 +1,6 @@
import * as path from "path";
import * as fs from "fs/promises";
import * as os from "os";
import * as processSetting from "@lib/API/processSetting"; import * as processSetting from "@lib/API/processSetting";
import { ConnectionStringParser } from "@lib/common/ConnectionString"; import { ConnectionStringParser } from "@lib/common/ConnectionString";
import { configURIBase } from "@lib/common/models/shared.const"; import { configURIBase } from "@lib/common/models/shared.const";
@@ -601,6 +604,45 @@ describe("runCommand abnormal cases", () => {
expect(exported2).toBe(roundTripInput); expect(exported2).toBe(roundTripInput);
}); });
describe("runCommand with decoupled vault path", () => {
it("push resolves target path relative to vaultPath, not databasePath", async () => {
const core = createCoreMock();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-test-"));
const localVaultPath = path.join(tempDir, "vault");
const localDatabasePath = path.join(tempDir, "db");
await fs.mkdir(localVaultPath);
await fs.mkdir(localDatabasePath);
const fileInVault = path.join(localVaultPath, "existing.md");
await fs.writeFile(fileInVault, "hello", "utf-8");
const decoupledContext = {
databasePath: localDatabasePath,
vaultPath: localVaultPath,
settingsPath: path.join(localDatabasePath, ".livesync/settings.json"),
} as any;
const options = {
command: "push" as const,
commandArgs: [fileInVault, fileInVault],
databasePath: localDatabasePath,
vaultPath: localVaultPath,
};
try {
const result = await runCommand(options, { ...decoupledContext, core });
expect(result).toBe(true);
expect(core.serviceModules.storageAccess.writeFileAuto).toHaveBeenCalledWith(
"existing.md",
expect.any(ArrayBuffer),
expect.any(Object)
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
describe("mark-resolved and unlock-remote commands", () => { describe("mark-resolved and unlock-remote commands", () => {
it("mark-resolved without args runs on active database", async () => { it("mark-resolved without args runs on active database", async () => {
const core = createCoreMock(); const core = createCoreMock();
+1
View File
@@ -46,6 +46,7 @@ export interface CLIOptions {
export interface CLICommandContext { export interface CLICommandContext {
databasePath: string; databasePath: string;
vaultPath: string;
core: LiveSyncBaseCore<ServiceContext, any>; core: LiveSyncBaseCore<ServiceContext, any>;
settingsPath: string; settingsPath: string;
originalSyncSettings: Pick< originalSyncSettings: Pick<
+15 -3
View File
@@ -329,8 +329,20 @@ export async function main() {
options.command === "mirror" && options.commandArgs[0] options.command === "mirror" && options.commandArgs[0]
? path.resolve(options.commandArgs[0]) ? path.resolve(options.commandArgs[0])
: options.vaultPath : options.vaultPath
? path.resolve(options.vaultPath) ? path.resolve(options.vaultPath)
: databasePath!; : databasePath!;
// Check if vault directory exists
try {
const stat = await fs.stat(vaultPath);
if (!stat.isDirectory()) {
console.error(`Error: Vault path ${vaultPath} is not a directory`);
process.exit(1);
}
} catch (error) {
console.error(`Error: Vault directory ${vaultPath} does not exist`);
process.exit(1);
}
infoLog(`Self-hosted LiveSync CLI`); infoLog(`Self-hosted LiveSync CLI`);
infoLog(`Database Path: ${databasePath}`); infoLog(`Database Path: ${databasePath}`);
@@ -541,7 +553,7 @@ export async function main() {
infoLog(""); infoLog("");
} }
const result = await runCommand(options, { databasePath, core, settingsPath, originalSyncSettings }); const result = await runCommand(options, { databasePath, vaultPath, core, settingsPath, originalSyncSettings });
if (!result) { if (!result) {
console.error(`[Error] Command '${options.command}' failed`); console.error(`[Error] Command '${options.command}' failed`);
process.exitCode = 1; process.exitCode = 1;
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CLI_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
cd "$CLI_DIR"
source "$SCRIPT_DIR/test-helpers.sh"
display_test_info
RUN_BUILD="${RUN_BUILD:-1}"
REMOTE_PATH="${REMOTE_PATH:-test/push-pull-decoupled.txt}"
cli_test_init_cli_cmd
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-test.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT
SETTINGS_FILE="${1:-$WORK_DIR/data.json}"
if [[ "$RUN_BUILD" == "1" ]]; then
echo "[INFO] building CLI..."
npm run build
fi
echo "[INFO] generating settings from DEFAULT_SETTINGS -> $SETTINGS_FILE"
cli_test_init_settings_file "$SETTINGS_FILE"
if [[ -n "${COUCHDB_URI:-}" && -n "${COUCHDB_USER:-}" && -n "${COUCHDB_PASSWORD:-}" && -n "${COUCHDB_DBNAME:-}" ]]; then
echo "[INFO] applying CouchDB env vars to generated settings"
cli_test_apply_couchdb_settings "$SETTINGS_FILE" "$COUCHDB_URI" "$COUCHDB_USER" "$COUCHDB_PASSWORD" "$COUCHDB_DBNAME"
else
echo "[WARN] CouchDB env vars are not fully set. push/pull may fail unless generated settings are updated."
cli_test_mark_settings_configured "$SETTINGS_FILE"
fi
VAULT_DIR="$WORK_DIR/vault"
DB_DIR="$WORK_DIR/db"
mkdir -p "$VAULT_DIR/test"
mkdir -p "$DB_DIR"
SRC_FILE="$WORK_DIR/push-source.txt"
PULLED_FILE="$WORK_DIR/pull-result.txt"
printf 'push-pull-decoupled-test %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SRC_FILE"
# 1. Test push command with decoupled vault directory
echo "[INFO] push with decoupled vault -> $REMOTE_PATH"
run_cli "$DB_DIR" --vault "$VAULT_DIR" --settings "$SETTINGS_FILE" push "$SRC_FILE" "$REMOTE_PATH"
# 2. Test pull command with decoupled vault directory
echo "[INFO] pull with decoupled vault <- $REMOTE_PATH"
run_cli "$DB_DIR" --vault "$VAULT_DIR" --settings "$SETTINGS_FILE" pull "$REMOTE_PATH" "$PULLED_FILE"
if cmp -s "$SRC_FILE" "$PULLED_FILE"; then
echo "[PASS] push/pull roundtrip with decoupled vault matched"
else
echo "[FAIL] push/pull roundtrip with decoupled vault mismatch" >&2
echo "--- source ---" >&2
cat "$SRC_FILE" >&2
echo "--- pulled ---" >&2
cat "$PULLED_FILE" >&2
exit 1
fi
# 3. Clean up pulled file and vault test directory to verify mirror
rm -f "$PULLED_FILE"
rm -rf "$VAULT_DIR/test"
# 4. Test mirror command with decoupled vault directory
echo "[INFO] mirror with decoupled vault"
run_cli "$DB_DIR" --vault "$VAULT_DIR" --settings "$SETTINGS_FILE" mirror
RESTORED_FILE="$VAULT_DIR/$REMOTE_PATH"
if cmp -s "$SRC_FILE" "$RESTORED_FILE"; then
echo "[PASS] mirror with decoupled vault matched"
else
echo "[FAIL] mirror with decoupled vault mismatch" >&2
echo "--- source ---" >&2
cat "$SRC_FILE" >&2
echo "--- mirrored/restored ---" >&2
cat "$RESTORED_FILE" 2>/dev/null || echo "<none>" >&2
exit 1
fi
echo "[PASS] decoupled database/vault E2E tests successfully completed"
+9
View File
@@ -43,6 +43,15 @@ cli_test_init_settings_file "$SETTINGS_FILE"
# isConfigured=true is required for mirror (canProceedScan checks this) # isConfigured=true is required for mirror (canProceedScan checks this)
cli_test_mark_settings_configured "$SETTINGS_FILE" cli_test_mark_settings_configured "$SETTINGS_FILE"
# Enable writeDocumentsIfConflicted to resolve unsynced conflicts during mirror
node -e '
const fs = require("fs");
const file = process.argv[1];
const data = JSON.parse(fs.readFileSync(file, "utf-8"));
data.writeDocumentsIfConflicted = true;
fs.writeFileSync(file, JSON.stringify(data, null, 2));
' "$SETTINGS_FILE"
# Preparation: Sync settings and files logic # Preparation: Sync settings and files logic
DB_SETTINGS="$DB_DIR/settings.json" DB_SETTINGS="$DB_DIR/settings.json"
cp "$SETTINGS_FILE" "$DB_SETTINGS" cp "$SETTINGS_FILE" "$DB_SETTINGS"
+312
View File
@@ -0,0 +1,312 @@
import { TempDir } from "./helpers/temp.ts";
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
type BenchmarkConfig = {
couchdbBackendUri: string;
couchdbProxyUri: string;
couchdbUser: string;
couchdbPassword: string;
couchdbDbname: string;
datasetDirName: string;
datasetSeed: string;
mdFileCount: number;
mdMinSizeBytes: number;
mdMaxSizeBytes: number;
binFileCount: number;
binSizeBytes: number;
syncTimeoutSeconds: number;
requestedRttMs: number;
passphrase: string;
encrypt: boolean;
};
function readEnvString(name: string, fallback: string): string {
const value = Deno.env.get(name)?.trim();
return value && value.length > 0 ? value : fallback;
}
function readEnvNumber(name: string, fallback: number): number {
const raw = Deno.env.get(name);
if (raw === undefined || raw.trim() === "") {
return fallback;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive number, got '${raw}'`);
}
return parsed;
}
function readEnvBool(name: string, fallback: boolean): boolean {
const raw = Deno.env.get(name);
if (raw === undefined || raw.trim() === "") {
return fallback;
}
return /^(1|true|yes|on)$/i.test(raw.trim());
}
function nowMs(): number {
return performance.now();
}
function formatMs(value: number): string {
return `${value.toFixed(1)} ms`;
}
function formatBytes(value: number): string {
if (value < 1024) {
return `${value} B`;
}
const kib = value / 1024;
if (kib < 1024) {
return `${kib.toFixed(1)} KiB`;
}
return `${(kib / 1024).toFixed(1)} MiB`;
}
function buildConfig(): BenchmarkConfig {
return {
couchdbBackendUri: readEnvString("BENCH_COUCHDB_BACKEND_URI", "http://127.0.0.1:5989"),
couchdbProxyUri: readEnvString("BENCH_COUCHDB_URI", "http://127.0.0.1:15989"),
couchdbUser: readEnvString("BENCH_COUCHDB_USER", readEnvString("username", "admin")),
couchdbPassword: readEnvString("BENCH_COUCHDB_PASSWORD", readEnvString("password", "password")),
couchdbDbname: readEnvString("BENCH_COUCHDB_DBNAME", `bench-couchdb-${Date.now()}`),
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)),
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)),
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)),
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)),
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
encrypt: readEnvBool("BENCH_ENCRYPT", true),
};
}
function readOptionalResultPath(): string | undefined {
const raw = Deno.env.get("BENCH_RESULT_JSON")?.trim();
if (!raw) {
return undefined;
}
return raw;
}
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
if (entries.length === 0) {
return [];
}
const md = entries.find((e) => e.kind === "md");
const bin = entries.find((e) => e.kind === "bin");
const middle = entries[Math.floor(entries.length / 2)];
const last = entries[entries.length - 1];
const unique = new Map<string, DatasetEntry>();
for (const entry of [md, bin, middle, last]) {
if (entry) {
unique.set(entry.relativePath, entry);
}
}
return [...unique.values()];
}
type ProxyHandle = {
stop: () => Promise<void>;
applied: boolean;
note: string;
};
function startCouchdbProxy(options: { backendUri: string; proxyUri: string; requestedRttMs: number }): ProxyHandle {
const backend = new URL(options.backendUri);
const proxy = new URL(options.proxyUri);
const halfDelayMs = Math.max(1, Math.floor(options.requestedRttMs / 2));
const controller = new AbortController();
const listener = Deno.serve(
{
hostname: proxy.hostname,
port: Number(proxy.port),
signal: controller.signal,
onError(error) {
console.error(`[Proxy] ${String(error)}`);
return new Response("proxy error", { status: 502 });
},
},
async (request) => {
await new Promise((resolve) => setTimeout(resolve, halfDelayMs));
const targetUrl = new URL(request.url);
targetUrl.protocol = backend.protocol;
targetUrl.host = backend.host;
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("content-length");
let requestBody: ArrayBuffer | undefined;
if (request.method !== "GET" && request.method !== "HEAD") {
try {
requestBody = await request.arrayBuffer();
} catch {
requestBody = undefined;
}
}
const upstream = await fetch(targetUrl, {
method: request.method,
headers,
body: requestBody,
redirect: "manual",
});
const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("content-length");
const responseBody = await upstream.arrayBuffer();
return new Response(responseBody, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
}
);
return {
applied: true,
note: `local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms pre-forward delay`,
stop: async () => {
controller.abort();
await listener.finished.catch(() => {});
},
};
}
async function main(): Promise<void> {
const config = buildConfig();
const resultPath = readOptionalResultPath();
await using workDir = await TempDir.create("livesync-cli-couchdb-bench");
const vaultA = workDir.join("vault-a");
const vaultB = workDir.join("vault-b");
const settingsA = workDir.join("settings-a.json");
const settingsB = workDir.join("settings-b.json");
await Deno.mkdir(vaultA, { recursive: true });
await Deno.mkdir(vaultB, { recursive: true });
await initSettingsFile(settingsA);
await initSettingsFile(settingsB);
await startCouchdb(config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, config.couchdbDbname);
const proxy = startCouchdbProxy({
backendUri: config.couchdbBackendUri,
proxyUri: config.couchdbProxyUri,
requestedRttMs: config.requestedRttMs,
});
try {
await Promise.all([
applyRemoteSyncSettings(settingsA, {
remoteType: "COUCHDB",
couchdbUri: config.couchdbProxyUri,
couchdbUser: config.couchdbUser,
couchdbPassword: config.couchdbPassword,
couchdbDbname: config.couchdbDbname,
encrypt: config.encrypt,
passphrase: config.passphrase,
}),
applyRemoteSyncSettings(settingsB, {
remoteType: "COUCHDB",
couchdbUri: config.couchdbProxyUri,
couchdbUser: config.couchdbUser,
couchdbPassword: config.couchdbPassword,
couchdbDbname: config.couchdbDbname,
encrypt: config.encrypt,
passphrase: config.passphrase,
}),
]);
const seedFiles = await createDeterministicDataset({
rootDir: vaultA,
datasetDirName: config.datasetDirName,
seed: config.datasetSeed,
mdCount: config.mdFileCount,
mdMinSizeBytes: config.mdMinSizeBytes,
mdMaxSizeBytes: config.mdMaxSizeBytes,
binCount: config.binFileCount,
binSizeBytes: config.binSizeBytes,
});
const mirrorStart = nowMs();
await runCliOrFail(vaultA, "--settings", settingsA, "mirror");
const mirrorElapsed = nowMs() - mirrorStart;
const syncAStart = nowMs();
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
const syncAElapsed = nowMs() - syncAStart;
const syncBStart = nowMs();
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
const syncBElapsed = nowMs() - syncBStart;
const sampleFiles = pickSampleFiles(seedFiles.entries);
for (const sample of sampleFiles) {
const pulledPath = workDir.join(`pulled-${sample.relativePath.split("/").join("_")}`);
await runCliOrFail(vaultB, "--settings", settingsB, "pull", sample.relativePath, pulledPath);
await assertFilesEqual(
sample.absolutePath,
pulledPath,
`sample file mismatch after CouchDB sync: ${sample.relativePath}`
);
}
const result = {
mode: "couchdb-cli-benchmark",
couchdbBackendUri: config.couchdbBackendUri,
couchdbProxyUri: config.couchdbProxyUri,
couchdbDbname: config.couchdbDbname,
rttRequestedMs: config.requestedRttMs,
proxyApplied: proxy.applied,
proxyNote: proxy.note,
datasetSeed: config.datasetSeed,
datasetDirName: config.datasetDirName,
totalFiles: seedFiles.totalFiles,
totalBytes: seedFiles.totalBytes,
mdFileCount: seedFiles.mdCount,
binFileCount: seedFiles.binCount,
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
syncAElapsedMs: Number(syncAElapsed.toFixed(1)),
syncBElapsedMs: Number(syncBElapsed.toFixed(1)),
totalSyncElapsedMs: Number((syncAElapsed + syncBElapsed).toFixed(1)),
throughputBytesPerSec: Number((seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000)).toFixed(2)),
throughputMiBPerSec: Number(
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) / 1024 / 1024).toFixed(4)
),
};
if (resultPath) {
await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2));
}
console.log(JSON.stringify(result, null, 2));
console.error(
`[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs(
mirrorElapsed
)}, synced in ${formatMs(syncAElapsed + syncBElapsed)} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`
);
} finally {
await proxy.stop();
await stopCouchdb().catch(() => {});
}
}
if (import.meta.main) {
main().catch((error) => {
console.error(`[Fatal Error]`, error);
Deno.exit(1);
});
}
+223
View File
@@ -0,0 +1,223 @@
import { TempDir } from "./helpers/temp.ts";
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
import { startCliInBackground } from "./helpers/backgroundCli.ts";
import { discoverPeer, maybeStartLocalRelay, stopLocalRelayIfStarted } from "./helpers/p2p.ts";
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
type BenchmarkConfig = {
relay: string;
appId: string;
roomId: string;
passphrase: string;
datasetDirName: string;
datasetSeed: string;
mdFileCount: number;
mdMinSizeBytes: number;
mdMaxSizeBytes: number;
binFileCount: number;
binSizeBytes: number;
peersTimeoutSeconds: number;
syncTimeoutSeconds: number;
};
function readEnvString(name: string, fallback: string): string {
const value = Deno.env.get(name)?.trim();
return value && value.length > 0 ? value : fallback;
}
function readEnvNumber(name: string, fallback: number): number {
const raw = Deno.env.get(name);
if (raw === undefined || raw.trim() === "") {
return fallback;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive number, got '${raw}'`);
}
return parsed;
}
function nowMs(): number {
return performance.now();
}
function formatMs(value: number): string {
return `${value.toFixed(1)} ms`;
}
function formatBytes(value: number): string {
if (value < 1024) {
return `${value} B`;
}
const kib = value / 1024;
if (kib < 1024) {
return `${kib.toFixed(1)} KiB`;
}
const mib = kib / 1024;
return `${mib.toFixed(1)} MiB`;
}
function buildConfig(): BenchmarkConfig {
return {
relay: readEnvString("BENCH_RELAY", "ws://localhost:4000/"),
appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"),
roomId: readEnvString("BENCH_ROOM_ID", `bench-room-${Date.now()}`),
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)),
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)),
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)),
peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 20),
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
};
}
function readOptionalResultPath(): string | undefined {
const raw = Deno.env.get("BENCH_RESULT_JSON")?.trim();
if (!raw) {
return undefined;
}
return raw;
}
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
if (entries.length === 0) {
return [];
}
const md = entries.find((e) => e.kind === "md");
const bin = entries.find((e) => e.kind === "bin");
const middle = entries[Math.floor(entries.length / 2)];
const last = entries[entries.length - 1];
const unique = new Map<string, DatasetEntry>();
for (const entry of [md, bin, middle, last]) {
if (entry) {
unique.set(entry.relativePath, entry);
}
}
return [...unique.values()];
}
async function main(): Promise<void> {
const config = buildConfig();
const resultPath = readOptionalResultPath();
const relayStarted = await maybeStartLocalRelay(config.relay);
await using workDir = await TempDir.create("livesync-cli-p2p-bench");
const hostVault = workDir.join("vault-host");
const clientVault = workDir.join("vault-client");
const hostSettings = workDir.join("settings-host.json");
const clientSettings = workDir.join("settings-client.json");
await Promise.all([
Deno.mkdir(hostVault, { recursive: true }),
Deno.mkdir(clientVault, { recursive: true }),
initSettingsFile(hostSettings),
initSettingsFile(clientSettings),
]);
await Promise.all([
applyP2pSettings(hostSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"),
applyP2pSettings(clientSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"),
]);
await Promise.all([
applyP2pTestTweaks(hostSettings, "p2p-bench-host", config.passphrase),
applyP2pTestTweaks(clientSettings, "p2p-bench-client", config.passphrase),
]);
const seedFiles = await createDeterministicDataset({
rootDir: hostVault,
datasetDirName: config.datasetDirName,
seed: config.datasetSeed,
mdCount: config.mdFileCount,
mdMinSizeBytes: config.mdMinSizeBytes,
mdMaxSizeBytes: config.mdMaxSizeBytes,
binCount: config.binFileCount,
binSizeBytes: config.binSizeBytes,
});
const mirrorStart = nowMs();
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
const mirrorElapsed = nowMs() - mirrorStart;
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
try {
const hostReadyStart = nowMs();
await host.waitUntilContains("P2P host is running", 20000);
const hostReadyElapsed = nowMs() - hostReadyStart;
const peerDiscoveryStart = nowMs();
const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds);
const peerDiscoveryElapsed = nowMs() - peerDiscoveryStart;
const syncStart = nowMs();
await runCliOrFail(
clientVault,
"--settings",
clientSettings,
"p2p-sync",
peer.id,
String(config.syncTimeoutSeconds)
);
const syncElapsed = nowMs() - syncStart;
const sampleFiles = pickSampleFiles(seedFiles.entries);
for (const sample of sampleFiles) {
const pulledPath = workDir.join(`pulled-${sample.relativePath.replaceAll("/", "_")}`);
await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath);
await assertFilesEqual(
sample.absolutePath,
pulledPath,
`sample file mismatch after sync: ${sample.relativePath}`
);
}
const result = {
mode: "p2p-cli-benchmark",
relay: config.relay,
appId: config.appId,
roomId: config.roomId,
datasetSeed: config.datasetSeed,
datasetDirName: config.datasetDirName,
peerId: peer.id,
peerName: peer.name,
totalFiles: seedFiles.totalFiles,
totalBytes: seedFiles.totalBytes,
mdFileCount: seedFiles.mdCount,
binFileCount: seedFiles.binCount,
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)),
peerDiscoveryElapsedMs: Number(peerDiscoveryElapsed.toFixed(1)),
syncElapsedMs: Number(syncElapsed.toFixed(1)),
throughputBytesPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2)),
throughputMiBPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024).toFixed(4)),
};
if (resultPath) {
await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2));
}
console.log(JSON.stringify(result, null, 2));
console.error(
`[Benchmark] mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs(mirrorElapsed)}, ` +
`synced in ${formatMs(syncElapsed)} ` +
`(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`
);
} finally {
await host.stop();
await stopLocalRelayIfStarted(relayStarted);
}
}
if (import.meta.main) {
main().catch((error) => {
console.error(`[Fatal Error]`, error);
Deno.exit(1);
});
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RESULTS_ROOT="${SCRIPT_DIR}/bench-results"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
OUT_DIR="${RESULTS_ROOT}/${TIMESTAMP}"
mkdir -p "${OUT_DIR}"
echo "[bench-wrapper] output directory: ${OUT_DIR}"
echo "[bench-wrapper] running p2p benchmark"
(
cd "${SCRIPT_DIR}"
BENCH_RESULT_JSON="${OUT_DIR}/p2p.json" deno task bench:p2p
)
echo "[bench-wrapper] running couchdb benchmark with RTT ${BENCH_COUCHDB_RTT_MS:-default} ms (emulating HTTP network latency)"
(
cd "${SCRIPT_DIR}"
BENCH_RESULT_JSON="${OUT_DIR}/couchdb.json" deno task bench:couchdb
)
cat > "${OUT_DIR}/README.txt" <<EOF
Bench wrapper result set
Generated at: ${TIMESTAMP}
Directory: ${OUT_DIR}
Files:
- p2p.json
- couchdb.json
EOF
echo "[bench-wrapper] verify outputs by cat"
echo "========== ${OUT_DIR}/README.txt =========="
cat "${OUT_DIR}/README.txt"
echo "========== ${OUT_DIR}/p2p.json =========="
cat "${OUT_DIR}/p2p.json"
echo "========== ${OUT_DIR}/couchdb.json =========="
cat "${OUT_DIR}/couchdb.json"
echo "[bench-wrapper] done"
echo "[bench-wrapper] result directory: ${OUT_DIR}"
+13 -2
View File
@@ -1,7 +1,10 @@
{ {
"tasks": { "tasks": {
"test": "deno test --env-file=.test.env -A --no-check test-*.ts", "test": "deno test --env-file=.test.env -A --no-check test-*.ts",
"test:local": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts test-mirror.ts", "test:local": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts test-mirror.ts test-daemon.ts",
"test:daemon": "deno test --env-file=.test.env -A --no-check test-daemon.ts",
"test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts",
"test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts",
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts", "test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts", "test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts", "test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
@@ -12,8 +15,16 @@
"test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts", "test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts",
"test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts", "test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts",
"test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts", "test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts",
"bench:p2p": "deno run --env-file=.test.env -A --no-check bench-p2p.ts",
"bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts",
"bench:item1": "bash ./bench-run-item1.sh",
"bench:item1:full": "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh",
"test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts", "test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
"test:e2e-matrix": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-matrix.ts" "test:e2e-matrix": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:couchdb-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc0' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:couchdb-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc1' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:minio-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc0' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts"
}, },
"imports": { "imports": {
"@std/assert": "jsr:@std/assert@^1.0.13", "@std/assert": "jsr:@std/assert@^1.0.13",
+17 -9
View File
@@ -1,4 +1,4 @@
import { CLI_DIR } from "./cli.ts"; import { CLI_DIR, TEE_ENABLED, formatTeeCommand, createLineTeeWriter } from "./cli.ts";
import { join } from "@std/path"; import { join } from "@std/path";
const CLI_DIST = join(CLI_DIR, "dist", "index.cjs"); const CLI_DIST = join(CLI_DIR, "dist", "index.cjs");
@@ -12,10 +12,9 @@ function decorateArgs(args: string[]): string[] {
async function pump( async function pump(
stream: ReadableStream<Uint8Array>, stream: ReadableStream<Uint8Array>,
sink: (text: string) => void, sink: (text: string) => void,
teeTarget: WritableStream<Uint8Array> | null teeTarget: { write: (chunk: Uint8Array) => void; close: () => void } | null
): Promise<void> { ): Promise<void> {
const reader = stream.getReader(); const reader = stream.getReader();
const writer = teeTarget?.getWriter();
const dec = new TextDecoder(); const dec = new TextDecoder();
try { try {
while (true) { while (true) {
@@ -23,12 +22,12 @@ async function pump(
if (done) break; if (done) break;
if (!value) continue; if (!value) continue;
sink(dec.decode(value, { stream: true })); sink(dec.decode(value, { stream: true }));
if (writer) { if (teeTarget) {
await writer.write(value); teeTarget.write(value);
} }
} }
} finally { } finally {
if (writer) writer.releaseLock(); if (teeTarget) teeTarget.close();
reader.releaseLock(); reader.releaseLock();
} }
} }
@@ -43,19 +42,20 @@ export class BackgroundCliProcess {
readonly child: Deno.ChildProcess, readonly child: Deno.ChildProcess,
readonly args: string[] readonly args: string[]
) { ) {
const cliArgs = decorateArgs(args);
this.#stdoutDone = pump( this.#stdoutDone = pump(
child.stdout, child.stdout,
(text) => { (text) => {
this.#stdout += text; this.#stdout += text;
}, },
null TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => Deno.stdout.writeSync(chunk)) : null
); );
this.#stderrDone = pump( this.#stderrDone = pump(
child.stderr, child.stderr,
(text) => { (text) => {
this.#stderr += text; this.#stderr += text;
}, },
null TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => Deno.stderr.writeSync(chunk)) : null
); );
} }
@@ -101,12 +101,20 @@ export class BackgroundCliProcess {
} }
export function startCliInBackground(...args: string[]): BackgroundCliProcess { export function startCliInBackground(...args: string[]): BackgroundCliProcess {
const cliArgs = decorateArgs(args);
const child = new Deno.Command("node", { const child = new Deno.Command("node", {
args: [CLI_DIST, ...decorateArgs(args)], args: [CLI_DIST, ...cliArgs],
cwd: CLI_DIR, cwd: CLI_DIR,
stdin: "null", stdin: "null",
stdout: "piped", stdout: "piped",
stderr: "piped", stderr: "piped",
}).spawn(); }).spawn();
if (TEE_ENABLED) {
Deno.stdout.writeSync(
new TextEncoder().encode(`[CLI tee pid=${child.pid}] process(bg): ${formatTeeCommand(cliArgs)}\n`)
);
}
return new BackgroundCliProcess(child, args); return new BackgroundCliProcess(child, args);
} }
+67 -9
View File
@@ -20,7 +20,7 @@ export interface CliResult {
code: number; code: number;
} }
const TEE_ENABLED = Deno.env.get("LIVESYNC_TEST_TEE") === "1"; export const TEE_ENABLED = Deno.env.get("LIVESYNC_TEST_TEE") === "1";
const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1"; const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1";
const DEBUG_ENABLED = Deno.env.get("LIVESYNC_CLI_DEBUG") === "1"; const DEBUG_ENABLED = Deno.env.get("LIVESYNC_CLI_DEBUG") === "1";
@@ -39,27 +39,73 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array {
return out; return out;
} }
export function formatTeeCommand(args: string[]): string {
return ["node", CLI_DIST, ...args].map((part) => JSON.stringify(part)).join(" ");
}
export function createLineTeeWriter(
pid: number,
streamName: "stdout" | "stderr",
writer: (chunk: Uint8Array) => void
): { write: (chunk: Uint8Array) => void; close: () => void } {
const enc = new TextEncoder();
const dec = new TextDecoder();
let pending = "";
let headerWritten = false;
const emitLine = (line: string) => {
if (!headerWritten) {
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}]\n`));
headerWritten = true;
}
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}] ${line}\n`));
};
const flush = (final = false) => {
let index = pending.indexOf("\n");
while (index >= 0) {
const line = pending.slice(0, index).replace(/\r$/, "");
pending = pending.slice(index + 1);
emitLine(line);
index = pending.indexOf("\n");
}
if (final && pending.length > 0) {
emitLine(pending.replace(/\r$/, ""));
pending = "";
}
};
return {
write(chunk: Uint8Array) {
pending += dec.decode(chunk, { stream: true });
flush(false);
},
close() {
pending += dec.decode();
flush(true);
},
};
}
async function collectStream( async function collectStream(
stream: ReadableStream<Uint8Array>, stream: ReadableStream<Uint8Array>,
teeTarget: WritableStream<Uint8Array> | null teeTarget: { write: (chunk: Uint8Array) => void; close: () => void } | null
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const reader = stream.getReader(); const reader = stream.getReader();
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
const writer = teeTarget?.getWriter();
try { try {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
if (value) { if (value) {
chunks.push(value); chunks.push(value);
if (writer) { if (teeTarget) {
await writer.write(value); teeTarget.write(value);
} }
} }
} }
} finally { } finally {
if (writer) { if (teeTarget) {
writer.releaseLock(); teeTarget.close();
} }
reader.releaseLock(); reader.releaseLock();
} }
@@ -76,8 +122,20 @@ async function runNodeCommand(args: string[], stdinData?: Uint8Array): Promise<C
stderr: "piped", stderr: "piped",
}).spawn(); }).spawn();
const stdoutPromise = collectStream(child.stdout, TEE_ENABLED ? Deno.stdout.writable : null); if (TEE_ENABLED) {
const stderrPromise = collectStream(child.stderr, TEE_ENABLED ? Deno.stderr.writable : null); Deno.stdout.writeSync(
new TextEncoder().encode(`[CLI tee pid=${child.pid}] process: ${formatTeeCommand(cliArgs)}\n`)
);
}
const stdoutPromise = collectStream(
child.stdout,
TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => Deno.stdout.writeSync(chunk)) : null
);
const stderrPromise = collectStream(
child.stderr,
TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => Deno.stderr.writeSync(chunk)) : null
);
if (stdinData) { if (stdinData) {
const w = child.stdin.getWriter(); const w = child.stdin.getWriter();
+123
View File
@@ -0,0 +1,123 @@
export type DeterministicDatasetConfig = {
rootDir: string;
datasetDirName: string;
seed: string;
mdCount: number;
mdMinSizeBytes: number;
mdMaxSizeBytes: number;
binCount: number;
binSizeBytes: number;
};
export type DatasetEntry = {
kind: "md" | "bin";
relativePath: string;
absolutePath: string;
size: number;
};
export type DeterministicDataset = {
rootDir: string;
datasetDirName: string;
seed: string;
entries: DatasetEntry[];
totalFiles: number;
totalBytes: number;
mdCount: number;
binCount: number;
};
function fnv1a32(input: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i) & 0xff;
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
function createXorshift32(seed: number): () => number {
let state = seed >>> 0;
if (state === 0) {
state = 0x9e3779b9;
}
return () => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return state >>> 0;
};
}
function createTextBytes(size: number, fileIndex: number, seed: string): Uint8Array {
const template =
`# Bench file ${fileIndex}\n` +
`seed: ${seed}\n` +
"lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n";
const templateBytes = new TextEncoder().encode(template);
const out = new Uint8Array(size);
for (let i = 0; i < size; i++) {
out[i] = templateBytes[i % templateBytes.length];
}
return out;
}
function toPath(rootDir: string, relativePath: string): string {
return `${rootDir}/${relativePath}`;
}
export async function createDeterministicDataset(config: DeterministicDatasetConfig): Promise<DeterministicDataset> {
if (config.mdCount < 0 || config.binCount < 0) {
throw new Error("mdCount and binCount must be non-negative");
}
if (config.mdMinSizeBytes <= 0 || config.mdMaxSizeBytes <= 0 || config.binSizeBytes <= 0) {
throw new Error("all size values must be positive");
}
if (config.mdMinSizeBytes > config.mdMaxSizeBytes) {
throw new Error("mdMinSizeBytes must be <= mdMaxSizeBytes");
}
const datasetRoot = toPath(config.rootDir, config.datasetDirName);
const mdDir = `${datasetRoot}/md`;
const binDir = `${datasetRoot}/bin`;
await Deno.mkdir(mdDir, { recursive: true });
await Deno.mkdir(binDir, { recursive: true });
const nextRandom = createXorshift32(fnv1a32(config.seed));
const mdRange = config.mdMaxSizeBytes - config.mdMinSizeBytes + 1;
const entries: DatasetEntry[] = [];
for (let index = 0; index < config.mdCount; index++) {
const size = config.mdMinSizeBytes + (nextRandom() % mdRange);
const relativePath = `${config.datasetDirName}/md/file-${String(index).padStart(4, "0")}.md`;
const absolutePath = toPath(config.rootDir, relativePath);
const body = createTextBytes(size, index, config.seed);
await Deno.writeFile(absolutePath, body);
entries.push({ kind: "md", relativePath, absolutePath, size });
}
for (let index = 0; index < config.binCount; index++) {
const size = config.binSizeBytes;
const relativePath = `${config.datasetDirName}/bin/file-${String(index).padStart(4, "0")}.bin`;
const absolutePath = toPath(config.rootDir, relativePath);
const body = new Uint8Array(size);
for (let i = 0; i < size; i++) {
body[i] = nextRandom() & 0xff;
}
await Deno.writeFile(absolutePath, body);
entries.push({ kind: "bin", relativePath, absolutePath, size });
}
const totalBytes = entries.reduce((sum, e) => sum + e.size, 0);
return {
rootDir: config.rootDir,
datasetDirName: config.datasetDirName,
seed: config.seed,
entries,
totalFiles: entries.length,
totalBytes,
mdCount: config.mdCount,
binCount: config.binCount,
};
}
+165 -24
View File
@@ -14,6 +14,11 @@ type DockerInvoker = {
let dockerInvokerPromise: Promise<DockerInvoker> | null = null; let dockerInvokerPromise: Promise<DockerInvoker> | null = null;
const DOCKER_TEE = Deno.env.get("LIVESYNC_DOCKER_TEE") === "1" || Deno.env.get("LIVESYNC_TEST_TEE") === "1"; const DOCKER_TEE = Deno.env.get("LIVESYNC_DOCKER_TEE") === "1" || Deno.env.get("LIVESYNC_TEST_TEE") === "1";
const trackedContainers = new Set<string>();
const CLEANUP_SIGNALS: Deno.Signal[] = ["SIGINT", "SIGTERM"];
let signalCleanupHandlersInstalled = false;
let signalCleanupInProgress = false;
const signalCleanupHandlers = new Map<Deno.Signal, () => void>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Low-level docker wrapper // Low-level docker wrapper
@@ -27,29 +32,53 @@ function parseCommand(command: string): { bin: string; prefix: string[] } {
return { bin: parts[0], prefix: parts.slice(1) }; return { bin: parts[0], prefix: parts.slice(1) };
} }
async function runCommand(bin: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { async function collectStream(
const cmd = new Deno.Command(bin, { stream: ReadableStream<Uint8Array>,
args, teeTarget: ((chunk: Uint8Array) => void) | null
stdin: "null", ): Promise<Uint8Array> {
stdout: "piped", const reader = stream.getReader();
stderr: "piped", const chunks: Uint8Array[] = [];
});
try { try {
const { code, stdout, stderr } = await cmd.output(); while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
chunks.push(value);
if (teeTarget) {
teeTarget(value);
}
}
} finally {
reader.releaseLock();
}
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}
async function runCommand(bin: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
try {
const child = new Deno.Command(bin, {
args,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).spawn();
const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => Deno.stdout.writeSync(chunk) : null);
const stderrPromise = collectStream(child.stderr, DOCKER_TEE ? (chunk) => Deno.stderr.writeSync(chunk) : null);
const [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise]);
const dec = new TextDecoder(); const dec = new TextDecoder();
const result = { const result = {
code, code: status.code,
stdout: dec.decode(stdout), stdout: dec.decode(stdout),
stderr: dec.decode(stderr), stderr: dec.decode(stderr),
}; };
if (DOCKER_TEE) {
if (result.stdout.trim().length > 0) {
console.log(`[docker:${bin}] ${result.stdout.trimEnd()}`);
}
if (result.stderr.trim().length > 0) {
console.error(`[docker:${bin}] ${result.stderr.trimEnd()}`);
}
}
return result; return result;
} catch (err) { } catch (err) {
if (err instanceof Deno.errors.NotFound) { if (err instanceof Deno.errors.NotFound) {
@@ -159,6 +188,73 @@ async function dockerOrFail(...args: string[]): Promise<string> {
return r.stdout; return r.stdout;
} }
async function stopAndRemoveContainer(container: string): Promise<void> {
await docker("stop", container).catch(() => {});
await docker("rm", container).catch(() => {});
}
async function cleanupTrackedContainers(reason: string): Promise<void> {
const names = [...trackedContainers];
if (names.length === 0) return;
console.warn(`[WARN] cleaning up tracked containers on ${reason}: ${names.join(", ")}`);
for (const container of names.reverse()) {
await stopAndRemoveContainer(container);
trackedContainers.delete(container);
}
}
async function handleSignalCleanup(signal: Deno.Signal): Promise<void> {
if (signalCleanupInProgress) return;
signalCleanupInProgress = true;
try {
await cleanupTrackedContainers(`signal ${signal}`);
} finally {
Deno.exit(signal === "SIGINT" ? 130 : 143);
}
}
function ensureSignalCleanupHandlers(): void {
if (signalCleanupHandlersInstalled) return;
signalCleanupHandlersInstalled = true;
for (const signal of CLEANUP_SIGNALS) {
const listener = () => {
void handleSignalCleanup(signal);
};
try {
Deno.addSignalListener(signal, listener);
signalCleanupHandlers.set(signal, listener);
} catch {
// Unsupported signal on this platform.
}
}
}
function removeSignalCleanupHandlers(): void {
if (!signalCleanupHandlersInstalled) return;
for (const [signal, listener] of signalCleanupHandlers) {
try {
Deno.removeSignalListener(signal, listener);
} catch {
// Ignore if already removed or unsupported.
}
}
signalCleanupHandlers.clear();
signalCleanupHandlersInstalled = false;
}
function trackContainer(container: string): void {
ensureSignalCleanupHandlers();
trackedContainers.add(container);
}
function untrackContainer(container: string): void {
trackedContainers.delete(container);
if (trackedContainers.size === 0) {
removeSignalCleanupHandlers();
}
}
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
@@ -235,8 +331,8 @@ const MINIO_IMAGE = "minio/minio";
const MINIO_MC_IMAGE = "minio/mc"; const MINIO_MC_IMAGE = "minio/mc";
export async function stopCouchdb(): Promise<void> { export async function stopCouchdb(): Promise<void> {
await docker("stop", COUCHDB_CONTAINER); await stopAndRemoveContainer(COUCHDB_CONTAINER);
await docker("rm", COUCHDB_CONTAINER); untrackContainer(COUCHDB_CONTAINER);
} }
/** /**
@@ -265,6 +361,7 @@ export async function startCouchdb(couchdbUri: string, user: string, password: s
"COUCHDB_SINGLE_NODE=y", "COUCHDB_SINGLE_NODE=y",
COUCHDB_IMAGE COUCHDB_IMAGE
); );
trackContainer(COUCHDB_CONTAINER);
console.log("[INFO] initialising CouchDB"); console.log("[INFO] initialising CouchDB");
await initCouchdb(couchdbUri, user, password); await initCouchdb(couchdbUri, user, password);
@@ -365,8 +462,8 @@ function shQuote(value: string): string {
} }
export async function stopMinio(): Promise<void> { export async function stopMinio(): Promise<void> {
await docker("stop", MINIO_CONTAINER); await stopAndRemoveContainer(MINIO_CONTAINER);
await docker("rm", MINIO_CONTAINER); untrackContainer(MINIO_CONTAINER);
} }
async function initMinioBucket( async function initMinioBucket(
@@ -446,6 +543,7 @@ export async function startMinio(
"--console-address", "--console-address",
":9001" ":9001"
); );
trackContainer(MINIO_CONTAINER);
console.log(`[INFO] initialising MinIO test bucket: ${bucket}`); console.log(`[INFO] initialising MinIO test bucket: ${bucket}`);
let initialised = false; let initialised = false;
@@ -493,8 +591,8 @@ EOF
exec /app/strfry --config /tmp/strfry.conf relay`; exec /app/strfry --config /tmp/strfry.conf relay`;
export async function stopP2pRelay(): Promise<void> { export async function stopP2pRelay(): Promise<void> {
await docker("stop", P2P_RELAY_CONTAINER); await stopAndRemoveContainer(P2P_RELAY_CONTAINER);
await docker("rm", P2P_RELAY_CONTAINER); untrackContainer(P2P_RELAY_CONTAINER);
} }
/** /**
@@ -523,8 +621,51 @@ export async function startP2pRelay(): Promise<void> {
"-lc", "-lc",
STRFRY_BOOTSTRAP_SH STRFRY_BOOTSTRAP_SH
); );
trackContainer(P2P_RELAY_CONTAINER);
} }
export function isLocalP2pRelay(relayUrl: string): boolean { export function isLocalP2pRelay(relayUrl: string): boolean {
return relayUrl === "ws://localhost:4000" || relayUrl === "ws://localhost:4000/"; return relayUrl.includes("localhost") || relayUrl.includes("127.0.0.1") || relayUrl.includes("[::1]");
}
// ---------------------------------------------------------------------------
// Coturn (STUN/TURN)
// ---------------------------------------------------------------------------
const COTURN_CONTAINER = "coturn-test";
const COTURN_IMAGE = "coturn/coturn:latest";
export async function stopCoturn(): Promise<void> {
await stopAndRemoveContainer(COTURN_CONTAINER);
untrackContainer(COTURN_CONTAINER);
}
export async function startCoturn(
port = 3478,
user = "testuser",
pass = "testpass",
realm = "livesync.test"
): Promise<void> {
console.log("[INFO] stopping leftover Coturn container if present");
await stopCoturn().catch(() => {});
const { getOptimalLoopbackIp } = await import("./net.ts");
const externalIp = await getOptimalLoopbackIp();
console.log(`[INFO] starting local Coturn container with external-ip ${externalIp}`);
await dockerOrFail(
"run",
"-d",
"--name",
COTURN_CONTAINER,
"-p",
`${port}:${port}`,
"-p",
`${port}:${port}/udp`,
COTURN_IMAGE,
"--log-file=stdout",
`--external-ip=${externalIp}`,
`--user=${user}:${pass}`,
`--realm=${realm}`
);
trackContainer(COTURN_CONTAINER);
} }
+68
View File
@@ -0,0 +1,68 @@
type WaitForPortOptions = {
timeoutMs?: number;
intervalMs?: number;
connectTimeoutMs?: number;
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
let timer: number | undefined;
try {
const connPromise = Deno.connect({ hostname, port });
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`connect timeout after ${timeoutMs}ms`)), timeoutMs);
});
const conn = await Promise.race([connPromise, timeoutPromise]);
conn.close();
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
export async function waitForPort(hostname: string, port: number, options: WaitForPortOptions = {}): Promise<void> {
const timeoutMs = options.timeoutMs ?? 15000;
const intervalMs = options.intervalMs ?? 250;
const connectTimeoutMs = options.connectTimeoutMs ?? 1000;
const started = Date.now();
let lastError: unknown;
while (Date.now() - started < timeoutMs) {
try {
await connectWithTimeout(hostname, port, connectTimeoutMs);
return;
} catch (error) {
lastError = error;
await sleep(intervalMs);
}
}
throw new Error(
`Port ${hostname}:${port} did not become ready within ${timeoutMs}ms` +
(lastError ? ` (last error: ${String(lastError)})` : "")
);
}
export async function getOptimalLoopbackIp(): Promise<string> {
const ipv4 = "127.0.0.1";
const ipv6 = "::1";
try {
const l = Deno.listen({ hostname: ipv4, port: 0 });
l.close();
return ipv4;
} catch {
try {
const l = Deno.listen({ hostname: ipv6, port: 0 });
l.close();
return ipv6;
} catch {
return ipv4; // fallback to default
}
}
}
+75 -16
View File
@@ -1,11 +1,26 @@
import { runCli } from "./cli.ts"; import { runCli } from "./cli.ts";
import { isLocalP2pRelay, startP2pRelay, stopP2pRelay } from "./docker.ts"; import { isLocalP2pRelay, startP2pRelay, stopP2pRelay, startCoturn, stopCoturn } from "./docker.ts";
import { waitForPort } from "./net.ts";
export type PeerEntry = { export type PeerEntry = {
id: string; id: string;
name: string; name: string;
}; };
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseRelayEndpoint(relay: string): { hostname: string; port: number } {
const url = new URL(relay);
const port = url.port ? Number(url.port) : url.protocol === "ws:" ? 80 : url.protocol === "wss:" ? 443 : NaN;
if (!Number.isFinite(port)) {
throw new Error(`Unsupported relay URL: ${relay}`);
}
const hostname = url.hostname === "localhost" ? "127.0.0.1" : url.hostname;
return { hostname, port };
}
export function parsePeerLines(output: string): PeerEntry[] { export function parsePeerLines(output: string): PeerEntry[] {
return output return output
.split(/\r?\n/) .split(/\r?\n/)
@@ -20,28 +35,58 @@ export async function discoverPeer(
timeoutSeconds: number, timeoutSeconds: number,
targetPeer?: string targetPeer?: string
): Promise<PeerEntry> { ): Promise<PeerEntry> {
const result = await runCli(vaultDir, "--settings", settingsFile, "p2p-peers", String(timeoutSeconds)); const retries = Math.max(0, Number(Deno.env.get("LIVESYNC_P2P_PEERS_RETRY") ?? "3"));
if (result.code !== 0) { let lastCombined = "";
throw new Error(`p2p-peers failed\n${result.combined}`);
} for (let attempt = 0; attempt <= retries; attempt++) {
const peers = parsePeerLines(result.stdout); const result = await runCli(vaultDir, "--settings", settingsFile, "p2p-peers", String(timeoutSeconds));
if (targetPeer) { lastCombined = result.combined;
const matched = peers.find((peer) => peer.id === targetPeer || peer.name === targetPeer);
if (matched) return matched; if (result.code === 0) {
} const peers = parsePeerLines(result.stdout);
if (peers.length === 0) { if (targetPeer) {
const fallback = result.combined.match(/Advertisement from\s+([^\s]+)/); const matched = peers.find((peer) => peer.id === targetPeer || peer.name === targetPeer);
if (fallback?.[1]) { if (matched) return matched;
return { id: fallback[1], name: fallback[1] }; }
if (peers.length > 0) {
return peers[0];
}
const fallback = result.combined.match(/Advertisement from\s+([^\s]+)/);
if (fallback?.[1]) {
return { id: fallback[1], name: fallback[1] };
}
} }
throw new Error(`No peers discovered\n${result.combined}`);
if (attempt < retries) {
const waitMs = 400 * (attempt + 1);
console.warn(
`[WARN] p2p-peers returned no usable peers, retrying (${attempt + 1}/${retries}) in ${waitMs}ms`
);
await sleep(waitMs);
continue;
}
throw new Error(
result.code !== 0 ? `p2p-peers failed\n${result.combined}` : `No peers discovered\n${result.combined}`
);
} }
return peers[0];
throw new Error(`No peers discovered\n${lastCombined}`);
} }
export async function maybeStartLocalRelay(relay: string): Promise<boolean> { export async function maybeStartLocalRelay(relay: string): Promise<boolean> {
if (!isLocalP2pRelay(relay)) return false; if (!isLocalP2pRelay(relay)) return false;
await startP2pRelay(); await startP2pRelay();
const endpoint = parseRelayEndpoint(relay);
await waitForPort(endpoint.hostname, endpoint.port, {
timeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000"),
intervalMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_INTERVAL_MS") ?? "250"),
connectTimeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_CONNECT_TIMEOUT_MS") ?? "1000"),
});
// Docker proxy accepts TCP connections instantly before the container's internal process is fully ready.
// Wait an additional few seconds to ensure strfry is actually accepting WebSockets.
await sleep(3000);
return true; return true;
} }
@@ -50,3 +95,17 @@ export async function stopLocalRelayIfStarted(started: boolean): Promise<void> {
await stopP2pRelay().catch(() => {}); await stopP2pRelay().catch(() => {});
} }
} }
export async function maybeStartCoturn(turnServers: string): Promise<boolean> {
if (turnServers.includes("localhost") || turnServers.includes("127.0.0.1") || turnServers.includes("[::1]")) {
await startCoturn();
return true;
}
return false;
}
export async function stopCoturnIfStarted(started: boolean): Promise<void> {
if (started) {
await stopCoturn().catch(() => {});
}
}
+5 -1
View File
@@ -172,7 +172,8 @@ export async function applyP2pSettings(
passphrase: string, passphrase: string,
appId = "self-hosted-livesync-cli-tests", appId = "self-hosted-livesync-cli-tests",
relays = "ws://localhost:4000/", relays = "ws://localhost:4000/",
autoAccept = "~.*" autoAccept = "~.*",
turnServers = "turn:127.0.0.1:3478"
): Promise<void> { ): Promise<void> {
const data = JSON.parse(await Deno.readTextFile(settingsFile)); const data = JSON.parse(await Deno.readTextFile(settingsFile));
data.P2P_Enabled = true; data.P2P_Enabled = true;
@@ -184,6 +185,9 @@ export async function applyP2pSettings(
data.P2P_relays = relays; data.P2P_relays = relays;
data.P2P_AutoAcceptingPeers = autoAccept; data.P2P_AutoAcceptingPeers = autoAccept;
data.P2P_AutoDenyingPeers = ""; data.P2P_AutoDenyingPeers = "";
data.P2P_turnServers = turnServers;
data.P2P_turnUsername = "testuser";
data.P2P_turnCredential = "testpass";
data.P2P_IsHeadless = true; data.P2P_IsHeadless = true;
data.isConfigured = true; data.isConfigured = true;
await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2)); await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
+120
View File
@@ -0,0 +1,120 @@
/**
* Deno port of test-daemon-linux.sh
*
* Tests daemon-related ignore rules behaviour.
*
* Tests that are runnable without a long-running daemon process are exercised
* here using the 'mirror' command, which calls the same 'isTargetFile' handler
* stack that the daemon uses.
*
* Covered cases:
* 1. .livesync/ignore with *.tmp pattern → ignored file is not synced to database
* 2. .livesync/ignore missing → no error, and normal synchronisation continues
* 3. import: .gitignore directive → patterns from .gitignore are merged
*
* Run:
* deno test -A test-daemon.ts
*/
import { join } from "@std/path";
import { assertEquals } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { runCliOrFail, runCli, assertContains, assertNotContains } from "./helpers/cli.ts";
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
Deno.test("daemon: ignore rules behaviour", async (t) => {
// -------------------------------------------------------------------------
// Case 1: .livesync/ignore with *.tmp → ignored file not synced to database
// -------------------------------------------------------------------------
await t.step("case 1: .livesync/ignore *.tmp prevents sync", async () => {
await using workDir = await TempDir.create("livesync-cli-daemon-c1");
const settingsFile = workDir.join("data.json");
const vaultDir = workDir.join("vault");
await Deno.mkdir(join(vaultDir, ".livesync"), { recursive: true });
await Deno.mkdir(join(vaultDir, "notes"), { recursive: true });
await initSettingsFile(settingsFile);
await markSettingsConfigured(settingsFile);
await Deno.writeTextFile(join(vaultDir, ".livesync", "ignore"), "*.tmp\n");
await Deno.writeTextFile(join(vaultDir, "notes", "normal.md"), "normal content\n");
await Deno.writeTextFile(join(vaultDir, "notes", "scratch.tmp"), "tmp content\n");
console.log("[INFO] Running mirror for Case 1...");
await runCliOrFail(vaultDir, "--settings", settingsFile, "mirror");
// The normal file should be in the database.
const resultNormal = workDir.join("case1-normal.txt");
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/normal.md", resultNormal);
const normalContent = await Deno.readTextFile(resultNormal);
assertEquals(normalContent, "normal content\n", "normal.md content mismatch after mirror");
// The .tmp file should NOT be in the database.
const dbList = await runCliOrFail(vaultDir, "--settings", settingsFile, "ls");
assertNotContains(dbList, "scratch.tmp", "scratch.tmp (ignored) was unexpectedly synced to database");
assertContains(dbList, "normal.md", "normal.md was not found in database after mirror");
console.log("[PASS] Case 1 verified successfully");
});
// -------------------------------------------------------------------------
// Case 2: .livesync/ignore absent → no error, and normal synchronisation continues
// -------------------------------------------------------------------------
await t.step("case 2: .livesync/ignore absent does not cause failure", async () => {
await using workDir = await TempDir.create("livesync-cli-daemon-c2");
const settingsFile = workDir.join("data2.json");
const vaultDir = workDir.join("vault2");
await Deno.mkdir(join(vaultDir, "notes"), { recursive: true });
await initSettingsFile(settingsFile);
await markSettingsConfigured(settingsFile);
// No .livesync directory at all.
await Deno.writeTextFile(join(vaultDir, "notes", "hello.md"), "hello\n");
console.log("[INFO] Running mirror for Case 2...");
const result = await runCli(vaultDir, "--settings", settingsFile, "mirror");
assertEquals(result.code, 0, "mirror exited non-zero when .livesync/ignore is absent");
// The normal file should have been synced.
const resultHello = workDir.join("case2-hello.txt");
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/hello.md", resultHello);
const helloContent = await Deno.readTextFile(resultHello);
assertEquals(helloContent, "hello\n", "file content mismatch when .livesync/ignore is absent");
console.log("[PASS] Case 2 verified successfully");
});
// -------------------------------------------------------------------------
// Case 3: import: .gitignore merges patterns
// -------------------------------------------------------------------------
await t.step("case 3: import: .gitignore directive merges patterns", async () => {
await using workDir = await TempDir.create("livesync-cli-daemon-c3");
const settingsFile = workDir.join("data3.json");
const vaultDir = workDir.join("vault3");
await Deno.mkdir(join(vaultDir, ".livesync"), { recursive: true });
await Deno.mkdir(join(vaultDir, "notes"), { recursive: true });
await initSettingsFile(settingsFile);
await markSettingsConfigured(settingsFile);
await Deno.writeTextFile(join(vaultDir, ".livesync", "ignore"), "import: .gitignore\n");
await Deno.writeTextFile(join(vaultDir, ".gitignore"), "# gitignore comment\n*.log\nbuild/\n");
await Deno.writeTextFile(join(vaultDir, "notes", "regular.md"), "regular note\n");
await Deno.writeTextFile(join(vaultDir, "notes", "debug.log"), "log content\n");
console.log("[INFO] Running mirror for Case 3...");
await runCliOrFail(vaultDir, "--settings", settingsFile, "mirror");
const dbList = await runCliOrFail(vaultDir, "--settings", settingsFile, "ls");
assertNotContains(
dbList,
"debug.log",
"debug.log (ignored via .gitignore import) was unexpectedly synced to database"
);
assertContains(dbList, "regular.md", "regular.md was not synced normally alongside .gitignore import rules");
console.log("[PASS] Case 3 verified successfully");
});
});
@@ -0,0 +1,87 @@
/**
* Deno port of test-decoupled-vault-linux.sh
*
* Tests push, pull, and mirror command behaviour when the vault directory is
* decoupled (separated) from the database directory.
*
* Run:
* deno test -A test-decoupled-vault.ts
*/
import { join } from "@std/path";
import { assertEquals } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { runCliOrFail } from "./helpers/cli.ts";
import { applyCouchdbSettings, initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
const REMOTE_PATH = Deno.env.get("REMOTE_PATH") ?? "test/push-pull-decoupled.txt";
Deno.test("decoupled database and vault", async () => {
await using workDir = await TempDir.create("livesync-cli-decoupled");
const settingsFile = workDir.join("data.json");
const vaultDir = workDir.join("vault");
const dbDir = workDir.join("db");
await Deno.mkdir(join(vaultDir, "test"), { recursive: true });
await Deno.mkdir(dbDir, { recursive: true });
const uri = Deno.env.get("COUCHDB_URI") ?? "http://127.0.0.1:5989/";
const user = Deno.env.get("COUCHDB_USER") ?? "admin";
const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword";
const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `decoupled-${Date.now()}`;
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
if (shouldStartDocker) {
await startCouchdb(uri, user, password, dbname);
}
try {
await initSettingsFile(settingsFile);
if (uri && user && password && dbname) {
console.log("[INFO] applying CouchDB environment variables to settings");
await applyCouchdbSettings(settingsFile, uri, user, password, dbname);
} else {
console.warn("[WARN] CouchDB environment variables are not fully set. Push and pull operations may fail.");
await markSettingsConfigured(settingsFile);
}
const srcFile = workDir.join("push-source.txt");
const pulledFile = workDir.join("pull-result.txt");
const content = `push-pull-decoupled-test ${new Date().toISOString()}\n`;
await Deno.writeTextFile(srcFile, content);
// 1. Test push command with decoupled vault directory
console.log(`[INFO] push with decoupled vault -> ${REMOTE_PATH}`);
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "push", srcFile, REMOTE_PATH);
// 2. Test pull command with decoupled vault directory
console.log(`[INFO] pull with decoupled vault <- ${REMOTE_PATH}`);
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "pull", REMOTE_PATH, pulledFile);
const pulled = await Deno.readTextFile(pulledFile);
assertEquals(pulled, content, "push/pull roundtrip with decoupled vault content mismatch");
console.log("[PASS] push/pull roundtrip with decoupled vault matched");
// 3. Clean up pulled file and vault test directory to verify mirror
await Deno.remove(pulledFile).catch(() => {});
await Deno.remove(join(vaultDir, "test"), { recursive: true }).catch(() => {});
// 4. Test mirror command with decoupled vault directory
console.log("[INFO] mirror with decoupled vault");
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "mirror");
const restoredFile = join(vaultDir, REMOTE_PATH);
const restored = await Deno.readTextFile(restoredFile);
assertEquals(restored, content, "mirror with decoupled vault content mismatch");
console.log("[PASS] mirror with decoupled vault matched");
} finally {
if (shouldStartDocker && !keepDocker) {
await stopCouchdb().catch(() => {});
}
}
});
+4
View File
@@ -39,6 +39,10 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
// isConfigured=true is required for canProceedScan in the mirror command. // isConfigured=true is required for canProceedScan in the mirror command.
await markSettingsConfigured(settingsFile); await markSettingsConfigured(settingsFile);
const data = JSON.parse(await Deno.readTextFile(settingsFile));
data.writeDocumentsIfConflicted = true;
await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
// Copy settings to the DB directory (separated-path mode) // Copy settings to the DB directory (separated-path mode)
const dbSettings = workDir.join("db", "settings.json"); const dbSettings = workDir.join("db", "settings.json");
await Deno.copyFile(settingsFile, dbSettings); await Deno.copyFile(settingsFile, dbSettings);
@@ -2,13 +2,28 @@ import { assert } from "@std/assert";
import { TempDir } from "./helpers/temp.ts"; import { TempDir } from "./helpers/temp.ts";
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts"; import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
import { startCliInBackground } from "./helpers/backgroundCli.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts";
import { discoverPeer, maybeStartLocalRelay, stopLocalRelayIfStarted } from "./helpers/p2p.ts"; import {
discoverPeer,
maybeStartLocalRelay,
stopLocalRelayIfStarted,
maybeStartCoturn,
stopCoturnIfStarted,
} from "./helpers/p2p.ts";
import { getOptimalLoopbackIp } from "./helpers/net.ts";
Deno.test("p2p-peers: discovers host through local relay", async () => { Deno.test("p2p-peers: discovers host through local relay", async () => {
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/"; const loopbackIp = await getOptimalLoopbackIp();
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`;
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`; const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
const passphrase = Deno.env.get("PASSPHRASE") ?? "test"; const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
const timeoutSeconds = Number(Deno.env.get("TIMEOUT_SECONDS") ?? "8"); const timeoutSeconds = Number(Deno.env.get("TIMEOUT_SECONDS") ?? "8");
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`;
const clientPeerName = Deno.env.get("CLIENT_PEER_NAME") ?? `p2p-client-${nonce}`;
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
await using workDir = await TempDir.create("livesync-cli-p2p-peers-local-relay"); await using workDir = await TempDir.create("livesync-cli-p2p-peers-local-relay");
const hostVault = workDir.join("vault-host"); const hostVault = workDir.join("vault-host");
@@ -19,24 +34,43 @@ Deno.test("p2p-peers: discovers host through local relay", async () => {
await Deno.mkdir(clientVault, { recursive: true }); await Deno.mkdir(clientVault, { recursive: true });
const relayStarted = await maybeStartLocalRelay(relay); const relayStarted = await maybeStartLocalRelay(relay);
const coturnStarted = await maybeStartCoturn(turnServers);
try { try {
await initSettingsFile(hostSettings); await initSettingsFile(hostSettings);
await initSettingsFile(clientSettings); await initSettingsFile(clientSettings);
await applyP2pSettings(hostSettings, roomId, passphrase, "self-hosted-livesync-cli-tests", relay); await applyP2pSettings(
await applyP2pSettings(clientSettings, roomId, passphrase, "self-hosted-livesync-cli-tests", relay); hostSettings,
await applyP2pTestTweaks(hostSettings, "p2p-host", passphrase); roomId,
await applyP2pTestTweaks(clientSettings, "p2p-client", passphrase); passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pSettings(
clientSettings,
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
try { try {
await host.waitUntilContains("P2P host is running", 20000); await host.waitUntilContains("P2P host is running", 20000);
const peer = await discoverPeer(clientVault, clientSettings, timeoutSeconds); const peer = await discoverPeer(clientVault, clientSettings, timeoutSeconds, hostPeerName);
assert(peer.id.length > 0); assert(peer.id.length > 0);
assert(peer.name.length > 0); assert(peer.name.length > 0);
assert(peer.name === hostPeerName, `expected peer '${hostPeerName}', got '${peer.name}'`);
} finally { } finally {
await host.stop(); await host.stop();
} }
} finally { } finally {
await stopLocalRelayIfStarted(relayStarted); await stopLocalRelayIfStarted(relayStarted);
await stopCoturnIfStarted(coturnStarted);
} }
}); });
+40 -7
View File
@@ -2,15 +2,30 @@ import { assert } from "@std/assert";
import { TempDir } from "./helpers/temp.ts"; import { TempDir } from "./helpers/temp.ts";
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts"; import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
import { startCliInBackground } from "./helpers/backgroundCli.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts";
import { discoverPeer, maybeStartLocalRelay, stopLocalRelayIfStarted } from "./helpers/p2p.ts"; import {
discoverPeer,
maybeStartLocalRelay,
stopLocalRelayIfStarted,
maybeStartCoturn,
stopCoturnIfStarted,
} from "./helpers/p2p.ts";
import { runCli } from "./helpers/cli.ts"; import { runCli } from "./helpers/cli.ts";
import { getOptimalLoopbackIp } from "./helpers/net.ts";
Deno.test("p2p-sync: discovers peer and completes sync", async () => { Deno.test("p2p-sync: discovers peer and completes sync", async () => {
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/"; const loopbackIp = await getOptimalLoopbackIp();
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`;
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`; const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
const passphrase = Deno.env.get("PASSPHRASE") ?? "test"; const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "12"); const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "12");
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15"); const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15");
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`;
const clientPeerName = Deno.env.get("CLIENT_PEER_NAME") ?? `p2p-client-${nonce}`;
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
await using workDir = await TempDir.create("livesync-cli-p2p-sync"); await using workDir = await TempDir.create("livesync-cli-p2p-sync");
const hostVault = workDir.join("vault-host"); const hostVault = workDir.join("vault-host");
@@ -21,13 +36,30 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
await Deno.mkdir(clientVault, { recursive: true }); await Deno.mkdir(clientVault, { recursive: true });
const relayStarted = await maybeStartLocalRelay(relay); const relayStarted = await maybeStartLocalRelay(relay);
const coturnStarted = await maybeStartCoturn(turnServers);
try { try {
await initSettingsFile(hostSettings); await initSettingsFile(hostSettings);
await initSettingsFile(clientSettings); await initSettingsFile(clientSettings);
await applyP2pSettings(hostSettings, roomId, passphrase, "self-hosted-livesync-cli-tests", relay); await applyP2pSettings(
await applyP2pSettings(clientSettings, roomId, passphrase, "self-hosted-livesync-cli-tests", relay); hostSettings,
await applyP2pTestTweaks(hostSettings, "p2p-host", passphrase); roomId,
await applyP2pTestTweaks(clientSettings, "p2p-client", passphrase); passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pSettings(
clientSettings,
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
try { try {
@@ -36,7 +68,7 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
clientVault, clientVault,
clientSettings, clientSettings,
peersTimeout, peersTimeout,
Deno.env.get("TARGET_PEER") ?? undefined Deno.env.get("TARGET_PEER") ?? hostPeerName
); );
const syncResult = await runCli( const syncResult = await runCli(
clientVault, clientVault,
@@ -55,5 +87,6 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
} }
} finally { } finally {
await stopLocalRelayIfStarted(relayStarted); await stopLocalRelayIfStarted(relayStarted);
await stopCoturnIfStarted(coturnStarted);
} }
}); });
@@ -1,17 +1,33 @@
import { assert } from "@std/assert"; import { assert } from "@std/assert";
import { TempDir } from "./helpers/temp.ts"; import { TempDir } from "./helpers/temp.ts";
import { applyP2pSettings, initSettingsFile } from "./helpers/settings.ts"; import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
import { startCliInBackground } from "./helpers/backgroundCli.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts";
import { discoverPeer, maybeStartLocalRelay, stopLocalRelayIfStarted } from "./helpers/p2p.ts"; import {
discoverPeer,
maybeStartLocalRelay,
stopLocalRelayIfStarted,
maybeStartCoturn,
stopCoturnIfStarted,
} from "./helpers/p2p.ts";
import { jsonStringField, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts"; import { jsonStringField, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
import { getOptimalLoopbackIp } from "./helpers/net.ts";
Deno.test("p2p: three nodes detect and resolve conflicts", async () => { Deno.test("p2p: three nodes detect and resolve conflicts", async () => {
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/"; const loopbackIp = await getOptimalLoopbackIp();
const roomId = `${Deno.env.get("ROOM_ID_PREFIX") ?? "p2p-room"}-${Date.now()}`; const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
const passphrase = `${Deno.env.get("PASSPHRASE_PREFIX") ?? "p2p-pass"}-${Date.now()}`;
const appId = Deno.env.get("APP_ID") ?? "self-hosted-livesync-cli-tests"; const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`;
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
const appId = "self-hosted-livesync-cli-tests";
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "10"); const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "10");
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15"); const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15");
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`;
const peerNameB = Deno.env.get("PEER_NAME_B") ?? `p2p-client-b-${nonce}`;
const peerNameC = Deno.env.get("PEER_NAME_C") ?? `p2p-client-c-${nonce}`;
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
await using workDir = await TempDir.create("livesync-cli-p2p-3nodes"); await using workDir = await TempDir.create("livesync-cli-p2p-3nodes");
const vaultA = workDir.join("vault-a"); const vaultA = workDir.join("vault-a");
@@ -25,17 +41,23 @@ Deno.test("p2p: three nodes detect and resolve conflicts", async () => {
await Deno.mkdir(vaultC, { recursive: true }); await Deno.mkdir(vaultC, { recursive: true });
const relayStarted = await maybeStartLocalRelay(relay); const relayStarted = await maybeStartLocalRelay(relay);
const coturnStarted = await maybeStartCoturn(turnServers);
try { try {
for (const settings of [settingsA, settingsB, settingsC]) { await initSettingsFile(settingsA);
await initSettingsFile(settings); await initSettingsFile(settingsB);
await applyP2pSettings(settings, roomId, passphrase, appId, relay); await initSettingsFile(settingsC);
} await applyP2pSettings(settingsA, roomId, passphrase, appId, relay, "~.*", turnServers);
await applyP2pSettings(settingsB, roomId, passphrase, appId, relay, "~.*", turnServers);
await applyP2pSettings(settingsC, roomId, passphrase, appId, relay, "~.*", turnServers);
await applyP2pTestTweaks(settingsA, hostPeerName, passphrase);
await applyP2pTestTweaks(settingsB, peerNameB, passphrase);
await applyP2pTestTweaks(settingsC, peerNameC, passphrase);
const host = startCliInBackground(vaultA, "--settings", settingsA, "p2p-host"); const host = startCliInBackground(vaultA, "--settings", settingsA, "p2p-host");
try { try {
await host.waitUntilContains("P2P host is running", 20000); await host.waitUntilContains("P2P host is running", 20000);
const peerFromB = await discoverPeer(vaultB, settingsB, peersTimeout); const peerFromB = await discoverPeer(vaultB, settingsB, peersTimeout, hostPeerName);
const peerFromC = await discoverPeer(vaultC, settingsC, peersTimeout); const peerFromC = await discoverPeer(vaultC, settingsC, peersTimeout, hostPeerName);
const targetPath = "p2p/conflicted-from-two-clients.txt"; const targetPath = "p2p/conflicted-from-two-clients.txt";
await runCliWithInputOrFail("from-client-b-v1\n", vaultB, "--settings", settingsB, "put", targetPath); await runCliWithInputOrFail("from-client-b-v1\n", vaultB, "--settings", settingsB, "put", targetPath);
@@ -114,5 +136,6 @@ Deno.test("p2p: three nodes detect and resolve conflicts", async () => {
} }
} finally { } finally {
await stopLocalRelayIfStarted(relayStarted); await stopLocalRelayIfStarted(relayStarted);
await stopCoturnIfStarted(coturnStarted);
} }
}); });
@@ -0,0 +1,126 @@
/**
* Deno port of test-remote-commands-linux.sh
*
* Tests remote management commands: remote-status, lock-remote, unlock-remote,
* and mark-resolved.
*
* Scenario:
* 1. Start CouchDB, create a test database, and perform an initial sync.
* 2. Run remote-status and assert that the output contains the database name in JSON format.
* 3. Run lock-remote and verify that the remote database is locked.
* 4. Lock the remote database milestone manually, verify status, and run unlock-remote.
* Assert that the output of unlock-remote contains the unlocked verification status.
* 5. Lock the remote database milestone manually, run mark-resolved, and verify that the
* current device is accepted.
*
* Run:
* deno test -A test-remote-commands.ts
*/
import { join } from "@std/path";
import { TempDir } from "./helpers/temp.ts";
import { runCli, assertContains } from "./helpers/cli.ts";
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
import { startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts";
async function runCliCombinedOrFail(...args: string[]): Promise<string> {
const res = await runCli(...args);
if (res.code !== 0) {
throw new Error(`CLI exited with code ${res.code}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
}
return res.combined;
}
Deno.test("remote management commands", async () => {
await using workDir = await TempDir.create("livesync-cli-remote-cmds");
const settingsFile = workDir.join("settings.json");
const vaultDir = workDir.join("vault");
await Deno.mkdir(vaultDir, { recursive: true });
const uri = Deno.env.get("COUCHDB_URI") ?? "http://127.0.0.1:5989/";
const user = Deno.env.get("COUCHDB_USER") ?? "admin";
const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword";
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `remotes-${dbSuffix}`;
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
if (shouldStartDocker) {
await startCouchdb(uri, user, password, dbname);
}
try {
await initSettingsFile(settingsFile);
await applyCouchdbSettings(settingsFile, uri, user, password, dbname, true);
console.log("[INFO] Performing initial sync to create milestone document...");
await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "sync");
// 1. remote-status outputs valid JSON with CouchDB details
console.log("[CASE] remote-status outputs valid JSON with CouchDB details");
const statusOutput = await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "remote-status");
assertContains(statusOutput, `"db_name": "${dbname}"`, "remote-status should return JSON containing db_name");
console.log("[PASS] remote-status verified");
// 2. lock-remote locks and verifies state
console.log("[CASE] lock-remote locks and verifies state");
const lockOutput = await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "lock-remote");
assertContains(
lockOutput,
"[Verification] Remote Database: LOCKED",
"lock-remote output should show that the remote database is locked"
);
console.log("[PASS] lock-remote verified");
// 3. unlock-remote unlocks and verifies state
console.log("[CASE] unlock-remote unlocks and verifies state");
// Manually lock milestone
console.log("[INFO] Manually locking milestone...");
await updateCouchdbDoc(uri, user, password, `${dbname}/_local/obsydian_livesync_milestone`, (doc) => {
doc.locked = true;
doc.accepted_nodes = [];
return doc;
});
// Run unlock-remote and verify output contains verification message
const unlockOutput = await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "unlock-remote");
assertContains(
unlockOutput,
"[Verification] Remote Database: UNLOCKED",
"unlock-remote output should contain verification status"
);
console.log("[PASS] unlock-remote verified");
// 4. mark-resolved resolves and verifies state
console.log("[CASE] mark-resolved resolves and verifies state");
// Manually lock milestone
console.log("[INFO] Manually locking milestone...");
await updateCouchdbDoc(uri, user, password, `${dbname}/_local/obsydian_livesync_milestone`, (doc) => {
doc.locked = true;
doc.accepted_nodes = [];
return doc;
});
// Run mark-resolved and verify output contains verification messages
const resolvedOutput = await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "mark-resolved");
assertContains(
resolvedOutput,
"[Verification] Remote Database: LOCKED",
"mark-resolved output should show that the remote database remains locked"
);
assertContains(
resolvedOutput,
"ACCEPTED",
"mark-resolved output should show that the current device node is accepted"
);
console.log("[PASS] mark-resolved verified");
console.log("[ALL PASS] All remote CLI commands verified successfully");
} finally {
if (shouldStartDocker && !keepDocker) {
await stopCouchdb().catch(() => {});
}
}
});
+23 -1
View File
@@ -39,6 +39,9 @@ src/apps/cli/testdeno/
test-mirror.ts test-mirror.ts
test-sync-two-local-databases.ts test-sync-two-local-databases.ts
test-sync-locked-remote.ts test-sync-locked-remote.ts
test-daemon.ts
test-decoupled-vault.ts
test-remote-commands.ts
``` ```
--- ---
@@ -54,6 +57,9 @@ Main tasks:
- `deno task test` - `deno task test`
- `deno task test:local` - `deno task test:local`
- `deno task test:daemon`
- `deno task test:decoupled-vault`
- `deno task test:remote-commands`
- `deno task test:push-pull` - `deno task test:push-pull`
- `deno task test:setup-put-cat` - `deno task test:setup-put-cat`
- `deno task test:mirror` - `deno task test:mirror`
@@ -183,6 +189,19 @@ Both CouchDB and P2P relay flows are bash-independent.
- `MINIO-enc0` - `MINIO-enc0`
- `MINIO-enc1` - `MINIO-enc1`
### `test-daemon.ts`
- Verifies daemon-related ignore rules behaviour.
- Exercises scenarios with `.livesync/ignore` wildcard rules, missing ignore rules, and imported `.gitignore` rules.
### `test-decoupled-vault.ts`
- Verifies push, pull, and mirror command behaviour when the vault directory is decoupled from the database directory.
### `test-remote-commands.ts`
- Verifies remote database management commands: `remote-status`, `lock-remote`, `unlock-remote`, and `mark-resolved`.
--- ---
## Running tests (PowerShell) ## Running tests (PowerShell)
@@ -198,11 +217,14 @@ deno task test:local
# Individual tests # Individual tests
deno task test:setup-put-cat deno task test:setup-put-cat
deno task test:mirror deno task test:mirror
deno task test:daemon
deno task test:push-pull deno task test:push-pull
deno task test:sync-locked-remote deno task test:sync-locked-remote
# CouchDB-based tests # CouchDB-based tests
deno task test:sync-two-local deno task test:sync-two-local
deno task test:decoupled-vault
deno task test:remote-commands
deno task test:e2e-couchdb deno task test:e2e-couchdb
# P2P-based tests # P2P-based tests
@@ -281,7 +303,7 @@ deno task test:sync-two-local
## Continuous Integration ## Continuous Integration
The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` is used to run these tests automatically on push and pull requests affecting the CLI. The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` runs automatically on pushes and pull requests affecting the CLI, executing the non-P2P test suite (`test:ci`). P2P tests (`test:p2p`) are excluded from automatic execution and must be run via manual dispatch (`workflow_dispatch`). You can optionally check the "Enable verbose and debug logging" checkbox during a manual dispatch to produce detailed trace logs for troubleshooting.
--- ---
+2 -2
View File
@@ -2,7 +2,7 @@ import { deleteDB, type IDBPDatabase, openDB } from "idb";
import type { KeyValueDatabase } from "../lib/src/interfaces/KeyValueDatabase.ts"; import type { KeyValueDatabase } from "../lib/src/interfaces/KeyValueDatabase.ts";
import { serialized } from "octagonal-wheels/concurrency/lock"; import { serialized } from "octagonal-wheels/concurrency/lock";
import { Logger } from "octagonal-wheels/common/logger"; import { Logger } from "octagonal-wheels/common/logger";
const databaseCache: { [key: string]: IDBPDatabase<any> } = {}; const databaseCache: { [key: string]: IDBPDatabase<unknown> } = {};
export { OpenKeyValueDatabase } from "./KeyValueDBv2.ts"; export { OpenKeyValueDatabase } from "./KeyValueDBv2.ts";
export const _OpenKeyValueDatabase = async (dbKey: string): Promise<KeyValueDatabase> => { export const _OpenKeyValueDatabase = async (dbKey: string): Promise<KeyValueDatabase> => {
@@ -11,7 +11,7 @@ export const _OpenKeyValueDatabase = async (dbKey: string): Promise<KeyValueData
delete databaseCache[dbKey]; delete databaseCache[dbKey];
} }
const storeKey = dbKey; const storeKey = dbKey;
let db: IDBPDatabase<any> | null = null; let db: IDBPDatabase<unknown> | null = null;
const _openDB = () => { const _openDB = () => {
return serialized("keyvaluedb-" + dbKey, async () => { return serialized("keyvaluedb-" + dbKey, async () => {
const dbInstance = await openDB(dbKey, 1, { const dbInstance = await openDB(dbKey, 1, {
+2 -2
View File
@@ -28,7 +28,7 @@ export async function OpenKeyValueDatabase(dbKey: string): Promise<KeyValueDatab
} }
export class IDBKeyValueDatabase implements KeyValueDatabase { export class IDBKeyValueDatabase implements KeyValueDatabase {
protected _dbPromise: Promise<IDBPDatabase<any>> | null = null; protected _dbPromise: Promise<IDBPDatabase<unknown>> | null = null;
protected dbKey: string; protected dbKey: string;
protected storeKey: string; protected storeKey: string;
protected _isDestroyed: boolean = false; protected _isDestroyed: boolean = false;
@@ -104,7 +104,7 @@ export class IDBKeyValueDatabase implements KeyValueDatabase {
this.destroyedPromise = Promise.resolve(); this.destroyedPromise = Promise.resolve();
} }
} }
get DB(): Promise<IDBPDatabase<any>> { get DB(): Promise<IDBPDatabase<unknown>> {
if (this._isDestroyed) { if (this._isDestroyed) {
return Promise.reject(new Error("Database is destroyed")); return Promise.reject(new Error("Database is destroyed"));
} }
+2 -2
View File
@@ -4,10 +4,10 @@ import { eventHub, EVENT_PLUGIN_UNLOADED } from "./events";
import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
type PeriodicProcessorHost = NecessaryServices<"API" | "control", never>; type PeriodicProcessorHost = NecessaryServices<"API" | "control", never>;
export class PeriodicProcessor { export class PeriodicProcessor {
_process: () => Promise<any>; _process: () => Promise<unknown>;
_timer?: number = undefined; _timer?: number = undefined;
_core: PeriodicProcessorHost; _core: PeriodicProcessorHost;
constructor(core: PeriodicProcessorHost, process: () => Promise<any>) { constructor(core: PeriodicProcessorHost, process: () => Promise<unknown>) {
// this._plugin = plugin; // this._plugin = plugin;
this._core = core; this._core = core;
this._process = process; this._process = process;
+5 -6
View File
@@ -9,16 +9,15 @@ import { isCloudantURI } from "@lib/pouchdb/utils_couchdb";
import { compatGlobal } from "@lib/common/coreEnvFunctions"; import { compatGlobal } from "@lib/common/coreEnvFunctions";
import { manifestVersion, packageVersion } from "@lib/common/coreEnvVars"; import { manifestVersion, packageVersion } from "@lib/common/coreEnvVars";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
function redactObject(obj: Record<string, any>, dotted: string, redactedValue = "REDACTED") { function redactObject(obj: Record<string, unknown>, dotted: string, redactedValue = "REDACTED") {
const keys = dotted.split("."); const keys = dotted.split(".");
let current = obj; let current = obj;
for (let i = 0; i < keys.length - 1; i++) { for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]; const key = keys[i];
if (!(key in current)) { if (!(key in current)) {
current[key] = {} as Record<string, any>; current[key] = {};
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment current = current[key] as Record<string, unknown>;
current = current[key];
} }
const lastKey = keys[keys.length - 1]; const lastKey = keys[keys.length - 1];
if (lastKey in current) { if (lastKey in current) {
@@ -27,7 +26,7 @@ function redactObject(obj: Record<string, any>, dotted: string, redactedValue =
return obj; return obj;
} }
export async function generateReport(settings: ObsidianLiveSyncSettings, core: LiveSyncBaseCore) { export async function generateReport(settings: ObsidianLiveSyncSettings, core: LiveSyncBaseCore) {
let responseConfig: Record<string, any> = {}; let responseConfig: Record<string, unknown> = {};
const REDACTED = "𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷"; const REDACTED = "𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷";
if (settings.remoteType == REMOTE_COUCHDB) { if (settings.remoteType == REMOTE_COUCHDB) {
try { try {
@@ -42,7 +41,7 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
undefined, undefined,
customHeaders customHeaders
); );
responseConfig = r.json as Record<string, any>; responseConfig = r.json as Record<string, unknown>;
redactObject(responseConfig, "couch_httpd_auth.secret"); redactObject(responseConfig, "couch_httpd_auth.secret");
redactObject(responseConfig, "couch_httpd_auth.authentication_db"); redactObject(responseConfig, "couch_httpd_auth.authentication_db");
redactObject(responseConfig, "couch_httpd_auth.authentication_redirect"); redactObject(responseConfig, "couch_httpd_auth.authentication_redirect");
+14 -14
View File
@@ -72,7 +72,7 @@ import {
} from "@lib/common/typeUtils.ts"; } from "@lib/common/typeUtils.ts";
export { isInternalFile, getPathFromUXFileInfo, getStoragePathFromUXFileInfo, getDatabasePathFromUXFileInfo }; export { isInternalFile, getPathFromUXFileInfo, getStoragePathFromUXFileInfo, getDatabasePathFromUXFileInfo };
const memos: { [key: string]: any } = {}; const memos: Record<string, unknown> = {};
export function memoObject<T>(key: string, obj: T): T { export function memoObject<T>(key: string, obj: T): T {
memos[key] = obj; memos[key] = obj;
return memos[key] as T; return memos[key] as T;
@@ -87,7 +87,7 @@ export async function memoIfNotExist<T>(key: string, func: () => T | Promise<T>)
} }
export function retrieveMemoObject<T>(key: string): T | false { export function retrieveMemoObject<T>(key: string): T | false {
if (key in memos) { if (key in memos) {
return memos[key]; return memos[key] as T;
} else { } else {
return false; return false;
} }
@@ -128,7 +128,7 @@ export const _requestToCouchDBFetch = async (
username: string, username: string,
password: string, password: string,
path?: string, path?: string,
body?: any, body?: unknown,
method?: string method?: string
) => { ) => {
const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]); const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]);
@@ -154,7 +154,7 @@ export const _requestToCouchDB = async (
credentials: CouchDBCredentials, credentials: CouchDBCredentials,
origin: string, origin: string,
path?: string, path?: string,
body?: any, body?: unknown,
method?: string, method?: string,
customHeaders?: Record<string, string> customHeaders?: Record<string, string>
) => { ) => {
@@ -263,27 +263,27 @@ export function compareFileFreshness(
const _cached = new Map< const _cached = new Map<
string, string,
{ {
value: any; value: unknown;
context: Map<string, any>; context: Map<string, unknown>;
} }
>(); >();
export type MemoOption = { export type MemoOption = {
key: string; key: string;
forceUpdate?: boolean; forceUpdate?: boolean;
validator?: (context: Map<string, any>) => boolean; validator?: (context: Map<string, unknown>) => boolean;
}; };
export function useMemo<T>( export function useMemo<T>(
{ key, forceUpdate, validator }: MemoOption, { key, forceUpdate, validator }: MemoOption,
updateFunc: (context: Map<string, any>, prev: T) => T updateFunc: (context: Map<string, unknown>, prev: T) => T
): T { ): T {
const cached = _cached.get(key); const cached = _cached.get(key);
const context = cached?.context || new Map<string, any>(); const context = cached?.context || new Map<string, unknown>();
if (cached && !forceUpdate && (!validator || (validator && !validator(context)))) { if (cached && !forceUpdate && (!validator || (validator && !validator(context)))) {
return cached.value; return cached.value as T;
} }
const value = updateFunc(context, cached?.value); const value = updateFunc(context, cached?.value as T);
if (value !== cached?.value) { if (value !== cached?.value) {
_cached.set(key, { value, context }); _cached.set(key, { value, context });
} }
@@ -294,7 +294,7 @@ export function useMemo<T>(
const _staticObj = new Map< const _staticObj = new Map<
string, string,
{ {
value: any; value: unknown;
} }
>(); >();
@@ -306,7 +306,7 @@ export function useStatic<T>(key: string, initial?: T) {
// } // }
const obj = _staticObj.get(key); const obj = _staticObj.get(key);
if (obj !== undefined) { if (obj !== undefined) {
return obj; return obj as { value: T };
} else { } else {
// let buf = initial; // let buf = initial;
const obj = { const obj = {
@@ -390,7 +390,7 @@ export async function autosaveCache<K, V>(db: KeyValueDatabase, mapKey: string):
}; };
} }
export function onlyInNTimes(n: number, proc: (progress: number) => any) { export function onlyInNTimes(n: number, proc: (progress: number) => unknown) {
let counter = 0; let counter = 0;
return function () { return function () {
if (counter++ % n == 0) { if (counter++ % n == 0) {
+12 -8
View File
@@ -1100,10 +1100,16 @@ export class ConfigSync extends LiveSyncCommands {
await delay(100); await delay(100);
this._log(`Config ${data.displayName || data.name} has been applied`, LOG_LEVEL_NOTICE); this._log(`Config ${data.displayName || data.name} has been applied`, LOG_LEVEL_NOTICE);
if (data.category == "PLUGIN_DATA" || data.category == "PLUGIN_MAIN") { if (data.category == "PLUGIN_DATA" || data.category == "PLUGIN_MAIN") {
//@ts-ignore const appWithPlugins = this.app as unknown as {
const manifests = Object.values(this.app.plugins.manifests) as any as PluginManifest[]; plugins: {
//@ts-ignore manifests: Record<string, PluginManifest>;
const enabledPlugins = this.app.plugins.enabledPlugins as Set<string>; enabledPlugins: Set<string>;
unloadPlugin(id: string): Promise<void>;
loadPlugin(id: string): Promise<void>;
};
};
const manifests = Object.values(appWithPlugins.plugins.manifests);
const enabledPlugins = appWithPlugins.plugins.enabledPlugins;
const pluginManifest = manifests.find( const pluginManifest = manifests.find(
(manifest) => enabledPlugins.has(manifest.id) && manifest.dir == `${baseDir}/plugins/${data.name}` (manifest) => enabledPlugins.has(manifest.id) && manifest.dir == `${baseDir}/plugins/${data.name}`
); );
@@ -1113,10 +1119,8 @@ export class ConfigSync extends LiveSyncCommands {
LOG_LEVEL_NOTICE, LOG_LEVEL_NOTICE,
"plugin-reload-" + pluginManifest.id "plugin-reload-" + pluginManifest.id
); );
// @ts-ignore await appWithPlugins.plugins.unloadPlugin(pluginManifest.id);
await this.app.plugins.unloadPlugin(pluginManifest.id); await appWithPlugins.plugins.loadPlugin(pluginManifest.id);
// @ts-ignore
await this.app.plugins.loadPlugin(pluginManifest.id);
this._log( this._log(
`Plugin reloaded: ${pluginManifest.name}`, `Plugin reloaded: ${pluginManifest.name}`,
LOG_LEVEL_NOTICE, LOG_LEVEL_NOTICE,
@@ -1224,10 +1224,16 @@ Offline Changed files: ${files.length}`;
const updatedFolders = [...this.queuedNotificationFiles]; const updatedFolders = [...this.queuedNotificationFiles];
this.queuedNotificationFiles.clear(); this.queuedNotificationFiles.clear();
try { try {
//@ts-ignore const appWithPlugins = this.app as unknown as {
const manifests = Object.values(this.app.plugins.manifests) as any as PluginManifest[]; plugins: {
//@ts-ignore manifests: Record<string, PluginManifest>;
const enabledPlugins = this.app.plugins.enabledPlugins as Set<string>; enabledPlugins: Set<string>;
unloadPlugin(id: string): Promise<void>;
loadPlugin(id: string): Promise<void>;
};
};
const manifests = Object.values(appWithPlugins.plugins.manifests);
const enabledPlugins = appWithPlugins.plugins.enabledPlugins;
const enabledPluginManifests = manifests.filter((e) => enabledPlugins.has(e.id)); const enabledPluginManifests = manifests.filter((e) => enabledPlugins.has(e.id));
const modifiedManifests = enabledPluginManifests.filter((e) => updatedFolders.indexOf(e?.dir ?? "") >= 0); const modifiedManifests = enabledPluginManifests.filter((e) => updatedFolders.indexOf(e?.dir ?? "") >= 0);
for (const manifest of modifiedManifests) { for (const manifest of modifiedManifests) {
@@ -1246,10 +1252,8 @@ Offline Changed files: ${files.length}`;
LOG_LEVEL_NOTICE, LOG_LEVEL_NOTICE,
"plugin-reload-" + updatePluginId "plugin-reload-" + updatePluginId
); );
// @ts-ignore await appWithPlugins.plugins.unloadPlugin(updatePluginId);
await this.app.plugins.unloadPlugin(updatePluginId); await appWithPlugins.plugins.loadPlugin(updatePluginId);
// @ts-ignore
await this.app.plugins.loadPlugin(updatePluginId);
this._log( this._log(
`Plugin reloaded: ${updatePluginName}`, `Plugin reloaded: ${updatePluginName}`,
LOG_LEVEL_NOTICE, LOG_LEVEL_NOTICE,
+8 -8
View File
@@ -67,25 +67,25 @@ export abstract class LiveSyncCommands {
_log: ReturnType<typeof createInstanceLogFunction>; _log: ReturnType<typeof createInstanceLogFunction>;
_verbose = (msg: any, key?: string) => { _verbose = (msg: unknown, key?: string) => {
this._log(msg, LOG_LEVEL_VERBOSE, key); this._log(msg, LOG_LEVEL_VERBOSE, key);
}; };
_info = (msg: any, key?: string) => { _info = (msg: unknown, key?: string) => {
this._log(msg, LOG_LEVEL_INFO, key); this._log(msg, LOG_LEVEL_INFO, key);
}; };
_notice = (msg: any, key?: string) => { _notice = (msg: unknown, key?: string) => {
this._log(msg, LOG_LEVEL_NOTICE, key); this._log(msg, LOG_LEVEL_NOTICE, key);
}; };
_progress = (prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE) => { _progress = (prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE) => {
const key = `keepalive-progress-${noticeIndex++}`; const key = `keepalive-progress-${noticeIndex++}`;
return { return {
log: (msg: any) => { log: (msg: unknown) => {
this._log(prefix + msg, level, key); this._log(prefix + String(msg), level, key);
}, },
once: (msg: any) => { once: (msg: unknown) => {
this._log(prefix + msg, level); this._log(prefix + String(msg), level);
}, },
done: (msg: string = "Done") => { done: (msg: string = "Done") => {
this._log(prefix + msg + MARK_DONE, level, key); this._log(prefix + msg + MARK_DONE, level, key);
@@ -93,7 +93,7 @@ export abstract class LiveSyncCommands {
}; };
}; };
_debug = (msg: any, key?: string) => { _debug = (msg: unknown, key?: string) => {
this._log(msg, LOG_LEVEL_VERBOSE, key); this._log(msg, LOG_LEVEL_VERBOSE, key);
}; };
@@ -17,6 +17,7 @@ import { arrayToChunkedArray } from "octagonal-wheels/collection";
import { EVENT_ANALYSE_DB_USAGE, EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events"; import { EVENT_ANALYSE_DB_USAGE, EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events";
import type { LiveSyncCouchDBReplicator } from "@/lib/src/replication/couchdb/LiveSyncReplicator"; import type { LiveSyncCouchDBReplicator } from "@/lib/src/replication/couchdb/LiveSyncReplicator";
import { delay } from "@/lib/src/common/utils"; import { delay } from "@/lib/src/common/utils";
import { isNotFoundError } from "@lib/common/utils.doc.ts";
// import { _requestToCouchDB } from "@/common/utils"; // import { _requestToCouchDB } from "@/common/utils";
const DB_KEY_SEQ = "gc-seq"; const DB_KEY_SEQ = "gc-seq";
const DB_KEY_CHUNK_SET = "chunk-set"; const DB_KEY_CHUNK_SET = "chunk-set";
@@ -393,8 +394,8 @@ Note: **Make sure to synchronise all devices before deletion.**
await processDoc(oldDoc, false); await processDoc(oldDoc, false);
} }
} }
} catch (ex) { } catch (ex: unknown) {
if ((ex as any)?.status == 404) { if (ex && typeof ex === "object" && isNotFoundError(ex)) {
this._log(`No revisions found for ${doc._id}`, LOG_LEVEL_VERBOSE); this._log(`No revisions found for ${doc._id}`, LOG_LEVEL_VERBOSE);
} else { } else {
this._log(`Error finding revisions for ${doc._id}`); this._log(`Error finding revisions for ${doc._id}`);
@@ -473,15 +474,23 @@ Are you ready to delete unused chunks?`;
keys: [...unusedSet], keys: [...unusedSet],
include_docs: true, include_docs: true,
}); });
interface PouchDBRow {
id: string;
key: string;
value: { rev: string; deleted?: boolean };
doc?: EntryDoc;
}
for (const chunk of deleteChunks.rows) { for (const chunk of deleteChunks.rows) {
if ((chunk as any)?.value?.deleted) { const c = chunk as unknown as PouchDBRow;
chunkSet.delete(chunk.key as DocumentID); if (c.value?.deleted) {
chunkSet.delete(c.key as DocumentID);
} }
} }
const deleteDocs = deleteChunks.rows const deleteDocs = deleteChunks.rows
.filter((e) => "doc" in e) .map((e) => e as unknown as PouchDBRow)
.filter((e) => e.doc != null)
.map((e) => ({ .map((e) => ({
...(e as any).doc!, ...e.doc!,
_deleted: true, _deleted: true,
})); }));
@@ -625,8 +634,19 @@ Success: ${successCount}, Errored: ${errored}`;
} }
} }
} }
interface DatabaseAnalysisResultItem {
title: string;
path: string;
rev: string;
revHash: string;
id?: string;
uniqueChunkCount: number;
sharedChunkCount: number;
uniqueChunkSize: number;
sharedChunkSize: number;
}
// Prepare results // Prepare results
const result = []; const result: DatabaseAnalysisResultItem[] = [];
// Calculate total size of chunks in the given set. // Calculate total size of chunks in the given set.
const getTotalSize = (ids: Set<DocumentID>) => { const getTotalSize = (ids: Set<DocumentID>) => {
return [...ids].reduce((acc, chunkId) => { return [...ids].reduce((acc, chunkId) => {
@@ -698,7 +718,7 @@ Success: ${successCount}, Errored: ${errored}`;
sharedChunkCount: 0, sharedChunkCount: 0,
uniqueChunkSize: orphanChunkSize, uniqueChunkSize: orphanChunkSize,
sharedChunkSize: 0, sharedChunkSize: 0,
} as any); });
const csvSrc = result.map((e) => { const csvSrc = result.map((e) => {
return [ return [
+1 -1
Submodule src/lib updated: 2eb8938ed5...3cae3909e1
@@ -20,7 +20,7 @@ import { InternalFileToUXFileInfoStub, TFileToUXFileInfoStub } from "@/modules/c
* Obsidian-specific type guard adapter * Obsidian-specific type guard adapter
*/ */
class ObsidianTypeGuardAdapter implements IStorageEventTypeGuardAdapter<TFile, TFolder> { class ObsidianTypeGuardAdapter implements IStorageEventTypeGuardAdapter<TFile, TFolder> {
isFile(file: any): file is TFile { isFile(file: unknown): file is TFile {
if (file instanceof TFile) { if (file instanceof TFile) {
return true; return true;
} }
@@ -30,7 +30,7 @@ class ObsidianTypeGuardAdapter implements IStorageEventTypeGuardAdapter<TFile, T
return false; return false;
} }
isFolder(item: any): item is TFolder { isFolder(item: unknown): item is TFolder {
if (item instanceof TFolder) { if (item instanceof TFolder) {
return true; return true;
} }
+7 -6
View File
@@ -61,18 +61,19 @@ export abstract class AbstractModule<
return this.testDone(false); return this.testDone(false);
} }
async _test(key: string, process: () => Promise<any>) { async _test(key: string, process: () => Promise<unknown>) {
this._log(`Testing ${key}`, LOG_LEVEL_VERBOSE); this._log(`Testing ${key}`, LOG_LEVEL_VERBOSE);
try { try {
const ret = await process(); const ret = await process();
if (ret !== true) { if (ret !== true) {
this.addTestResult(key, false, ret.toString()); this.addTestResult(key, false, String(ret));
return this.testFail(`${key} failed: ${ret}`); return this.testFail(`${key} failed: ${String(ret)}`);
} }
this.addTestResult(key, true, ""); this.addTestResult(key, true, "");
} catch (ex: any) { } catch (ex) {
this.addTestResult(key, false, "Failed by Exception", ex.toString()); const exStr = String(ex);
return this.testFail(`${key} failed: ${ex}`); this.addTestResult(key, false, "Failed by Exception", exStr);
return this.testFail(`${key} failed: ${exStr}`);
} }
return this.testDone(); return this.testDone();
} }
+9 -4
View File
@@ -7,7 +7,8 @@ export type OverridableFunctionsKeys<T> = {
export type ChainableExecuteFunction<T> = { export type ChainableExecuteFunction<T> = {
[K in keyof T as K extends `$${string}` [K in keyof T as K extends `$${string}`
? T[K] extends (...args: any) => ChainableFunctionResult ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
T[K] extends (...args: any) => ChainableFunctionResult
? K ? K
: never : never
: never]: T[K]; : never]: T[K];
@@ -26,26 +27,30 @@ export type ChainableFunctionResultOrAll = Promise<boolean | undefined | string
type AllExecuteFunction<T> = { type AllExecuteFunction<T> = {
[K in keyof T as K extends `$all${string}` [K in keyof T as K extends `$all${string}`
? T[K] extends (...args: any[]) => ChainableFunctionResultOrAll ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
T[K] extends (...args: any[]) => ChainableFunctionResultOrAll
? K ? K
: never : never
: never]: T[K]; : never]: T[K];
}; };
type EveryExecuteFunction<T> = { type EveryExecuteFunction<T> = {
[K in keyof T as K extends `$every${string}` [K in keyof T as K extends `$every${string}`
? T[K] extends (...args: any[]) => ChainableFunctionResult ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
T[K] extends (...args: any[]) => ChainableFunctionResult
? K ? K
: never : never
: never]: T[K]; : never]: T[K];
}; };
type AnyExecuteFunction<T> = { type AnyExecuteFunction<T> = {
[K in keyof T as K extends `$any${string}` [K in keyof T as K extends `$any${string}`
? T[K] extends (...args: any[]) => ChainableFunctionResult ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
T[K] extends (...args: any[]) => ChainableFunctionResult
? K ? K
: never : never
: never]: T[K]; : never]: T[K];
}; };
type InjectableFunction<T> = { type InjectableFunction<T> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K in keyof T as K extends `$$${string}` ? (T[K] extends (...args: any[]) => any ? K : never) : never]: T[K]; [K in keyof T as K extends `$$${string}` ? (T[K] extends (...args: any[]) => any ? K : never) : never]: T[K];
}; };
export type AllExecuteProps = AllExecuteFunction<LiveSyncCore>; export type AllExecuteProps = AllExecuteFunction<LiveSyncCore>;
+2 -2
View File
@@ -21,7 +21,7 @@ import { MARK_LOG_NETWORK_ERROR } from "@lib/services/lib/logUtils";
function isOnlineAndCanReplicate( function isOnlineAndCanReplicate(
errorManager: UnresolvedErrorManager, errorManager: UnresolvedErrorManager,
host: NecessaryServices<"API", any>, host: NecessaryServices<"API", never>,
showMessage: boolean showMessage: boolean
): Promise<boolean> { ): Promise<boolean> {
const errorMessage = "Network is offline"; const errorMessage = "Network is offline";
@@ -34,7 +34,7 @@ function isOnlineAndCanReplicate(
} }
async function canReplicateWithPBKDF2( async function canReplicateWithPBKDF2(
errorManager: UnresolvedErrorManager, errorManager: UnresolvedErrorManager,
host: NecessaryServices<"replicator" | "setting", any>, host: NecessaryServices<"replicator" | "setting", never>,
showMessage: boolean showMessage: boolean
): Promise<boolean> { ): Promise<boolean> {
const currentSettings = host.services.setting.currentSettings(); const currentSettings = host.services.setting.currentSettings();
+4 -3
View File
@@ -18,6 +18,7 @@ import {
type LOG_LEVEL, type LOG_LEVEL,
} from "@lib/common/logger"; } from "@lib/common/logger";
import { fireAndForget, isAnyNote, throttle } from "@lib/common/utils"; import { fireAndForget, isAnyNote, throttle } from "@lib/common/utils";
import { isNotFoundError } from "@lib/common/utils.doc.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2";
import { serialized } from "octagonal-wheels/concurrency/lock"; import { serialized } from "octagonal-wheels/concurrency/lock";
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
@@ -39,7 +40,7 @@ export class ReplicateResultProcessor {
private log(message: string, level: LOG_LEVEL = LOG_LEVEL_INFO) { private log(message: string, level: LOG_LEVEL = LOG_LEVEL_INFO) {
Logger(`[ReplicateResultProcessor] ${message}`, level); Logger(`[ReplicateResultProcessor] ${message}`, level);
} }
private logError(e: any) { private logError(e: unknown) {
Logger(e, LOG_LEVEL_VERBOSE); Logger(e, LOG_LEVEL_VERBOSE);
} }
private replicator: ModuleReplicator; private replicator: ModuleReplicator;
@@ -466,8 +467,8 @@ export class ReplicateResultProcessor {
return false; // This means that the document already processed (While no conflict existed). return false; // This means that the document already processed (While no conflict existed).
} }
return true; // This mostly should not happen, but we have to process it just in case. return true; // This mostly should not happen, but we have to process it just in case.
} catch (e: any) { } catch (e: unknown) {
if ("status" in e && e.status == 404) { if (e && typeof e === "object" && isNotFoundError(e)) {
// getRaw failed due to not existing, it may not be happened normally especially on replication. // getRaw failed due to not existing, it may not be happened normally especially on replication.
// If the process caused by some other reason, we **probably** have to process it. // If the process caused by some other reason, we **probably** have to process it.
// Note that this is not a common case. // Note that this is not a common case.
@@ -18,14 +18,14 @@ import type { InjectableServiceHub } from "../../lib/src/services/InjectableServ
import type { LiveSyncCore } from "../../main.ts"; import type { LiveSyncCore } from "../../main.ts";
import { REMOTE_P2P } from "@lib/common/models/setting.const.ts"; import { REMOTE_P2P } from "@lib/common/models/setting.const.ts";
function valueToString(value: any) { function valueToString(value: unknown) {
if (typeof value === "boolean") { if (typeof value === "boolean") {
return value ? "true" : "false"; return value ? "true" : "false";
} }
if (typeof value === "object") { if (typeof value === "object" && value !== null) {
return JSON.stringify(value); return JSON.stringify(value);
} }
return `${value}`; return String(value);
} }
export class ModuleResolvingMismatchedTweaks extends AbstractModule { export class ModuleResolvingMismatchedTweaks extends AbstractModule {
@@ -68,9 +68,13 @@ export class ObsHttpHandler extends FetchHttpHandler {
contentType = transformedHeaders["content-type"]; contentType = transformedHeaders["content-type"];
} }
let transformedBody: any = body; let transformedBody: string | ArrayBuffer | undefined = undefined;
if (ArrayBuffer.isView(body)) { if (typeof body === "string" || body instanceof ArrayBuffer) {
transformedBody = new Uint8Array(body.buffer).buffer; transformedBody = body;
} else if (ArrayBuffer.isView(body)) {
transformedBody = body.buffer as ArrayBuffer;
} else if (body != null) {
transformedBody = body as string | ArrayBuffer;
} }
const param: RequestUrlParam = { const param: RequestUrlParam = {
@@ -36,13 +36,14 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
this.services.appLifecycle.performRestart(); this.services.appLifecycle.performRestart();
} }
initialCallback: any; initialCallback: (() => unknown) | undefined;
swapSaveCommand() { swapSaveCommand() {
this._log("Modifying callback of the save command", LOG_LEVEL_VERBOSE); this._log("Modifying callback of the save command", LOG_LEVEL_VERBOSE);
const saveCommandDefinition = (this.app as any).commands?.commands?.["editor:save-file"]; const app = this.app as unknown as { commands?: { commands?: Record<string, { callback?: () => unknown }> } };
const saveCommandDefinition = app.commands?.commands?.["editor:save-file"];
const save = saveCommandDefinition?.callback; const save = saveCommandDefinition?.callback;
if (typeof save === "function") { if (typeof save === "function" && saveCommandDefinition) {
this.initialCallback = save; this.initialCallback = save;
saveCommandDefinition.callback = () => { saveCommandDefinition.callback = () => {
scheduleTask("syncOnEditorSave", 250, () => { scheduleTask("syncOnEditorSave", 250, () => {
+2 -2
View File
@@ -6,7 +6,7 @@ let plugin: ObsidianLiveSyncPlugin;
export function enableTestFunction(plugin_: ObsidianLiveSyncPlugin) { export function enableTestFunction(plugin_: ObsidianLiveSyncPlugin) {
plugin = plugin_; plugin = plugin_;
} }
export function addDebugFileLog(message: any, stackLog = false) { export function addDebugFileLog(message: unknown, stackLog = false) {
fireAndForget( fireAndForget(
serialized("debug-log", async () => { serialized("debug-log", async () => {
const now = new Date(); const now = new Date();
@@ -16,7 +16,7 @@ export function addDebugFileLog(message: any, stackLog = false) {
// const messageContent = typeof message == "string" ? message : message instanceof Error ? `${message.name}:${message.message}` : JSON.stringify(message, null, 2); // const messageContent = typeof message == "string" ? message : message instanceof Error ? `${message.name}:${message.message}` : JSON.stringify(message, null, 2);
const timestamp = now.toLocaleString(); const timestamp = now.toLocaleString();
const timestampEpoch = now; const timestampEpoch = now;
let out = { timestamp: timestamp, epoch: timestampEpoch } as Record<string, any>; let out = { timestamp: timestamp, epoch: timestampEpoch } as Record<string, unknown>;
if (message instanceof Error) { if (message instanceof Error) {
// debugger; // debugger;
// console.dir(message.stack); // console.dir(message.stack);
@@ -139,7 +139,7 @@ export class DocumentHistoryModal extends Modal {
this.range.value = `${this.revs_info.length - 1 - rIndex}`; this.range.value = `${this.revs_info.length - 1 - rIndex}`;
} }
} }
const index = this.revs_info.length - 1 - (this.range.value as any) / 1; const index = this.revs_info.length - 1 - Number(this.range.value);
const rev = this.revs_info[index]; const rev = this.revs_info[index];
await this.showExactRev(rev.rev); await this.showExactRev(rev.rev);
} }
@@ -251,7 +251,7 @@ export class DocumentHistoryModal extends Modal {
} }
let rendered = false; let rendered = false;
if (this.showDiff) { if (this.showDiff) {
const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1); const prevRevIdx = this.revs_info.length - 1 - (Number(this.range.value) - 1);
if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) { if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) {
const oldRev = this.revs_info[prevRevIdx].rev; const oldRev = this.revs_info[prevRevIdx].rev;
const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true); const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true);
@@ -550,7 +550,7 @@ export class DocumentHistoryModal extends Modal {
if (this.showDiff) { if (this.showDiff) {
checkbox.checked = true; checkbox.checked = true;
} }
checkbox.addEventListener("input", (evt: any) => { checkbox.addEventListener("input", (evt) => {
this.showDiff = checkbox.checked; this.showDiff = checkbox.checked;
this.app.saveLocalStorage("ols-history-highlightdiff", this.showDiff == true ? "1" : null); this.app.saveLocalStorage("ols-history-highlightdiff", this.showDiff == true ? "1" : null);
this.updateDiffNavVisibility(); this.updateDiffNavVisibility();
@@ -565,7 +565,7 @@ export class DocumentHistoryModal extends Modal {
if (this.diffOnly) { if (this.diffOnly) {
checkbox.checked = true; checkbox.checked = true;
} }
checkbox.addEventListener("input", (evt: any) => { checkbox.addEventListener("input", (evt) => {
this.diffOnly = checkbox.checked; this.diffOnly = checkbox.checked;
this.app.saveLocalStorage("ols-history-diffonly", this.diffOnly == true ? "1" : null); this.app.saveLocalStorage("ols-history-diffonly", this.diffOnly == true ? "1" : null);
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
+2 -2
View File
@@ -48,7 +48,7 @@ import { generateReport } from "@/common/reportTool.ts";
// DI the log again. // DI the log again.
const recentLogEntries = reactiveSource<LogEntry[]>([]); const recentLogEntries = reactiveSource<LogEntry[]>([]);
const globalLogFunction = (message: any, level?: number, key?: string) => { const globalLogFunction = (message: unknown, level?: number, key?: string) => {
const messageX = const messageX =
message instanceof Error message instanceof Error
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message }) ? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
@@ -501,7 +501,7 @@ ${stringifyYaml(info)}
}) })
); );
} }
__addLog(message: any, level: LOG_LEVEL = LOG_LEVEL_INFO, key = ""): void { __addLog(message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key = ""): void {
if (level == LOG_LEVEL_DEBUG && !showDebugLog) { if (level == LOG_LEVEL_DEBUG && !showDebugLog) {
return; return;
} }
@@ -35,7 +35,7 @@ export class LiveSyncSetting extends Setting {
hasPassword: boolean = false; hasPassword: boolean = false;
invalidateValue?: () => void; invalidateValue?: () => void;
setValue?: (value: any) => void; setValue?: (value: unknown) => void;
constructor(containerEl: HTMLElement) { constructor(containerEl: HTMLElement) {
super(containerEl); super(containerEl);
LiveSyncSetting.env.settingComponents.push(this); LiveSyncSetting.env.settingComponents.push(this);
@@ -102,6 +102,7 @@ export class LiveSyncSetting extends Setting {
} }
return conf; return conf;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
autoWireComponent(component: ValueComponent<any>, conf?: ConfigurationItem, opt?: AutoWireOption) { autoWireComponent(component: ValueComponent<any>, conf?: ConfigurationItem, opt?: AutoWireOption) {
this.placeHolderBuf = conf?.placeHolder || opt?.placeHolder || ""; this.placeHolderBuf = conf?.placeHolder || opt?.placeHolder || "";
if (conf?.level == LEVEL_ADVANCED) { if (conf?.level == LEVEL_ADVANCED) {
@@ -287,6 +287,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
// UI Element Wrapper --> // UI Element Wrapper -->
settingComponents = [] as Setting[]; settingComponents = [] as Setting[];
controlledElementFunc = [] as UpdateFunction[]; controlledElementFunc = [] as UpdateFunction[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSavedHandlers = [] as OnSavedHandler<any>[]; onSavedHandlers = [] as OnSavedHandler<any>[];
inWizard: boolean = false; inWizard: boolean = false;
@@ -442,7 +442,9 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
...e.doc, ...e.doc,
_deleted: true, _deleted: true,
})); }));
const r = await this.core.localDatabase.bulkDocsRaw(newData as any[]); const r = await this.core.localDatabase.bulkDocsRaw(
newData as Parameters<typeof this.core.localDatabase.bulkDocsRaw>[0]
);
// Do not care about the result. // Do not care about the result.
Logger( Logger(
`${r.length} items have been removed, to confirm how many items are left, please perform it again.`, `${r.length} items have been removed, to confirm how many items are left, please perform it again.`,
@@ -7,7 +7,7 @@ import {
type ObsidianLiveSyncSettings, type ObsidianLiveSyncSettings,
LOG_LEVEL_VERBOSE, LOG_LEVEL_VERBOSE,
} from "../../../lib/src/common/types.ts"; } from "../../../lib/src/common/types.ts";
import { Menu } from "@/deps.ts"; import { Menu, ButtonComponent } from "@/deps.ts";
import { $msg } from "../../../lib/src/common/i18n.ts"; import { $msg } from "../../../lib/src/common/i18n.ts";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
@@ -39,7 +39,7 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
const workObj = { ...editingSettings } as ObsidianLiveSyncSettings; const workObj = { ...editingSettings } as ObsidianLiveSyncSettings;
const keys = Object.keys(OnDialogSettingsDefault); const keys = Object.keys(OnDialogSettingsDefault);
for (const k of keys) { for (const k of keys) {
delete (workObj as any)[k]; delete (workObj as unknown as Record<string, unknown>)[k];
} }
return workObj; return workObj;
} }
@@ -72,7 +72,7 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin
return ConnectionStringParser.serialize({ type: "couchdb", settings }); return ConnectionStringParser.serialize({ type: "couchdb", settings });
} }
function setEmojiButton(button: any, emoji: string, tooltip: string) { function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
button.setButtonText(emoji); button.setButtonText(emoji);
button.setTooltip(tooltip, { delay: 10, placement: "top" }); button.setTooltip(tooltip, { delay: 10, placement: "top" });
// button.buttonEl.addClass("clickable-icon"); // button.buttonEl.addClass("clickable-icon");
@@ -145,8 +145,8 @@ export function paneSetup(
let remoteTroubleShootMDSrc = ""; let remoteTroubleShootMDSrc = "";
try { try {
remoteTroubleShootMDSrc = await request(`${rawRepoURI}${basePath}/${filename}`); remoteTroubleShootMDSrc = await request(`${rawRepoURI}${basePath}/${filename}`);
} catch (ex: any) { } catch (ex) {
remoteTroubleShootMDSrc = `${$msg("obsidianLiveSyncSettingTab.logErrorOccurred")}\n${ex.toString()}`; remoteTroubleShootMDSrc = `${$msg("obsidianLiveSyncSettingTab.logErrorOccurred")}\n${String(ex)}`;
} }
const remoteTroubleShootMD = remoteTroubleShootMDSrc.replace( const remoteTroubleShootMD = remoteTroubleShootMDSrc.replace(
/\((.*?(.png)|(.jpg))\)/g, /\((.*?(.png)|(.jpg))\)/g,
@@ -5,7 +5,7 @@ import { type Writable, writable, get } from "svelte/store";
* Props passed to Svelte panels, containing a writable port * Props passed to Svelte panels, containing a writable port
* to communicate with the panel * to communicate with the panel
*/ */
export type SveltePanelProps<T = any> = { export type SveltePanelProps<T = unknown> = {
port: Writable<T | undefined>; port: Writable<T | undefined>;
}; };
@@ -13,7 +13,7 @@ export type SveltePanelProps<T = any> = {
* A class to manage a Svelte panel within Obsidian * A class to manage a Svelte panel within Obsidian
* Especially useful for settings panels * Especially useful for settings panels
*/ */
export class SveltePanel<T = any> { export class SveltePanel<T = unknown> {
private _mountedComponent: ReturnType<typeof mount>; private _mountedComponent: ReturnType<typeof mount>;
private _componentValue = writable<T | undefined>(undefined); private _componentValue = writable<T | undefined>(undefined);
/** /**
@@ -13,6 +13,8 @@ import {
} from "../../../lib/src/common/utils"; } from "../../../lib/src/common/utils";
import { getConfig, type AllSettingItemKey } from "./settingConstants"; import { getConfig, type AllSettingItemKey } from "./settingConstants";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger"; import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import { isNotFoundError } from "@lib/common/utils.doc.ts";
import { LiveSyncError } from "@lib/common/LSError.ts";
/** /**
* Generates a summary of P2P configuration settings * Generates a summary of P2P configuration settings
@@ -95,13 +97,13 @@ export function getSummaryFromPartialSettings(setting: Partial<ObsidianLiveSyncS
export async function copyMigrationDocs(docName: string, dbFrom: PouchDB.Database, dbTo: PouchDB.Database) { export async function copyMigrationDocs(docName: string, dbFrom: PouchDB.Database, dbTo: PouchDB.Database) {
try { try {
const doc = await dbFrom.get(docName); const doc = await dbFrom.get(docName);
delete (doc as any)._rev; delete (doc as { _rev?: string })._rev;
await dbTo.put(doc); await dbTo.put(doc);
} catch (e) { } catch (e: unknown) {
if ((e as any).status === 404) { if (e && typeof e === "object" && isNotFoundError(e)) {
return; return;
} }
throw e; throw LiveSyncError.fromError(e);
} }
} }
@@ -259,8 +259,8 @@ export const checkConfig = async (
addResult($msg("obsidianLiveSyncSettingTab.msgDone"), ["ob-btn-config-head"]); addResult($msg("obsidianLiveSyncSettingTab.msgDone"), ["ob-btn-config-head"]);
addResult($msg("obsidianLiveSyncSettingTab.msgConnectionProxyNote"), ["ob-btn-config-info"]); addResult($msg("obsidianLiveSyncSettingTab.msgConnectionProxyNote"), ["ob-btn-config-info"]);
Logger($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"), LOG_LEVEL_INFO); Logger($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"), LOG_LEVEL_INFO);
} catch (ex: any) { } catch (ex) {
if (ex?.status == 401) { if (ex && typeof ex === "object" && "status" in ex && ex.status == 401) {
isSuccessful = false; isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errAccessForbidden")); addResult($msg("obsidianLiveSyncSettingTab.errAccessForbidden"));
addResult($msg("obsidianLiveSyncSettingTab.errCannotContinueTest")); addResult($msg("obsidianLiveSyncSettingTab.errCannotContinueTest"));
@@ -7,8 +7,14 @@ import { isCloudantURI } from "../../../../lib/src/pouchdb/utils_couchdb";
import { generateCredentialObject } from "../../../../lib/src/replication/httplib"; import { generateCredentialObject } from "../../../../lib/src/replication/httplib";
export type ResultMessage = { message: string; classes: string[] }; export type ResultMessage = { message: string; classes: string[] };
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] }; export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
export type ResultOk = { message: string; result: "ok"; value?: any }; export type ResultOk = { message: string; result: "ok"; value?: unknown };
export type ResultError = { message: string; result: "error"; value: any; fixMessage: string; fix(): Promise<void> }; export type ResultError = {
message: string;
result: "error";
value?: string;
fixMessage: string;
fix(): Promise<void>;
};
export type ConfigCheckResult = ResultOk | ResultError | ResultMessage | ResultErrorMessage; export type ConfigCheckResult = ResultOk | ResultError | ResultMessage | ResultErrorMessage;
/** /**
* Compares two version strings to determine if the baseVersion is greater than or equal to the version. * Compares two version strings to determine if the baseVersion is greater than or equal to the version.
@@ -35,7 +41,7 @@ function isGreaterThanOrEqual(baseVersion: string, version: string) {
* @param value setting value to update * @param value setting value to update
* @returns true if the update was successful, false otherwise * @returns true if the update was successful, false otherwise
*/ */
async function updateRemoteSetting(setting: ObsidianLiveSyncSettings, key: string, value: any) { async function updateRemoteSetting(setting: ObsidianLiveSyncSettings, key: string, value: string) {
const customHeaders = parseHeaderValues(setting.couchDB_CustomHeaders); const customHeaders = parseHeaderValues(setting.couchDB_CustomHeaders);
const credential = generateCredentialObject(setting); const credential = generateCredentialObject(setting);
const res = await requestToCouchDBWithCredentials( const res = await requestToCouchDBWithCredentials(
@@ -64,17 +70,17 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
const addMessage = (msg: string, classes: string[] = []) => { const addMessage = (msg: string, classes: string[] = []) => {
result.push({ message: msg, classes }); result.push({ message: msg, classes });
}; };
const addSuccess = (msg: string, value?: any) => { const addSuccess = (msg: string, value?: unknown) => {
result.push({ message: msg, result: "ok", value }); result.push({ message: msg, result: "ok", value });
}; };
const _addError = (message: string, fixMessage: string, fix: () => Promise<void>, value?: any) => { const _addError = (message: string, fixMessage: string, fix: () => Promise<void>, value?: string) => {
result.push({ message, result: "error", fixMessage, fix, value }); result.push({ message, result: "error", fixMessage, fix, value });
}; };
const addErrorMessage = (msg: string, classes: string[] = []) => { const addErrorMessage = (msg: string, classes: string[] = []) => {
result.push({ message: msg, result: "error", classes }); result.push({ message: msg, result: "error", classes });
}; };
const addError = (message: string, fixMessage: string, key: string, expected: any) => { const addError = (message: string, fixMessage: string, key: string, expected: string) => {
_addError(message, fixMessage, async () => { _addError(message, fixMessage, async () => {
await updateRemoteSetting(editingSettings, key, expected); await updateRemoteSetting(editingSettings, key, expected);
}); });
@@ -279,8 +285,8 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
addMessage($msg("obsidianLiveSyncSettingTab.msgDone"), ["ob-btn-config-head"]); addMessage($msg("obsidianLiveSyncSettingTab.msgDone"), ["ob-btn-config-head"]);
addMessage($msg("obsidianLiveSyncSettingTab.msgConnectionProxyNote"), ["ob-btn-config-info"]); addMessage($msg("obsidianLiveSyncSettingTab.msgConnectionProxyNote"), ["ob-btn-config-info"]);
addMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone")); addMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"));
} catch (ex: any) { } catch (ex) {
if (ex?.status == 401) { if (ex && typeof ex === "object" && "status" in ex && ex.status == 401) {
addErrorMessage($msg("obsidianLiveSyncSettingTab.errAccessForbidden")); addErrorMessage($msg("obsidianLiveSyncSettingTab.errAccessForbidden"));
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCannotContinueTest")); addErrorMessage($msg("obsidianLiveSyncSettingTab.errCannotContinueTest"));
addMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone")); addMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"));
+2 -2
View File
@@ -125,11 +125,11 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
registerWindow(type: string, factory: ViewCreator): void { registerWindow(type: string, factory: ViewCreator): void {
return this.context.plugin.registerView(type, factory); return this.context.plugin.registerView(type, factory);
} }
addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => any): HTMLElement { addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => void): HTMLElement {
return this.context.plugin.addRibbonIcon(icon, title, callback); return this.context.plugin.addRibbonIcon(icon, title, callback);
} }
registerProtocolHandler(action: string, handler: (params: Record<string, string>) => any): void { registerProtocolHandler(action: string, handler: (params: Record<string, string>) => void): void {
return this.context.plugin.registerObsidianProtocolHandler(action, handler); return this.context.plugin.registerObsidianProtocolHandler(action, handler);
} }
+7 -7
View File
@@ -50,7 +50,7 @@ export async function deleteFlagFile(host: NecessaryServices<never, "storageAcce
} }
const REMOTE_KEEP_CURRENT = "Use active remote"; const REMOTE_KEEP_CURRENT = "Use active remote";
const REMOTE_CANCEL = "Cancel"; const REMOTE_CANCEL = "Cancel";
async function askAndActivateRemoteDatabase(host: NecessaryServices<"UI" | "setting", any>, log: LogFunction) { async function askAndActivateRemoteDatabase(host: NecessaryServices<"UI" | "setting", never>, log: LogFunction) {
const settings = host.services.setting.currentSettings(); const settings = host.services.setting.currentSettings();
if (settings.remoteConfigurations && Object.keys(settings.remoteConfigurations).length > 1) { if (settings.remoteConfigurations && Object.keys(settings.remoteConfigurations).length > 1) {
const message = const message =
@@ -216,7 +216,7 @@ export function createFetchAllFlagHandler(
* @returns updated configuration if applied, otherwise null. * @returns updated configuration if applied, otherwise null.
*/ */
export async function adjustSettingToRemote( export async function adjustSettingToRemote(
host: NecessaryServices<"tweakValue" | "UI" | "setting", any>, host: NecessaryServices<"tweakValue" | "UI" | "setting", never>,
log: LogFunction, log: LogFunction,
config: ObsidianLiveSyncSettings config: ObsidianLiveSyncSettings
) { ) {
@@ -243,7 +243,7 @@ export async function adjustSettingToRemote(
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks); const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
// Check if any necessary tweak value is different from current config. // Check if any necessary tweak value is different from current config.
const differentItems = Object.entries(necessary).filter(([key, value]) => { const differentItems = Object.entries(necessary).filter(([key, value]) => {
return (config as any)[key] !== value; return config[key as keyof ObsidianLiveSyncSettings] !== value;
}); });
if (differentItems.length === 0) { if (differentItems.length === 0) {
log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE); log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE);
@@ -261,7 +261,7 @@ export async function adjustSettingToRemote(
config = { config = {
...config, ...config,
...Object.fromEntries(differentItems), ...Object.fromEntries(differentItems),
} satisfies ObsidianLiveSyncSettings; };
await host.services.setting.applyExternalSettings(config, true); await host.services.setting.applyExternalSettings(config, true);
log("Remote configuration applied.", LOG_LEVEL_NOTICE); log("Remote configuration applied.", LOG_LEVEL_NOTICE);
canProceed = true; canProceed = true;
@@ -277,7 +277,7 @@ export async function adjustSettingToRemote(
* @param config current configuration to retrieve remote preferred config * @param config current configuration to retrieve remote preferred config
*/ */
export async function adjustSettingToRemoteIfNeeded( export async function adjustSettingToRemoteIfNeeded(
host: NecessaryServices<"tweakValue" | "UI" | "setting", any>, host: NecessaryServices<"tweakValue" | "UI" | "setting", never>,
log: LogFunction, log: LogFunction,
extra: { preventFetchingConfig: boolean }, extra: { preventFetchingConfig: boolean },
config: ObsidianLiveSyncSettings config: ObsidianLiveSyncSettings
@@ -309,7 +309,7 @@ export async function adjustSettingToRemoteIfNeeded(
* @returns result of the process, or false if error occurs. * @returns result of the process, or false if error occurs.
*/ */
export async function processVaultInitialisation( export async function processVaultInitialisation(
host: NecessaryServices<"setting", any>, host: NecessaryServices<"setting", never>,
log: LogFunction, log: LogFunction,
proc: () => Promise<boolean>, proc: () => Promise<boolean>,
keepSuspending = false keepSuspending = false
@@ -341,7 +341,7 @@ export async function processVaultInitialisation(
} }
export async function verifyAndUnlockSuspension( export async function verifyAndUnlockSuspension(
host: NecessaryServices<"setting" | "appLifecycle" | "UI", any>, host: NecessaryServices<"setting" | "appLifecycle" | "UI", never>,
log: LogFunction log: LogFunction
) { ) {
if (!host.services.setting.currentSettings().suspendFileWatching) { if (!host.services.setting.currentSettings().suspendFileWatching) {
@@ -6,11 +6,11 @@ import { TFile, TFolder } from "obsidian";
*/ */
export class ObsidianTypeGuardAdapter implements ITypeGuardAdapter<TFile, TFolder> { export class ObsidianTypeGuardAdapter implements ITypeGuardAdapter<TFile, TFolder> {
isFile(file: any): file is TFile { isFile(file: unknown): file is TFile {
return file instanceof TFile; return file instanceof TFile;
} }
isFolder(item: any): item is TFolder { isFolder(item: unknown): item is TFolder {
return item instanceof TFolder; return item instanceof TFolder;
} }
} }
@@ -55,6 +55,7 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile> {
return await this.app.vault.trash(file, force); return await this.app.vault.trash(file, force);
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
trigger(name: string, ...data: any[]): any { trigger(name: string, ...data: any[]): any {
return this.app.vault.trigger(name, ...data); return this.app.vault.trigger(name, ...data);
} }
+3 -2
View File
@@ -296,8 +296,9 @@ body {
content: " ❓"; content: " ❓";
} }
.sls-item-invalid-value { .sls-setting .setting-item-control input.sls-item-invalid-value,
background-color: rgba(var(--background-modifier-error-rgb), 0.3) !important; .sls-setting .setting-item-control textarea.sls-item-invalid-value {
background-color: rgba(var(--background-modifier-error-rgb), 0.3);
} }
.sls-setting-disabled input[type=text], .sls-setting-disabled input[type=text],
+21 -1
View File
@@ -3,7 +3,17 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## Unreleased ## unreleased
### Fixed (CLI, automated)
- Fixed an issue where the mirror command could fail to apply updates when conflict preservation checks prevented overwriting unsynchronised local changes, even when the `force` parameter or `writeDocumentsIfConflicted` setting was enabled.
### Improved
- (CLI) Ported the remaining bash regression tests (`test-daemon-linux.sh`, `test-decoupled-vault-linux.sh`, and `test-remote-commands-linux.sh`) to Deno for cross-platform validation.
## 0.25.74
8th June, 2026 8th June, 2026
@@ -11,6 +21,7 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941). - Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941).
- Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911). - Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911).
- The startup sequence now saves the state more effectively (Thank you so much for @bmcyver)!
## Only CLI ## Only CLI
@@ -18,7 +29,16 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
I should also consider the version numbering for the CLI... I should also consider the version numbering for the CLI...
### Improved
- Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`. - Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`.
- --vault option is now available for daemon and mirror commands! (Thank you so much for @starskyzheng)!
- Decoupled the database directory path from the actual vault directory path using the `--vault` (or `-V`) option.
### Fixed (preventive)
- Validated that the specified vault path exists and is indeed a directory before starting the CLI.
- Integrated path resolution and validations for one-off commands (such as `'push'`, `'pull'`, `'cat'`, `'rm'`, `'info'`, and `'resolve'`) against the decoupled vault path instead of the database path.
## 0.25.73 ## 0.25.73