diff --git a/.gitignore b/.gitignore index 42862fb8..97ecba90 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,7 @@ dist *.js.map *.js *.d.ts +!**/types/**/*.d.ts # yarn.lock diff --git a/.vscode/settings.json b/.vscode/settings.json index f7764e18..7c6c58a9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,94 @@ { - "files.exclude": { - "**/*.d.ts": { "when": "$(basename).ts" }, - "**/*.js.map": { "when": "$(basename)" }, - "**/*.js": { "when": "$(basename).ts" }, - "**/node_modules": true + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.expand": false, + "explorer.fileNesting.patterns": { + ".clang-tidy": ".clang-format, .clangd, compile_commands.json", + ".env": "*.env, .env.*, .envrc, env.d.ts", + ".gitignore": ".gitattributes, .gitmodules, .gitmessage, .mailmap, .git-blame*", + ".project": ".classpath", + "+layout.svelte": "+layout.ts,+layout.ts,+layout.js,+layout.server.ts,+layout.server.js,+layout.gql", + "+page.svelte": "+page.server.ts,+page.server.js,+page.ts,+page.js,+page.gql", + "ansible.cfg": "ansible.cfg, .ansible-lint, requirements.yml", + "app.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "artisan": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, rspack.config.*, server.php, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, webpack.mix.js, windi.config.*", + "astro.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "build-wrapper.log": "build-wrapper*.log, build-wrapper-dump*.json, build-wrapper-win*.exe, build-wrapper-linux*, build-wrapper-macosx*", + "BUILD.bazel": "*.bzl, *.bazel, *.bazelrc, bazel.rc, .bazelignore, .bazelproject, WORKSPACE", + "Cargo.toml": ".clippy.toml, .rustfmt.toml, cargo.lock, clippy.toml, cross.toml, rust-toolchain.toml, rustfmt.toml", + "CMakeLists.txt": "*.cmake, *.cmake.in, .cmake-format.yaml, CMakePresets.json, CMakeCache.txt", + "composer.json": ".php*.cache, composer.lock, phpunit.xml*, psalm*.xml", + "default.nix": "shell.nix", + "deno.json*": "*.env, .env.*, .envrc, api-extractor.json, deno.lock, env.d.ts, import-map.json, import_map.json, jsconfig.*, tsconfig.*, tsdoc.*", + "Dockerfile": "*.dockerfile, .devcontainer.*, .dockerignore, captain-definition, compose.*, docker-compose.*, dockerfile*", + "flake.nix": "flake.lock", + "gatsby-config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, gatsby-browser.*, gatsby-node.*, gatsby-ssr.*, gatsby-transformer.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "gemfile": ".ruby-version, gemfile.lock", + "go.mod": ".air*, go.sum", + "go.work": "go.work.sum", + "hatch.toml": ".editorconfig, .flake8, .isort.cfg, .python-version, hatch.toml, requirements*.in, requirements*.pip, requirements*.txt, tox.ini", + "I*.cs": "$(capture).cs", + "Makefile": "*.mk", + "mix.exs": ".credo.exs, .dialyzer_ignore.exs, .formatter.exs, .iex.exs, .tool-versions, mix.lock", + "next.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, next-env.d.ts, next-i18next.config.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "nuxt.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .nuxtignore, .nuxtrc, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "package.json": "*.code-workspace, .browserslist*, .circleci*, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json*, .editorconfig, .eslint*, .firebase*, .flowconfig, .github*, .gitlab*, .gitmojirc.json, .gitpod*, .huskyrc*, .jslint*, .knip.*, .lintstagedrc*, .markdownlint*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .pylintrc, .release-please*.json, .releaserc*, .ruff.toml, .sentry*, .simple-git-hooks*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, apollo.config.*, appveyor*, azure-pipelines*, biome.json*, bower.json, build.config.*, bun.lockb, bunfig.toml, commitlint*, crowdin*, dangerfile*, dlint.json, dprint.json*, electron-builder.*, eslint*, firebase.json, grunt*, gulp*, jenkins*, knip.*, lerna*, lint-staged*, nest-cli.*, netlify*, nodemon*, npm-shrinkwrap.json, nx.*, package-lock.json, package.nls*.json, phpcs.xml, pm2.*, pnpm*, prettier*, pullapprove*, pyrightconfig.json, release-please*.json, release-tasks.sh, release.config.*, renovate*, rollup.config.*, rspack*, ruff.toml, simple-git-hooks*, sonar-project.properties, stylelint*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, webpack*, workspace.json, wrangler.toml, xo.config.*, yarn*", + "Pipfile": ".editorconfig, .flake8, .isort.cfg, .python-version, Pipfile, Pipfile.lock, requirements*.in, requirements*.pip, requirements*.txt, tox.ini", + "pubspec.yaml": ".metadata, .packages, all_lint_rules.yaml, analysis_options.yaml, build.yaml, pubspec.lock, pubspec_overrides.yaml", + "pyproject.toml": ".commitlint*, .dlint.json, .dprint.json*, .editorconfig, .eslint*, .flake8, .flowconfig, .isort.cfg, .jslint*, .lintstagedrc*, .markdownlint*, .pdm-python, .pdm.toml, .prettier*, .pylintrc, .python-version, .ruff.toml, .stylelint*, .textlint*, .xo-config*, .yamllint*, MANIFEST.in, Pipfile, Pipfile.lock, biome.json*, commitlint*, dangerfile*, dlint.json, dprint.json*, eslint*, hatch.toml, lint-staged*, pdm.lock, phpcs.xml, poetry.lock, poetry.toml, prettier*, pyproject.toml, pyrightconfig.json, requirements*.in, requirements*.pip, requirements*.txt, ruff.toml, setup.cfg, setup.py, stylelint*, tox.ini, tslint*, uv.lock, uv.toml, xo.config.*", + "quasar.conf.js": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, quasar.extensions.json, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, Readme-*, Readme_*, Release_Notes*, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, security.md, sponsors*", + "Readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, Readme-*, Readme_*, Release_Notes*, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, security.md, sponsors*", + "README*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, Readme-*, Readme_*, Release_Notes*, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, security.md, sponsors*", + "remix.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, remix.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "requirements.txt": ".editorconfig, .flake8, .isort.cfg, .python-version, requirements*.in, requirements*.pip, requirements*.txt, tox.ini", + "rush.json": "*.code-workspace, .browserslist*, .circleci*, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json*, .editorconfig, .eslint*, .firebase*, .flowconfig, .github*, .gitlab*, .gitmojirc.json, .gitpod*, .huskyrc*, .jslint*, .knip.*, .lintstagedrc*, .markdownlint*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .pylintrc, .release-please*.json, .releaserc*, .ruff.toml, .sentry*, .simple-git-hooks*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, apollo.config.*, appveyor*, azure-pipelines*, biome.json*, bower.json, build.config.*, bun.lockb, bunfig.toml, commitlint*, crowdin*, dangerfile*, dlint.json, dprint.json*, electron-builder.*, eslint*, firebase.json, grunt*, gulp*, jenkins*, knip.*, lerna*, lint-staged*, nest-cli.*, netlify*, nodemon*, npm-shrinkwrap.json, nx.*, package-lock.json, package.nls*.json, phpcs.xml, pm2.*, pnpm*, prettier*, pullapprove*, pyrightconfig.json, release-please*.json, release-tasks.sh, release.config.*, renovate*, rollup.config.*, rspack*, ruff.toml, simple-git-hooks*, sonar-project.properties, stylelint*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, webpack*, workspace.json, wrangler.toml, xo.config.*, yarn*", + "sanity.config.*": "sanity.cli.*, sanity.types.ts, schema.json", + "setup.cfg": ".editorconfig, .flake8, .isort.cfg, .python-version, MANIFEST.in, requirements*.in, requirements*.pip, requirements*.txt, setup.cfg, tox.ini", + "setup.py": ".editorconfig, .flake8, .isort.cfg, .python-version, MANIFEST.in, requirements*.in, requirements*.pip, requirements*.txt, setup.cfg, setup.py, tox.ini", + "shims.d.ts": "*.d.ts", + "svelte.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, houdini.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, mdsvex.config.js, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vite.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "vite.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "vue.config.*": "*.env, .babelrc*, .codecov, .cssnanorc*, .env.*, .envrc, .htmlnanorc*, .lighthouserc.*, .mocha*, .postcssrc*, .terserrc*, api-extractor.json, ava.config.*, babel.config.*, capacitor.config.*, contentlayer.config.*, cssnano.config.*, cypress.*, env.d.ts, formkit.config.*, formulate.config.*, histoire.config.*, htmlnanorc.*, i18n.config.*, ionic.config.*, jasmine.*, jest.config.*, jsconfig.*, karma*, lighthouserc.*, panda.config.*, playwright.config.*, postcss.config.*, puppeteer.config.*, rspack.config.*, sst.config.*, svgo.config.*, tailwind.config.*, tsconfig.*, tsdoc.*, uno.config.*, unocss.config.*, vitest.config.*, vuetify.config.*, webpack.config.*, windi.config.*", + "*.asax": "$(capture).*.cs, $(capture).*.vb", + "*.ascx": "$(capture).*.cs, $(capture).*.vb", + "*.ashx": "$(capture).*.cs, $(capture).*.vb", + "*.aspx": "$(capture).*.cs, $(capture).*.vb", + "*.axaml": "$(capture).axaml.cs", + "*.bloc.dart": "$(capture).event.dart, $(capture).state.dart", + "*.c": "$(capture).h", + "*.cc": "$(capture).hpp, $(capture).h, $(capture).hxx, $(capture).hh", + "*.cjs": "$(capture).cjs.map, $(capture).*.cjs, $(capture)_*.cjs", + "*.component.ts": "$(capture).component.html, $(capture).component.spec.ts, $(capture).component.css, $(capture).component.scss, $(capture).component.sass, $(capture).component.less", + "*.cpp": "$(capture).hpp, $(capture).h, $(capture).hxx, $(capture).hh", + "*.cs": "$(capture).*.cs", + "*.cshtml": "$(capture).cshtml.cs", + "*.csproj": "*.config, *proj.user, appsettings.*, bundleconfig.json", + "*.css": "$(capture).css.map, $(capture).*.css", + "*.cxx": "$(capture).hpp, $(capture).h, $(capture).hxx, $(capture).hh", + "*.dart": "$(capture).freezed.dart, $(capture).g.dart", + "*.db": "*.db-shm, *.db-wal", + "*.ex": "$(capture).html.eex, $(capture).html.heex, $(capture).html.leex", + "*.fs": "$(capture).fs.js, $(capture).fs.js.map, $(capture).fs.jsx, $(capture).fs.ts, $(capture).fs.tsx, $(capture).fs.rs, $(capture).fs.php, $(capture).fs.dart", + "*.go": "$(capture)_test.go", + "*.java": "$(capture).class", + "*.js": "$(capture).js.map, $(capture).*.js, $(capture)_*.js", + "*.jsx": "$(capture).js, $(capture).*.jsx, $(capture)_*.js, $(capture)_*.jsx, $(capture).less, $(capture).module.less, $(capture).module.less.d.ts, $(capture).scss, $(capture).module.scss, $(capture).module.scss.d.ts", + "*.master": "$(capture).*.cs, $(capture).*.vb", + "*.md": "$(capture).*", + "*.mjs": "$(capture).mjs.map, $(capture).*.mjs, $(capture)_*.mjs", + "*.module.ts": "$(capture).resolver.ts, $(capture).controller.ts, $(capture).service.ts", + "*.mts": "$(capture).mts.map, $(capture).*.mts, $(capture)_*.mts", + "*.pubxml": "$(capture).pubxml.user", + "*.py": "$(capture).pyi", + "*.razor": "$(capture).razor.cs, $(capture).razor.css, $(capture).razor.scss", + "*.resx": "$(capture).*.resx, $(capture).designer.cs, $(capture).designer.vb", + "*.tex": "$(capture).acn, $(capture).acr, $(capture).alg, $(capture).aux, $(capture).bbl, $(capture).blg, $(capture).fdb_latexmk, $(capture).fls, $(capture).glg, $(capture).glo, $(capture).gls, $(capture).idx, $(capture).ind, $(capture).ist, $(capture).lof, $(capture).log, $(capture).lot, $(capture).out, $(capture).pdf, $(capture).synctex.gz, $(capture).toc, $(capture).xdv", + "*.ts": "$(capture).js, $(capture).d.ts.map, $(capture).*.ts, $(capture)_*.js, $(capture)_*.ts", + "*.tsx": "$(capture).ts, $(capture).*.tsx, $(capture)_*.ts, $(capture)_*.tsx, $(capture).less, $(capture).module.less, $(capture).module.less.d.ts, $(capture).scss, $(capture).module.scss, $(capture).module.scss.d.ts, $(capture).css.ts", + "*.vbproj": "*.config, *proj.user, appsettings.*, bundleconfig.json", + "*.vue": "$(capture).*.ts, $(capture).*.js, $(capture).story.vue", + "*.w": "$(capture).*.w, I$(capture).w", + "*.xaml": "$(capture).xaml.cs" }, "editor.formatOnSave": true, "todo-tree.tree.showCountsInTree": true, diff --git a/packages/cronjob/README.md b/packages/cronjob/README.md index c2ddfbd6..a86ad61e 100644 --- a/packages/cronjob/README.md +++ b/packages/cronjob/README.md @@ -3,8 +3,8 @@ [![Continuous Integration](https://github.com/kaka-ng/fastify-plugins/actions/workflows/ci-cronjob.yml/badge.svg)](https://github.com/kaka-ng/fastify-plugins/actions/workflows/ci-cronjob.yml) [![NPM version](https://img.shields.io/npm/v/@kakang/fastify-cronjob.svg?style=flat)](https://www.npmjs.com/package/@kakang/fastify-cronjob) -This plugin is inspired by [JoSK](https://github.com/veliovgroup/josk) -for managing cronjob in Node.js cluster +This plugin using [JoSK](https://github.com/veliovgroup/josk) +underneth for managing cronjob in Node.js cluster ## Install @@ -17,8 +17,7 @@ yarn add @kakang/fastify-cronjob ## Usage ```ts -import fastifyCronJob from '@kakang/fastify-cronjob' -import { MongoDBAdapter } from '@kakang/fastify-cronjob/lib/adapter/mongodb' +import fastifyCronJob, { MongoAdapter, RedisAdapter } from '@kakang/fastify-cronjob' import { MongoClient } from 'mongodb' const client = new MongoClient('mongodb://127.0.0.1:27017') @@ -26,17 +25,15 @@ await client.connect() const db = client.db('cronjob') fastify.register(fastifyCronjob, { - adapter: MongoDBAdapter, - adapterOptions: { - application: 'cronjob', + adapter: new MongoAdapter({ db - } + }) }) ``` ## API -### .setInterval(fn, ms, uid[, context]) +### .setInterval(fn, ms, uid) Job that runs on defined interval @@ -62,7 +59,7 @@ fastify.cronjob.setInterval(function (context) { }, 1000, 'promise') ``` -### .setTimeout(fn, ms, uid[, context]) +### .setTimeout(fn, ms, uid) Job that runs on defined timeout @@ -88,7 +85,7 @@ fastify.cronjob.setTimeout(function (context) { }, 1000, 'promise') ``` -### .setImmediate(fn, uid[, context]) +### .setImmediate(fn, uid) Job that runs immediately @@ -114,7 +111,7 @@ fastify.cronjob.setImmediate(function (context) { }, 'promise') ``` -### .setCronJob(fn, cron, uid[, context]) +### .setCronJob(fn, cron, uid) Job that runs on defined cron string @@ -140,7 +137,7 @@ fastify.cronjob.setCronJob(function (context) { }, '* * * * * *', 'promise') ``` -### .setLoopTask(fn, uid[, context]) +### .setLoopTask(fn, uid) Job that runs immediately one follow the other. diff --git a/packages/cronjob/lib/adapter/adapter.ts b/packages/cronjob/lib/adapter/adapter.ts deleted file mode 100644 index 8576fe78..00000000 --- a/packages/cronjob/lib/adapter/adapter.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type CreateTask, type Task } from '../cronjob' -import { kAdapter } from '../symbols' - -export interface AdapterOptions { -} - -export class Adapter { - readonly #options: AdapterOptions - - constructor (options: AdapterOptions) { - this.#options = options - } - - static [kAdapter]: any = true - - async prepare (): Promise { - } - - async fetchTasks (executeAt: number): Promise { - throw Error('missing implementation of fetchTasks') - } - - async createTask (task: CreateTask): Promise { - throw Error('missing implementation of createTask') - } - - async updateTasks (tasks: string[], executeAt: number): Promise { - throw Error('missing implementation of updateTasks') - } - - async updateTask (uid: string, executeAt: number, isDeleted: boolean): Promise { - throw Error('missing implementation of updateTask') - } - - async deleteTask (uid: string): Promise { - throw Error('missing implementation of deleteTask') - } - - async aquireLock (name: string, expireAt: number): Promise { - throw Error('missing implementation of aquireLock') - } - - async releaseLock (name: string): Promise { - throw Error('missing implementation of releaseLock') - } -} diff --git a/packages/cronjob/lib/adapter/mongodb.ts b/packages/cronjob/lib/adapter/mongodb.ts deleted file mode 100644 index ee6ff2c6..00000000 --- a/packages/cronjob/lib/adapter/mongodb.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { type Collection, type CreateIndexesOptions, type Db, type IndexSpecification } from 'mongodb' -import { type CreateTask, type Task } from '../cronjob' -import { Adapter, type AdapterOptions } from './adapter' - -export interface MongoDBAdapterOptions extends AdapterOptions { - db: Db -} - -async function ensureIndex (collection: Collection, indexSpec: IndexSpecification, options: CreateIndexesOptions): Promise { - try { - await collection.createIndex(indexSpec, options) - } catch {} -} -export class MongoDBAdapter extends Adapter { - db: Db - collection: Collection - collectionLock: Collection - - constructor (options: MongoDBAdapterOptions) { - super(options) - this.db = options.db - this.collection = this.db.collection('__cron__.task') - this.collectionLock = this.db.collection('__cron__.lock') - } - - async prepare (): Promise { - await ensureIndex(this.collection, { uid: 1 }, { unique: true, background: false }) - await ensureIndex(this.collection, { uid: 1, isDeleted: 1 }, { unique: false, background: false }) - await ensureIndex(this.collection, { executeAt: 1 }, { unique: false, background: false }) - - await ensureIndex(this.collectionLock, { expireAt: 1 }, { unique: false, expireAfterSeconds: 1, background: false }) - await ensureIndex(this.collectionLock, { application: 1 }, { unique: true, background: false }) - } - - async fetchTasks (executeAt: number): Promise { - const cursor = this.collection.find({ - executeAt: { - $lte: executeAt, - }, - }) - return await cursor.toArray() - } - - async createTask (task: CreateTask): Promise { - const _task = await this.collection.findOne({ uid: task.uid }) - const executeAt = Date.now() + task.delay - if (_task === null) { - await this.collection.insertOne({ - uid: task.uid, - once: task.once ?? false, - delay: task.delay, - executeAt, - isDeleted: false, - }) - } else { - const $set: any = { isDeleted: false } - if (_task.delay !== task.delay) { - $set.delay = task.delay - } - if (_task.executeAt !== executeAt) { - $set.executeAt = executeAt - } - if ($set !== null) { - await this.collection.updateOne({ - uid: task.uid, - }, { - $set, - }) - } - } - } - - async updateTasks (uids: string[], executeAt: number): Promise { - await this.collection.updateMany({ - uid: { - $in: uids, - }, - }, { - $set: { - executeAt, - }, - }) - } - - async updateTask (uid: string, executeAt: number, isDeleted: boolean): Promise { - const result = await this.collection.findOneAndUpdate({ - uid, - }, { - $set: { - executeAt, - isDeleted, - }, - }) - return result as Task | null - } - - async deleteTask (uid: string): Promise { - await this.collection.deleteOne({ uid }) - } - - async aquireLock (name: string, expireAt: number): Promise { - const result = await this.collectionLock.findOne({ application: name }) - if (result !== null) return false - await this.collectionLock.insertOne({ application: name, expireAt }) - return true - } - - async releaseLock (name: string): Promise { - await this.collectionLock.deleteOne({ application: name }) - } -} diff --git a/packages/cronjob/lib/cronjob.ts b/packages/cronjob/lib/cronjob.ts index 39f99e1a..994855a3 100644 --- a/packages/cronjob/lib/cronjob.ts +++ b/packages/cronjob/lib/cronjob.ts @@ -1,335 +1,399 @@ import { parseExpression } from 'cron-parser' -import EventEmitter from 'events' -import { type Adapter } from './adapter/adapter' - -type _TaskExecutor = () => Promise -export type TaskExecutor = (context: Context) => Promise - -export interface Task { - uid: string - once: boolean - delay: number - executeAt: number - isDeleted: boolean +import { + JoSk, + MongoAdapterOptions as JoSkMongoAdapterOptions, + RedisAdapter +} from 'josk' +import { Collection, Db } from 'mongodb' + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/try#using_promise.try +const promiseTry = function (func: Function) { + return new Promise((resolve, reject) => { + try { + resolve(func()) + } catch (err) { + reject(err) + } + }) } -export interface CreateTask { - uid: string - once?: boolean - delay: number +export class CronJob extends JoSk { + async setCronJob (func: () => void | Promise, cron: string, uid: string): Promise { + const nextTimestamp = +parseExpression(cron).next().toDate() + const that = this + return await this.setInterval(function (ready) { + ready(parseExpression(cron).next().toDate()) + // since we are cron + // we should not throw when there is error + promiseTry(func).catch((error) => { + if (typeof that.onError === 'function') { + that.onError('cronjob recieved error', { + description: 'cronjob recieved error', + error, + uid + }) + } + }) + }, nextTimestamp - Date.now(), uid) + } + + async setLoopTask (func: () => void | Promise, uid: string): Promise { + const that = this + return await this.setImmediate(function () { + promiseTry(func) + .catch((error) => { + if (typeof that.onError === 'function') { + that.onError('loop task recieved error', { + description: 'loop task recieved error', + error: error as Error, + uid + }) + } + }) + .finally(() => { + that.setLoopTask(func, uid) + }) + }, uid) + } } -export interface CronJobOptions { - application: string - adapter: Adapter - context?: unknown +/** + * Extracted from https://github.com/veliovgroup/josk/commit/e81e51ddbdb8f119331534616988ea81174da027 + * License as BSD 3-Clause "New" or "Revised" License https://github.com/veliovgroup/josk/blob/e81e51ddbdb8f119331534616988ea81174da027/LICENSE + * It is modified to provide more customization. + */ - minTickMS?: number - maxTickMS?: number - maxExecutionMS?: number +interface MongoAdapterOptions extends JoSkMongoAdapterOptions { + collectionName?: string } -export class CronJob extends EventEmitter { - // state - application: string - #tasks: Record - #deleted: Record - nextTick: null | NodeJS.Timeout - isDestroyed: boolean - isLocked: boolean - adapter: Adapter - readonly #context: RootContext - - // timing options - minTickMS: number - maxTickMS: number - maxExecutionMS: number - - constructor (options: CronJobOptions) { - super() - this.application = options.application - this.#tasks = {} - this.#deleted = {} - this.nextTick = null - this.isDestroyed = false - this.isLocked = false - this.adapter = options.adapter - this.#context = options.context as any - - this.minTickMS = options?.minTickMS ?? 128 - this.maxTickMS = options?.maxTickMS ?? 768 - this.maxExecutionMS = options?.maxExecutionMS ?? 900_000 - this.#tick() - } +const ensureIndex = async (collection: Collection, keys: any, opts: any) => { + try { + await collection.createIndex(keys, opts) + } catch (e: any) { + if (e.code === 85) { + let indexName + const indexes = await collection.indexes() + for (const index of indexes) { + let drop = true + for (const indexKey of Object.keys(keys)) { + if (typeof index.key[indexKey] === 'undefined') { + drop = false + break + } + } + + for (const indexKey of Object.keys(index.key)) { + if (typeof keys[indexKey] === 'undefined') { + drop = false + break + } + } + + if (drop) { + indexName = index.name + break + } + } - async setInterval( - executor: TaskExecutor, - ms: number, - uid: string, - context?: Context - ): Promise { - if (this.isDestroyed) return '' - // we need to prefix uid - !uid.startsWith('interval-') && (uid = `interval-${uid}`) - this.#tasks[uid] = async () => { - try { - await executor((context ?? this.#context) as Context) - } catch (err) { - this.emit('error', err) + if (indexName) { + await collection.dropIndex(indexName) + await collection.createIndex(keys, opts) } + } else { + console.info(`[INFO] [josk] [MongoAdapter] [ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection.collectionName}" collection`, { keys, opts, details: e }) } - await this.adapter.createTask({ - uid, - once: false, - delay: ms, - }) - return uid } +} - async setTimeout( - executor: TaskExecutor, - ms: number, - uid: string, - context?: Context - ): Promise { - if (this.isDestroyed) return '' - // we need to prefix uid - !uid.startsWith('timeout-') && (uid = `timeout-${uid}`) - this.#tasks[uid] = async () => { - try { - await executor((context ?? this.#context) as Context) - } catch (err) { - this.emit('error', err) - } +const logError = (error: Error | unknown, ...args: unknown[]) => { + if (error) { + console.error('[josk] [MongoAdapter] [logError]:', error, ...args) + } +} + +export class MongoAdapter { + name: string + prefix: string + collectionName: string + lockCollectionName: string + resetOnInit: boolean + + uniqueName: string + db: Db + collection: Collection + lockCollection: Collection + joskInstance!: JoSk + + constructor (opts: MongoAdapterOptions) { + this.name = 'mongo' + this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '' + this.collectionName = opts.collectionName ?? '__JobTasks__' + this.lockCollectionName = opts.lockCollectionName ?? `${this.collectionName}.lock` + this.resetOnInit = opts.resetOnInit ?? false + + if (!opts.db) { + const err: any = Error('{db} option is required for MongoAdapter') + err.description = 'MongoDB database {db} option is required, e.g. returned from `MongoClient.connect` method' + throw err + } + + this.db = opts.db + this.uniqueName = `${this.collectionName}${this.prefix}` + this.collection = opts.db.collection(this.uniqueName) + ensureIndex(this.collection, { uid: 1 }, { background: false, unique: true }) + ensureIndex(this.collection, { uid: 1, isDeleted: 1 }, { background: false }) + ensureIndex(this.collection, { executeAt: 1 }, { background: false }) + + this.lockCollection = opts.db.collection(this.lockCollectionName) + ensureIndex(this.lockCollection, { expireAt: 1 }, { background: false, expireAfterSeconds: 1 }) + ensureIndex(this.lockCollection, { uniqueName: 1 }, { background: false, unique: true }) + + if (this.resetOnInit) { + this.collection.deleteMany({ + isInterval: false + }).then(() => {}).catch(logError) + + this.lockCollection.deleteMany({ + uniqueName: this.uniqueName + }).then(() => {}).catch(logError) } - await this.adapter.createTask({ - uid, - once: true, - delay: ms, - }) - return uid } - async setImmediate( - executor: TaskExecutor, - uid: string, - context?: Context - ): Promise { - if (this.isDestroyed) return '' - // we need to prefix uid - !uid.startsWith('immediate-') && (uid = `immediate-${uid}`) - this.#tasks[uid] = async () => { - try { - await executor((context ?? this.#context) as Context) - } catch (err) { - this.emit('error', err) + /** + * @async + * @memberOf MongoAdapter + * @name ping + * @description Check connection to MongoDB + * @returns {Promise} + */ + async ping () { + if (!this.joskInstance) { + const reason = 'JoSk instance not yet assigned to {joskInstance} of Storage Adapter context' + return { + status: reason, + code: 503, + statusCode: 503, + error: new Error(reason), } } - await this.adapter.createTask({ - uid, - once: true, - delay: 0, - }) - return uid - } - async setCronJob( - executor: TaskExecutor, - cron: string, - uid: string, - context?: Context - ): Promise { - return await this.#setCronJob(executor, cron, uid, true, context) - } + try { + const ping = await this.db.command({ ping: 1 }) + if (ping?.ok === 1) { + return { + status: 'OK', + code: 200, + statusCode: 200, + } + } + } catch (pingError) { + return { + status: 'Internal Server Error', + code: 500, + statusCode: 500, + error: pingError + } + } - async #setCronJob( - executor: TaskExecutor, - cron: string, - uid: string, - fresh: boolean, - context?: Context - ): Promise { - const nextExecuteAt = Number(parseExpression(cron).next().toDate()) - const ms = nextExecuteAt - Date.now() - const _uid = `timeout-cron-${uid}` - if (fresh) this.#deleted[_uid] = false - if (this.#deleted[_uid]) return '' - - return await this.setTimeout(async (context) => { - if (this.#deleted[_uid]) return - setImmediate(() => { - // we execute immediately for the next task - Promise.race([ - executor(context), - this.#setCronJob(executor, cron, uid, false, context), - ]).catch((err) => { - this.emit('error', err) - }) - }) - }, ms, _uid, context) + return { + status: 'Service Unavailable', + code: 503, + statusCode: 503, + error: new Error('Service Unavailable') + } } - async setLoopTask( - executor: TaskExecutor, - uid: string, - context?: Context - ): Promise { - return await this.#setLoopTask(executor, uid, true, context) - } + async acquireLock () { + const expireAt = new Date(Date.now() + this.joskInstance.zombieTime) - async #setLoopTask( - executor: TaskExecutor, - uid: string, - fresh: boolean, - context?: Context - ): Promise { - const _uid = `immediate-loop-${uid}` - if (fresh) this.#deleted[_uid] = false - if (this.#deleted[_uid]) return '' - return await this.setImmediate(async (context) => { - if (this.#deleted[_uid]) return - try { - await executor(context) - } finally { - await this.#setLoopTask(executor, uid, false, context) + try { + const record = await this.lockCollection.findOne({ + uniqueName: this.uniqueName + }, { + projection: { + uniqueName: 1 + } + }) + + if (record?.uniqueName === this.uniqueName) { + return false } - }, _uid, context) - } - clearInterval (uid: string): void { - this.#deleted[uid] = true - this.#deleteTask(uid) - } + const result = await this.lockCollection.insertOne({ + uniqueName: this.uniqueName, + expireAt + }) - clearTimeout (uid: string): void { - this.clearInterval(uid) - } + if (result.insertedId) { + return true + } + return false + } catch (opError: any) { + if (opError?.code === 11000) { + return false + } - async #_deleteTask (uid: string): Promise { - const task = await this.adapter.updateTask(uid, Date.now() + this.maxExecutionMS, true) - if (task !== null) { - await this.adapter.deleteTask(uid) + this.joskInstance.__errorHandler(opError, '[acquireLock] [opError]', 'Exception inside MongoAdapter#acquireLock() method') + return false } } - #deleteTask (uid: string): void { - this - .#_deleteTask(uid) - .catch((err) => { - this.emit('error', err) - }) - } - - async destroy (): Promise { - if (!this.isDestroyed) { - this.isDestroyed = true - this.nextTick !== null && clearTimeout(this.nextTick) - this.nextTick = null - // we need to release lock when destroy - await this.#releaseLock() - return true - } - return false + async releaseLock () { + await this.lockCollection.deleteOne({ uniqueName: this.uniqueName }) } - async #aquireLock (nextExecuteAt: number): Promise { + async remove (uid: string) { try { - this.isLocked = await this.adapter.aquireLock(this.application, nextExecuteAt) - return this.isLocked - } catch (err) { - this.emit('error', err) + const result = await this.collection.findOneAndUpdate({ + uid, + isDeleted: false + }, { + $set: { + isDeleted: true + } + }, { + returnDocument: 'before', + projection: { + _id: 1, + isDeleted: 1 + } + }) + + const res = result?._id ? result : result?.value // mongodb 5 vs. 6 compatibility + if (res?.isDeleted === false) { + const deleteResult = await this.collection.deleteOne({ _id: res._id }) + return deleteResult?.deletedCount >= 1 + } + + return false + } catch (opError: any) { + this.joskInstance.__errorHandler(opError, '[remove] [opError]', 'Exception inside MongoAdapter#remove() method', uid) return false } } - async #releaseLock (): Promise { + async add (uid: string, isInterval: boolean, delay: number) { + const next = Date.now() + delay + try { - if (!this.isLocked) return - await this.adapter.releaseLock(this.application) - } catch (err) { - this.emit('error', err) - } - } + const task = await this.collection.findOne({ + uid + }) + + if (!task) { + await this.collection.insertOne({ + uid, + delay, + executeAt: new Date(next), + isInterval, + isDeleted: false + }) - async #_execute (task: Task): Promise { - if (this.isDestroyed || task.isDeleted) return + return true + } - const done = async (executeAt: number): Promise => { - await this.adapter.updateTask(task.uid, executeAt, task.once) + if (task.isDeleted === false) { + let update: any = null + if (task.delay !== delay) { + update = { delay } + } + + if (+task.executeAt !== next) { + if (!update) { + update = {} + } + update.executeAt = new Date(next) + } + + if (update) { + await this.collection.updateOne({ + uid + }, { + $set: update + }) + } + + return true + } + + return false + } catch (opError: any) { + this.joskInstance.__errorHandler(opError, '[add] [opError]', 'Exception inside MongoAdapter#add()', uid) + return false } + } - const executor = this.#tasks[task.uid] - if (typeof executor !== 'function') { - // when we missing runtime - // we delay the task to maxExecutionMS - await done(Date.now() + this.maxExecutionMS) - this.emit('error', Error(`Task "${task.uid}" is missing runtime function.`)) - return + async update (task: any, nextExecuteAt: Date) { + if (typeof task !== 'object' || typeof task.uid !== 'string') { + this.joskInstance.__errorHandler({ task }, '[MongoAdapter] [update] [task]', 'Task malformed or undefined') + return false } - // when it only run once, we remove task before execute - // it prevent another instance to pickup the task - if (task.once) await this.#_deleteTask(task.uid) - // execute - await executor() - const timestamp = Date.now() - const nextExecuteAt = timestamp + task.delay - // we emit executed event - this.emit('executed', { - uid: task.uid, - delay: task.delay, - timestamp, - }) - // when it allows to run multiple times - // we update the executedAt information - if (!task.once) await done(nextExecuteAt) - } + if (!(nextExecuteAt instanceof Date)) { + this.joskInstance.__errorHandler({ nextExecuteAt }, '[MongoAdapter] [update] [nextExecuteAt]', 'Next execution date is malformed or undefined', task.uid) + return false + } - #execute (task: Task): void { - this - .#_execute(task) - .catch((err) => { - this.emit('error', err) + try { + const updateResult = await this.collection.updateOne({ + uid: task.uid + }, { + $set: { + executeAt: nextExecuteAt + } }) + return updateResult?.modifiedCount >= 1 + } catch (opError) { + this.joskInstance.__errorHandler(opError, '[MongoAdapter] [update] [opError]', 'Exception inside RedisAdapter#update() method', task.uid) + return false + } } - async #_executeAll (): Promise { - if (this.isDestroyed) return + async iterate (nextExecuteAt: Date) { + const _ids = [] + const tasks = [] - const now = Date.now() - const nextExecuteAt = now + this.maxExecutionMS - const isLocked = await this.#aquireLock(nextExecuteAt) - if (!isLocked) { - this.#tick() - return - } + const cursor = this.collection.find({ + executeAt: { + $lte: new Date() + } + }, { + projection: { + _id: 1, + uid: 1, + delay: 1, + isDeleted: 1, + isInterval: 1 + } + }) try { - const tasks = await this.adapter.fetchTasks(now) - // we hold all tasks by delay executeAt to the maxExecutionMS - await this.adapter.updateTasks(tasks.map((o) => o.uid), nextExecuteAt) - for (const task of tasks) { - this.#execute(task) + let task: any + while (await cursor.hasNext()) { + task = await cursor.next() + _ids.push(task._id) + tasks.push(task) } - } catch (err) { - this.emit('error', err) - } finally { - await this.#releaseLock() - this.#tick() + await this.collection.updateMany({ + _id: { + $in: _ids + } + }, { + $set: { + executeAt: nextExecuteAt + } + }) + } catch (mongoError) { + logError('[iterate] mongoError:', mongoError) } - } - #executeAll (): void { - this - .#_executeAll() - .catch((err) => { - this.emit('error', err) - }) - } + for (const task of tasks) { + this.joskInstance.__execute(task) + } - #tick (): void { - this.nextTick = setTimeout(() => { - this.#executeAll() - }, Math.round((Math.random() * this.maxTickMS) + this.minTickMS)) + await cursor.close() } } + +export { RedisAdapter } diff --git a/packages/cronjob/lib/error.ts b/packages/cronjob/lib/error.ts deleted file mode 100644 index d2ff1f2f..00000000 --- a/packages/cronjob/lib/error.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { format } from 'util' - -function createError (code: string, message: string, statusCode: number = 500): (...args: any[]) => Error { - code = code.toUpperCase() - - return function CustomError () { - const err: any = Error(format(message, ...arguments)) - err.code = code - err.statusCode = statusCode - return err - } -} - -export const FST_CJ_INVALID_OPTION = createError('FST_CJ_INVALID_OPTION', '%s is expected to be "%s", but recieved "%s"') diff --git a/packages/cronjob/lib/index.ts b/packages/cronjob/lib/index.ts index b8daf4e0..f655e2b7 100644 --- a/packages/cronjob/lib/index.ts +++ b/packages/cronjob/lib/index.ts @@ -1,42 +1,25 @@ import { type FastifyPluginAsync } from 'fastify' import FastifyPlugin from 'fastify-plugin' -import { type Adapter, type AdapterOptions } from './adapter/adapter' -import { CronJob, type CronJobOptions } from './cronjob' -import { FST_CJ_INVALID_OPTION } from './error' -import { kAdapter } from './symbols' +import { JoSkOptions } from 'josk' +import { CronJob } from './cronjob' declare module 'fastify' { interface FastifyInstance { - cronjob: CronJob + cronjob: CronJob } } -export interface FastifyCronJobOption extends Omit { - adapter: typeof Adapter - adapterOption: AdapterOptions +export interface FastifyCronJobOption extends JoSkOptions { + } const plugin: FastifyPluginAsync = async function (fastify, option) { - // we check if adapter provide special symbol - if (option.adapter?.[kAdapter] !== true) { - throw FST_CJ_INVALID_OPTION('option.adapter', 'Adapter', option.adapter) - } - - const { adapter: Adapter } = option - const adapter = new Adapter(option.adapterOption) - - await adapter.prepare() - - const cronjob = new CronJob({ - context: fastify, - ...option, - adapter, - }) + const cronjob = new CronJob(option) fastify.decorate('cronjob', cronjob) - fastify.addHook('onClose', async function () { - await cronjob.destroy() + fastify.addHook('onClose', () => { + cronjob.destroy() }) } @@ -51,3 +34,5 @@ export const fastifyCronJob = FastifyPlugin(plugin, { dependencies: [], encapsulate: false, }) + +export { MongoAdapter, RedisAdapter } from './cronjob' diff --git a/packages/cronjob/lib/symbols.ts b/packages/cronjob/lib/symbols.ts deleted file mode 100644 index bd46d111..00000000 --- a/packages/cronjob/lib/symbols.ts +++ /dev/null @@ -1 +0,0 @@ -export const kAdapter = Symbol('[FastifyCronJob.Adapter]') diff --git a/packages/cronjob/package.json b/packages/cronjob/package.json index f2c6f1b6..a515b203 100644 --- a/packages/cronjob/package.json +++ b/packages/cronjob/package.json @@ -12,22 +12,6 @@ ".": { "import": "./lib/mjs/index.js", "require": "./lib/index.js" - }, - "./lib/adapter/adapter": { - "import": "./lib/mjs/adapter/adapter.js", - "require": "./lib/adapter/adapter.js" - }, - "./lib/adapter/adapter.js": { - "import": "./lib/mjs/adapter/adapter.js", - "require": "./lib/adapter/adapter.js" - }, - "./lib/adapter/mongodb": { - "import": "./lib/mjs/adapter/mongodb.js", - "require": "./lib/adapter/mongodb.js" - }, - "./lib/adapter/mongodb.js": { - "import": "./lib/mjs/adapter/mongodb.js", - "require": "./lib/adapter/mongodb.js" } }, "scripts": { @@ -58,8 +42,10 @@ "@types/node": "^22.7.5", "c8": "^10.1.2", "cross-env": "^7.0.3", + "dotenv": "^16.4.5", "eslint": "^9.12.0", "fastify": "^5.0.0", + "josk": "^5.0.0", "mongodb": "^6.9.0", "neostandard": "^0.11.6", "rimraf": "^6.0.1", @@ -72,6 +58,7 @@ "fastify-plugin": "^5.0.1" }, "peerDependencies": { + "josk": "^5.0.0", "mongodb": "^6.9.0" }, "peerDependenciesMeta": { diff --git a/packages/cronjob/test/adapter/mongodb.test.ts b/packages/cronjob/test/adapter/mongodb.test.ts deleted file mode 100644 index 5afe7d0f..00000000 --- a/packages/cronjob/test/adapter/mongodb.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { test } from '@kakang/unit' -import { parseExpression } from 'cron-parser' -import Fastify from 'fastify' -import { MongoClient } from 'mongodb' -import { fastifyCronJob } from '../../lib' -import { MongoDBAdapter, type MongoDBAdapterOptions } from '../../lib/adapter/mongodb' - -const MONGODB_URI = 'mongodb://127.0.0.1:27017/?replicaSet=rs0' -const minTickMS = 32 -const maxTickMS = 256 -const RANDOM_GAP = (maxTickMS - minTickMS) + 3072 - -function createDeferredPromise (): { promise: Promise, resolve: () => void, reject: () => void } { - const promise: any = {} - promise.promise = new Promise(function (resolve, reject) { - promise.resolve = resolve - promise.reject = reject - }) - return promise -} - -function isJob (uid: unknown, expected: string): boolean { - return String(uid).includes(expected) -} - -test('MongoDBAdapter', async function (t) { - const client = new MongoClient(MONGODB_URI) - await client.connect() - - const db = client.db('foobar') - const fastify = Fastify() - - const adapterOption: MongoDBAdapterOptions = { - db, - } - await fastify.register(fastifyCronJob, { - application: t.name, - adapter: MongoDBAdapter, - adapterOption, - minTickMS, - maxTickMS, - maxExecutionMS: 8000, - }) - - fastify.cronjob.on('executed', function (task) { - ticks[task.uid]++ - if (timestamps[task.uid].length < 2) { - timestamps[task.uid].push(task.timestamp as number) - } else { - timestamps[task.uid][1] = task.timestamp as number - } - - const now = Date.now() - - if ( - ( - isJob(task.uid, 'interval') || - isJob(task.uid, 'timeout') || - isJob(task.uid, 'immediate') - ) && timestamps[task.uid].length === 2 - ) { - const expected = timestamps[task.uid][0] - const _from = expected - RANDOM_GAP - const _to = expected + RANDOM_GAP - const diff = now - expected - - if (isJob(task.uid, 'interval')) { - if (ticks[task.uid] >= 2) { - fastify.cronjob.clearInterval(task.uid as string) - t.equal(ticks[task.uid], 2) - dones[task.uid]() - } else { - timestamps[task.uid][0] = now + task.delay - t.equal(ticks[task.uid], 1) - } - } else if (isJob(task.uid, 'cron')) { - if (ticks[task.uid] >= 2) { - fastify.cronjob.clearInterval(task.uid as string) - t.equal(ticks[task.uid] >= 2, true) - dones[task.uid]() - } else { - timestamps[task.uid][0] = now + task.delay - t.equal(ticks[task.uid], 1) - } - } else if (isJob(task.uid, 'loop')) { - fastify.cronjob.clearInterval(task.uid as string) - t.equal(ticks[task.uid] >= 1, true) - dones[task.uid]() - } else { - fastify.cronjob.clearInterval(task.uid as string) - t.equal(ticks[task.uid], 1) - dones[task.uid]() - } - - if (!isJob(task.uid, 'loop') && !isJob(task.uid, 'cron')) { - t.equal(_from < now && now < _to, true) - t.equal(diff < RANDOM_GAP, true) - } - } - }) - - const dones: Record void> = {} - const timestamps: Record = {} - const ticks: Record = {} - - const checkInterval = async function (interval: number): Promise { - const promise = createDeferredPromise() - const uid = await fastify.cronjob.setInterval(async () => {}, interval, '' + interval) - dones[uid] = promise.resolve - timestamps[uid] = [Date.now() + interval] - ticks[uid] = 0 - await promise.promise - } - - const checkTimeout = async function (interval: number): Promise { - const promise = createDeferredPromise() - const uid = await fastify.cronjob.setTimeout(async () => {}, interval, '' + interval) - dones[uid] = promise.resolve - timestamps[uid] = [Date.now() + interval] - ticks[uid] = 0 - await promise.promise - } - - const checkCronJob = async function (cron: string): Promise { - const next = parseExpression(cron).next().toDate() - const promise = createDeferredPromise() - const uid = await fastify.cronjob.setCronJob(async () => {}, cron, cron) - dones[uid] = promise.resolve - timestamps[uid] = [+next] - ticks[uid] = 0 - await promise.promise - } - - const checkLoopTask = async function (name: number): Promise { - const promise = createDeferredPromise() - const uid = await fastify.cronjob.setLoopTask(async () => {}, '' + name) - dones[uid] = promise.resolve - timestamps[uid] = [Date.now()] - ticks[uid] = 0 - await promise.promise - } - - t.after(async function () { - await fastify.close() - await db.dropDatabase() - await client.close(true) - }) - - t.test('fastify.cronjob', function (t, done) { - const ok: typeof t.ok = t.ok - ok(fastify.cronjob) - t.equal(typeof fastify.cronjob.setTimeout, 'function') - t.equal(typeof fastify.cronjob.setInterval, 'function') - t.equal(typeof fastify.cronjob.setImmediate, 'function') - done() - }) - - t.test('interval', async function () { - const promises = [ - checkInterval(384), - checkInterval(512), - checkInterval(640), - checkInterval(768), - checkInterval(778), - checkInterval(788), - checkInterval(789), - checkInterval(800), - checkInterval(801), - checkInterval(802), - ] - await Promise.allSettled(promises) - }) - - t.test('timeout', async function () { - const promises = [ - checkTimeout(384), - checkTimeout(512), - checkTimeout(640), - checkTimeout(768), - checkTimeout(778), - checkTimeout(788), - checkTimeout(789), - checkTimeout(800), - checkTimeout(801), - checkTimeout(802), - ] - await Promise.allSettled(promises) - }) - - t.test('cronjob', async function () { - const promises = [ - checkCronJob('* * * * * *'), - checkCronJob('*/2 * * * * *'), - checkCronJob('*/3 * * * * *'), - checkCronJob('*/4 * * * * *'), - checkCronJob('*/5 * * * * *'), - ] - await Promise.allSettled(promises) - }) - - t.test('looptask', async function () { - const promises = [ - checkLoopTask(384), - ] - await Promise.allSettled(promises) - }) -}) diff --git a/packages/cronjob/test/config.ts b/packages/cronjob/test/config.ts new file mode 100644 index 00000000..463f47f2 --- /dev/null +++ b/packages/cronjob/test/config.ts @@ -0,0 +1,6 @@ +import dotenv from 'dotenv' +dotenv.config() + +// Use environment variables for local +// Use fallback value for Github Actions +export const MONGODB_URL = process.env.MONGODB_URL ?? 'mongodb://127.0.0.1:27017/?replicaSet=rs0' diff --git a/packages/cronjob/test/error.test.ts b/packages/cronjob/test/error.test.ts deleted file mode 100644 index ea2cb6bf..00000000 --- a/packages/cronjob/test/error.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { test } from '@kakang/unit' -import Fastify from 'fastify' -import { fastifyCronJob } from '../lib' - -test('missing Adapter', async function (t) { - const ok: typeof t.ok = t.ok - try { - const fastify = Fastify() - fastify.register(fastifyCronJob) - await fastify.ready() - ok(false, 'should not success') - } catch (err: any) { - ok(err) - t.equal(err.code, 'FST_CJ_INVALID_OPTION') - } -}) diff --git a/packages/cronjob/test/mongodb.test.ts b/packages/cronjob/test/mongodb.test.ts new file mode 100644 index 00000000..000ed1fb --- /dev/null +++ b/packages/cronjob/test/mongodb.test.ts @@ -0,0 +1,53 @@ +import Fastify, { FastifyInstance } from 'fastify' +import { MongoClient } from 'mongodb' +import { after, before, test, TestContext } from 'node:test' +import { setTimeout } from 'node:timers/promises' +import { fastifyCronJob, MongoAdapter } from '../lib' +import { MONGODB_URL } from './config' + +let client: MongoClient +let fastify: FastifyInstance +before(async () => { + client = new MongoClient(MONGODB_URL) + fastify = Fastify() + + const db = client.db('cicd') + + await fastify.register(fastifyCronJob, { + adapter: new MongoAdapter({ + db, + collectionName: 'sys.timer' + }) + }) + + await fastify.ready() +}) + +after(async () => { + await fastify.close() + await setTimeout(500) + await client.close() +}) + +test('cron', async function (t: TestContext) { + const cronTick: Date[] = [] + const cron = await fastify.cronjob.setCronJob(() => { + cronTick.push(new Date()) + }, '*/2 * * * * *', '*/2 * * * * *') + await setTimeout(5000) + fastify.cronjob.clearTimeout(cron) + + t.assert.ok(cronTick.length >= 1 && cronTick.length <= 3) +}) + +test('loop', async function (t: TestContext) { + const loopTick: Date[] = [] + const loop = await fastify.cronjob.setLoopTask(async () => { + loopTick.push(new Date()) + await setTimeout(2000) + }, '1s') + await setTimeout(5000) + fastify.cronjob.clearTimeout(loop) + + t.assert.ok(loopTick.length >= 1 && loopTick.length <= 3) +}) diff --git a/packages/cronjob/tsconfig.json b/packages/cronjob/tsconfig.json index e2f72419..a9eb649a 100644 --- a/packages/cronjob/tsconfig.json +++ b/packages/cronjob/tsconfig.json @@ -19,7 +19,8 @@ "noImplicitThis": true, "skipLibCheck": true, - "esModuleInterop": true + "esModuleInterop": true, + "typeRoots": ["./node_modules/@types", "./types"] }, "exclude": ["node_modules"] } diff --git a/packages/cronjob/types/josk.d.ts b/packages/cronjob/types/josk.d.ts new file mode 100644 index 00000000..3b6b1b47 --- /dev/null +++ b/packages/cronjob/types/josk.d.ts @@ -0,0 +1,115 @@ +import { Collection, Db } from 'mongodb' + +declare module 'josk' { + + interface ErrorDetails { + description: string + error: Error + uid: null | string + task?: unknown + } + + interface ExecutedDetails { + uid: string + date: Date + delay: number + timestamp: number + } + + type OnErrorFunc = (title: string, details: ErrorDetails) => void + type OnExecutedFunc = (uid: string, details: ExecutedDetails) => void + type AsyncTaskFunc = () => Promise + type SyncTaskFunc = () => void + type SyncNextTaskFunc = (ready: (next?: Date) => void) => void + + export interface JoSkOptions { + adapter: unknown + debug?: boolean + autoClear?: boolean + zombieTime?: number + minRevolvingDelay ?: number + maxRevolvingDelay ?: number + onError?: OnErrorFunc | false + onExecuted?: OnExecutedFunc | false + } + + export class JoSk { + debug: boolean + autoClear: boolean + isDestroyed: boolean + zombieTime: number + minRevolvingDelay: number + maxRevolvingDelay: number + onError: OnErrorFunc | false + onExecuted: OnExecutedFunc | false + nextRevolutionTimeout: null | NodeJS.Timeout + + tasks: Record + + // Public API + constructor (options: JoSkOptions) + async ping (): Promise + async setInterval (func: AsyncTaskFunc | SyncNextTaskFunc, delay: number, uid: string): Promise + async setTimeout (func: AsyncTaskFunc | SyncTaskFunc, delay: number, uid: string): Promise + async setImmediate (func: AsyncTaskFunc | SyncTaskFunc, uid: string): Promise + async clearInterval (uid: string): Promise + async clearTimeout (uid: string): Promise + destroy (): boolean + + // Internal API + _debug (...args: unknown): void + __checkState (): boolean + async __remove (timerId: string): Promise + async __add (uid: string, isInterval: boolean, delay: number): Promise + async __execute (task: AsyncTaskFunc | SyncTaskFunc): Promise + async __iterate (): Promise + __tick (): void + __errorHandler (error: Error | unknown, title: string, description: string, uid?: string): void + } + + export interface RedisAdapterOptions { + client: unknown + prefix?: string + resetOnInit?: boolean + } + + export class RedisAdapter { + constructor (options: RedisAdapterOptions) + + async ping (): Promise + async acquireLock (): Promise + async releaseLock (): Promise + async remove (uid: string): Promise + async add (uid: string, isInterval: boolean, delay: number): Promise + async update (task: unknown, nextExecuteAt: Date): Promise + async iterate (nextExecuteAt: Date): Promise + + __getTaskKey (uid: string): void + } + + export interface MongoAdapterOptions { + db: Db + lockCollectionName?: string + prefix?: string + resetOnInit?: boolean + } + + export class MongoAdapter { + db: Db + prefix: string + uniqueName: string + lockCollectionName: string + collection: Collection + lockCollection: Collection + + constructor (options: MongoAdapterOptions) + + async ping (): Promise + async acquireLock (): Promise + async releaseLock (): Promise + async remove (uid: string): Promise + async add (uid: string, isInterval: boolean, delay: number): Promise + async update (task: unknown, nextExecuteAt: Date): Promise + async iterate (nextExecuteAt: Date): Promise + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b177afa1..0b1209f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,12 +72,18 @@ importers: cross-env: specifier: ^7.0.3 version: 7.0.3 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 eslint: specifier: ^9.12.0 version: 9.12.0 fastify: specifier: ^5.0.0 version: 5.0.0 + josk: + specifier: ^5.0.0 + version: 5.0.0 mongodb: specifier: ^6.9.0 version: 6.9.0 @@ -1324,6 +1330,10 @@ packages: jose@5.9.3: resolution: {integrity: sha512-egLIoYSpcd+QUF+UHgobt5YzI2Pkw/H39ou9suW687MY6PmCwPmkNV/4TNjn1p2tX5xO3j0d0sq5hiYE24bSlg==} + josk@5.0.0: + resolution: {integrity: sha512-hD82IqX1kCTzetZn9sk8jOiMW7emArlYclsykk4+BOSkQlnfRN01tyAoNsOO9eYkkUg3rcGcui1h4A/iwkHp2w==} + engines: {node: '>=14.20.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3206,6 +3216,8 @@ snapshots: jose@5.9.3: {} + josk@5.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: