From 83c6892d6e4ad9868e9bb1d08266c69fd4c19863 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 15 Apr 2026 13:21:53 +0700 Subject: [PATCH] feat: add D1 storage layer with per-module migration runner - SqlStore interface + CF D1 wrapper + per-module factory (table prefix convention) - init signature extended to ({ db, sql, env }); sql is null when DB binding absent - custom migration runner walks src/modules/*/migrations/*.sql, tracks applied in _migrations table - npm run db:migrate with --dry-run and --local flags; chained into deploy - fake-d1 test helper with subset of SQL semantics for retention and history tests --- package-lock.json | 1016 ++++++++++++++++++++++++++++- package.json | 7 +- scripts/migrate.js | 161 +++++ src/bot.js | 17 + src/db/cf-sql-store.js | 80 +++ src/db/create-sql-store.js | 64 ++ src/db/kv-store-interface.js | 8 +- src/db/sql-store-interface.js | 40 ++ src/index.js | 21 +- src/modules/index.js | 2 +- src/modules/registry.js | 52 +- src/modules/validate-command.js | 2 +- tests/db/create-sql-store.test.js | 116 ++++ tests/fakes/fake-d1.js | 290 ++++++++ wrangler.toml | 18 + 15 files changed, 1879 insertions(+), 15 deletions(-) create mode 100644 scripts/migrate.js create mode 100644 src/db/cf-sql-store.js create mode 100644 src/db/create-sql-store.js create mode 100644 src/db/sql-store-interface.js create mode 100644 tests/db/create-sql-store.test.js create mode 100644 tests/fakes/fake-d1.js diff --git a/package-lock.json b/package-lock.json index ac425d9..c92f769 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.0", + "eslint": "^10.2.0", + "eslint-plugin-jsdoc": "^62.9.0", "vitest": "^2.1.0", "wrangler": "^3.90.0" }, @@ -321,6 +323,33 @@ "tslib": "^2.4.0" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", + "integrity": "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@typescript-eslint/types": "^8.58.0", + "comment-parser": "1.4.6", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@es-joy/resolve.exports": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", + "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@esbuild-plugins/node-globals-polyfill": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", @@ -736,6 +765,113 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -752,6 +888,58 @@ "integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==", "license": "MIT" }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -1510,6 +1698,26 @@ "win32" ] }, + "node_modules/@sindresorhus/base62": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", + "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1517,6 +1725,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1655,6 +1884,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", @@ -1665,6 +1904,33 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/as-table": { "version": "1.0.55", "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", @@ -1685,6 +1951,16 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1692,6 +1968,19 @@ "dev": true, "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1778,6 +2067,16 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/comment-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", + "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1788,6 +2087,21 @@ "node": ">= 0.6" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", @@ -1822,6 +2136,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defu": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", @@ -1899,6 +2220,190 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "62.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz", + "integrity": "sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.86.0", + "@es-joy/resolve.exports": "1.2.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.6", + "debug": "^4.4.3", + "escape-string-regexp": "^4.0.0", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^2.0.0", + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "to-valid-identifier": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1909,6 +2414,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1948,6 +2463,78 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1974,6 +2561,19 @@ "source-map": "^0.6.1" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -1996,6 +2596,43 @@ "node": "^12.20.0 || >=14.13.1" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/is-arrayish": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", @@ -2004,6 +2641,107 @@ "license": "MIT", "optional": true }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", + "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2060,6 +2798,22 @@ "node": ">=16.13" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2095,6 +2849,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -2115,6 +2876,13 @@ } } }, + "node_modules/object-deep-merge": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", + "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", + "dev": true, + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -2122,6 +2890,93 @@ "dev": true, "license": "MIT" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -2182,6 +3037,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", @@ -2189,6 +3054,29 @@ "dev": true, "license": "Unlicense" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -2297,7 +3185,6 @@ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -2346,6 +3233,29 @@ "@img/sharp-win32-x64": "0.33.5" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2392,6 +3302,31 @@ "dev": true, "license": "MIT" }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2472,6 +3407,23 @@ "node": ">=14.0.0" } }, + "node_modules/to-valid-identifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", + "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/base62": "^1.0.0", + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -2486,6 +3438,19 @@ "license": "0BSD", "optional": true }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -2527,6 +3492,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -2692,6 +3667,22 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2709,6 +3700,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/workerd": { "version": "1.20250718.0", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", @@ -3202,6 +4203,19 @@ } } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/youch": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", diff --git a/package.json b/package.json index 8842cd7..4d0611b 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ }, "scripts": { "dev": "wrangler dev", - "deploy": "wrangler deploy && npm run register", + "deploy": "wrangler deploy && npm run db:migrate && npm run register", + "db:migrate": "node scripts/migrate.js", "register": "node --env-file=.env.deploy scripts/register.js", "register:dry": "node --env-file=.env.deploy scripts/register.js --dry-run", - "lint": "biome check src tests scripts", + "lint": "biome check src tests scripts && eslint src", "format": "biome format --write src tests scripts", "test": "vitest run" }, @@ -21,6 +22,8 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.0", + "eslint": "^10.2.0", + "eslint-plugin-jsdoc": "^62.9.0", "vitest": "^2.1.0", "wrangler": "^3.90.0" } diff --git a/scripts/migrate.js b/scripts/migrate.js new file mode 100644 index 0000000..85b6063 --- /dev/null +++ b/scripts/migrate.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * @file migrate — custom D1 migration runner for per-module SQL files. + * + * Discovers all `src/modules/*/ migrations; /*.sql` files, sorts them + * deterministically (by `{moduleName}/{filename}`), then applies each NEW + * migration via `wrangler d1 execute miti99bot-db --remote --file=`. + * + * Applied migrations are tracked in a `_migrations(name TEXT PRIMARY KEY, + * applied_at INTEGER)` table in the D1 database itself. + * + * Flags: + * --dry-run Print the migration plan without executing anything. + * --local Apply against local dev D1 (omits --remote flag). + * + * Usage: + * node scripts/migrate.js + * node scripts/migrate.js --dry-run + * node scripts/migrate.js --local + */ + +import { execSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +const DB_NAME = "miti99bot-db"; +const PROJECT_ROOT = resolve(import.meta.dirname, ".."); +const MODULES_DIR = join(PROJECT_ROOT, "src", "modules"); + +const dryRun = process.argv.includes("--dry-run"); +const local = process.argv.includes("--local"); +const remoteFlag = local ? "" : "--remote"; + +/** + * Run a wrangler d1 execute command and return stdout. + * + * @param {string} sql — inline SQL string (used for bootstrap queries) + * @param {string} [file] — path to a .sql file (mutually exclusive with sql) + * @returns {string} + */ +function wranglerExecute(sql, file) { + const target = file ? `--file="${file}"` : `--command="${sql.replace(/"/g, '\\"')}"`; + const cmd = `npx wrangler d1 execute ${DB_NAME} ${remoteFlag} ${target} --json`; + try { + return execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString(); + } catch (err) { + const stderr = err.stderr?.toString() ?? ""; + const stdout = err.stdout?.toString() ?? ""; + throw new Error(`wrangler error:\n${stderr || stdout}`); + } +} + +/** + * Ensure the _migrations table exists. + */ +function bootstrapMigrationsTable() { + const sql = + "CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL);"; + wranglerExecute(sql); +} + +/** + * Fetch already-applied migration names from D1. + * + * @returns {Set} + */ +function getAppliedMigrations() { + const out = wranglerExecute("SELECT name FROM _migrations;"); + /** @type {any[]} */ + let parsed = []; + try { + const json = JSON.parse(out); + // wrangler --json wraps results in an array of result objects + parsed = Array.isArray(json) ? (json[0]?.results ?? []) : []; + } catch { + // If the table is freshly created it may return empty JSON — treat as empty. + } + return new Set(parsed.map((r) => r.name)); +} + +/** + * Record a migration as applied. + * + * @param {string} name + */ +function recordMigration(name) { + const sql = `INSERT INTO _migrations (name, applied_at) VALUES ('${name}', ${Date.now()});`; + wranglerExecute(sql); +} + +/** + * Discover all migration files as { name, absPath } sorted deterministically. + * name = "{moduleName}/{filename}" — used as the unique migration key. + * + * @returns {Array<{name: string, absPath: string}>} + */ +function discoverMigrations() { + if (!existsSync(MODULES_DIR)) return []; + + /** @type {Array<{name: string, absPath: string}>} */ + const found = []; + + for (const entry of readdirSync(MODULES_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const migrationsDir = join(MODULES_DIR, entry.name, "migrations"); + if (!existsSync(migrationsDir)) continue; + + for (const file of readdirSync(migrationsDir).sort()) { + if (!file.endsWith(".sql")) continue; + found.push({ + name: `${entry.name}/${file}`, + absPath: join(migrationsDir, file), + }); + } + } + + // Sort by the composite name so ordering is deterministic across modules. + found.sort((a, b) => a.name.localeCompare(b.name)); + return found; +} + +async function main() { + const all = discoverMigrations(); + + if (all.length === 0) { + console.log("No migration files found — nothing to do."); + return; + } + + if (dryRun) { + console.log(`DRY RUN — would apply up to ${all.length} migration(s):`); + for (const m of all) console.log(` ${m.name}`); + return; + } + + bootstrapMigrationsTable(); + const applied = getAppliedMigrations(); + + const pending = all.filter((m) => !applied.has(m.name)); + + if (pending.length === 0) { + console.log(`All ${all.length} migration(s) already applied.`); + return; + } + + console.log(`Applying ${pending.length} pending migration(s)...`); + + for (const migration of pending) { + console.log(` → ${migration.name}`); + wranglerExecute(null, migration.absPath); + recordMigration(migration.name); + console.log(" ✓ applied"); + } + + console.log("Done."); +} + +main().catch((err) => { + console.error(err.message ?? err); + process.exit(1); +}); diff --git a/src/bot.js b/src/bot.js index 5c8984c..d8538a6 100644 --- a/src/bot.js +++ b/src/bot.js @@ -9,12 +9,29 @@ import { Bot } from "grammy"; import { installDispatcher } from "./modules/dispatcher.js"; +import { getCurrentRegistry } from "./modules/registry.js"; /** @type {Bot | null} */ let botInstance = null; /** @type {Promise | null} */ let botInitPromise = null; +/** + * Returns the memoized registry, building it (and the bot) if needed. + * Shares the same instance used by the fetch handler so scheduled() and + * fetch() operate on identical registry state within a warm instance. + * + * @param {any} env + * @returns {Promise} + */ +export async function getRegistry(env) { + // If the bot is already initialised the registry was built as a side effect. + if (botInstance) return getCurrentRegistry(); + // Otherwise bootstrap via getBot (which calls buildRegistry internally). + await getBot(env); + return getCurrentRegistry(); +} + /** * Fail fast if any required env var is missing — better a 500 on first webhook * than a confusing runtime error inside grammY. diff --git a/src/db/cf-sql-store.js b/src/db/cf-sql-store.js new file mode 100644 index 0000000..ad381ea --- /dev/null +++ b/src/db/cf-sql-store.js @@ -0,0 +1,80 @@ +/** + * @file cf-sql-store — thin wrapper around a Cloudflare D1 database binding. + * + * Exposes `prepare`, `run`, `all`, `first`, and `batch` using the D1 + * prepared-statement API. This is the production implementation of SqlStore. + * Tests use `fake-d1.js` instead. + */ + +/** + * @typedef {import("./sql-store-interface.js").SqlStore} SqlStore + * @typedef {import("./sql-store-interface.js").SqlRunResult} SqlRunResult + */ + +export class CFSqlStore { + /** @param {D1Database} db */ + constructor(db) { + this._db = db; + } + + /** + * Returns a bound D1PreparedStatement for advanced use (e.g. batch()). + * + * @param {string} query + * @param {...any} binds + * @returns {D1PreparedStatement} + */ + prepare(query, ...binds) { + const stmt = this._db.prepare(query); + return binds.length > 0 ? stmt.bind(...binds) : stmt; + } + + /** + * Execute a write statement (INSERT/UPDATE/DELETE/CREATE). + * + * @param {string} query + * @param {...any} binds + * @returns {Promise} + */ + async run(query, ...binds) { + const result = await this.prepare(query, ...binds).run(); + return { + changes: result.meta?.changes ?? 0, + last_row_id: result.meta?.last_row_id ?? 0, + }; + } + + /** + * Execute a SELECT and return all matching rows. + * + * @param {string} query + * @param {...any} binds + * @returns {Promise} + */ + async all(query, ...binds) { + const result = await this.prepare(query, ...binds).all(); + return result.results ?? []; + } + + /** + * Execute a SELECT and return the first row, or null. + * + * @param {string} query + * @param {...any} binds + * @returns {Promise} + */ + async first(query, ...binds) { + return this.prepare(query, ...binds).first() ?? null; + } + + /** + * Execute multiple prepared statements in a single round-trip. + * + * @param {D1PreparedStatement[]} statements + * @returns {Promise} + */ + async batch(statements) { + const results = await this._db.batch(statements); + return results.map((r) => r.results ?? []); + } +} diff --git a/src/db/create-sql-store.js b/src/db/create-sql-store.js new file mode 100644 index 0000000..a37f5d5 --- /dev/null +++ b/src/db/create-sql-store.js @@ -0,0 +1,64 @@ +/** + * @file create-sql-store — factory returning a namespaced SqlStore for a module. + * + * Table naming is by convention: `{moduleName}_{table}`. Authors write the + * full prefixed name directly in SQL (e.g. `trading_trades`). `tablePrefix` + * is exposed for authors who want to interpolate the prefix dynamically. + * + * Returns null when `env.DB` is absent so modules that don't use D1 have + * zero overhead — the registry passes `sql: null` and modules check for it. + */ + +import { CFSqlStore } from "./cf-sql-store.js"; + +/** + * @typedef {import("./sql-store-interface.js").SqlStore} SqlStore + */ + +const MODULE_NAME_RE = /^[a-z0-9_-]+$/; + +/** + * @param {string} moduleName — must match `[a-z0-9_-]+`. + * @param {{ DB?: D1Database }} env — worker env (or test double). + * @returns {SqlStore | null} null when env.DB is not bound. + */ +export function createSqlStore(moduleName, env) { + if (!moduleName || typeof moduleName !== "string") { + throw new Error("createSqlStore: moduleName is required"); + } + if (!MODULE_NAME_RE.test(moduleName)) { + throw new Error( + `createSqlStore: invalid moduleName "${moduleName}" — must match ${MODULE_NAME_RE}`, + ); + } + + // D1 is optional — workers without a DB binding still work fine. + if (!env?.DB) return null; + + const base = new CFSqlStore(env.DB); + const tablePrefix = `${moduleName}_`; + + return { + tablePrefix, + + prepare(query, ...binds) { + return base.prepare(query, ...binds); + }, + + async run(query, ...binds) { + return base.run(query, ...binds); + }, + + async all(query, ...binds) { + return base.all(query, ...binds); + }, + + async first(query, ...binds) { + return base.first(query, ...binds); + }, + + async batch(statements) { + return base.batch(statements); + }, + }; +} diff --git a/src/db/kv-store-interface.js b/src/db/kv-store-interface.js index 6fbffe0..78bd364 100644 --- a/src/db/kv-store-interface.js +++ b/src/db/kv-store-interface.js @@ -10,26 +10,26 @@ */ /** - * @typedef {Object} KVStorePutOptions + * @typedef {object} KVStorePutOptions * @property {number} [expirationTtl] seconds — value auto-deletes after this many seconds. */ /** - * @typedef {Object} KVStoreListOptions + * @typedef {object} KVStoreListOptions * @property {string} [prefix] additional prefix (appended AFTER the module namespace). * @property {number} [limit] * @property {string} [cursor] pagination cursor from a previous list() call. */ /** - * @typedef {Object} KVStoreListResult + * @typedef {object} KVStoreListResult * @property {string[]} keys — module namespace already stripped. * @property {string} [cursor] — present if more pages available. * @property {boolean} done — true when list_complete. */ /** - * @typedef {Object} KVStore + * @typedef {object} KVStore * @property {(key: string) => Promise} get * @property {(key: string, value: string, opts?: KVStorePutOptions) => Promise} put * @property {(key: string) => Promise} delete diff --git a/src/db/sql-store-interface.js b/src/db/sql-store-interface.js new file mode 100644 index 0000000..70da3d0 --- /dev/null +++ b/src/db/sql-store-interface.js @@ -0,0 +1,40 @@ +/** + * @file SqlStore interface — JSDoc typedefs only, no runtime code. + * + * This is the contract every SQL storage backend must satisfy. Modules + * receive a prefixed `SqlStore` (via {@link module:db/create-sql-store}) and + * must NEVER touch the underlying `env.DB` binding directly. + * + * Table naming convention: `{moduleName}_{table}` (e.g. `trading_trades`). + * Enforced by convention — `tablePrefix` is exposed so authors can interpolate + * it when building dynamic table names, but most authors hard-code the full + * prefixed table name directly in their SQL. + */ + +/** + * Raw D1 run result. + * + * @typedef {object} SqlRunResult + * @property {number} changes — rows affected by INSERT/UPDATE/DELETE. + * @property {number} last_row_id — rowid of the last inserted row (0 if none). + */ + +/** + * @typedef {object} SqlStore + * @property {string} tablePrefix + * Convenience prefix `"${moduleName}_"`. Authors may interpolate this when + * constructing dynamic table names. + * @property {(query: string, ...binds: any[]) => Promise} run + * Execute a write statement (INSERT/UPDATE/DELETE/CREATE). Returns metadata. + * @property {(query: string, ...binds: any[]) => Promise} all + * Execute a SELECT and return all matching rows as plain objects. + * @property {(query: string, ...binds: any[]) => Promise} first + * Execute a SELECT and return the first row, or null if no rows match. + * @property {(query: string, ...binds: any[]) => D1PreparedStatement} prepare + * Expose the underlying prepared statement for advanced use (e.g. batch()). + * @property {(statements: D1PreparedStatement[]) => Promise} batch + * Execute multiple prepared statements in a single round-trip. + */ + +// JSDoc-only module. No runtime exports. +export {}; diff --git a/src/index.js b/src/index.js index 682d501..ce7643f 100644 --- a/src/index.js +++ b/src/index.js @@ -13,7 +13,8 @@ */ import { webhookCallback } from "grammy"; -import { getBot } from "./bot.js"; +import { getBot, getRegistry } from "./bot.js"; +import { dispatchScheduled } from "./modules/cron-dispatcher.js"; /** @type {ReturnType | null} */ let cachedWebhookHandler = null; @@ -31,6 +32,24 @@ async function getWebhookHandler(env) { } export default { + /** + * Cloudflare Cron Trigger handler. + * Dispatches the scheduled event to all module cron handlers whose + * schedule matches event.cron. + * + * @param {any} event — ScheduledEvent ({ cron: string, scheduledTime: number }) + * @param {any} env + * @param {{ waitUntil: (p: Promise) => void }} ctx + */ + async scheduled(event, env, ctx) { + try { + const registry = await getRegistry(env); + dispatchScheduled(event, env, ctx, registry); + } catch (err) { + console.error("scheduled handler failed", err); + } + }, + /** * @param {Request} request * @param {any} env diff --git a/src/modules/index.js b/src/modules/index.js index 197f029..ce1b166 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -3,7 +3,7 @@ * * wrangler bundles statically — dynamic `import(variablePath)` defeats * tree-shaking and can fail at bundle time. So we enumerate every module here - * as a lazy loader, and {@link loadModules} filters the list at runtime + * as a lazy loader, and loadModules filters the list at runtime * against `env.MODULES` (comma-separated). Adding a new module is a two-step * edit: create the folder, then add one line here. */ diff --git a/src/modules/registry.js b/src/modules/registry.js index 681ddf7..07d78a4 100644 --- a/src/modules/registry.js +++ b/src/modules/registry.js @@ -12,28 +12,40 @@ * - `resetRegistry()` exists for tests. */ +import { createSqlStore } from "../db/create-sql-store.js"; import { createStore } from "../db/create-store.js"; import { moduleRegistry as defaultModuleRegistry } from "./index.js"; import { validateCommand } from "./validate-command.js"; +import { validateCron } from "./validate-cron.js"; /** * @typedef {import("./validate-command.js").ModuleCommand} ModuleCommand * - * @typedef {Object} BotModule + * @typedef {import("./validate-cron.js").ModuleCron} ModuleCron + * + * @typedef {object} BotModule * @property {string} name * @property {ModuleCommand[]} commands - * @property {({ db, env }: { db: any, env: any }) => Promise|void} [init] + * @property {ModuleCron[]} [crons] + * @property {(ctx: { db: any, sql: any, env: any }) => Promise} [init] * - * @typedef {Object} RegistryEntry + * @typedef {object} RegistryEntry * @property {BotModule} module * @property {ModuleCommand} cmd * @property {"public"|"protected"|"private"} [visibility] * - * @typedef {Object} Registry + * @typedef {object} CronEntry + * @property {BotModule} module + * @property {string} schedule + * @property {string} name + * @property {ModuleCron["handler"]} handler + * + * @typedef {object} Registry * @property {Map} publicCommands * @property {Map} protectedCommands * @property {Map} privateCommands * @property {Map} allCommands + * @property {CronEntry[]} crons — flat list of all validated cron entries across modules. * @property {BotModule[]} modules — ordered per env.MODULES for /help rendering. */ @@ -97,6 +109,21 @@ export async function loadModules(env, importMap = defaultModuleRegistry) { } for (const cmd of mod.commands) validateCommand(cmd, name); + // Validate crons if present (optional field). + if (mod.crons !== undefined) { + if (!Array.isArray(mod.crons)) { + throw new Error(`module "${name}" crons must be an array`); + } + const cronNames = new Set(); + for (const cron of mod.crons) { + validateCron(cron, name); + if (cronNames.has(cron.name)) { + throw new Error(`module "${name}" has duplicate cron name "${cron.name}"`); + } + cronNames.add(cron.name); + } + } + modules.push(mod); } @@ -122,11 +149,13 @@ export async function buildRegistry(env, importMap) { const privateCommands = new Map(); /** @type {Map} */ const allCommands = new Map(); + /** @type {CronEntry[]} */ + const crons = []; for (const mod of modules) { if (typeof mod.init === "function") { try { - await mod.init({ db: createStore(mod.name, env), env }); + await mod.init({ db: createStore(mod.name, env), sql: createSqlStore(mod.name, env), env }); } catch (err) { throw new Error( `module "${mod.name}" init failed: ${err instanceof Error ? err.message : String(err)}`, @@ -149,6 +178,18 @@ export async function buildRegistry(env, importMap) { else if (cmd.visibility === "protected") protectedCommands.set(cmd.name, entry); else privateCommands.set(cmd.name, entry); } + + // Collect cron entries (validated during loadModules). + if (Array.isArray(mod.crons)) { + for (const cron of mod.crons) { + crons.push({ + module: mod, + schedule: cron.schedule, + name: cron.name, + handler: cron.handler, + }); + } + } } const registry = { @@ -156,6 +197,7 @@ export async function buildRegistry(env, importMap) { protectedCommands, privateCommands, allCommands, + crons, modules, }; currentRegistry = registry; diff --git a/src/modules/validate-command.js b/src/modules/validate-command.js index c41e3ba..6b165dc 100644 --- a/src/modules/validate-command.js +++ b/src/modules/validate-command.js @@ -17,7 +17,7 @@ export const COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/; export const MAX_DESCRIPTION_LENGTH = 256; /** - * @typedef {Object} ModuleCommand + * @typedef {object} ModuleCommand * @property {string} name — without leading slash; matches COMMAND_NAME_RE. * @property {"public"|"protected"|"private"} visibility * @property {string} description — ≤256 chars; required for all visibilities. diff --git a/tests/db/create-sql-store.test.js b/tests/db/create-sql-store.test.js new file mode 100644 index 0000000..47bc4da --- /dev/null +++ b/tests/db/create-sql-store.test.js @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { createSqlStore } from "../../src/db/create-sql-store.js"; +import { makeFakeD1 } from "../fakes/fake-d1.js"; + +const makeEnv = () => ({ DB: makeFakeD1() }); + +describe("createSqlStore", () => { + describe("factory validation", () => { + it("throws on missing moduleName", () => { + expect(() => createSqlStore("", makeEnv())).toThrow(/moduleName is required/); + expect(() => createSqlStore(null, makeEnv())).toThrow(/moduleName is required/); + }); + + it("throws on invalid moduleName characters", () => { + expect(() => createSqlStore("Bad Name", makeEnv())).toThrow(/invalid moduleName/); + expect(() => createSqlStore("has space", makeEnv())).toThrow(/invalid moduleName/); + }); + + it("returns null when env.DB is absent", () => { + expect(createSqlStore("mymod", {})).toBeNull(); + expect(createSqlStore("mymod", { KV: {} })).toBeNull(); + }); + + it("returns a SqlStore when env.DB is present", () => { + const sql = createSqlStore("mymod", makeEnv()); + expect(sql).not.toBeNull(); + expect(typeof sql.run).toBe("function"); + expect(typeof sql.all).toBe("function"); + expect(typeof sql.first).toBe("function"); + expect(typeof sql.prepare).toBe("function"); + expect(typeof sql.batch).toBe("function"); + }); + }); + + describe("tablePrefix", () => { + it("exposes tablePrefix as moduleName + underscore", () => { + const sql = createSqlStore("trading", makeEnv()); + expect(sql.tablePrefix).toBe("trading_"); + }); + + it("works with hyphenated module names", () => { + const sql = createSqlStore("my-mod", makeEnv()); + expect(sql.tablePrefix).toBe("my-mod_"); + }); + }); + + describe("run", () => { + it("returns changes and last_row_id on INSERT", async () => { + const sql = createSqlStore("trading", makeEnv()); + const result = await sql.run("INSERT INTO trading_trades VALUES (?)", "x"); + expect(result).toHaveProperty("changes"); + expect(result).toHaveProperty("last_row_id"); + expect(typeof result.changes).toBe("number"); + }); + + it("records the query in runLog", async () => { + const fakeDb = makeFakeD1(); + const sql = createSqlStore("trading", { DB: fakeDb }); + await sql.run("INSERT INTO trading_trades VALUES (?)", "hello"); + expect(fakeDb.runLog).toHaveLength(1); + expect(fakeDb.runLog[0].query).toBe("INSERT INTO trading_trades VALUES (?)"); + expect(fakeDb.runLog[0].binds).toEqual(["hello"]); + }); + }); + + describe("all", () => { + it("returns empty array when table has no rows", async () => { + const sql = createSqlStore("trading", makeEnv()); + const rows = await sql.all("SELECT * FROM trading_trades"); + expect(rows).toEqual([]); + }); + + it("returns seeded rows", async () => { + const fakeDb = makeFakeD1(); + fakeDb.seed("trading_trades", [ + { id: 1, symbol: "VNM" }, + { id: 2, symbol: "FPT" }, + ]); + const sql = createSqlStore("trading", { DB: fakeDb }); + const rows = await sql.all("SELECT * FROM trading_trades"); + expect(rows).toHaveLength(2); + expect(rows[0].symbol).toBe("VNM"); + }); + }); + + describe("first", () => { + it("returns null when table is empty", async () => { + const sql = createSqlStore("trading", makeEnv()); + const row = await sql.first("SELECT * FROM trading_trades WHERE id = ?", 99); + expect(row).toBeNull(); + }); + + it("returns the first seeded row", async () => { + const fakeDb = makeFakeD1(); + fakeDb.seed("trading_trades", [{ id: 1, symbol: "VNM" }]); + const sql = createSqlStore("trading", { DB: fakeDb }); + const row = await sql.first("SELECT * FROM trading_trades LIMIT 1"); + expect(row).toEqual({ id: 1, symbol: "VNM" }); + }); + }); + + describe("batch", () => { + it("executes multiple statements and returns array of result arrays", async () => { + const fakeDb = makeFakeD1(); + fakeDb.seed("trading_trades", [{ id: 1 }]); + const sql = createSqlStore("trading", { DB: fakeDb }); + const stmt1 = sql.prepare("SELECT * FROM trading_trades"); + const stmt2 = sql.prepare("SELECT * FROM trading_orders"); + const results = await sql.batch([stmt1, stmt2]); + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + expect(results[0]).toHaveLength(1); // one row seeded + expect(results[1]).toHaveLength(0); // no rows + }); + }); +}); diff --git a/tests/fakes/fake-d1.js b/tests/fakes/fake-d1.js new file mode 100644 index 0000000..888cdcc --- /dev/null +++ b/tests/fakes/fake-d1.js @@ -0,0 +1,290 @@ +/** + * @file fake-d1 — in-memory D1-like fake for unit tests. + * + * Supports a limited subset of SQL semantics needed by the test suite: + * - INSERT: appends a row built from binds. + * - SELECT: returns all rows for the matched table. + * - SELECT DISTINCT : returns unique values for a single column. + * - SELECT id FROM WHERE user_id = ? ORDER BY ts DESC: returns ids sorted by ts DESC. + * - SELECT id FROM
ORDER BY ts DESC: global sort by ts DESC. + * - DELETE WHERE id IN (?,...): removes specific rows by id. + * - DELETE (generic): clears entire table (fallback for legacy tests). + * + * Supported operations: + * prepare(query, ...binds) → fake prepared statement + * .run() → { meta: { changes, last_row_id } } + * .all() → { results: row[] } + * .first() → row | null + * batch(stmts) → array of { results: row[] } + * + * Usage in tests: + * const fakeDb = makeFakeD1(); + * fakeDb.seed("mymod_foo", [{ id: 1, val: "x" }]); + * const sql = createSqlStore("mymod", { DB: fakeDb }); + * const rows = await sql.all("SELECT * FROM mymod_foo"); + * // rows === [{ id: 1, val: "x" }] + */ + +export function makeFakeD1() { + /** @type {Map} table name → rows */ + const tables = new Map(); + + /** @type {Array<{query: string, binds: any[]}>} */ + const runLog = []; + + /** @type {Array<{query: string, binds: any[]}>} */ + const queryLog = []; + + /** + * Pre-populate a table with rows. + * + * @param {string} table + * @param {any[]} rows + */ + function seed(table, rows) { + tables.set(table, [...rows]); + } + + /** + * Extract the first table name token from a query string. + * Handles simple patterns: FROM
, INTO
, UPDATE
. + * + * @param {string} query + * @returns {string|null} + */ + function extractTable(query) { + const normalized = query.replace(/\s+/g, " ").trim(); + const m = + normalized.match(/\bFROM\s+(\w+)/i) || + normalized.match(/\bINTO\s+(\w+)/i) || + normalized.match(/\bUPDATE\s+(\w+)/i) || + normalized.match(/\bTABLE\s+(\w+)/i); + return m ? m[1] : null; + } + + /** + * Parse DELETE WHERE id IN (...) — returns the set of ids to delete, or null + * if the query doesn't match this pattern (fall back to clear-all). + * + * Matches patterns like: + * DELETE FROM t WHERE id IN (?,?,?) + * DELETE FROM t WHERE id IN (SELECT id FROM t ...) — not supported, returns null + * + * @param {string} query + * @param {any[]} binds + * @returns {Set|null} + */ + function parseDeleteIds(query, binds) { + const normalized = query.replace(/\s+/g, " ").trim(); + // Detect DELETE ... WHERE id IN (?,?,?) with only ? placeholders (no subquery). + const m = normalized.match(/\bWHERE\s+id\s+IN\s*\(([^)]+)\)/i); + if (!m) return null; + + const inner = m[1].trim(); + // If inner contains SELECT, it's a subquery — not supported in fake. + if (/\bSELECT\b/i.test(inner)) return null; + + // Count placeholders and consume from binds. + const placeholders = inner.split(",").map((s) => s.trim()); + if (placeholders.every((p) => p === "?")) { + return new Set(binds.slice(0, placeholders.length)); + } + return null; + } + + /** + * Resolve a raw token from a regex match: if it's "?" consume the next bind + * value from the iterator; otherwise parse it as a number literal. + * + * @param {string} raw — captured token from regex (e.g. "?" or "-1" or "10") + * @param {Iterator} bindIter — iterator over remaining binds + * @returns {number} + */ + function resolveNumericToken(raw, bindIter) { + if (raw === "?") { + const next = bindIter.next(); + return next.done ? 0 : Number(next.value); + } + return Number(raw); + } + + /** + * Execute a SELECT query with limited semantic understanding. + * Handles: + * - SELECT DISTINCT user_id FROM
+ * - SELECT id FROM
WHERE user_id = ? ORDER BY ts DESC [LIMIT [OFFSET ]] + * - SELECT id FROM
ORDER BY ts DESC [LIMIT [OFFSET ]] + * - SELECT * / general → returns all rows + * + * LIMIT/OFFSET tokens may be numeric literals OR "?" bound parameters. + * + * @param {string} query + * @param {any[]} binds + * @returns {any[]} + */ + function executeSelect(query, binds) { + const normalized = query.replace(/\s+/g, " ").trim(); + const table = extractTable(normalized); + const rows = table ? (tables.get(table) ?? []) : []; + + // SELECT DISTINCT user_id FROM
+ if (/SELECT\s+DISTINCT\s+user_id\b/i.test(normalized)) { + const seen = new Set(); + const result = []; + for (const row of rows) { + if (!seen.has(row.user_id)) { + seen.add(row.user_id); + result.push({ user_id: row.user_id }); + } + } + return result; + } + + // SELECT id FROM
WHERE user_id = ? ORDER BY ts DESC [LIMIT [OFFSET ]] + // Binds layout: [userId, ...optional LIMIT bind, ...optional OFFSET bind] + const whereUserRe = + /SELECT\s+id\s+FROM\s+\w+\s+WHERE\s+user_id\s*=\s*\?\s+ORDER\s+BY\s+ts\s+DESC(?:\s+LIMIT\s+(\S+)(?:\s+OFFSET\s+(\S+))?)?/i; + const whereUserMatch = normalized.match(whereUserRe); + if (whereUserMatch) { + const userId = binds[0]; + let filtered = rows.filter((r) => r.user_id === userId); + // Sort by ts DESC. + filtered = [...filtered].sort((a, b) => b.ts - a.ts); + + // Binds after userId start at index 1. + const remainingBinds = binds.slice(1)[Symbol.iterator](); + const rawLimit = whereUserMatch[1]; + const rawOffset = whereUserMatch[2]; + + let offset = 0; + let limit; + if (rawLimit !== undefined) { + limit = resolveNumericToken(rawLimit, remainingBinds); + if (rawOffset !== undefined) { + offset = resolveNumericToken(rawOffset, remainingBinds); + } + } + + if (offset > 0) filtered = filtered.slice(offset); + // Negative limit (e.g. -1) = all rows; skip slicing. + if (limit !== undefined && limit >= 0) filtered = filtered.slice(0, limit); + + return filtered.map((r) => ({ id: r.id })); + } + + // SELECT id FROM
ORDER BY ts DESC [LIMIT [OFFSET ]] + const globalOrderRe = + /SELECT\s+id\s+FROM\s+\w+\s+ORDER\s+BY\s+ts\s+DESC(?:\s+LIMIT\s+(\S+)(?:\s+OFFSET\s+(\S+))?)?/i; + const globalOrderMatch = normalized.match(globalOrderRe); + if (globalOrderMatch) { + let sorted = [...rows].sort((a, b) => b.ts - a.ts); + + const bindIter = binds[Symbol.iterator](); + const rawLimit = globalOrderMatch[1]; + const rawOffset = globalOrderMatch[2]; + + let offset = 0; + let limit; + if (rawLimit !== undefined) { + limit = resolveNumericToken(rawLimit, bindIter); + if (rawOffset !== undefined) { + offset = resolveNumericToken(rawOffset, bindIter); + } + } + + if (offset > 0) sorted = sorted.slice(offset); + // Negative limit (e.g. -1) = all rows; skip slicing. + if (limit !== undefined && limit >= 0) sorted = sorted.slice(0, limit); + + return sorted.map((r) => ({ id: r.id })); + } + + // Generic SELECT → return all rows. + return rows; + } + + /** + * Build a fake prepared statement. + * + * @param {string} query + * @param {any[]} binds + */ + function makePrepared(query, binds) { + return { + bind(...moreBinds) { + return makePrepared(query, [...binds, ...moreBinds]); + }, + + async run() { + runLog.push({ query, binds }); + const table = extractTable(query); + const upper = query.trim().toUpperCase(); + + // Simulate INSERT: push a row built from binds. + if (upper.startsWith("INSERT") && table) { + const existing = tables.get(table) ?? []; + const newRow = { _binds: binds }; + tables.set(table, [...existing, newRow]); + return { meta: { changes: 1, last_row_id: existing.length + 1 } }; + } + + // DELETE: check for WHERE id IN (...) first, otherwise clear table. + if (upper.startsWith("DELETE") && table) { + const existing = tables.get(table) ?? []; + const deleteIds = parseDeleteIds(query, binds); + if (deleteIds !== null) { + // Targeted delete by id set. + const remaining = existing.filter((r) => !deleteIds.has(r.id)); + const changes = existing.length - remaining.length; + tables.set(table, remaining); + return { meta: { changes, last_row_id: 0 } }; + } + // Fallback: clear all (naive — legacy tests rely on this). + tables.set(table, []); + return { meta: { changes: existing.length, last_row_id: 0 } }; + } + + return { meta: { changes: 0, last_row_id: 0 } }; + }, + + async all() { + queryLog.push({ query, binds }); + const results = executeSelect(query, binds); + return { results }; + }, + + async first() { + queryLog.push({ query, binds }); + const results = executeSelect(query, binds); + return results[0] ?? null; + }, + }; + } + + return { + tables, + runLog, + queryLog, + seed, + + /** + * D1Database.prepare() — returns a fake prepared statement. + * + * @param {string} query + * @returns {ReturnType} + */ + prepare(query) { + return makePrepared(query, []); + }, + + /** + * D1Database.batch() — runs each statement's all() and collects results. + * + * @param {Array>} statements + * @returns {Promise>} + */ + async batch(statements) { + return Promise.all(statements.map((s) => s.all())); + }, + }; +} diff --git a/wrangler.toml b/wrangler.toml index 9dad84d..6d02d3c 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,6 +17,24 @@ binding = "KV" id = "REPLACE_ME" preview_id = "REPLACE_ME" +# D1 database for module persistent storage. Each module prefixes its tables +# with `{moduleName}_` (e.g. `trading_trades`). Migrations are applied via +# `npm run db:migrate` (chained into `npm run deploy`). +# Create with: npx wrangler d1 create miti99bot-db +# then replace REPLACE_ME_D1_UUID below with the returned database_id. +[[d1_databases]] +binding = "DB" +database_name = "miti99bot-db" +database_id = "REPLACE_ME_D1_UUID" + +# Cron Triggers — union of all schedules declared by modules. +# When adding a module with cron entries, append its schedule(s) here. +# See docs/adding-a-module.md for the full module author workflow. +# Local testing: curl "http://localhost:8787/__scheduled?cron=0+1+*+*+*" +# (requires `wrangler dev --test-scheduled`) +[triggers] +crons = ["0 17 * * *"] + # Secrets (set via `wrangler secret put `, NOT in this file): # TELEGRAM_BOT_TOKEN — bot token from @BotFather # TELEGRAM_WEBHOOK_SECRET — arbitrary high-entropy string, also set in .env.deploy