From f212d9692831b632e507078de8dffb2b03cd6b53 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 21 Jul 2026 06:44:12 -0500 Subject: [PATCH] Make Stats freshness honest and polling incremental --- .github/workflows/ci.yml | 2 + showcase/README.md | 5 + showcase/angular/package-lock.json | 1373 ++++++++++++++++- showcase/angular/package.json | 8 + .../src/app/core/stats/dataset-state.types.ts | 30 + .../app/core/stats/fsb-telemetry.service.ts | 79 +- .../src/app/core/stats/fsb-telemetry.types.ts | 19 +- .../app/core/stats/github-stats.service.ts | 77 +- .../src/app/core/stats/github-stats.types.ts | 26 +- .../src/app/core/stats/stats-fetch-error.ts | 72 + .../src/app/core/stats/stats-view.model.ts | 177 ++- .../app/pages/stats/stats-page.component.html | 51 +- .../app/pages/stats/stats-page.component.scss | 30 +- .../pages/stats/stats-page.component.spec.ts | 499 ++++++ .../app/pages/stats/stats-page.component.ts | 273 +++- showcase/angular/src/locale/messages.de.xlf | 272 ++-- showcase/angular/src/locale/messages.es.xlf | 272 ++-- showcase/angular/src/locale/messages.ja.xlf | 272 ++-- showcase/angular/src/locale/messages.xlf | 226 +-- .../angular/src/locale/messages.zh-CN.xlf | 272 ++-- .../angular/src/locale/messages.zh-TW.xlf | 272 ++-- showcase/angular/tsconfig.spec.json | 2 +- showcase/server/.env.example | 4 + showcase/server/src/db/queries.js | 114 +- showcase/server/src/db/schema.js | 13 +- showcase/server/src/routes/public-stats.js | 5 +- .../server/src/telemetry/github-poller.js | 629 +++++--- tests/fsb-telemetry-service.test.js | 8 +- tests/server-github-cache.test.js | 50 +- tests/server-github-poller.test.js | 1058 +++++++------ tests/stats-chart-overhaul.test.js | 48 +- tests/stats-view-state.test.js | 335 ++-- 32 files changed, 4692 insertions(+), 1881 deletions(-) create mode 100644 showcase/angular/src/app/core/stats/dataset-state.types.ts create mode 100644 showcase/angular/src/app/core/stats/stats-fetch-error.ts create mode 100644 showcase/angular/src/app/pages/stats/stats-page.component.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c848691dd..acd189f4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,8 @@ jobs: run: npm --prefix showcase/angular run test:translation-tools - name: Reject English-copy and cross-locale-identical targets run: npm --prefix showcase/angular run verify:translation-quality + - name: Run Angular component tests + run: npm --prefix showcase/angular run test:ci - name: Verify ng extract-i18n produces no diff (CI-02) run: | mkdir -p /tmp/extract-check diff --git a/showcase/README.md b/showcase/README.md index 37b17b205..7206e6a71 100644 --- a/showcase/README.md +++ b/showcase/README.md @@ -68,6 +68,11 @@ Runtime defaults: - `DB_PATH=/data/fsb-data.db` - `NODE_ENV=production` +Production should also provide `GITHUB_TOKEN` as a Fly secret. Use a +fine-grained token owned by an FSB collaborator, restricted to the FSB +repository with Metadata read and Contents read. The value is consumed only by +the server-side Stats poller and is never sent to browsers. + The production container is built in two stages. The first stage builds the Angular static output. The final stage installs server dependencies, copies the Express source, copies the Angular browser output into `public/`, and creates `/data` for SQLite persistence. ## Server Responsibilities diff --git a/showcase/angular/package-lock.json b/showcase/angular/package-lock.json index b5850fa3b..c2a2ac8e1 100644 --- a/showcase/angular/package-lock.json +++ b/showcase/angular/package-lock.json @@ -30,9 +30,16 @@ "@angular/compiler-cli": "^20.3.19", "@angular/localize": "^20.3.19", "@types/express": "^4.17.17", + "@types/jasmine": "~5.1.0", "@types/node": "^18.18.0", "angular-eslint": "^20.7.0", "eslint": "^9.39.4", + "jasmine-core": "~5.9.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.9.3", "typescript-eslint": "^8.59.3" } @@ -1699,6 +1706,16 @@ "node": ">=6.9.0" } }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -4800,6 +4817,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -4890,6 +4914,16 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -4937,6 +4971,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jasmine": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.15.tgz", + "integrity": "sha512-ZAC8KjmV2MJxbNTrwXFN+HKeajpXQZp6KpPiR6Aa4XvaEnjP6qh23lL/Rqb7AYzlp3h/rcwDrQ7Gg7q28cQTQg==", + "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", @@ -5008,6 +5049,16 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -5460,6 +5511,33 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5503,6 +5581,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", @@ -5536,6 +5624,19 @@ "node": ">=14.0.0" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -5637,6 +5738,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -5960,6 +6074,91 @@ "dev": true, "license": "MIT" }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -6066,6 +6265,23 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6121,6 +6337,26 @@ "node": ">=8" } }, + "node_modules/di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -6223,6 +6459,61 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", + "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "punycode": "^1.4.1", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ent/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -6790,6 +7081,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "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", @@ -6859,6 +7157,19 @@ "node": ">=16.0.0" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -6930,6 +7241,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6948,6 +7280,21 @@ "node": ">= 0.6" } }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fs-minipass": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", @@ -6961,6 +7308,13 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -7147,6 +7501,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -7192,6 +7562,13 @@ "node": "20 || >=22" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -7252,6 +7629,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -7266,6 +7658,13 @@ "node": ">= 14" } }, + "node_modules/http-proxy/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -7354,6 +7753,18 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7389,6 +7800,19 @@ "node": ">= 0.10" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -7454,13 +7878,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "dev": true, "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -7474,6 +7927,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7508,6 +7974,67 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jasmine-core": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.9.0.tgz", + "integrity": "sha512-OMUvF1iI6+gSRYOhMrH4QYothVLN9C3EJ6wm4g7zLJlnaTl8zbaPOr0bTw70l7QxkoM7sVFOWo83u9B2Fe2Zng==", + "dev": true, + "license": "MIT" + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -7589,35 +8116,488 @@ "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/karma": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", + "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@colors/colors": "1.5.0", + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.4.1", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.5", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.7.2", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + }, + "bin": { + "karma": "bin/karma" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/karma-chrome-launcher": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", + "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "which": "^1.2.1" + } + }, + "node_modules/karma-chrome-launcher/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/karma-coverage": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", + "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.1", + "istanbul-reports": "^3.0.5", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/karma-coverage/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma-coverage/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma-coverage/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/karma-coverage/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/karma-jasmine": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", + "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jasmine-core": "^4.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "karma": "^6.0.0" + } + }, + "node_modules/karma-jasmine-html-reporter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", + "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "jasmine-core": "^4.0.0 || ^5.0.0", + "karma": "^6.0.0", + "karma-jasmine": "^5.0.0" + } + }, + "node_modules/karma-jasmine/node_modules/jasmine-core": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", + "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/karma/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/karma/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/karma/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/karma/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/karma/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/karma/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/karma/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/karma/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/karma/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/karma/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/karma/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "node_modules/karma/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "node_modules/karma/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" + "license": "ISC", + "engines": { + "node": ">=10" + } }, "node_modules/keyv": { "version": "4.5.4", @@ -7723,6 +8703,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7831,6 +8818,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7851,6 +8855,22 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-fetch-happen": { "version": "15.0.5", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", @@ -7993,6 +9013,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -8126,6 +9156,19 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -8329,6 +9372,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-bundled": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", @@ -8760,6 +9813,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -8946,6 +10009,16 @@ "node": ">=6" } }, + "node_modules/qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.9" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -9007,6 +10080,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -9017,6 +10100,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -9082,6 +10172,76 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -9184,6 +10344,24 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9438,6 +10616,50 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", @@ -9579,6 +10801,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -9696,6 +10933,29 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -9803,6 +11063,33 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici": { "version": "6.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", @@ -9820,6 +11107,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -9898,6 +11195,16 @@ "node": ">= 0.8" } }, + "node_modules/void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -10039,6 +11346,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xhr2": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz", diff --git a/showcase/angular/package.json b/showcase/angular/package.json index d64cf91f1..8e56b6493 100644 --- a/showcase/angular/package.json +++ b/showcase/angular/package.json @@ -8,6 +8,7 @@ "prebuild": "node scripts/build-crawler-files.mjs", "build": "ng build", "test": "ng test", + "test:ci": "ng test --watch=false --browsers=ChromeHeadless", "smoke:crawler": "node scripts/smoke-crawler.mjs", "smoke:crawler:local": "node scripts/smoke-crawler-local.mjs", "serve:ssr:showcase-angular": "node ../dist/showcase-angular/server/server.mjs", @@ -40,9 +41,16 @@ "@angular/compiler-cli": "^20.3.19", "@angular/localize": "^20.3.19", "@types/express": "^4.17.17", + "@types/jasmine": "~5.1.0", "@types/node": "^18.18.0", "angular-eslint": "^20.7.0", "eslint": "^9.39.4", + "jasmine-core": "~5.9.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.9.3", "typescript-eslint": "^8.59.3" } diff --git a/showcase/angular/src/app/core/stats/dataset-state.types.ts b/showcase/angular/src/app/core/stats/dataset-state.types.ts new file mode 100644 index 000000000..427a2b3e1 --- /dev/null +++ b/showcase/angular/src/app/core/stats/dataset-state.types.ts @@ -0,0 +1,30 @@ +/** Metadata describing a usable stats snapshot and the latest refresh check. */ +export interface DatasetAvailability { + /** Time represented by the usable snapshot. */ + snapshotAt: number | null; + /** Time the source was most recently checked, whether or not it changed. */ + checkedAt: number; + /** Upstream HTTP status, or a client-side failure label. */ + upstreamStatus: string; + /** Earliest advertised retry time after a failed upstream check. */ + nextRetryAt?: number; +} + +/** Metadata known when a refresh attempt fails. */ +export interface DatasetFailureMetadata { + checkedAt: number; + upstreamStatus: string; + nextRetryAt?: number; +} + +/** + * Transport state for one stats source. + * + * Freshness and coverage are intentionally not encoded in this union. A + * ready state means only that a usable snapshot exists; the selected view + * applies its own SLA to `availability.snapshotAt`. + */ +export type DatasetState = + | { kind: 'loading' } + | { kind: 'ready'; data: T; availability: DatasetAvailability } + | { kind: 'error'; message: string; failure?: DatasetFailureMetadata }; diff --git a/showcase/angular/src/app/core/stats/fsb-telemetry.service.ts b/showcase/angular/src/app/core/stats/fsb-telemetry.service.ts index 09c92d7d7..c84020cb7 100644 --- a/showcase/angular/src/app/core/stats/fsb-telemetry.service.ts +++ b/showcase/angular/src/app/core/stats/fsb-telemetry.service.ts @@ -1,16 +1,23 @@ // Browser lifecycle for the two aggregate FSB endpoints. Each route visit -// begins in loading state; failed refreshes preserve a prior snapshot as -// explicitly stale, and stop() aborts in-flight work from the old visit. +// begins in loading state; failed refreshes preserve a prior snapshot with +// updated check metadata, and stop() aborts in-flight work from the old visit. import { Injectable, PLATFORM_ID, inject } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; import { BehaviorSubject } from 'rxjs'; import { + DatasetAvailability, DatasetState, FSBTelemetryHeadline, FSBTelemetrySeries, } from './fsb-telemetry.types'; +import { + availabilityAfterFailure, + httpStatsFetchError, + retryAfterTimestamp, + statsFailureMetadata, +} from './stats-fetch-error'; const API_ROOT = '/api/public-stats'; const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -20,6 +27,11 @@ interface EtagCacheEntry { body: unknown; } +interface FetchResult { + data: T; + availability: DatasetAvailability; +} + @Injectable({ providedIn: 'root' }) export class FSBTelemetryService { private readonly platformId = inject(PLATFORM_ID); @@ -131,22 +143,26 @@ export class FSBTelemetryService { generation: number ): Promise { try { - const data = await this.fetchJson(url); + const result = await this.fetchJson(url); if (!this.isCurrent(generation)) return; - subject.next({ kind: 'ready', data, fetchedAt: generatedAt(data) ?? Date.now() }); + subject.next({ + kind: 'ready', + data: result.data, + availability: result.availability, + }); } catch (err) { if (!this.isCurrent(generation) || isAbortError(err)) return; const message = humanError(err); + const failure = statsFailureMetadata(err); const previous = subject.value; - if (previous.kind === 'ready' || previous.kind === 'partial' || previous.kind === 'stale') { + if (previous.kind === 'ready') { subject.next({ - kind: 'stale', + kind: 'ready', data: previous.data, - fetchedAt: previous.fetchedAt, - message, + availability: availabilityAfterFailure(previous.availability, err), }); } else { - subject.next({ kind: 'error', message }); + subject.next({ kind: 'error', message, failure }); } } } @@ -163,7 +179,7 @@ export class FSBTelemetryService { * rate-limited. If a 500 ever fires, the caller surfaces it as an error * state and the next poll retries. */ - private async fetchJson(url: string): Promise { + private async fetchJson(url: string): Promise> { if (!isPlatformBrowser(this.platformId)) throw new Error('Stats are browser-only'); const headers: Record = { @@ -189,15 +205,29 @@ export class FSBTelemetryService { if (response.status === 304 && cached) { // ETag match: reuse cached body, don't read response. - return cached.body as T; + const data = cached.body as T; + return { + data, + availability: fsbAvailability(data, response.status), + }; } + const retryAfter = response.headers.get('retry-after'); if (response.status === 503) { - this.scheduleRetry(response.headers.get('retry-after')); - throw new Error('FSB stats are warming up; retrying shortly.'); + this.scheduleRetry(retryAfter); + throw httpStatsFetchError( + 'FSB stats are warming up; retrying shortly.', + response.status, + retryAfter + ); } if (!response.ok) { - throw new Error(`FSB stats ${response.status} on ${url}`); + if (retryAfter !== null) this.scheduleRetry(retryAfter); + throw httpStatsFetchError( + `FSB stats ${response.status} on ${url}`, + response.status, + retryAfter + ); } const parsed = (await response.json()) as T; @@ -205,14 +235,18 @@ export class FSBTelemetryService { if (etag) { this.etagCache.set(url, { etag, body: parsed }); } - return parsed; + return { + data: parsed, + availability: fsbAvailability(parsed, response.status), + }; } private scheduleRetry(retryAfter: string | null): void { if (!this.started || this.retryHandle !== null) return; - const seconds = Number(retryAfter); - const delay = Number.isFinite(seconds) && seconds > 0 - ? Math.min(seconds * 1000, POLL_INTERVAL_MS) + const now = Date.now(); + const requestedRetryAt = retryAfterTimestamp(retryAfter, now); + const delay = requestedRetryAt !== undefined + ? Math.min(Math.max(0, requestedRetryAt - now), POLL_INTERVAL_MS) : 30_000; this.retryHandle = setTimeout(() => { this.retryHandle = null; @@ -239,3 +273,12 @@ function generatedAt(value: unknown): number | null { const parsed = Date.parse(String((value as { generated_at?: unknown }).generated_at ?? '')); return Number.isFinite(parsed) ? parsed : null; } + +function fsbAvailability(value: unknown, status: number): DatasetAvailability { + const checkedAt = Date.now(); + return { + snapshotAt: generatedAt(value), + checkedAt, + upstreamStatus: String(status), + }; +} diff --git a/showcase/angular/src/app/core/stats/fsb-telemetry.types.ts b/showcase/angular/src/app/core/stats/fsb-telemetry.types.ts index aeee670e5..a90681f95 100644 --- a/showcase/angular/src/app/core/stats/fsb-telemetry.types.ts +++ b/showcase/angular/src/app/core/stats/fsb-telemetry.types.ts @@ -2,6 +2,10 @@ // // Mirrors github-stats.types.ts structure: one DatasetState discriminated // union + per-endpoint response interfaces. No I/O here; types only. + +import type { DatasetState as SharedDatasetState } from './dataset-state.types'; + +export type { DatasetAvailability } from './dataset-state.types'; // // The FSB public-stats server endpoint is mounted at /api/public-stats and // has no rate limiter (it is server-cached with a 30 s memo + 60 s @@ -121,16 +125,5 @@ export interface FSBTelemetrySeries { d365: FSBTelemetrySeriesPoint[]; } -/** - * Per-dataset state emitted by the service's BehaviorSubjects. - * - * Mirror of github-stats.types DatasetState. A failed refresh after a prior - * success emits `stale` with the last usable snapshot; an initial failure emits - * `error`. - */ -export type DatasetState = - | { kind: 'loading' } - | { kind: 'ready'; data: T; fetchedAt: number } - | { kind: 'partial'; data: T; fetchedAt: number; message: string } - | { kind: 'stale'; data: T; fetchedAt: number; message: string } - | { kind: 'error'; message: string }; +/** Shared transport state retained here as a compatibility export. */ +export type DatasetState = SharedDatasetState; diff --git a/showcase/angular/src/app/core/stats/github-stats.service.ts b/showcase/angular/src/app/core/stats/github-stats.service.ts index aa3f612bb..f774c37f3 100644 --- a/showcase/angular/src/app/core/stats/github-stats.service.ts +++ b/showcase/angular/src/app/core/stats/github-stats.service.ts @@ -9,6 +9,7 @@ import { BehaviorSubject } from 'rxjs'; import { CommitEvent, + DatasetAvailability, DatasetState, ForkEvent, GitHubCommitsStats, @@ -25,6 +26,12 @@ import { StatsResponseFreshness, statsResponseFreshness, } from './stats-view.model'; +import { + availabilityAfterFailure, + httpStatsFetchError, + retryAfterTimestamp, + statsFailureMetadata, +} from './stats-fetch-error'; // Quick task 260516-7l5 -- same-origin server-side cache. The server polls // GitHub once per 5 min into showcase/server/.../github_cache and serves each @@ -169,36 +176,24 @@ export class GitHubStatsService { try { const result = await this.fetchJson(url, normalize); if (!this.isCurrent(generation)) return; - if (result.cacheState === 'stale') { - subject.next({ - kind: 'stale', - data: result.data, - fetchedAt: result.fetchedAt, - message: 'The server is showing its last known snapshot.', - }); - } else if (hasIncompleteHistory(result.data)) { - subject.next({ - kind: 'partial', - data: result.data, - fetchedAt: result.fetchedAt, - message: 'Historical coverage is incomplete.', - }); - } else { - subject.next({ kind: 'ready', data: result.data, fetchedAt: result.fetchedAt }); - } + subject.next({ + kind: 'ready', + data: result.data, + availability: availabilityFromResponse(result), + }); } catch (err) { if (!this.isCurrent(generation) || isAbortError(err)) return; const message = humanError(err); + const failure = statsFailureMetadata(err); const previous = subject.value; - if (previous.kind === 'ready' || previous.kind === 'partial' || previous.kind === 'stale') { + if (previous.kind === 'ready') { subject.next({ - kind: 'stale', + kind: 'ready', data: previous.data, - fetchedAt: previous.fetchedAt, - message, + availability: availabilityAfterFailure(previous.availability, err), }); } else { - subject.next({ kind: 'error', message }); + subject.next({ kind: 'error', message, failure }); } } } @@ -206,8 +201,8 @@ export class GitHubStatsService { /** * Fetch a single JSON resource same-origin with ETag round-trip support. * Returns the validated body on 200 or 304. A cold-cache 503 schedules an - * earlier Retry-After refresh and throws into the dataset's honest - * error/stale state. + * earlier Retry-After refresh and throws into either Unavailable or a + * metadata-noted last-known-good snapshot. */ private async fetchJson( url: string, @@ -235,12 +230,22 @@ export class GitHubStatsService { if (response.status === 304 && cached) { return { data: cached.body, ...statsResponseFreshness(response.headers) }; } + const retryAfter = response.headers.get('retry-after'); if (response.status === 503) { - this.scheduleRetry(response.headers.get('retry-after')); - throw new Error('Stats are warming up; retrying shortly.'); + this.scheduleRetry(retryAfter); + throw httpStatsFetchError( + 'Stats are warming up; retrying shortly.', + response.status, + retryAfter + ); } if (!response.ok) { - throw new Error(`stats ${response.status} on ${url}`); + if (retryAfter !== null) this.scheduleRetry(retryAfter); + throw httpStatsFetchError( + `stats ${response.status} on ${url}`, + response.status, + retryAfter + ); } const parsed = normalize(await response.json()); @@ -253,9 +258,10 @@ export class GitHubStatsService { private scheduleRetry(retryAfter: string | null): void { if (!this.started || this.retryHandle !== null) return; - const seconds = Number(retryAfter); - const delay = Number.isFinite(seconds) && seconds > 0 - ? Math.min(seconds * 1000, POLL_INTERVAL_MS) + const now = Date.now(); + const requestedRetryAt = retryAfterTimestamp(retryAfter, now); + const delay = requestedRetryAt !== undefined + ? Math.min(Math.max(0, requestedRetryAt - now), POLL_INTERVAL_MS) : 30_000; this.retryHandle = setTimeout(() => { this.retryHandle = null; @@ -547,8 +553,11 @@ function isAbortError(err: unknown): boolean { return typeof DOMException !== 'undefined' && err instanceof DOMException && err.name === 'AbortError'; } -function hasIncompleteHistory(value: unknown): boolean { - return typeof value === 'object' && value !== null && - 'history_complete' in value && - (value as { history_complete?: unknown }).history_complete === false; +function availabilityFromResponse(result: StatsResponseFreshness): DatasetAvailability { + return { + snapshotAt: result.snapshotAt, + checkedAt: result.checkedAt, + upstreamStatus: result.upstreamStatus, + ...(result.nextRetryAt === undefined ? {} : { nextRetryAt: result.nextRetryAt }), + }; } diff --git a/showcase/angular/src/app/core/stats/github-stats.types.ts b/showcase/angular/src/app/core/stats/github-stats.types.ts index e76ddf46c..030200cc1 100644 --- a/showcase/angular/src/app/core/stats/github-stats.types.ts +++ b/showcase/angular/src/app/core/stats/github-stats.types.ts @@ -1,8 +1,12 @@ // Types for the /stats Easter-egg page (quick task 260514-1nv). // // All shapes here are subsets of GitHub's public REST API responses (the only -// fields we actually consume), plus pure-data aggregator outputs and a -// per-dataset discriminated-union state. No I/O here -- types only. +// fields we actually consume), plus pure-data aggregator outputs. No I/O +// here -- types only. + +import type { DatasetState as SharedDatasetState } from './dataset-state.types'; + +export type { DatasetAvailability } from './dataset-state.types'; /** GitHub chart view identifiers supported by the stats helpers. */ export type StatsViewId = @@ -106,19 +110,5 @@ export interface WeeklyDelta { deltaPct: number | null; } -/** - * Per-dataset state emitted by the service's BehaviorSubjects. - * - * - `loading`: pre-first-fetch and during SSR. Component renders skeleton. - * - `ready`: normal happy-path; data is the parsed (or aggregated) payload. - * - `partial`: usable snapshot whose historical series is known incomplete. - * - `stale`: a refresh failed after a prior success; data remains usable but - * consumers must label it stale rather than live. - * - `error`: first load failed and no usable snapshot exists. - */ -export type DatasetState = - | { kind: 'loading' } - | { kind: 'ready'; data: T; fetchedAt: number } - | { kind: 'partial'; data: T; fetchedAt: number; message: string } - | { kind: 'stale'; data: T; fetchedAt: number; message: string } - | { kind: 'error'; message: string }; +/** Shared transport state retained here as a compatibility export. */ +export type DatasetState = SharedDatasetState; diff --git a/showcase/angular/src/app/core/stats/stats-fetch-error.ts b/showcase/angular/src/app/core/stats/stats-fetch-error.ts new file mode 100644 index 000000000..c0ce2d9c4 --- /dev/null +++ b/showcase/angular/src/app/core/stats/stats-fetch-error.ts @@ -0,0 +1,72 @@ +import type { + DatasetAvailability, + DatasetFailureMetadata, +} from './dataset-state.types'; + +/** A failed HTTP response with transport metadata preserved for the UI. */ +export class StatsFetchError extends Error { + override readonly name = 'StatsFetchError'; + + constructor( + message: string, + readonly failure: DatasetFailureMetadata + ) { + super(message); + } +} + +/** Parse either Retry-After seconds or an HTTP-date into an absolute time. */ +export function retryAfterTimestamp( + retryAfter: string | null, + checkedAt: number +): number | undefined { + if (retryAfter === null) return undefined; + const trimmed = retryAfter.trim(); + if (trimmed.length === 0) return undefined; + + const seconds = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN; + if (Number.isFinite(seconds)) { + return checkedAt + seconds * 1000; + } + + const parsedDate = Date.parse(trimmed); + return Number.isFinite(parsedDate) ? Math.max(checkedAt, parsedDate) : undefined; +} + +export function httpStatsFetchError( + message: string, + status: number, + retryAfter: string | null, + checkedAt = Date.now() +): StatsFetchError { + const nextRetryAt = retryAfterTimestamp(retryAfter, checkedAt); + return new StatsFetchError(message, { + checkedAt, + upstreamStatus: String(status), + ...(nextRetryAt === undefined ? {} : { nextRetryAt }), + }); +} + +/** Preserve the snapshot while replacing metadata with the failed check. */ +export function availabilityAfterFailure( + previous: DatasetAvailability, + error: unknown, + checkedAt = Date.now() +): DatasetAvailability { + const failure = statsFailureMetadata(error, checkedAt); + return { + snapshotAt: previous.snapshotAt, + checkedAt: failure.checkedAt, + upstreamStatus: failure.upstreamStatus, + ...(failure.nextRetryAt === undefined ? {} : { nextRetryAt: failure.nextRetryAt }), + }; +} + +export function statsFailureMetadata( + error: unknown, + checkedAt = Date.now() +): DatasetFailureMetadata { + return error instanceof StatsFetchError + ? error.failure + : { checkedAt, upstreamStatus: 'request-error' }; +} diff --git a/showcase/angular/src/app/core/stats/stats-view.model.ts b/showcase/angular/src/app/core/stats/stats-view.model.ts index d806c9ffa..42484b761 100644 --- a/showcase/angular/src/app/core/stats/stats-view.model.ts +++ b/showcase/angular/src/app/core/stats/stats-view.model.ts @@ -5,6 +5,13 @@ import type { GitHubStarHistoryPoint, GitHubStarsStats, } from './github-stats.types'; +import type { DatasetAvailability } from './dataset-state.types'; + +export const GITHUB_FRESHNESS_SLA_MS = 15 * 60 * 1000; +export const ACTIVE_FRESHNESS_SLA_MS = 15 * 60 * 1000; +export const AGGREGATE_FRESHNESS_SLA_MS = 2 * 60 * 60 * 1000; +export const STATS_HARD_LIMIT_MS = 24 * 60 * 60 * 1000; +export const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000; export type StatsDataSource = | 'stars' @@ -23,14 +30,12 @@ export type StatsSourceStateMap = Record> export type StatsViewDataState = | { kind: 'loading' } - | { kind: 'ready'; fetchedAt: number } - | { kind: 'partial'; fetchedAt: number; message: string } - | { kind: 'stale'; fetchedAt: number; message: string } + | { kind: 'ready'; snapshotAt: number | null; checkedAt: number } + | { kind: 'partial'; snapshotAt: number | null; checkedAt: number; message: string } | { kind: 'error'; message: string }; -export interface StatsResponseFreshness { +export interface StatsResponseFreshness extends DatasetAvailability { cacheState: 'fresh' | 'stale'; - fetchedAt: number; } export function initialStatsSourceStates(): StatsSourceStateMap { @@ -55,25 +60,44 @@ export function sourcesForStatsView(view: RenderedStatsView): readonly StatsData switch (view) { case 'stars-cumulative': return ['stars']; case 'commits-cumulative': return ['commits']; - // The headline numbers are request-time, but the globe is backed by the - // hourly regional aggregate. Both sources must be healthy before this - // compound view can call itself Live. - case 'fsb-active-now': return ['fsb-active', 'fsb-headline']; + // Regions are an optional visualization. Only the request-time active + // snapshot determines whether Active now is Live. + case 'fsb-active-now': return ['fsb-active']; case 'fsb-tokens': return ['fsb-headline', 'fsb-series']; case 'fsb-popular-mcp': return ['fsb-headline']; } } +export function freshnessSlaForStatsView(view: RenderedStatsView): number { + switch (view) { + case 'stars-cumulative': + case 'commits-cumulative': + return GITHUB_FRESHNESS_SLA_MS; + case 'fsb-active-now': + return ACTIVE_FRESHNESS_SLA_MS; + case 'fsb-tokens': + case 'fsb-popular-mcp': + return AGGREGATE_FRESHNESS_SLA_MS; + } +} + export function selectedStatsViewState( view: RenderedStatsView, - states: StatsSourceStateMap + states: StatsSourceStateMap, + now = Date.now() ): StatsViewDataState { - return combineDatasetStates(sourcesForStatsView(view).map((source) => states[source])); + return combineDatasetStates( + sourcesForStatsView(view).map((source) => states[source]), + freshnessSlaForStatsView(view), + now + ); } /** Combine only the sources used by the selected view. */ export function combineDatasetStates( - states: readonly DatasetState[] + states: readonly DatasetState[], + freshnessSlaMs: number, + now = Date.now() ): StatsViewDataState { const error = states.find((state) => state.kind === 'error'); if (error?.kind === 'error') return { kind: 'error', message: error.message }; @@ -82,69 +106,73 @@ export function combineDatasetStates( } const snapshots = states.filter( - (state): state is Extract, { kind: 'ready' | 'partial' | 'stale' }> => - state.kind === 'ready' || state.kind === 'partial' || state.kind === 'stale' + (state): state is Extract, { kind: 'ready' }> => + state.kind === 'ready' ); - const fetchedAt = Math.min(...snapshots.map((state) => state.fetchedAt)); - const stale = snapshots.find((state) => state.kind === 'stale'); - if (stale?.kind === 'stale') { - return { kind: 'stale', fetchedAt, message: stale.message }; + if (snapshots.some((state) => + (state.availability.snapshotAt !== null && + !Number.isFinite(state.availability.snapshotAt)) || + !Number.isFinite(state.availability.checkedAt) + )) { + return { kind: 'error', message: 'Stats response is missing freshness metadata.' }; } - const partial = snapshots.find((state) => state.kind === 'partial'); - if (partial?.kind === 'partial') { - return { kind: 'partial', fetchedAt, message: partial.message }; - } - return { kind: 'ready', fetchedAt }; -} -export function aggregateDatasetState( - data: T, - checkedAt: number, - now = Date.now() -): DatasetState { - const aggregateUpdatedAt = Date.parse(data.aggregate_updated_at ?? ''); - if (!Number.isFinite(aggregateUpdatedAt)) { + const normalizedSnapshotTimes = snapshots.map((state) => { + const time = state.availability.snapshotAt; + return time === null || time > now + MAX_FUTURE_SKEW_MS ? null : time; + }); + const knownSnapshotTimes = normalizedSnapshotTimes.flatMap((time) => + time === null ? [] : [time] + ); + const hasUnknownSnapshotTime = knownSnapshotTimes.length !== snapshots.length; + const snapshotAt = hasUnknownSnapshotTime ? null : Math.min(...knownSnapshotTimes); + const checkedAt = Math.min(...snapshots.map((state) => state.availability.checkedAt)); + const ages = knownSnapshotTimes.map((time) => Math.max(0, now - time)); + if (ages.some((age) => age > STATS_HARD_LIMIT_MS)) { return { - kind: 'partial', - data, - fetchedAt: checkedAt, - message: 'Historical aggregates are not available yet.', + kind: 'error', + message: 'The last usable snapshot is more than 24 hours old.', }; } - if (now - aggregateUpdatedAt > 2 * 60 * 60 * 1000) { + if (hasUnknownSnapshotTime || ages.some((age) => age > freshnessSlaMs)) { return { - kind: 'stale', - data, - fetchedAt: aggregateUpdatedAt, - message: 'Historical aggregates have not updated in over two hours.', + kind: 'partial', + snapshotAt, + checkedAt, + message: 'One or more required snapshots are outside the freshness window.', }; } - return { kind: 'ready', data, fetchedAt: aggregateUpdatedAt }; + return { kind: 'ready', snapshotAt, checkedAt }; +} + +export function aggregateDatasetState( + data: T, + availability: DatasetAvailability +): DatasetState { + const aggregateUpdatedAt = Date.parse(data.aggregate_updated_at ?? ''); + return { + kind: 'ready', + data, + availability: { + ...availability, + snapshotAt: Number.isFinite(aggregateUpdatedAt) ? aggregateUpdatedAt : null, + }, + }; } -/** Downgrade a fresh request-time snapshot when v2 agent-count coverage is incomplete. */ +/** Use the request timestamp for Active now; reporting coverage is separate quality data. */ export function activeSnapshotDatasetState(data: T, checkedAt: number): DatasetState { - const activeUsers = Number(data.active_users_now); - const reportingUsers = Number(data.active_agents_reporting_users_now); - const hasV2Metadata = Number(data.active_count_version) >= 2 && - data.active_metric_semantics === 'reported_registry_count_v2' && - Number.isInteger(activeUsers) && activeUsers >= 0 && - Number.isInteger(reportingUsers) && reportingUsers >= 0 && - reportingUsers <= activeUsers; - if (!hasV2Metadata || reportingUsers < activeUsers) { - return { - kind: 'partial', - data, - fetchedAt: checkedAt, - message: 'Active-agent reporting coverage is incomplete.', - }; - } - return { kind: 'ready', data, fetchedAt: checkedAt }; + generated_at?: string | null; +}>(data: T, availability: DatasetAvailability): DatasetState { + const generatedAt = Date.parse(data.generated_at ?? ''); + return { + kind: 'ready', + data, + availability: { + ...availability, + snapshotAt: Number.isFinite(generatedAt) ? generatedAt : availability.snapshotAt, + }, + }; } /** @@ -173,12 +201,27 @@ export function rollingSevenDayStars(stats: GitHubStarsStats): number | null { /** Read authoritative cache freshness headers, including on HTTP 304. */ export function statsResponseFreshness(headers: Pick): StatsResponseFreshness { const cacheState = headers.get('x-fsb-stats-cache'); - const fetchedAtHeader = headers.get('x-fsb-stats-fetched-at'); - const fetchedAt = Date.parse(fetchedAtHeader ?? ''); - if ((cacheState !== 'fresh' && cacheState !== 'stale') || !Number.isFinite(fetchedAt)) { + const snapshotAt = Date.parse(headers.get('x-fsb-stats-fetched-at') ?? ''); + const checkedAt = Date.parse(headers.get('x-fsb-stats-checked-at') ?? ''); + const upstreamStatus = headers.get('x-fsb-stats-upstream-status')?.trim() ?? ''; + const nextRetryHeader = headers.get('x-fsb-stats-next-retry-at'); + const nextRetryAt = nextRetryHeader === null ? undefined : Date.parse(nextRetryHeader); + if ( + (cacheState !== 'fresh' && cacheState !== 'stale') || + !Number.isFinite(snapshotAt) || + !Number.isFinite(checkedAt) || + upstreamStatus.length === 0 || + (nextRetryAt !== undefined && !Number.isFinite(nextRetryAt)) + ) { throw new Error('Stats response is missing freshness metadata'); } - return { cacheState, fetchedAt }; + return { + cacheState, + snapshotAt, + checkedAt, + upstreamStatus, + ...(nextRetryAt === undefined ? {} : { nextRetryAt }), + }; } export function normalizeGitHubStars(value: unknown): GitHubStarsStats { diff --git a/showcase/angular/src/app/pages/stats/stats-page.component.html b/showcase/angular/src/app/pages/stats/stats-page.component.html index a3e50ebe5..e2f6027c1 100644 --- a/showcase/angular/src/app/pages/stats/stats-page.component.html +++ b/showcase/angular/src/app/pages/stats/stats-page.component.html @@ -13,12 +13,10 @@

Stats + [attr.title]="stateBadgeTitle" [attr.aria-label]="stateBadgeAriaLabel" role="status"> @switch (viewState.kind) { @case ('ready') { Live } @case ('partial') { Partial } - @case ('stale') { Stale } @case ('loading') { Loading } @case ('error') { Unavailable } } @@ -44,20 +42,22 @@

Stats active right now } - - {{ formattedFsbTotal }} - users (365d) - - @if (hasFsbAgentDaysSinceActiveV2) { + @if (hasFsbAggregateSnapshot) { - {{ formattedFsbAgentDays }} - agent-days since active v2 + {{ formattedFsbTotal }} + users (365d) + + @if (hasFsbAgentDaysSinceActiveV2) { + + {{ formattedFsbAgentDays }} + agent-days since active v2 + + } + + {{ formattedFsbTokens }} + tokens (24h) } - - {{ formattedFsbTokens }} - tokens (24h) - } @@ -70,16 +70,18 @@

Stats - - This view is showing the last available snapshot while fresh data is unavailable. + @if (viewState.kind === 'partial') { +
+ + {{ partialFreshnessMessage }}
} - @if (viewState.kind === 'partial') { -
- - This view is usable, but some reporting or historical coverage is incomplete. + @for (notice of qualityNotices; track notice.id) { +
+ + {{ notice.message }}
}
@@ -124,11 +126,6 @@

Stats Not enough anonymous activity yet to break down by region — check back soon. } - @if (regionAggregateState === 'partial') { - The regional aggregate is not available yet. - } @else if (regionAggregateState === 'stale') { - The regional breakdown is older than two hours. - }

} diff --git a/showcase/angular/src/app/pages/stats/stats-page.component.scss b/showcase/angular/src/app/pages/stats/stats-page.component.scss index 33fd98c9e..d9aa7aac9 100644 --- a/showcase/angular/src/app/pages/stats/stats-page.component.scss +++ b/showcase/angular/src/app/pages/stats/stats-page.component.scss @@ -151,22 +151,13 @@ animation: stats-live-pulse 2.2s ease-in-out infinite; } -.stats-live-badge.is-stale { +.stats-live-badge.is-partial { color: #d97706; border-color: color-mix(in srgb, #d97706 45%, var(--border-color)); } -.stats-live-badge.is-stale::before { - background: #d97706; -} - -.stats-live-badge.is-partial { - color: #2563eb; - border-color: color-mix(in srgb, #2563eb 45%, var(--border-color)); -} - .stats-live-badge.is-partial::before { - background: #2563eb; + background: #d97706; } @keyframes stats-live-pulse { @@ -291,7 +282,7 @@ text-align: center; } -.stale-notice { +.stats-notice { display: flex; align-items: flex-start; gap: 0.5rem; @@ -305,21 +296,30 @@ line-height: 1.45; } -.stale-notice i { +.stats-notice i { flex: 0 0 auto; color: #d97706; margin-top: 0.1rem; } -.partial-notice { +.quality-notice { border-color: color-mix(in srgb, #2563eb 38%, var(--border-color)); background: color-mix(in srgb, #2563eb 9%, transparent); } -.partial-notice i { +.quality-notice i { color: #2563eb; } +.retry-notice { + border-color: var(--border-color); + background: color-mix(in srgb, var(--bg-elevated) 72%, transparent); +} + +.retry-notice i { + color: var(--text-muted); +} + .chart-mount canvas { width: 100% !important; height: 100% !important; diff --git a/showcase/angular/src/app/pages/stats/stats-page.component.spec.ts b/showcase/angular/src/app/pages/stats/stats-page.component.spec.ts new file mode 100644 index 000000000..3516e44a1 --- /dev/null +++ b/showcase/angular/src/app/pages/stats/stats-page.component.spec.ts @@ -0,0 +1,499 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; + +import { GlobeVisualizationService } from '../../core/globe/globe-visualization.service'; +import { FSBTelemetryService } from '../../core/stats/fsb-telemetry.service'; +import { + DatasetState as FSBDatasetState, + FSBTelemetryHeadline, + FSBTelemetrySeries, +} from '../../core/stats/fsb-telemetry.types'; +import { GitHubStatsService } from '../../core/stats/github-stats.service'; +import { + DatasetAvailability, + DatasetState, + GitHubCommitsStats, + GitHubStarsStats, +} from '../../core/stats/github-stats.types'; +import { + AnyViewId, + STATS_CHART_LOADER, + StatsPageComponent, +} from './stats-page.component'; + +class FakeGitHubStatsService { + readonly stars$ = new BehaviorSubject>({ kind: 'loading' }); + readonly commits$ = new BehaviorSubject>({ kind: 'loading' }); + readonly start = jasmine.createSpy('githubStart'); + readonly stop = jasmine.createSpy('githubStop'); +} + +class FakeFSBTelemetryService { + readonly headline$ = new BehaviorSubject>({ kind: 'loading' }); + readonly series$ = new BehaviorSubject>({ kind: 'loading' }); + readonly start = jasmine.createSpy('fsbStart'); + readonly stop = jasmine.createSpy('fsbStop'); +} + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; + reject(reason: unknown): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function availability( + snapshotAt: number | null = Date.now(), + upstreamStatus = '200', + checkedAt = Date.now() +): DatasetAvailability { + return { snapshotAt, checkedAt, upstreamStatus }; +} + +function readyState( + data: T, + sourceAvailability = availability() +): DatasetState { + return { kind: 'ready', data, availability: sourceAvailability }; +} + +function stars(total = 12, historyComplete = true): GitHubStarsStats { + return { + schema_version: 1, + total, + history: [{ day_utc: '2026-07-21', total }], + history_complete: historyComplete, + source: 'repository-count', + as_of: '2026-07-21T12:00:00Z', + }; +} + +function starState( + snapshotAt = Date.now(), + upstreamStatus = '200', + historyComplete = true +): DatasetState { + return readyState( + stars(12, historyComplete), + availability(snapshotAt, upstreamStatus) + ); +} + +function commits(historyComplete = true): GitHubCommitsStats { + return { + schema_version: 1, + total: 20, + last_30_days: 4, + history: [{ day_utc: '2026-07-21', total: 20 }], + history_complete: historyComplete, + as_of: new Date().toISOString(), + }; +} + +function fsbHeadline( + overrides: Partial = {} +): FSBTelemetryHeadline { + const now = new Date().toISOString(); + return { + generated_at: now, + aggregate_as_of_day: now.slice(0, 10), + aggregate_updated_at: now, + active_users_now: 2, + active_agents_now: 3, + active_agents_reporting_users_now: 2, + active_agents_coverage: 1, + active_agents_bucket: '2-4', + active_count_version: 2, + active_history_since: '2026-07-01', + active_history_complete: true, + active_metric_semantics: 'reported_registry_count_v2', + total_users: 4, + users_365d: 4, + total_agents_lifetime: null, + agent_days_lifetime: null, + agent_days_since_active_v2: 8, + tokens_total_lifetime: 1000, + tokens_24h: 100, + popular_mcp_clients: [], + popular_agents: [], + popular_regions: [{ label: 'United States', uniq: 5 }], + avg_agents_per_user: 1.5, + avg_agents_per_reporting_user: 1.5, + ...overrides, + }; +} + +function fsbSeries( + overrides: Partial = {} +): FSBTelemetrySeries { + const now = new Date().toISOString(); + return { + generated_at: now, + aggregate_as_of_day: now.slice(0, 10), + aggregate_updated_at: now, + d30: [], + d90: [], + d365: [], + ...overrides, + }; +} + +describe('StatsPageComponent visualization lifecycle', () => { + let github: FakeGitHubStatsService; + let fsb: FakeFSBTelemetryService; + let globeStop: jasmine.Spy; + let globe: jasmine.SpyObj; + let chartLoader: Deferred<{ default: typeof FakeChart }>; + let chartInstances: FakeChart[]; + + class FakeChart { + readonly destroy = jasmine.createSpy('chartDestroy'); + + constructor( + readonly canvas: HTMLCanvasElement, + readonly config: any + ) { + chartInstances.push(this); + } + } + + beforeEach(async () => { + github = new FakeGitHubStatsService(); + fsb = new FakeFSBTelemetryService(); + globeStop = jasmine.createSpy('globeStop'); + globe = jasmine.createSpyObj('globe', ['setupGlobe']); + globe.setupGlobe.and.returnValue(globeStop); + chartLoader = deferred<{ default: typeof FakeChart }>(); + chartInstances = []; + + await TestBed.configureTestingModule({ + imports: [StatsPageComponent], + providers: [ + { provide: GitHubStatsService, useValue: github }, + { provide: FSBTelemetryService, useValue: fsb }, + { provide: GlobeVisualizationService, useValue: globe }, + { provide: STATS_CHART_LOADER, useValue: () => chartLoader.promise }, + ], + }) + .overrideComponent(StatsPageComponent, { + set: { + imports: [], + template: ` + @if (isViewRenderable) { + + + } + `, + }, + }) + .compileComponents(); + }); + + async function createFixture( + selectedView: AnyViewId = 'stars-cumulative' + ): Promise> { + const fixture = TestBed.createComponent(StatsPageComponent); + fixture.componentInstance.selectedView = selectedView; + fixture.autoDetectChanges(); + await fixture.whenStable(); + return fixture; + } + + async function settle(fixture: ComponentFixture): Promise { + await Promise.resolve(); + await fixture.whenStable(); + } + + it('renders the initial Stars chart when data arrives before Chart.js', async () => { + const fixture = await createFixture(); + + github.stars$.next(starState()); + await settle(fixture); + expect(chartInstances).toHaveSize(0); + expect(fixture.nativeElement.querySelector('canvas')).toBeNull(); + + chartLoader.resolve({ default: FakeChart }); + await settle(fixture); + + expect(chartInstances).toHaveSize(1); + expect(chartInstances[0].canvas).toBe(fixture.nativeElement.querySelector('canvas')); + expect(chartInstances[0].config.type).toBe('line'); + }); + + it('renders the initial Stars chart when Chart.js loads before data', async () => { + const fixture = await createFixture(); + + chartLoader.resolve({ default: FakeChart }); + await settle(fixture); + expect(chartInstances).toHaveSize(0); + expect(fixture.nativeElement.querySelector('canvas')).toBeNull(); + + github.stars$.next(starState()); + await settle(fixture); + + expect(chartInstances).toHaveSize(1); + expect(chartInstances[0].canvas).toBe(fixture.nativeElement.querySelector('canvas')); + }); + + it('mounts a chart for a usable snapshot outside the freshness SLA', async () => { + const fixture = await createFixture(); + chartLoader.resolve({ default: FakeChart }); + await settle(fixture); + + github.stars$.next(starState(Date.now() - 16 * 60 * 1000)); + await settle(fixture); + + expect(fixture.componentInstance.viewState.kind).toBe('partial'); + expect(chartInstances).toHaveSize(1); + expect(chartInstances[0].canvas).toBe(fixture.nativeElement.querySelector('canvas')); + }); + + it('coalesces a compound Active now update into one post-render redraw', async () => { + const fixture = await createFixture('fsb-active-now'); + const redraw = spyOn(fixture.componentInstance, 'redrawChart').and.callThrough(); + + // One headline event updates both sources used by Active now. Both update + // paths request a redraw, but only the final post-render callback may run. + fsb.headline$.next(readyState(fsbHeadline())); + await settle(fixture); + + expect(redraw).toHaveBeenCalledTimes(1); + expect(globe.setupGlobe).toHaveBeenCalledTimes(1); + }); + + it('mounts the initial Active now globe without waiting for Chart.js', async () => { + const fixture = await createFixture('fsb-active-now'); + + fsb.headline$.next(readyState(fsbHeadline())); + await settle(fixture); + + expect(chartInstances).toHaveSize(0); + expect(globe.setupGlobe).toHaveBeenCalledTimes(1); + expect(globe.setupGlobe.calls.mostRecent().args[0]).toBe( + fixture.nativeElement.querySelectorAll('canvas')[1] + ); + }); + + it('does not mount a chart when the loader resolves after destruction', async () => { + const fixture = await createFixture(); + github.stars$.next(starState()); + await settle(fixture); + + fixture.destroy(); + chartLoader.resolve({ default: FakeChart }); + await chartLoader.promise; + await Promise.resolve(); + + expect(chartInstances).toHaveSize(0); + expect(github.stop).toHaveBeenCalledTimes(1); + expect(fsb.stop).toHaveBeenCalledTimes(1); + }); +}); + +describe('StatsPageComponent freshness and quality', () => { + let github: FakeGitHubStatsService; + let fsb: FakeFSBTelemetryService; + + class RenderedChart { + destroy(): void {} + } + + beforeEach(async () => { + github = new FakeGitHubStatsService(); + fsb = new FakeFSBTelemetryService(); + const globe = jasmine.createSpyObj('globe', ['setupGlobe']); + globe.setupGlobe.and.returnValue(() => undefined); + + await TestBed.configureTestingModule({ + imports: [StatsPageComponent], + providers: [ + provideRouter([]), + { provide: GitHubStatsService, useValue: github }, + { provide: FSBTelemetryService, useValue: fsb }, + { provide: GlobeVisualizationService, useValue: globe }, + { + provide: STATS_CHART_LOADER, + useValue: () => Promise.resolve({ default: RenderedChart }), + }, + ], + }).compileComponents(); + }); + + async function createFixture( + selectedView: AnyViewId = 'stars-cumulative' + ): Promise> { + const fixture = TestBed.createComponent(StatsPageComponent); + fixture.componentInstance.selectedView = selectedView; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return fixture; + } + + async function render(fixture: ComponentFixture): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + function text(fixture: ComponentFixture, selector: string): string { + return String(fixture.nativeElement.querySelector(selector)?.textContent ?? '') + .replace(/\s+/g, ' ') + .trim(); + } + + it('shows Live with a separate notice for incomplete star dates', async () => { + const fixture = await createFixture(); + + github.stars$.next(starState(Date.now(), '200', false)); + await render(fixture); + + expect(text(fixture, '.stats-live-badge')).toBe('Live'); + expect( + fixture.nativeElement.querySelector('.stats-live-badge').getAttribute('title') + ).toMatch(/Latest snapshot from .+; last checked .+/); + expect( + fixture.nativeElement.querySelector('.stats-live-badge').getAttribute('aria-label') + ).toMatch(/^Live\. Latest snapshot from .+; last checked .+/); + expect(text(fixture, '.quality-notice')).toBe( + 'The latest star total is available. Exact dates are unavailable for some star changes.' + ); + }); + + it('shows Live with a neutral retry notice after a failed fresh GitHub check', async () => { + const fixture = await createFixture(); + + github.stars$.next(starState(Date.now() - 5 * 60 * 1000, '403')); + await render(fixture); + + expect(text(fixture, '.stats-live-badge')).toBe('Live'); + expect(fixture.nativeElement.querySelector('.retry-notice')).not.toBeNull(); + expect(text(fixture, '.retry-notice')).toContain('This snapshot is still current'); + }); + + it('does not call a future-skewed failed snapshot current', async () => { + const fixture = await createFixture(); + + github.stars$.next(starState(Date.now() + 6 * 60 * 1000, '403')); + await render(fixture); + + expect(text(fixture, '.stats-live-badge')).toBe('Partial'); + expect(fixture.nativeElement.querySelector('.retry-notice')).toBeNull(); + }); + + it('maps an old usable snapshot to Partial and a snapshot over 24 hours to Unavailable', async () => { + const fixture = await createFixture(); + + github.stars$.next(starState(Date.now() - 16 * 60 * 1000)); + await render(fixture); + expect(text(fixture, '.stats-live-badge')).toBe('Partial'); + expect(text(fixture, '.freshness-notice')).toContain( + 'a fresh update is temporarily unavailable' + ); + + github.stars$.next(starState(Date.now() - 24 * 60 * 60 * 1000 - 1)); + await render(fixture); + expect(text(fixture, '.stats-live-badge')).toBe('Unavailable'); + }); + + it('shows Live and coverage quality for Active now with 0 of 32 reporters', async () => { + const fixture = await createFixture('fsb-active-now'); + fsb.headline$.next(readyState(fsbHeadline({ + active_users_now: 32, + active_agents_now: 0, + active_agents_reporting_users_now: 0, + active_agents_coverage: 0, + avg_agents_per_reporting_user: 0, + aggregate_updated_at: null, + popular_regions: [], + }))); + await render(fixture); + + expect(text(fixture, '.stats-live-badge')).toBe('Live'); + expect(text(fixture, '.quality-notice')).toContain( + 'Agent totals include 0 of 32 active users in this snapshot that reported an agent count.' + ); + expect(text(fixture, '.quality-notice:nth-of-type(2)')).toContain( + 'regional aggregate is not available' + ); + const metricValues = Array.from( + fixture.nativeElement.querySelectorAll('.stats-tab-metrics strong') + ).map((node) => (node as HTMLElement).textContent?.trim()); + expect(metricValues).toEqual(['—', '—']); + }); + + it('hides expired aggregate pills while a fresh Active snapshot remains Live', async () => { + const fixture = await createFixture('fsb-active-now'); + fsb.headline$.next(readyState(fsbHeadline({ + aggregate_updated_at: new Date(Date.now() - 24 * 60 * 60 * 1000 - 1).toISOString(), + }))); + await render(fixture); + + expect(text(fixture, '.stats-live-badge')).toBe('Live'); + expect(fixture.componentInstance.hasFsbAggregateSnapshot).toBeFalse(); + expect(text(fixture, '.stats-headline')).toContain('active right now'); + expect(text(fixture, '.stats-headline')).not.toContain('users (365d)'); + expect(text(fixture, '.stats-headline')).not.toContain('agent-days since active v2'); + expect(text(fixture, '.stats-headline')).not.toContain('tokens (24h)'); + }); + + it('does not let a delayed regional aggregate downgrade Active now', async () => { + const fixture = await createFixture('fsb-active-now'); + fsb.headline$.next(readyState(fsbHeadline({ + aggregate_updated_at: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + }))); + await render(fixture); + + expect(text(fixture, '.stats-live-badge')).toBe('Live'); + expect(text(fixture, '.quality-notice')).toContain( + 'The regional breakdown is older than two hours.' + ); + }); + + it('shows Active now as Partial when the request timestamp is missing', async () => { + const fixture = await createFixture('fsb-active-now'); + fsb.headline$.next(readyState( + fsbHeadline({ generated_at: 'not-a-date' }), + availability(null) + )); + await render(fixture); + + expect(text(fixture, '.stats-live-badge')).toBe('Partial'); + expect( + fixture.nativeElement.querySelector('.stats-live-badge').getAttribute('aria-label') + ).toContain('Partial. Latest snapshot update time unavailable'); + }); + + it('keeps Tokens loading until independently ordered required responses arrive', async () => { + const fixture = await createFixture('fsb-tokens'); + + fsb.series$.next(readyState(fsbSeries())); + await render(fixture); + expect(text(fixture, '.stats-live-badge')).toBe('Loading'); + + fsb.headline$.next(readyState(fsbHeadline())); + await render(fixture); + expect(text(fixture, '.stats-live-badge')).toBe('Live'); + }); + + it('shows commit history quality without downgrading a fresh snapshot', async () => { + const fixture = await createFixture('commits-cumulative'); + github.commits$.next(readyState(commits(false))); + await render(fixture); + + expect(text(fixture, '.stats-live-badge')).toBe('Live'); + expect(text(fixture, '.quality-notice')).toBe( + 'Commit history is incomplete, so totals and the chart may omit older commits.' + ); + }); +}); diff --git a/showcase/angular/src/app/pages/stats/stats-page.component.ts b/showcase/angular/src/app/pages/stats/stats-page.component.ts index 23880f790..e77c7205b 100644 --- a/showcase/angular/src/app/pages/stats/stats-page.component.ts +++ b/showcase/angular/src/app/pages/stats/stats-page.component.ts @@ -15,9 +15,11 @@ // suspenders for the rare case a crawler stumbles in via the footer link. import { - AfterViewInit, + AfterRenderRef, Component, ElementRef, + InjectionToken, + Injector, LOCALE_ID, NgZone, OnDestroy, @@ -47,9 +49,10 @@ import { FSBTelemetrySeries, } from '../../core/stats/fsb-telemetry.types'; import { + AGGREGATE_FRESHNESS_SLA_MS, activeSnapshotDatasetState, aggregateDatasetState, - combineDatasetStates, + GITHUB_FRESHNESS_SLA_MS, initialStatsSourceStates, rollingSevenDayStars, selectedStatsViewState, @@ -64,6 +67,16 @@ import { GlobeVisualizationService } from '../../core/globe/globe-visualization. import { GlobeRegion } from '../../core/globe/globe-visualization.types'; import { LanguagePickerComponent } from '../../layout/language-picker/language-picker.component'; +type StatsChartLoader = () => Promise; + +export const STATS_CHART_LOADER = new InjectionToken( + 'STATS_CHART_LOADER', + { + providedIn: 'root', + factory: () => () => import('chart.js/auto'), + } +); + // The picker combines two GitHub aggregate views with three FSB telemetry // views. The local union keeps component switches limited to those five. export type FSBViewId = 'fsb-active-now' | 'fsb-tokens' | 'fsb-popular-mcp'; @@ -88,6 +101,12 @@ interface AccessibleDatum { value: string; } +interface StatsQualityNotice { + id: string; + message: string; + kind: 'quality' | 'retry'; +} + // Redesigned fan-picker tab item -- `dy` is a vertical arc offset (px) so the // non-active tabs read as a fanned-out arc rather than a flat list. interface FanItem { @@ -103,7 +122,7 @@ interface FanItem { templateUrl: './stats-page.component.html', styleUrl: './stats-page.component.scss', }) -export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { +export class StatsPageComponent implements OnInit, OnDestroy { private readonly title = inject(Title); private readonly meta = inject(Meta); private readonly doc = inject(DOCUMENT); @@ -114,6 +133,8 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { private readonly fsbService = inject(FSBTelemetryService); private readonly globeService = inject(GlobeVisualizationService); private readonly zone = inject(NgZone); + private readonly injector = inject(Injector); + private readonly chartLoader = inject(STATS_CHART_LOADER); readonly appVersion = APP_VERSION; readonly fallbackErrorMessage = $localize`:@@stats.error.default:Network or parse error.`; @@ -182,26 +203,23 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { get isViewRenderable(): boolean { return this.viewState.kind === 'ready' || - this.viewState.kind === 'partial' || - this.viewState.kind === 'stale'; + this.viewState.kind === 'partial'; } - get fsbSummaryState(): StatsViewDataState { - return combineDatasetStates([ - this.sourceStates['fsb-active'], - this.sourceStates['fsb-headline'], - ]); + get hasFsbHeadlineSnapshot(): boolean { + if (this.latestFsbHeadline === null) return false; + const activeState = selectedStatsViewState('fsb-active-now', this.sourceStates); + return activeState.kind === 'ready' || activeState.kind === 'partial' || + this.hasFsbAggregateSnapshot; } - get hasFsbHeadlineSnapshot(): boolean { - const state = this.fsbSummaryState; - return this.latestFsbHeadline !== null && - (state.kind === 'ready' || state.kind === 'partial' || state.kind === 'stale'); + get hasFsbAggregateSnapshot(): boolean { + const aggregateState = selectedStatsViewState('fsb-popular-mcp', this.sourceStates); + return aggregateState.kind === 'ready' || aggregateState.kind === 'partial'; } get isFsbActiveSnapshotCurrent(): boolean { - const state = this.sourceStates['fsb-active']; - return state.kind === 'ready' || state.kind === 'partial'; + return selectedStatsViewState('fsb-active-now', this.sourceStates).kind === 'ready'; } get isMcpEmpty(): boolean { @@ -214,18 +232,26 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { return this.formatStateTitle(this.viewState); } + get stateBadgeAriaLabel(): string { + const status = (() => { + switch (this.viewState.kind) { + case 'ready': return $localize`:@@stats.live.label:Live`; + case 'partial': return $localize`:@@stats.status.partial:Partial`; + case 'loading': return $localize`:@@stats.status.loadingLabel:Loading`; + case 'error': return $localize`:@@stats.status.unavailableLabel:Unavailable`; + } + })(); + return `${status}. ${this.stateBadgeTitle}`; + } + private formatStateTitle(state: StatsViewDataState): string { - if (state.kind === 'ready' || state.kind === 'partial' || state.kind === 'stale') { - const formatted = new Intl.DateTimeFormat(this.localeId, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(state.fetchedAt); - if (state.kind === 'ready') { - return $localize`:@@stats.status.freshAt:Fresh snapshot checked ${formatted}:checkedAt:`; + if (state.kind === 'ready' || state.kind === 'partial') { + const checkedAt = this.formatDateTime(state.checkedAt); + if (state.snapshotAt === null) { + return $localize`:@@stats.status.snapshotUnknownChecked:Latest snapshot update time unavailable; last checked ${checkedAt}:checkedAt:`; } - return state.kind === 'partial' - ? $localize`:@@stats.status.partialAt:Partial snapshot checked ${formatted}:checkedAt:` - : $localize`:@@stats.status.staleAt:Stale snapshot from ${formatted}:fetchedAt:`; + const snapshotAt = this.formatDateTime(state.snapshotAt); + return $localize`:@@stats.status.snapshotAndChecked:Latest snapshot from ${snapshotAt}:snapshotAt:; last checked ${checkedAt}:checkedAt:`; } return state.kind === 'loading' ? $localize`:@@stats.status.loading:Loading selected stats` @@ -237,6 +263,87 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { return state.kind === 'error' ? state.message : ''; } + get partialFreshnessMessage(): string { + const state = this.viewState; + if (state.kind !== 'partial') return ''; + if (state.snapshotAt === null) { + return $localize`:@@stats.partial.unknownTime:Showing the latest available snapshot; its update time is unavailable. A fresh update is temporarily unavailable.`; + } + const snapshotAt = this.formatDateTime(state.snapshotAt); + return $localize`:@@stats.partial.notice:Showing the latest available snapshot from ${snapshotAt}:snapshotAt:; a fresh update is temporarily unavailable.`; + } + + get qualityNotices(): readonly StatsQualityNotice[] { + const notices: StatsQualityNotice[] = []; + if (this.selectedView === 'stars-cumulative' && this.latestStars?.history_complete === false) { + notices.push({ + id: 'stars-history', + kind: 'quality', + message: $localize`:@@stats.quality.starsHistory:The latest star total is available. Exact dates are unavailable for some star changes.`, + }); + } + if (this.selectedView === 'commits-cumulative' && this.latestCommits?.history_complete === false) { + notices.push({ + id: 'commits-history', + kind: 'quality', + message: $localize`:@@stats.quality.commitsHistory:Commit history is incomplete, so totals and the chart may omit older commits.`, + }); + } + + if (this.selectedView === 'stars-cumulative' || this.selectedView === 'commits-cumulative') { + const source = this.sourceStates[ + this.selectedView === 'stars-cumulative' ? 'stars' : 'commits' + ]; + if ( + this.viewState.kind === 'ready' && + source.kind === 'ready' && + source.availability.snapshotAt !== null && + Date.now() - source.availability.snapshotAt <= GITHUB_FRESHNESS_SLA_MS && + !this.isSuccessfulUpstreamStatus(source.availability.upstreamStatus) + ) { + notices.push({ + id: 'github-retry', + kind: 'retry', + message: $localize`:@@stats.quality.githubRetry:GitHub refresh is delayed. This snapshot is still current, and another check is scheduled.`, + }); + } + } + + if (this.selectedView === 'fsb-active-now') { + const activeUsers = Math.max(0, Math.trunc(Number(this.latestFsbHeadline?.active_users_now) || 0)); + const rawReporters = Number(this.latestFsbHeadline?.active_agents_reporting_users_now); + const hasTrustedCoverage = Number(this.latestFsbHeadline?.active_count_version) >= 2 && + this.latestFsbHeadline?.active_metric_semantics === 'reported_registry_count_v2' && + Number.isInteger(rawReporters) && rawReporters >= 0 && rawReporters <= activeUsers; + const reporters = hasTrustedCoverage + ? Math.min(activeUsers, rawReporters) + : 0; + if (activeUsers > 0 && reporters < activeUsers) { + const reporterCount = this.fmtNum(reporters); + const activeUserCount = this.fmtNum(activeUsers); + notices.push({ + id: 'active-coverage', + kind: 'quality', + message: $localize`:@@stats.quality.activeCoverage:Agent totals include ${reporterCount}:reporters: of ${activeUserCount}:activeUsers: active users in this snapshot that reported an agent count.`, + }); + } + if (this.regionAggregateState === 'missing') { + notices.push({ + id: 'regions-missing', + kind: 'quality', + message: $localize`:@@stats.quality.regionsMissing:The regional aggregate is not available yet.`, + }); + } else if (this.regionAggregateState === 'delayed') { + notices.push({ + id: 'regions-delayed', + kind: 'quality', + message: $localize`:@@stats.quality.regionsDelayed:The regional breakdown is older than two hours.`, + }); + } + } + return notices; + } + get chartAriaLabel(): string { switch (this.selectedView) { case 'stars-cumulative': @@ -401,12 +508,12 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { return regions.some((r) => regionCentroid(r.label) !== null); } - get regionAggregateState(): 'ready' | 'partial' | 'stale' | 'unavailable' { - const state = this.sourceStates['fsb-headline']; - if (state.kind === 'ready') return 'ready'; - if (state.kind === 'partial') return 'partial'; - if (state.kind === 'stale') return 'stale'; - return 'unavailable'; + get regionAggregateState(): 'ready' | 'missing' | 'delayed' { + const aggregateUpdatedAt = Date.parse(this.latestFsbHeadline?.aggregate_updated_at ?? ''); + if (!Number.isFinite(aggregateUpdatedAt)) return 'missing'; + return Date.now() - aggregateUpdatedAt > AGGREGATE_FRESHNESS_SLA_MS + ? 'delayed' + : 'ready'; } get fanItemsLeft(): readonly FanItem[] { @@ -431,7 +538,7 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { // lets redrawChart() keep the globe spinning across redraws that don't // change the region data. Meaningful only while stopGlobe is set. private lastGlobeKey = ''; - private pendingViewRedrawFrame: number | null = null; + private pendingViewRedraw: AfterRenderRef | null = null; private subs: Subscription[] = []; // Set by ngOnDestroy so the async bootstrap() can tell when it lost the @@ -478,12 +585,6 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { this.meta.updateTag({ name: 'robots', content: 'noindex, nofollow' }); } - ngAfterViewInit(): void { - // No-op -- chart bootstrap is handled inside afterNextRender so the - // canvas may not exist yet on first ngAfterViewInit (template @if - // skeleton state). bootstrap() handles canvas availability defensively. - } - ngOnDestroy(): void { this.destroyed = true; this.statsService.stop(); @@ -645,6 +746,13 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { return new Intl.NumberFormat(this.localeId).format(Math.round(value || 0)); } + private formatDateTime(value: number): string { + return new Intl.DateTimeFormat(this.localeId, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(value); + } + private fmtBig(value: number): string { if (value >= 1_000) { return new Intl.NumberFormat(this.localeId, { @@ -655,6 +763,10 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { return this.fmtNum(value); } + private isSuccessfulUpstreamStatus(status: string): boolean { + return status === '200' || status === '304'; + } + private bootstrapData(): void { if (!isPlatformBrowser(this.platformId) || this.destroyed) return; this.sourceStates = initialStatsSourceStates(); @@ -665,41 +777,53 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { this.statsService.start(); this.fsbService.start(); this.subs.push( - this.statsService.stars$.subscribe((state) => this.onSourceUpdate('stars', state)), - this.statsService.commits$.subscribe((state) => this.onSourceUpdate('commits', state)), - this.fsbService.headline$.subscribe((state) => this.onFsbHeadlineUpdate(state)), - this.fsbService.series$.subscribe((state) => this.onFsbSeriesUpdate(state)) + this.statsService.stars$.subscribe((state) => + this.zone.run(() => this.onSourceUpdate('stars', state)) + ), + this.statsService.commits$.subscribe((state) => + this.zone.run(() => this.onSourceUpdate('commits', state)) + ), + this.fsbService.headline$.subscribe((state) => + this.zone.run(() => this.onFsbHeadlineUpdate(state)) + ), + this.fsbService.series$.subscribe((state) => + this.zone.run(() => this.onFsbSeriesUpdate(state)) + ) ); } private async bootstrapChart(): Promise { if (!isPlatformBrowser(this.platformId)) return; try { - const chartMod = await import('chart.js/auto'); + const chartMod = await this.chartLoader(); if (this.destroyed) return; - this.ChartCtor = chartMod.default ?? chartMod; - this.chartLibraryState = 'ready'; - this.scheduleViewRedraw(); + this.zone.run(() => { + if (this.destroyed) return; + this.ChartCtor = chartMod.default ?? chartMod; + this.chartLibraryState = 'ready'; + this.scheduleViewRedraw(); + }); } catch (err) { console.warn('[stats-page] failed to load chart.js', err); if (this.destroyed) return; - this.chartLibraryState = 'error'; - this.scheduleViewRedraw(); + this.zone.run(() => { + if (this.destroyed) return; + this.chartLibraryState = 'error'; + this.scheduleViewRedraw(); + }); } } private onFsbHeadlineUpdate(state: FSBDatasetState): void { - if (state.kind === 'ready' || state.kind === 'partial' || state.kind === 'stale') { - this.latestFsbHeadline = state.data; - } if (state.kind === 'ready') { + this.latestFsbHeadline = state.data; this.onSourceState( 'fsb-active', - activeSnapshotDatasetState(state.data, state.fetchedAt) + activeSnapshotDatasetState(state.data, state.availability) ); this.onSourceState( 'fsb-headline', - aggregateDatasetState(state.data, state.fetchedAt) + aggregateDatasetState(state.data, state.availability) ); } else { this.onSourceState('fsb-active', state); @@ -708,17 +832,17 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { } private onFsbSeriesUpdate(state: FSBDatasetState): void { - if (state.kind === 'ready' || state.kind === 'partial' || state.kind === 'stale') { + if (state.kind === 'ready') { this.latestFsbSeries = state.data; } this.onSourceState( 'fsb-series', - state.kind === 'ready' ? aggregateDatasetState(state.data, state.fetchedAt) : state + state.kind === 'ready' ? aggregateDatasetState(state.data, state.availability) : state ); } private onSourceUpdate(key: 'stars' | 'commits', state: DatasetState): void { - if (state.kind === 'ready' || state.kind === 'partial' || state.kind === 'stale') { + if (state.kind === 'ready') { switch (key) { case 'stars': this.latestStars = state.data as GitHubStarsStats; @@ -741,7 +865,7 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { state as DatasetState ); if (!sourcesForStatsView(this.selectedView).includes(source)) return; - if (state.kind === 'ready' || state.kind === 'partial' || state.kind === 'stale') { + if (state.kind === 'ready') { this.clearChartRenderError(this.selectedView); } if (!this.isViewRenderable) this.teardownVisualization(); @@ -750,29 +874,22 @@ export class StatsPageComponent implements OnInit, AfterViewInit, OnDestroy { private scheduleViewRedraw(): void { this.cancelPendingViewRedraw(); - if ( - !isPlatformBrowser(this.platformId) || - typeof window === 'undefined' || - typeof window.requestAnimationFrame !== 'function' - ) { - this.redrawChart(); - return; - } - this.pendingViewRedrawFrame = window.requestAnimationFrame(() => { - this.pendingViewRedrawFrame = null; - this.redrawChart(); - }); + if (!isPlatformBrowser(this.platformId) || this.destroyed) return; + this.pendingViewRedraw = afterNextRender( + { + mixedReadWrite: () => { + this.pendingViewRedraw = null; + if (this.destroyed) return; + this.zone.runOutsideAngular(() => this.redrawChart()); + }, + }, + { injector: this.injector } + ); } private cancelPendingViewRedraw(): void { - if ( - this.pendingViewRedrawFrame !== null && - typeof window !== 'undefined' && - typeof window.cancelAnimationFrame === 'function' - ) { - window.cancelAnimationFrame(this.pendingViewRedrawFrame); - } - this.pendingViewRedrawFrame = null; + this.pendingViewRedraw?.destroy(); + this.pendingViewRedraw = null; } private redrawChart(): void { diff --git a/showcase/angular/src/locale/messages.de.xlf b/showcase/angular/src/locale/messages.de.xlf index 989e73d42..1127abae1 100644 --- a/showcase/angular/src/locale/messages.de.xlf +++ b/showcase/angular/src/locale/messages.de.xlf @@ -9619,11 +9619,11 @@ Aktuell src/app/pages/stats/stats-page.component.html - 19,20 + 18,19 - src/app/pages/stats/stats-page.component.html - 69,70 + src/app/pages/stats/stats-page.component.ts + 238 @@ -9631,23 +9631,11 @@ Teilweise src/app/pages/stats/stats-page.component.html - 20,21 - - - src/app/pages/stats/stats-page.component.html - 70,71 - - - - Stale - Veraltet - - src/app/pages/stats/stats-page.component.html - 21,22 + 19,20 - src/app/pages/stats/stats-page.component.html - 71,72 + src/app/pages/stats/stats-page.component.ts + 239 @@ -9655,11 +9643,11 @@ Wird geladen src/app/pages/stats/stats-page.component.html - 22,23 + 20,21 - src/app/pages/stats/stats-page.component.html - 72,73 + src/app/pages/stats/stats-page.component.ts + 240 @@ -9667,11 +9655,11 @@ Nicht verfügbar src/app/pages/stats/stats-page.component.html - 23,26 + 21,24 - src/app/pages/stats/stats-page.component.html - 73,75 + src/app/pages/stats/stats-page.component.ts + 241 @@ -9679,7 +9667,7 @@ Kennzahlen des aktuellen Tabs src/app/pages/stats/stats-page.component.html - 30,31 + 28,29 @@ -9687,11 +9675,7 @@ Aggregierte FSB-Metriken src/app/pages/stats/stats-page.component.html - 38,39 - - - src/app/pages/stats/stats-page.component.html - 66,68 + 37,38 @@ -9699,7 +9683,7 @@ jetzt aktiv src/app/pages/stats/stats-page.component.html - 43,45 + 42,45 @@ -9707,7 +9691,7 @@ Nutzer (365 T.) src/app/pages/stats/stats-page.component.html - 49,51 + 48,50 @@ -9715,7 +9699,7 @@ Agententage seit Active v2 src/app/pages/stats/stats-page.component.html - 54,56 + 53,56 @@ -9723,7 +9707,7 @@ Token (24 Std.) src/app/pages/stats/stats-page.component.html - 59,62 + 58,61 @@ -9731,23 +9715,7 @@ Statistiken werden geladen ... src/app/pages/stats/stats-page.component.html - 83,85 - - - - This view is showing the last available snapshot while fresh data is unavailable. - Diese Ansicht zeigt den letzten verfügbaren Stand, solange keine aktuellen Daten verfügbar sind. - - src/app/pages/stats/stats-page.component.html - 90,92 - - - - This view is usable, but some reporting or historical coverage is incomplete. - Diese Ansicht ist nutzbar, aber die Berichts- oder Verlaufsabdeckung ist teilweise unvollständig. - - src/app/pages/stats/stats-page.component.html - 96,98 + 69,71 @@ -9755,7 +9723,7 @@ Noch keine MCP-Client-Daten vorhanden. src/app/pages/stats/stats-page.component.html - 115,117 + 103,105 @@ -9763,7 +9731,7 @@ Regionale Verteilung aus dem heutigen Stundenaggregat. src/app/pages/stats/stats-page.component.html - 137,139 + 125,127 @@ -9771,23 +9739,7 @@ Noch nicht genügend anonyme Aktivität für eine Aufschlüsselung nach Region – schau bald wieder vorbei. src/app/pages/stats/stats-page.component.html - 139,141 - - - - The regional aggregate is not available yet. - Das regionale Aggregat ist noch nicht verfügbar. - - src/app/pages/stats/stats-page.component.html - 142,143 - - - - The regional breakdown is older than two hours. - Die regionale Aufschlüsselung ist älter als zwei Stunden. - - src/app/pages/stats/stats-page.component.html - 144,146 + 127,130 @@ -9795,7 +9747,7 @@ Statistiken konnten nicht geladen werden src/app/pages/stats/stats-page.component.html - 151,152 + 134,135 @@ -9803,7 +9755,7 @@ Die Seite versucht es automatisch alle 5 Minuten erneut. src/app/pages/stats/stats-page.component.html - 152,154 + 135,137 @@ -9811,7 +9763,7 @@ Statistikansicht src/app/pages/stats/stats-page.component.html - 158,159 + 141,142 @@ -9819,7 +9771,7 @@ Erstellt von Lakshman Turlapati src/app/pages/stats/stats-page.component.html - 192,193 + 175,176 @@ -9827,7 +9779,7 @@ src/app/pages/stats/stats-page.component.html - 194,196 + 177,179 @@ -9835,7 +9787,7 @@ Netzwerk- oder Analysefehler. src/app/pages/stats/stats-page.component.ts - 123 + 140 @@ -9843,7 +9795,7 @@ Sterne src/app/pages/stats/stats-page.component.ts - 130 + 147 @@ -9851,7 +9803,7 @@ Änderungen src/app/pages/stats/stats-page.component.ts - 132 + 148 @@ -9859,15 +9811,7 @@ Jetzt aktiv src/app/pages/stats/stats-page.component.ts - 128 - - - - active agents - aktive Agenten - - src/app/pages/stats/stats-page.component.ts - 333 + 149 @@ -9875,7 +9819,7 @@ Token src/app/pages/stats/stats-page.component.ts - 134 + 150 @@ -9883,7 +9827,7 @@ Beliebt src/app/pages/stats/stats-page.component.ts - 135 + 151 @@ -9891,7 +9835,7 @@ Die Diagrammbibliothek konnte nicht geladen werden. src/app/pages/stats/stats-page.component.ts - 176 + 191 @@ -9899,35 +9843,27 @@ Das ausgewählte Diagramm konnte nicht dargestellt werden. src/app/pages/stats/stats-page.component.ts - 183 + 198 src/app/pages/stats/stats-page.component.ts - 869 + 970 - - Fresh snapshot checked - Aktueller Stand geprüft am + + Latest snapshot update time unavailable; last checked + Aktualisierungszeitpunkt der letzten Momentaufnahme nicht verfügbar; zuletzt geprüft: src/app/pages/stats/stats-page.component.ts - 234 - - - - Partial snapshot checked - Teilweiser Stand geprüft am - - src/app/pages/stats/stats-page.component.ts - 237 + 251 - - Stale snapshot from - Veralteter Stand vom + + Latest snapshot from ; last checked + Letzte Momentaufnahme vom ; zuletzt geprüft: src/app/pages/stats/stats-page.component.ts - 238 + 254 @@ -9935,7 +9871,7 @@ Ausgewählte Statistiken werden geladen src/app/pages/stats/stats-page.component.ts - 241 + 257 @@ -9943,7 +9879,71 @@ Ausgewählte Statistiken nicht verfügbar src/app/pages/stats/stats-page.component.ts - 242 + 258 + + + + Showing the latest available snapshot; its update time is unavailable. A fresh update is temporarily unavailable. + Die letzte verfügbare Momentaufnahme wird angezeigt; ihr Aktualisierungszeitpunkt ist nicht verfügbar. Eine aktuelle Aktualisierung ist vorübergehend nicht verfügbar. + + src/app/pages/stats/stats-page.component.ts + 270 + + + + Showing the latest available snapshot from ; a fresh update is temporarily unavailable. + Die letzte verfügbare Momentaufnahme vom wird angezeigt; eine aktuelle Aktualisierung ist vorübergehend nicht verfügbar. + + src/app/pages/stats/stats-page.component.ts + 273 + + + + The latest star total is available. Exact dates are unavailable for some star changes. + Die neueste Gesamtzahl der Sterne ist verfügbar. Für einige Änderungen sind keine genauen Datumsangaben verfügbar. + + src/app/pages/stats/stats-page.component.ts + 282 + + + + Commit history is incomplete, so totals and the chart may omit older commits. + Der Commit-Verlauf ist unvollständig, daher können Gesamtwerte und Diagramm ältere Commits auslassen. + + src/app/pages/stats/stats-page.component.ts + 289 + + + + GitHub refresh is delayed. This snapshot is still current, and another check is scheduled. + Die GitHub-Aktualisierung ist verzögert. Diese Momentaufnahme ist weiterhin aktuell, und eine weitere Prüfung ist geplant. + + src/app/pages/stats/stats-page.component.ts + 307 + + + + Agent totals include of active users in this snapshot that reported an agent count. + Die Agent-Gesamtwerte umfassen von aktiven Benutzern in dieser Momentaufnahme, die eine Agent-Anzahl gemeldet haben. + + src/app/pages/stats/stats-page.component.ts + 327 + + + + The regional aggregate is not available yet. + Das regionale Aggregat ist noch nicht verfügbar. + + src/app/pages/stats/stats-page.component.ts + 334 + + + + The regional breakdown is older than two hours. + Die regionale Aufschlüsselung ist älter als zwei Stunden. + + src/app/pages/stats/stats-page.component.ts + 340 @@ -9951,7 +9951,7 @@ Kumulierte Repository-Sterne im Zeitverlauf src/app/pages/stats/stats-page.component.ts - 253 + 350 @@ -9959,7 +9959,7 @@ Kumulierte Repository-Commits im Zeitverlauf src/app/pages/stats/stats-page.component.ts - 255 + 352 @@ -9967,7 +9967,7 @@ FSB-Token-Nutzung in den letzten 30 Tagen src/app/pages/stats/stats-page.component.ts - 257 + 354 @@ -9975,7 +9975,7 @@ Anteil der erfassten MCP-Clients src/app/pages/stats/stats-page.component.ts - 259 + 356 @@ -9983,7 +9983,7 @@ Globus mit der heutigen stündlichen regionalen FSB-Verteilung src/app/pages/stats/stats-page.component.ts - 300 + 397 @@ -9991,7 +9991,7 @@ Sterne insgesamt src/app/pages/stats/stats-page.component.ts - 309 + 406 @@ -9999,7 +9999,7 @@ letzte 7 Tage src/app/pages/stats/stats-page.component.ts - 310 + 407 @@ -10007,7 +10007,7 @@ Commits insgesamt src/app/pages/stats/stats-page.component.ts - 322 + 414 @@ -10015,7 +10015,15 @@ letzte 30 Tage src/app/pages/stats/stats-page.component.ts - 326 + 418 + + + + active agents + aktive Agenten + + src/app/pages/stats/stats-page.component.ts + 432 @@ -10023,7 +10031,7 @@ Durchschnitt/meldender Nutzer src/app/pages/stats/stats-page.component.ts - 335 + 436 @@ -10031,7 +10039,7 @@ Token src/app/pages/stats/stats-page.component.ts - 341 + 446 @@ -10039,7 +10047,7 @@ Token (24 Std.) src/app/pages/stats/stats-page.component.ts - 342 + 447 @@ -10047,7 +10055,7 @@ erfasste Clients src/app/pages/stats/stats-page.component.ts - 348 + 453 @@ -10055,7 +10063,7 @@ Am häufigsten: src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10063,7 +10071,7 @@ häufigster Client src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10071,7 +10079,7 @@ Statistikansicht auswählen. Aktuelle Ansicht: src/app/pages/stats/stats-page.component.ts - 383 + 491 @@ -10079,7 +10087,7 @@ FSB · Statistiken src/app/pages/stats/stats-page.component.ts - 467 + 575 @@ -10087,7 +10095,7 @@ Aggregierte Live-Daten zur Verbreitung und Nutzung von FSB sowie zu Repository-Aktivitäten. src/app/pages/stats/stats-page.component.ts - 475 + 583 @@ -10095,7 +10103,7 @@ Kumulative Sterne src/app/pages/stats/stats-page.component.ts - 1106 + 1082 @@ -10103,7 +10111,7 @@ Kumulative Commits src/app/pages/stats/stats-page.component.ts - 1133 + 1103 @@ -10111,7 +10119,7 @@ Token (letzte 30 Tage) src/app/pages/stats/stats-page.component.ts - 1161 + 1131 @@ -10119,7 +10127,7 @@ Beliebte MCP-Clients src/app/pages/stats/stats-page.component.ts - 1184 + 1154 diff --git a/showcase/angular/src/locale/messages.es.xlf b/showcase/angular/src/locale/messages.es.xlf index 66580b02a..d59e8d6ee 100644 --- a/showcase/angular/src/locale/messages.es.xlf +++ b/showcase/angular/src/locale/messages.es.xlf @@ -9619,11 +9619,11 @@ En directo src/app/pages/stats/stats-page.component.html - 19,20 + 18,19 - src/app/pages/stats/stats-page.component.html - 69,70 + src/app/pages/stats/stats-page.component.ts + 238 @@ -9631,23 +9631,11 @@ Parcial src/app/pages/stats/stats-page.component.html - 20,21 - - - src/app/pages/stats/stats-page.component.html - 70,71 - - - - Stale - Desactualizado - - src/app/pages/stats/stats-page.component.html - 21,22 + 19,20 - src/app/pages/stats/stats-page.component.html - 71,72 + src/app/pages/stats/stats-page.component.ts + 239 @@ -9655,11 +9643,11 @@ Cargando src/app/pages/stats/stats-page.component.html - 22,23 + 20,21 - src/app/pages/stats/stats-page.component.html - 72,73 + src/app/pages/stats/stats-page.component.ts + 240 @@ -9667,11 +9655,11 @@ No disponible src/app/pages/stats/stats-page.component.html - 23,26 + 21,24 - src/app/pages/stats/stats-page.component.html - 73,75 + src/app/pages/stats/stats-page.component.ts + 241 @@ -9679,7 +9667,7 @@ Métricas de la pestaña actual src/app/pages/stats/stats-page.component.html - 30,31 + 28,29 @@ -9687,11 +9675,7 @@ Métricas agregadas de FSB src/app/pages/stats/stats-page.component.html - 38,39 - - - src/app/pages/stats/stats-page.component.html - 66,68 + 37,38 @@ -9699,7 +9683,7 @@ activos ahora src/app/pages/stats/stats-page.component.html - 43,45 + 42,45 @@ -9707,7 +9691,7 @@ usuarios (365 d) src/app/pages/stats/stats-page.component.html - 49,51 + 48,50 @@ -9715,7 +9699,7 @@ Días-agente desde Active v2 src/app/pages/stats/stats-page.component.html - 54,56 + 53,56 @@ -9723,7 +9707,7 @@ tókenes (24 h) src/app/pages/stats/stats-page.component.html - 59,62 + 58,61 @@ -9731,23 +9715,7 @@ Cargando estadísticas... src/app/pages/stats/stats-page.component.html - 83,85 - - - - This view is showing the last available snapshot while fresh data is unavailable. - Esta vista muestra la última instantánea disponible mientras no hay datos actualizados. - - src/app/pages/stats/stats-page.component.html - 90,92 - - - - This view is usable, but some reporting or historical coverage is incomplete. - Esta vista se puede usar, pero parte de la cobertura de informes o del historial está incompleta. - - src/app/pages/stats/stats-page.component.html - 96,98 + 69,71 @@ -9755,7 +9723,7 @@ Aún no hay datos de clientes MCP. src/app/pages/stats/stats-page.component.html - 115,117 + 103,105 @@ -9763,7 +9731,7 @@ Distribución regional del agregado horario de hoy. src/app/pages/stats/stats-page.component.html - 137,139 + 125,127 @@ -9771,23 +9739,7 @@ Aún no hay suficiente actividad anónima para desglosar por región: vuelve a consultarlo pronto. src/app/pages/stats/stats-page.component.html - 139,141 - - - - The regional aggregate is not available yet. - El agregado regional aún no está disponible. - - src/app/pages/stats/stats-page.component.html - 142,143 - - - - The regional breakdown is older than two hours. - El desglose regional tiene más de dos horas. - - src/app/pages/stats/stats-page.component.html - 144,146 + 127,130 @@ -9795,7 +9747,7 @@ No se pudieron cargar las estadísticas src/app/pages/stats/stats-page.component.html - 151,152 + 134,135 @@ -9803,7 +9755,7 @@ La página vuelve a intentarlo automáticamente cada 5 minutos. src/app/pages/stats/stats-page.component.html - 152,154 + 135,137 @@ -9811,7 +9763,7 @@ Vista de estadísticas src/app/pages/stats/stats-page.component.html - 158,159 + 141,142 @@ -9819,7 +9771,7 @@ Creado por Lakshman Turlapati src/app/pages/stats/stats-page.component.html - 192,193 + 175,176 @@ -9827,7 +9779,7 @@ src/app/pages/stats/stats-page.component.html - 194,196 + 177,179 @@ -9835,7 +9787,7 @@ Error de red o de análisis. src/app/pages/stats/stats-page.component.ts - 123 + 140 @@ -9843,7 +9795,7 @@ Estrellas src/app/pages/stats/stats-page.component.ts - 130 + 147 @@ -9851,7 +9803,7 @@ Confirmaciones src/app/pages/stats/stats-page.component.ts - 132 + 148 @@ -9859,15 +9811,7 @@ Activos ahora src/app/pages/stats/stats-page.component.ts - 128 - - - - active agents - agentes activos - - src/app/pages/stats/stats-page.component.ts - 333 + 149 @@ -9875,7 +9819,7 @@ Tókenes src/app/pages/stats/stats-page.component.ts - 134 + 150 @@ -9883,7 +9827,7 @@ Más usados src/app/pages/stats/stats-page.component.ts - 135 + 151 @@ -9891,7 +9835,7 @@ No se pudo cargar la biblioteca de gráficos. src/app/pages/stats/stats-page.component.ts - 176 + 191 @@ -9899,35 +9843,27 @@ No se pudo renderizar el gráfico seleccionado. src/app/pages/stats/stats-page.component.ts - 183 + 198 src/app/pages/stats/stats-page.component.ts - 869 + 970 - - Fresh snapshot checked - Instantánea actualizada comprobada el + + Latest snapshot update time unavailable; last checked + No se conoce la hora de actualización de la última instantánea; última comprobación: src/app/pages/stats/stats-page.component.ts - 234 - - - - Partial snapshot checked - Instantánea parcial comprobada el - - src/app/pages/stats/stats-page.component.ts - 237 + 251 - - Stale snapshot from - Instantánea desactualizada del + + Latest snapshot from ; last checked + Última instantánea del ; última comprobación: src/app/pages/stats/stats-page.component.ts - 238 + 254 @@ -9935,7 +9871,7 @@ Cargando las estadísticas seleccionadas src/app/pages/stats/stats-page.component.ts - 241 + 257 @@ -9943,7 +9879,71 @@ Las estadísticas seleccionadas no están disponibles src/app/pages/stats/stats-page.component.ts - 242 + 258 + + + + Showing the latest available snapshot; its update time is unavailable. A fresh update is temporarily unavailable. + Se muestra la última instantánea disponible; no se conoce su hora de actualización. Temporalmente no hay una actualización reciente disponible. + + src/app/pages/stats/stats-page.component.ts + 270 + + + + Showing the latest available snapshot from ; a fresh update is temporarily unavailable. + Se muestra la última instantánea disponible del ; temporalmente no hay una actualización reciente disponible. + + src/app/pages/stats/stats-page.component.ts + 273 + + + + The latest star total is available. Exact dates are unavailable for some star changes. + El total de estrellas más reciente está disponible. No hay fechas exactas para algunos cambios de estrellas. + + src/app/pages/stats/stats-page.component.ts + 282 + + + + Commit history is incomplete, so totals and the chart may omit older commits. + El historial de confirmaciones está incompleto, por lo que los totales y el gráfico pueden omitir confirmaciones antiguas. + + src/app/pages/stats/stats-page.component.ts + 289 + + + + GitHub refresh is delayed. This snapshot is still current, and another check is scheduled. + La actualización de GitHub se ha retrasado. Esta instantánea sigue estando actualizada y hay otra comprobación programada. + + src/app/pages/stats/stats-page.component.ts + 307 + + + + Agent totals include of active users in this snapshot that reported an agent count. + Los totales de agentes incluyen a de usuarios activos de esta instantánea que informaron un recuento de agentes. + + src/app/pages/stats/stats-page.component.ts + 327 + + + + The regional aggregate is not available yet. + El agregado regional aún no está disponible. + + src/app/pages/stats/stats-page.component.ts + 334 + + + + The regional breakdown is older than two hours. + El desglose regional tiene más de dos horas. + + src/app/pages/stats/stats-page.component.ts + 340 @@ -9951,7 +9951,7 @@ Estrellas acumuladas del repositorio a lo largo del tiempo src/app/pages/stats/stats-page.component.ts - 253 + 350 @@ -9959,7 +9959,7 @@ Commits acumulados del repositorio a lo largo del tiempo src/app/pages/stats/stats-page.component.ts - 255 + 352 @@ -9967,7 +9967,7 @@ Uso de tókenes de FSB durante los últimos 30 días src/app/pages/stats/stats-page.component.ts - 257 + 354 @@ -9975,7 +9975,7 @@ Proporción de clientes MCP registrados src/app/pages/stats/stats-page.component.ts - 259 + 356 @@ -9983,7 +9983,7 @@ Globo que muestra la distribución regional horaria de FSB de hoy src/app/pages/stats/stats-page.component.ts - 300 + 397 @@ -9991,7 +9991,7 @@ estrellas totales src/app/pages/stats/stats-page.component.ts - 309 + 406 @@ -9999,7 +9999,7 @@ últimos 7 días src/app/pages/stats/stats-page.component.ts - 310 + 407 @@ -10007,7 +10007,7 @@ confirmaciones totales src/app/pages/stats/stats-page.component.ts - 322 + 414 @@ -10015,7 +10015,15 @@ últimos 30 días src/app/pages/stats/stats-page.component.ts - 326 + 418 + + + + active agents + agentes activos + + src/app/pages/stats/stats-page.component.ts + 432 @@ -10023,7 +10031,7 @@ promedio/usuario informante src/app/pages/stats/stats-page.component.ts - 335 + 436 @@ -10031,7 +10039,7 @@ tókenes src/app/pages/stats/stats-page.component.ts - 341 + 446 @@ -10039,7 +10047,7 @@ tókenes (24 h) src/app/pages/stats/stats-page.component.ts - 342 + 447 @@ -10047,7 +10055,7 @@ clientes con seguimiento src/app/pages/stats/stats-page.component.ts - 348 + 453 @@ -10055,7 +10063,7 @@ principal: src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10063,7 +10071,7 @@ cliente principal src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10071,7 +10079,7 @@ Elige la vista de estadísticas. Vista actual: src/app/pages/stats/stats-page.component.ts - 383 + 491 @@ -10079,7 +10087,7 @@ FSB · Estadísticas src/app/pages/stats/stats-page.component.ts - 467 + 575 @@ -10087,7 +10095,7 @@ Indicadores agregados en directo sobre la adopción, el uso y el repositorio de FSB. src/app/pages/stats/stats-page.component.ts - 475 + 583 @@ -10095,7 +10103,7 @@ Estrellas acumuladas src/app/pages/stats/stats-page.component.ts - 1106 + 1082 @@ -10103,7 +10111,7 @@ Confirmaciones acumuladas src/app/pages/stats/stats-page.component.ts - 1133 + 1103 @@ -10111,7 +10119,7 @@ Tókenes (últimos 30 días) src/app/pages/stats/stats-page.component.ts - 1161 + 1131 @@ -10119,7 +10127,7 @@ Clientes MCP populares src/app/pages/stats/stats-page.component.ts - 1184 + 1154 diff --git a/showcase/angular/src/locale/messages.ja.xlf b/showcase/angular/src/locale/messages.ja.xlf index 77a40e8f6..adeb5778f 100644 --- a/showcase/angular/src/locale/messages.ja.xlf +++ b/showcase/angular/src/locale/messages.ja.xlf @@ -9619,11 +9619,11 @@ ライブ src/app/pages/stats/stats-page.component.html - 19,20 + 18,19 - src/app/pages/stats/stats-page.component.html - 69,70 + src/app/pages/stats/stats-page.component.ts + 238 @@ -9631,23 +9631,11 @@ 一部のみ src/app/pages/stats/stats-page.component.html - 20,21 - - - src/app/pages/stats/stats-page.component.html - 70,71 - - - - Stale - 古いデータ - - src/app/pages/stats/stats-page.component.html - 21,22 + 19,20 - src/app/pages/stats/stats-page.component.html - 71,72 + src/app/pages/stats/stats-page.component.ts + 239 @@ -9655,11 +9643,11 @@ 読み込み中 src/app/pages/stats/stats-page.component.html - 22,23 + 20,21 - src/app/pages/stats/stats-page.component.html - 72,73 + src/app/pages/stats/stats-page.component.ts + 240 @@ -9667,11 +9655,11 @@ 利用不可 src/app/pages/stats/stats-page.component.html - 23,26 + 21,24 - src/app/pages/stats/stats-page.component.html - 73,75 + src/app/pages/stats/stats-page.component.ts + 241 @@ -9679,7 +9667,7 @@ 現在のタブの指標 src/app/pages/stats/stats-page.component.html - 30,31 + 28,29 @@ -9687,11 +9675,7 @@ FSB集計指標 src/app/pages/stats/stats-page.component.html - 38,39 - - - src/app/pages/stats/stats-page.component.html - 66,68 + 37,38 @@ -9699,7 +9683,7 @@ 現在アクティブ src/app/pages/stats/stats-page.component.html - 43,45 + 42,45 @@ -9707,7 +9691,7 @@ ユーザー(365日) src/app/pages/stats/stats-page.component.html - 49,51 + 48,50 @@ -9715,7 +9699,7 @@ Active v2 以降のエージェント日数 src/app/pages/stats/stats-page.component.html - 54,56 + 53,56 @@ -9723,7 +9707,7 @@ トークン(24時間) src/app/pages/stats/stats-page.component.html - 59,62 + 58,61 @@ -9731,23 +9715,7 @@ 統計を読み込み中... src/app/pages/stats/stats-page.component.html - 83,85 - - - - This view is showing the last available snapshot while fresh data is unavailable. - 最新データを利用できないため、このビューには最後に取得できたスナップショットを表示しています。 - - src/app/pages/stats/stats-page.component.html - 90,92 - - - - This view is usable, but some reporting or historical coverage is incomplete. - このビューは利用できますが、レポートまたは履歴のカバレッジが一部不完全です。 - - src/app/pages/stats/stats-page.component.html - 96,98 + 69,71 @@ -9755,7 +9723,7 @@ MCPクライアントのデータはまだありません。 src/app/pages/stats/stats-page.component.html - 115,117 + 103,105 @@ -9763,7 +9731,7 @@ 本日の時間別集計による地域分布です。 src/app/pages/stats/stats-page.component.html - 137,139 + 125,127 @@ -9771,23 +9739,7 @@ 地域別に分類できるほどの匿名アクティビティがまだありません。しばらくしてからもう一度ご確認ください。 src/app/pages/stats/stats-page.component.html - 139,141 - - - - The regional aggregate is not available yet. - 地域集計はまだ利用できません。 - - src/app/pages/stats/stats-page.component.html - 142,143 - - - - The regional breakdown is older than two hours. - 地域別データは2時間以上前のものです。 - - src/app/pages/stats/stats-page.component.html - 144,146 + 127,130 @@ -9795,7 +9747,7 @@ 統計を読み込めませんでした src/app/pages/stats/stats-page.component.html - 151,152 + 134,135 @@ -9803,7 +9755,7 @@ ページは 5 分ごとに自動で再試行します。 src/app/pages/stats/stats-page.component.html - 152,154 + 135,137 @@ -9811,7 +9763,7 @@ 統計ビュー src/app/pages/stats/stats-page.component.html - 158,159 + 141,142 @@ -9819,7 +9771,7 @@ 制作:Lakshman Turlapati src/app/pages/stats/stats-page.component.html - 192,193 + 175,176 @@ -9827,7 +9779,7 @@ src/app/pages/stats/stats-page.component.html - 194,196 + 177,179 @@ -9835,7 +9787,7 @@ ネットワークまたは解析エラー。 src/app/pages/stats/stats-page.component.ts - 123 + 140 @@ -9843,7 +9795,7 @@ スター src/app/pages/stats/stats-page.component.ts - 130 + 147 @@ -9851,7 +9803,7 @@ コミット src/app/pages/stats/stats-page.component.ts - 132 + 148 @@ -9859,15 +9811,7 @@ 現在アクティブ src/app/pages/stats/stats-page.component.ts - 128 - - - - active agents - アクティブエージェント - - src/app/pages/stats/stats-page.component.ts - 333 + 149 @@ -9875,7 +9819,7 @@ トークン src/app/pages/stats/stats-page.component.ts - 134 + 150 @@ -9883,7 +9827,7 @@ 人気 src/app/pages/stats/stats-page.component.ts - 135 + 151 @@ -9891,7 +9835,7 @@ グラフライブラリを読み込めませんでした。 src/app/pages/stats/stats-page.component.ts - 176 + 191 @@ -9899,35 +9843,27 @@ 選択したグラフを表示できませんでした。 src/app/pages/stats/stats-page.component.ts - 183 - - - src/app/pages/stats/stats-page.component.ts - 869 + 198 - - - Fresh snapshot checked - 最新スナップショットの確認日時: src/app/pages/stats/stats-page.component.ts - 234 + 970 - - Partial snapshot checked - 一部のみのスナップショットの確認日時: + + Latest snapshot update time unavailable; last checked + 最新スナップショットの更新時刻は不明です。最終確認: src/app/pages/stats/stats-page.component.ts - 237 + 251 - - Stale snapshot from - 古いスナップショットの取得日時: + + Latest snapshot from ; last checked + 最新スナップショット: 、最終確認: src/app/pages/stats/stats-page.component.ts - 238 + 254 @@ -9935,7 +9871,7 @@ 選択した統計を読み込み中 src/app/pages/stats/stats-page.component.ts - 241 + 257 @@ -9943,7 +9879,71 @@ 選択した統計は利用できません src/app/pages/stats/stats-page.component.ts - 242 + 258 + + + + Showing the latest available snapshot; its update time is unavailable. A fresh update is temporarily unavailable. + 利用可能な最新スナップショットを表示しています。更新時刻は不明です。最新の更新は一時的に利用できません。 + + src/app/pages/stats/stats-page.component.ts + 270 + + + + Showing the latest available snapshot from ; a fresh update is temporarily unavailable. + 時点の最新スナップショットを表示しています。最新の更新は一時的に利用できません。 + + src/app/pages/stats/stats-page.component.ts + 273 + + + + The latest star total is available. Exact dates are unavailable for some star changes. + 最新のスター総数を利用できます。一部のスター増減について正確な日付は利用できません。 + + src/app/pages/stats/stats-page.component.ts + 282 + + + + Commit history is incomplete, so totals and the chart may omit older commits. + コミット履歴が不完全なため、合計とグラフで古いコミットが省略される場合があります。 + + src/app/pages/stats/stats-page.component.ts + 289 + + + + GitHub refresh is delayed. This snapshot is still current, and another check is scheduled. + GitHub の更新が遅れています。このスナップショットはまだ最新で、次の確認が予定されています。 + + src/app/pages/stats/stats-page.component.ts + 307 + + + + Agent totals include of active users in this snapshot that reported an agent count. + エージェント合計には、このスナップショットでエージェント数を報告した / 人のアクティブユーザーが含まれます。 + + src/app/pages/stats/stats-page.component.ts + 327 + + + + The regional aggregate is not available yet. + 地域別集計はまだ利用できません。 + + src/app/pages/stats/stats-page.component.ts + 334 + + + + The regional breakdown is older than two hours. + 地域別内訳は 2 時間以上前のものです。 + + src/app/pages/stats/stats-page.component.ts + 340 @@ -9951,7 +9951,7 @@ リポジトリの累積スター数の推移 src/app/pages/stats/stats-page.component.ts - 253 + 350 @@ -9959,7 +9959,7 @@ リポジトリの累積コミット数の推移 src/app/pages/stats/stats-page.component.ts - 255 + 352 @@ -9967,7 +9967,7 @@ 過去30日間のFSBトークン使用量 src/app/pages/stats/stats-page.component.ts - 257 + 354 @@ -9975,7 +9975,7 @@ 追跡対象MCPクライアントの割合 src/app/pages/stats/stats-page.component.ts - 259 + 356 @@ -9983,7 +9983,7 @@ 本日の時間別FSB地域分布を示す地球儀 src/app/pages/stats/stats-page.component.ts - 300 + 397 @@ -9991,7 +9991,7 @@ 総スター数 src/app/pages/stats/stats-page.component.ts - 309 + 406 @@ -9999,7 +9999,7 @@ 過去 7 日間 src/app/pages/stats/stats-page.component.ts - 310 + 407 @@ -10007,7 +10007,7 @@ 総コミット数 src/app/pages/stats/stats-page.component.ts - 322 + 414 @@ -10015,7 +10015,15 @@ 過去 30 日間 src/app/pages/stats/stats-page.component.ts - 326 + 418 + + + + active agents + アクティブエージェント + + src/app/pages/stats/stats-page.component.ts + 432 @@ -10023,7 +10031,7 @@ 平均/報告ユーザー src/app/pages/stats/stats-page.component.ts - 335 + 436 @@ -10031,7 +10039,7 @@ トークン src/app/pages/stats/stats-page.component.ts - 341 + 446 @@ -10039,7 +10047,7 @@ トークン(24時間) src/app/pages/stats/stats-page.component.ts - 342 + 447 @@ -10047,7 +10055,7 @@ 追跡対象クライアント src/app/pages/stats/stats-page.component.ts - 348 + 453 @@ -10055,7 +10063,7 @@ 最多: src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10063,7 +10071,7 @@ 最多クライアント src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10071,7 +10079,7 @@ 統計ビューを選択します。現在のビュー: src/app/pages/stats/stats-page.component.ts - 383 + 491 @@ -10079,7 +10087,7 @@ FSB · 統計 src/app/pages/stats/stats-page.component.ts - 467 + 575 @@ -10087,7 +10095,7 @@ FSBの導入状況、利用状況、リポジトリシグナルをリアルタイムで集計。 src/app/pages/stats/stats-page.component.ts - 475 + 583 @@ -10095,7 +10103,7 @@ 累計スター数 src/app/pages/stats/stats-page.component.ts - 1106 + 1082 @@ -10103,7 +10111,7 @@ 累計コミット数 src/app/pages/stats/stats-page.component.ts - 1133 + 1103 @@ -10111,7 +10119,7 @@ トークン(過去30日間) src/app/pages/stats/stats-page.component.ts - 1161 + 1131 @@ -10119,7 +10127,7 @@ 人気の MCP クライアント src/app/pages/stats/stats-page.component.ts - 1184 + 1154 diff --git a/showcase/angular/src/locale/messages.xlf b/showcase/angular/src/locale/messages.xlf index d14fa6958..35dc754a6 100644 --- a/showcase/angular/src/locale/messages.xlf +++ b/showcase/angular/src/locale/messages.xlf @@ -8420,77 +8420,86 @@ Live src/app/pages/stats/stats-page.component.html - 19,20 + 18,19 + + + src/app/pages/stats/stats-page.component.ts + 238 Partial src/app/pages/stats/stats-page.component.html - 20,21 + 19,20 - - - Stale - src/app/pages/stats/stats-page.component.html - 21,22 + src/app/pages/stats/stats-page.component.ts + 239 Loading src/app/pages/stats/stats-page.component.html - 22,23 + 20,21 + + + src/app/pages/stats/stats-page.component.ts + 240 Unavailable src/app/pages/stats/stats-page.component.html - 23,26 + 21,24 + + + src/app/pages/stats/stats-page.component.ts + 241 Current tab metrics src/app/pages/stats/stats-page.component.html - 30,31 + 28,29 FSB aggregate metrics src/app/pages/stats/stats-page.component.html - 39,40 + 37,38 active right now src/app/pages/stats/stats-page.component.html - 44,47 + 42,45 users (365d) src/app/pages/stats/stats-page.component.html - 49,51 + 48,50 agent-days since active v2 src/app/pages/stats/stats-page.component.html - 54,57 + 53,56 tokens (24h) src/app/pages/stats/stats-page.component.html - 59,62 + 58,61 @@ -8500,344 +8509,365 @@ 69,71 - - This view is showing the last available snapshot while fresh data is unavailable. - - src/app/pages/stats/stats-page.component.html - 76,78 - - - - This view is usable, but some reporting or historical coverage is incomplete. - - src/app/pages/stats/stats-page.component.html - 82,84 - - No MCP client data yet. src/app/pages/stats/stats-page.component.html - 101,103 + 103,105 Regional distribution from today's hourly aggregate. src/app/pages/stats/stats-page.component.html - 123,125 + 125,127 Not enough anonymous activity yet to break down by region — check back soon. src/app/pages/stats/stats-page.component.html - 125,127 - - - - The regional aggregate is not available yet. - - src/app/pages/stats/stats-page.component.html - 128,129 - - - - The regional breakdown is older than two hours. - - src/app/pages/stats/stats-page.component.html - 130,132 + 127,130 Could not load stats src/app/pages/stats/stats-page.component.html - 137,138 + 134,135 The page retries automatically every 5 minutes. src/app/pages/stats/stats-page.component.html - 138,140 + 135,137 Stats view src/app/pages/stats/stats-page.component.html - 144,145 + 141,142 Made by Lakshman Turlapati src/app/pages/stats/stats-page.component.html - 178,179 + 175,176 src/app/pages/stats/stats-page.component.html - 180,182 + 177,179 Network or parse error. src/app/pages/stats/stats-page.component.ts - 119 + 140 Stars src/app/pages/stats/stats-page.component.ts - 126 + 147 Commits src/app/pages/stats/stats-page.component.ts - 127 + 148 Active now src/app/pages/stats/stats-page.component.ts - 128 + 149 Tokens src/app/pages/stats/stats-page.component.ts - 129 + 150 Popular src/app/pages/stats/stats-page.component.ts - 130 + 151 Could not load chart library. src/app/pages/stats/stats-page.component.ts - 170 + 191 Could not render the selected chart. src/app/pages/stats/stats-page.component.ts - 177 + 198 src/app/pages/stats/stats-page.component.ts - 853 + 970 - - Fresh snapshot checked + + Latest snapshot update time unavailable; last checked src/app/pages/stats/stats-page.component.ts - 224 - - - - Partial snapshot checked - - src/app/pages/stats/stats-page.component.ts - 227 + 251 - - Stale snapshot from + + Latest snapshot from ; last checked src/app/pages/stats/stats-page.component.ts - 228 + 254 Loading selected stats src/app/pages/stats/stats-page.component.ts - 231 + 257 Selected stats unavailable src/app/pages/stats/stats-page.component.ts - 232 + 258 + + + + Showing the latest available snapshot; its update time is unavailable. A fresh update is temporarily unavailable. + + src/app/pages/stats/stats-page.component.ts + 270 + + + + Showing the latest available snapshot from ; a fresh update is temporarily unavailable. + + src/app/pages/stats/stats-page.component.ts + 273 + + + + The latest star total is available. Exact dates are unavailable for some star changes. + + src/app/pages/stats/stats-page.component.ts + 282 + + + + Commit history is incomplete, so totals and the chart may omit older commits. + + src/app/pages/stats/stats-page.component.ts + 289 + + + + GitHub refresh is delayed. This snapshot is still current, and another check is scheduled. + + src/app/pages/stats/stats-page.component.ts + 307 + + + + Agent totals include of active users in this snapshot that reported an agent count. + + src/app/pages/stats/stats-page.component.ts + 327 + + + + The regional aggregate is not available yet. + + src/app/pages/stats/stats-page.component.ts + 334 + + + + The regional breakdown is older than two hours. + + src/app/pages/stats/stats-page.component.ts + 340 Cumulative repository stars over time src/app/pages/stats/stats-page.component.ts - 243 + 350 Cumulative repository commits over time src/app/pages/stats/stats-page.component.ts - 245 + 352 FSB token usage over the last 30 days src/app/pages/stats/stats-page.component.ts - 247 + 354 Share of tracked MCP clients src/app/pages/stats/stats-page.component.ts - 249 + 356 Globe showing today's hourly FSB regional distribution src/app/pages/stats/stats-page.component.ts - 290 + 397 total stars src/app/pages/stats/stats-page.component.ts - 299 + 406 last 7 days src/app/pages/stats/stats-page.component.ts - 300 + 407 total commits src/app/pages/stats/stats-page.component.ts - 307 + 414 last 30 days src/app/pages/stats/stats-page.component.ts - 311 + 418 active agents src/app/pages/stats/stats-page.component.ts - 325 + 432 avg/reporting user src/app/pages/stats/stats-page.component.ts - 329 + 436 tokens src/app/pages/stats/stats-page.component.ts - 339 + 446 tokens (24h) src/app/pages/stats/stats-page.component.ts - 340 + 447 tracked clients src/app/pages/stats/stats-page.component.ts - 346 + 453 top: src/app/pages/stats/stats-page.component.ts - 347 + 454 top client src/app/pages/stats/stats-page.component.ts - 347 + 454 Choose stats view. Current view: src/app/pages/stats/stats-page.component.ts - 384 + 491 FSB · Stats src/app/pages/stats/stats-page.component.ts - 468 + 575 Live aggregate adoption, usage, and repository signals for FSB. src/app/pages/stats/stats-page.component.ts - 476 + 583 Cumulative stars src/app/pages/stats/stats-page.component.ts - 965 + 1082 Cumulative commits src/app/pages/stats/stats-page.component.ts - 986 + 1103 Tokens (last 30 days) src/app/pages/stats/stats-page.component.ts - 1014 + 1131 Popular MCP clients src/app/pages/stats/stats-page.component.ts - 1037 + 1154 diff --git a/showcase/angular/src/locale/messages.zh-CN.xlf b/showcase/angular/src/locale/messages.zh-CN.xlf index 98d8bb9c3..21fb88217 100644 --- a/showcase/angular/src/locale/messages.zh-CN.xlf +++ b/showcase/angular/src/locale/messages.zh-CN.xlf @@ -9619,11 +9619,11 @@ 现场直播 src/app/pages/stats/stats-page.component.html - 19,20 + 18,19 - src/app/pages/stats/stats-page.component.html - 69,70 + src/app/pages/stats/stats-page.component.ts + 238 @@ -9631,23 +9631,11 @@ 部分 src/app/pages/stats/stats-page.component.html - 20,21 - - - src/app/pages/stats/stats-page.component.html - 70,71 - - - - Stale - 陈旧 - - src/app/pages/stats/stats-page.component.html - 21,22 + 19,20 - src/app/pages/stats/stats-page.component.html - 71,72 + src/app/pages/stats/stats-page.component.ts + 239 @@ -9655,11 +9643,11 @@ 加载中 src/app/pages/stats/stats-page.component.html - 22,23 + 20,21 - src/app/pages/stats/stats-page.component.html - 72,73 + src/app/pages/stats/stats-page.component.ts + 240 @@ -9667,11 +9655,11 @@ 不可用 src/app/pages/stats/stats-page.component.html - 23,26 + 21,24 - src/app/pages/stats/stats-page.component.html - 73,75 + src/app/pages/stats/stats-page.component.ts + 241 @@ -9679,7 +9667,7 @@ 当前标签页指标 src/app/pages/stats/stats-page.component.html - 30,31 + 28,29 @@ -9687,11 +9675,7 @@ FSB 汇总指标 src/app/pages/stats/stats-page.component.html - 38,39 - - - src/app/pages/stats/stats-page.component.html - 66,68 + 37,38 @@ -9699,7 +9683,7 @@ 当前活跃 src/app/pages/stats/stats-page.component.html - 43,45 + 42,45 @@ -9707,7 +9691,7 @@ 用户(365 天) src/app/pages/stats/stats-page.component.html - 49,51 + 48,50 @@ -9715,7 +9699,7 @@ Active v2 以来的智能体天数 src/app/pages/stats/stats-page.component.html - 54,56 + 53,56 @@ -9723,7 +9707,7 @@ 令牌(24 小时) src/app/pages/stats/stats-page.component.html - 59,62 + 58,61 @@ -9731,23 +9715,7 @@ 正在装入数据... src/app/pages/stats/stats-page.component.html - 83,85 - - - - This view is showing the last available snapshot while fresh data is unavailable. - 当前无法获取最新数据,此视图正在显示最后可用的快照。 - - src/app/pages/stats/stats-page.component.html - 90,92 - - - - This view is usable, but some reporting or historical coverage is incomplete. - 此视图可用,但部分上报或历史覆盖不完整。 - - src/app/pages/stats/stats-page.component.html - 96,98 + 69,71 @@ -9755,7 +9723,7 @@ 尚无 MCP 客户端数据。 src/app/pages/stats/stats-page.component.html - 115,117 + 103,105 @@ -9763,7 +9731,7 @@ 来自今日每小时汇总的地区分布。 src/app/pages/stats/stats-page.component.html - 137,139 + 125,127 @@ -9771,23 +9739,7 @@ 匿名活动数据尚不足以按地区细分——请稍后再来查看。 src/app/pages/stats/stats-page.component.html - 139,141 - - - - The regional aggregate is not available yet. - 地区汇总尚不可用。 - - src/app/pages/stats/stats-page.component.html - 142,143 - - - - The regional breakdown is older than two hours. - 地区明细已超过两小时未更新。 - - src/app/pages/stats/stats-page.component.html - 144,146 + 127,130 @@ -9795,7 +9747,7 @@ 无法装入数据 src/app/pages/stats/stats-page.component.html - 151,152 + 134,135 @@ -9803,7 +9755,7 @@ 页面每5分钟自动重试一次. src/app/pages/stats/stats-page.component.html - 152,154 + 135,137 @@ -9811,7 +9763,7 @@ 显示视图 src/app/pages/stats/stats-page.component.html - 158,159 + 141,142 @@ -9819,7 +9771,7 @@ 制作人:Lakshman Turlapati src/app/pages/stats/stats-page.component.html - 192,193 + 175,176 @@ -9827,7 +9779,7 @@ src/app/pages/stats/stats-page.component.html - 194,196 + 177,179 @@ -9835,7 +9787,7 @@ 网络或剖析错误 。 src/app/pages/stats/stats-page.component.ts - 123 + 140 @@ -9843,7 +9795,7 @@ 星标 src/app/pages/stats/stats-page.component.ts - 130 + 147 @@ -9851,7 +9803,7 @@ 提交 src/app/pages/stats/stats-page.component.ts - 132 + 148 @@ -9859,15 +9811,7 @@ 当前活跃 src/app/pages/stats/stats-page.component.ts - 128 - - - - active agents - 活动代理 - - src/app/pages/stats/stats-page.component.ts - 333 + 149 @@ -9875,7 +9819,7 @@ 令牌 src/app/pages/stats/stats-page.component.ts - 134 + 150 @@ -9883,7 +9827,7 @@ 热门 src/app/pages/stats/stats-page.component.ts - 135 + 151 @@ -9891,7 +9835,7 @@ 无法装入图表库 。 src/app/pages/stats/stats-page.component.ts - 176 + 191 @@ -9899,35 +9843,27 @@ 无法呈现所选图表。 src/app/pages/stats/stats-page.component.ts - 183 - - - src/app/pages/stats/stats-page.component.ts - 869 + 198 - - - Fresh snapshot checked - 最新快照检查于 src/app/pages/stats/stats-page.component.ts - 234 + 970 - - Partial snapshot checked - 部分快照检查于 + + Latest snapshot update time unavailable; last checked + 最新快照的更新时间不可用;上次检查时间为 src/app/pages/stats/stats-page.component.ts - 237 + 251 - - Stale snapshot from - 陈旧快照来自 + + Latest snapshot from ; last checked + 最新快照时间为 ;上次检查时间为 src/app/pages/stats/stats-page.component.ts - 238 + 254 @@ -9935,7 +9871,7 @@ 正在加载所选统计数据 src/app/pages/stats/stats-page.component.ts - 241 + 257 @@ -9943,7 +9879,71 @@ 所选统计数据不可用 src/app/pages/stats/stats-page.component.ts - 242 + 258 + + + + Showing the latest available snapshot; its update time is unavailable. A fresh update is temporarily unavailable. + 正在显示最新可用快照;其更新时间不可用。暂时无法获取最新更新。 + + src/app/pages/stats/stats-page.component.ts + 270 + + + + Showing the latest available snapshot from ; a fresh update is temporarily unavailable. + 正在显示 的最新可用快照;暂时无法获取最新更新。 + + src/app/pages/stats/stats-page.component.ts + 273 + + + + The latest star total is available. Exact dates are unavailable for some star changes. + 最新的星标总数可用。部分星标变动没有准确日期。 + + src/app/pages/stats/stats-page.component.ts + 282 + + + + Commit history is incomplete, so totals and the chart may omit older commits. + 提交历史不完整,因此总数和图表可能会遗漏较早的提交。 + + src/app/pages/stats/stats-page.component.ts + 289 + + + + GitHub refresh is delayed. This snapshot is still current, and another check is scheduled. + GitHub 刷新延迟。此快照仍为最新数据,系统已安排再次检查。 + + src/app/pages/stats/stats-page.component.ts + 307 + + + + Agent totals include of active users in this snapshot that reported an agent count. + 智能体总数包含此快照中 位已上报智能体数量的用户,共有 位活跃用户。 + + src/app/pages/stats/stats-page.component.ts + 327 + + + + The regional aggregate is not available yet. + 区域汇总数据尚不可用。 + + src/app/pages/stats/stats-page.component.ts + 334 + + + + The regional breakdown is older than two hours. + 区域明细数据已超过两小时。 + + src/app/pages/stats/stats-page.component.ts + 340 @@ -9951,7 +9951,7 @@ 代码库累计星标随时间变化 src/app/pages/stats/stats-page.component.ts - 253 + 350 @@ -9959,7 +9959,7 @@ 代码库累计提交随时间变化 src/app/pages/stats/stats-page.component.ts - 255 + 352 @@ -9967,7 +9967,7 @@ 过去 30 天的 FSB 令牌用量 src/app/pages/stats/stats-page.component.ts - 257 + 354 @@ -9975,7 +9975,7 @@ 已跟踪 MCP 客户端的占比 src/app/pages/stats/stats-page.component.ts - 259 + 356 @@ -9983,7 +9983,7 @@ 显示今日每小时 FSB 地区分布的地球仪 src/app/pages/stats/stats-page.component.ts - 300 + 397 @@ -9991,7 +9991,7 @@ 恒星总数 src/app/pages/stats/stats-page.component.ts - 309 + 406 @@ -9999,7 +9999,7 @@ 过去7天 src/app/pages/stats/stats-page.component.ts - 310 + 407 @@ -10007,7 +10007,7 @@ 承付总额 src/app/pages/stats/stats-page.component.ts - 322 + 414 @@ -10015,7 +10015,15 @@ 过去30天 src/app/pages/stats/stats-page.component.ts - 326 + 418 + + + + active agents + 活动代理 + + src/app/pages/stats/stats-page.component.ts + 432 @@ -10023,7 +10031,7 @@ 平均值/上报用户 src/app/pages/stats/stats-page.component.ts - 335 + 436 @@ -10031,7 +10039,7 @@ 令牌 src/app/pages/stats/stats-page.component.ts - 341 + 446 @@ -10039,7 +10047,7 @@ 令牌(24 小时) src/app/pages/stats/stats-page.component.ts - 342 + 447 @@ -10047,7 +10055,7 @@ 跟踪客户 src/app/pages/stats/stats-page.component.ts - 348 + 453 @@ -10055,7 +10063,7 @@ 顶部 : src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10063,7 +10071,7 @@ 顶端客户端 src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10071,7 +10079,7 @@ 选择统计视图。当前视图: src/app/pages/stats/stats-page.component.ts - 383 + 491 @@ -10079,7 +10087,7 @@ FSB 数据 src/app/pages/stats/stats-page.component.ts - 467 + 575 @@ -10087,7 +10095,7 @@ 实时汇总 FSB 的采用情况、使用情况和代码仓库信号。 src/app/pages/stats/stats-page.component.ts - 475 + 583 @@ -10095,7 +10103,7 @@ 累积恒星 src/app/pages/stats/stats-page.component.ts - 1106 + 1082 @@ -10103,7 +10111,7 @@ 累计承付款 src/app/pages/stats/stats-page.component.ts - 1133 + 1103 @@ -10111,7 +10119,7 @@ 令牌(过去 30 天) src/app/pages/stats/stats-page.component.ts - 1161 + 1131 @@ -10119,7 +10127,7 @@ 热门 MCP 客户端 src/app/pages/stats/stats-page.component.ts - 1184 + 1154 diff --git a/showcase/angular/src/locale/messages.zh-TW.xlf b/showcase/angular/src/locale/messages.zh-TW.xlf index f967a224c..28851e40e 100644 --- a/showcase/angular/src/locale/messages.zh-TW.xlf +++ b/showcase/angular/src/locale/messages.zh-TW.xlf @@ -9619,11 +9619,11 @@ 即時 src/app/pages/stats/stats-page.component.html - 19,20 + 18,19 - src/app/pages/stats/stats-page.component.html - 69,70 + src/app/pages/stats/stats-page.component.ts + 238 @@ -9631,23 +9631,11 @@ 部分 src/app/pages/stats/stats-page.component.html - 20,21 - - - src/app/pages/stats/stats-page.component.html - 70,71 - - - - Stale - 過時 - - src/app/pages/stats/stats-page.component.html - 21,22 + 19,20 - src/app/pages/stats/stats-page.component.html - 71,72 + src/app/pages/stats/stats-page.component.ts + 239 @@ -9655,11 +9643,11 @@ 載入中 src/app/pages/stats/stats-page.component.html - 22,23 + 20,21 - src/app/pages/stats/stats-page.component.html - 72,73 + src/app/pages/stats/stats-page.component.ts + 240 @@ -9667,11 +9655,11 @@ 無法使用 src/app/pages/stats/stats-page.component.html - 23,26 + 21,24 - src/app/pages/stats/stats-page.component.html - 73,75 + src/app/pages/stats/stats-page.component.ts + 241 @@ -9679,7 +9667,7 @@ 目前分頁指標 src/app/pages/stats/stats-page.component.html - 30,31 + 28,29 @@ -9687,11 +9675,7 @@ FSB 彙總指標 src/app/pages/stats/stats-page.component.html - 38,39 - - - src/app/pages/stats/stats-page.component.html - 66,68 + 37,38 @@ -9699,7 +9683,7 @@ 目前活躍 src/app/pages/stats/stats-page.component.html - 43,45 + 42,45 @@ -9707,7 +9691,7 @@ 使用者(365 天) src/app/pages/stats/stats-page.component.html - 49,51 + 48,50 @@ -9715,7 +9699,7 @@ Active v2 以來的代理程式天數 src/app/pages/stats/stats-page.component.html - 54,56 + 53,56 @@ -9723,7 +9707,7 @@ 權杖(24 小時) src/app/pages/stats/stats-page.component.html - 59,62 + 58,61 @@ -9731,23 +9715,7 @@ 正在載入統計資料… src/app/pages/stats/stats-page.component.html - 83,85 - - - - This view is showing the last available snapshot while fresh data is unavailable. - 目前無法取得最新資料,此檢視正在顯示最後可用的快照。 - - src/app/pages/stats/stats-page.component.html - 90,92 - - - - This view is usable, but some reporting or historical coverage is incomplete. - 此檢視可用,但部分回報或歷史涵蓋範圍不完整。 - - src/app/pages/stats/stats-page.component.html - 96,98 + 69,71 @@ -9755,7 +9723,7 @@ 尚無 MCP 用戶端資料。 src/app/pages/stats/stats-page.component.html - 115,117 + 103,105 @@ -9763,7 +9731,7 @@ 來自今日每小時彙總的地區分布。 src/app/pages/stats/stats-page.component.html - 137,139 + 125,127 @@ -9771,23 +9739,7 @@ 匿名活動資料尚不足以依地區細分——請稍後再回來檢視。 src/app/pages/stats/stats-page.component.html - 139,141 - - - - The regional aggregate is not available yet. - 地區彙總尚無法使用。 - - src/app/pages/stats/stats-page.component.html - 142,143 - - - - The regional breakdown is older than two hours. - 地區明細已超過兩小時未更新。 - - src/app/pages/stats/stats-page.component.html - 144,146 + 127,130 @@ -9795,7 +9747,7 @@ 無法載入統計資料 src/app/pages/stats/stats-page.component.html - 151,152 + 134,135 @@ -9803,7 +9755,7 @@ 頁面每 5 分鐘會自動重試。 src/app/pages/stats/stats-page.component.html - 152,154 + 135,137 @@ -9811,7 +9763,7 @@ 統計檢視 src/app/pages/stats/stats-page.component.html - 158,159 + 141,142 @@ -9819,7 +9771,7 @@ Lakshman Turlapati 製作 src/app/pages/stats/stats-page.component.html - 192,193 + 175,176 @@ -9827,7 +9779,7 @@ src/app/pages/stats/stats-page.component.html - 194,196 + 177,179 @@ -9835,7 +9787,7 @@ 網路或剖析錯誤。 src/app/pages/stats/stats-page.component.ts - 123 + 140 @@ -9843,7 +9795,7 @@ 星標 src/app/pages/stats/stats-page.component.ts - 130 + 147 @@ -9851,7 +9803,7 @@ 提交 src/app/pages/stats/stats-page.component.ts - 132 + 148 @@ -9859,15 +9811,7 @@ 目前活躍 src/app/pages/stats/stats-page.component.ts - 128 - - - - active agents - 活躍代理 - - src/app/pages/stats/stats-page.component.ts - 333 + 149 @@ -9875,7 +9819,7 @@ 權杖 src/app/pages/stats/stats-page.component.ts - 134 + 150 @@ -9883,7 +9827,7 @@ 熱門 src/app/pages/stats/stats-page.component.ts - 135 + 151 @@ -9891,7 +9835,7 @@ 無法載入圖表程式庫。 src/app/pages/stats/stats-page.component.ts - 176 + 191 @@ -9899,35 +9843,27 @@ 無法呈現所選圖表。 src/app/pages/stats/stats-page.component.ts - 183 - - - src/app/pages/stats/stats-page.component.ts - 869 + 198 - - - Fresh snapshot checked - 最新快照檢查於 src/app/pages/stats/stats-page.component.ts - 234 + 970 - - Partial snapshot checked - 部分快照檢查於 + + Latest snapshot update time unavailable; last checked + 最新快照的更新時間無法取得;上次檢查時間為 src/app/pages/stats/stats-page.component.ts - 237 + 251 - - Stale snapshot from - 過時快照來自 + + Latest snapshot from ; last checked + 最新快照時間為 ;上次檢查時間為 src/app/pages/stats/stats-page.component.ts - 238 + 254 @@ -9935,7 +9871,7 @@ 正在載入所選統計資料 src/app/pages/stats/stats-page.component.ts - 241 + 257 @@ -9943,7 +9879,71 @@ 所選統計資料無法使用 src/app/pages/stats/stats-page.component.ts - 242 + 258 + + + + Showing the latest available snapshot; its update time is unavailable. A fresh update is temporarily unavailable. + 正在顯示最新可用快照;其更新時間無法取得。暫時無法取得最新更新。 + + src/app/pages/stats/stats-page.component.ts + 270 + + + + Showing the latest available snapshot from ; a fresh update is temporarily unavailable. + 正在顯示 的最新可用快照;暫時無法取得最新更新。 + + src/app/pages/stats/stats-page.component.ts + 273 + + + + The latest star total is available. Exact dates are unavailable for some star changes. + 最新的星號總數可用。部分星號變動沒有確切日期。 + + src/app/pages/stats/stats-page.component.ts + 282 + + + + Commit history is incomplete, so totals and the chart may omit older commits. + 提交歷史不完整,因此總數和圖表可能會遺漏較早的提交。 + + src/app/pages/stats/stats-page.component.ts + 289 + + + + GitHub refresh is delayed. This snapshot is still current, and another check is scheduled. + GitHub 重新整理延遲。此快照仍為最新資料,系統已排定再次檢查。 + + src/app/pages/stats/stats-page.component.ts + 307 + + + + Agent totals include of active users in this snapshot that reported an agent count. + 代理程式總數包含此快照中 位已回報代理程式數量的使用者,共有 位活躍使用者。 + + src/app/pages/stats/stats-page.component.ts + 327 + + + + The regional aggregate is not available yet. + 區域彙總資料尚無法使用。 + + src/app/pages/stats/stats-page.component.ts + 334 + + + + The regional breakdown is older than two hours. + 區域明細資料已超過兩小時。 + + src/app/pages/stats/stats-page.component.ts + 340 @@ -9951,7 +9951,7 @@ 程式庫累計星標隨時間變化 src/app/pages/stats/stats-page.component.ts - 253 + 350 @@ -9959,7 +9959,7 @@ 程式庫累計提交隨時間變化 src/app/pages/stats/stats-page.component.ts - 255 + 352 @@ -9967,7 +9967,7 @@ 過去 30 天的 FSB 權杖用量 src/app/pages/stats/stats-page.component.ts - 257 + 354 @@ -9975,7 +9975,7 @@ 已追蹤 MCP 用戶端的占比 src/app/pages/stats/stats-page.component.ts - 259 + 356 @@ -9983,7 +9983,7 @@ 顯示今日每小時 FSB 地區分布的地球儀 src/app/pages/stats/stats-page.component.ts - 300 + 397 @@ -9991,7 +9991,7 @@ 星標總數 src/app/pages/stats/stats-page.component.ts - 309 + 406 @@ -9999,7 +9999,7 @@ 最近 7 天 src/app/pages/stats/stats-page.component.ts - 310 + 407 @@ -10007,7 +10007,7 @@ 提交總數 src/app/pages/stats/stats-page.component.ts - 322 + 414 @@ -10015,7 +10015,15 @@ 最近 30 天 src/app/pages/stats/stats-page.component.ts - 326 + 418 + + + + active agents + 活躍代理 + + src/app/pages/stats/stats-page.component.ts + 432 @@ -10023,7 +10031,7 @@ 平均值/回報使用者 src/app/pages/stats/stats-page.component.ts - 335 + 436 @@ -10031,7 +10039,7 @@ 權杖 src/app/pages/stats/stats-page.component.ts - 341 + 446 @@ -10039,7 +10047,7 @@ 權杖(24 小時) src/app/pages/stats/stats-page.component.ts - 342 + 447 @@ -10047,7 +10055,7 @@ 追蹤中的用戶端 src/app/pages/stats/stats-page.component.ts - 348 + 453 @@ -10055,7 +10063,7 @@ 最高: src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10063,7 +10071,7 @@ 最高用戶端 src/app/pages/stats/stats-page.component.ts - 349 + 454 @@ -10071,7 +10079,7 @@ 選擇統計檢視。目前檢視: src/app/pages/stats/stats-page.component.ts - 383 + 491 @@ -10079,7 +10087,7 @@ FSB · 統計 src/app/pages/stats/stats-page.component.ts - 467 + 575 @@ -10087,7 +10095,7 @@ FSB 的即時彙總採用情況、使用量與儲存庫指標。 src/app/pages/stats/stats-page.component.ts - 475 + 583 @@ -10095,7 +10103,7 @@ 累計星標 src/app/pages/stats/stats-page.component.ts - 1106 + 1082 @@ -10103,7 +10111,7 @@ 累計提交 src/app/pages/stats/stats-page.component.ts - 1133 + 1103 @@ -10111,7 +10119,7 @@ 權杖(過去 30 天) src/app/pages/stats/stats-page.component.ts - 1161 + 1131 @@ -10119,7 +10127,7 @@ 熱門 MCP 客戶端 src/app/pages/stats/stats-page.component.ts - 1184 + 1154 diff --git a/showcase/angular/tsconfig.spec.json b/showcase/angular/tsconfig.spec.json index 5d13f8ac1..358f1b6af 100644 --- a/showcase/angular/tsconfig.spec.json +++ b/showcase/angular/tsconfig.spec.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": ["jasmine"] + "types": ["jasmine", "@angular/localize"] }, "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] } diff --git a/showcase/server/.env.example b/showcase/server/.env.example index 80259b281..c3ea97c38 100644 --- a/showcase/server/.env.example +++ b/showcase/server/.env.example @@ -1,3 +1,7 @@ # FSB Server Configuration FSB_SERVER_SECRET=change-this-to-a-random-secret-string PORT=3847 + +# Server-only fine-grained GitHub token used by the public Stats cache poller. +# Restrict it to fullselfbrowsing/FSB with Metadata read and Contents read. +GITHUB_TOKEN= diff --git a/showcase/server/src/db/queries.js b/showcase/server/src/db/queries.js index 76f0a75b8..6e627e9db 100644 --- a/showcase/server/src/db/queries.js +++ b/showcase/server/src/db/queries.js @@ -390,15 +390,16 @@ class Queries { ); // Quick task 260516-7l5 -- GitHub stats cache. - // One row per endpoint_id (7 total). Populated by src/telemetry/github-poller.js + // One row per allowlisted endpoint_id (3 total). Populated by src/telemetry/github-poller.js // every 5 minutes. Read by src/routes/public-stats.js (/github/:endpoint_id sub-route). this.upsertGithubCache = this.db.prepare(` INSERT INTO github_cache ( endpoint_id, payload_json, etag, fetched_at, http_status, rate_limit_remaining, rate_limit_reset, is_complete, payload_version, - last_attempt_at, last_error_status, consecutive_failures, next_retry_at + last_attempt_at, last_error_status, consecutive_failures, next_retry_at, + poll_state_json ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?) ON CONFLICT(endpoint_id) DO UPDATE SET payload_json = excluded.payload_json, etag = excluded.etag, @@ -411,7 +412,8 @@ class Queries { last_attempt_at = excluded.last_attempt_at, last_error_status = NULL, consecutive_failures = 0, - next_retry_at = excluded.next_retry_at + next_retry_at = excluded.next_retry_at, + poll_state_json = excluded.poll_state_json `); // A 304 is only a successful refresh for an already-complete cache. Legacy // or failed partial rows never send an ETag, so this guard is defensive. @@ -426,9 +428,10 @@ class Queries { INSERT INTO github_cache ( endpoint_id, payload_json, etag, fetched_at, http_status, rate_limit_remaining, rate_limit_reset, is_complete, payload_version, - last_attempt_at, last_error_status, consecutive_failures, next_retry_at + last_attempt_at, last_error_status, consecutive_failures, next_retry_at, + poll_state_json ) - VALUES (?, 'null', NULL, 0, ?, ?, ?, 0, 0, ?, ?, 1, ?) + VALUES (?, 'null', NULL, 0, ?, ?, ?, 0, 0, ?, ?, 1, ?, NULL) ON CONFLICT(endpoint_id) DO UPDATE SET http_status = excluded.http_status, rate_limit_remaining = excluded.rate_limit_remaining, @@ -438,11 +441,40 @@ class Queries { consecutive_failures = github_cache.consecutive_failures + 1, next_retry_at = excluded.next_retry_at `); + this.upsertGithubCacheFailureSnapshot = this.db.prepare(` + INSERT INTO github_cache ( + endpoint_id, payload_json, etag, fetched_at, http_status, + rate_limit_remaining, rate_limit_reset, is_complete, payload_version, + last_attempt_at, last_error_status, consecutive_failures, next_retry_at, + poll_state_json + ) + VALUES (?, ?, NULL, ?, ?, ?, ?, 1, ?, ?, ?, 1, ?, ?) + ON CONFLICT(endpoint_id) DO UPDATE SET + payload_json = excluded.payload_json, + etag = NULL, + fetched_at = excluded.fetched_at, + http_status = excluded.http_status, + rate_limit_remaining = excluded.rate_limit_remaining, + rate_limit_reset = excluded.rate_limit_reset, + is_complete = 1, + payload_version = excluded.payload_version, + last_attempt_at = excluded.last_attempt_at, + last_error_status = excluded.last_error_status, + consecutive_failures = github_cache.consecutive_failures + 1, + next_retry_at = excluded.next_retry_at, + poll_state_json = excluded.poll_state_json + `); + this.refreshGithubCacheSnapshotDuringBackoff = this.db.prepare(` + UPDATE github_cache + SET payload_json = ?, etag = NULL, fetched_at = ?, is_complete = 1, + payload_version = ?, poll_state_json = ? + WHERE endpoint_id = ? + `); this.selectGithubCache = this.db.prepare( `SELECT endpoint_id, payload_json, etag, fetched_at, http_status, rate_limit_remaining, rate_limit_reset, is_complete, payload_version, last_attempt_at, last_error_status, - consecutive_failures, next_retry_at + consecutive_failures, next_retry_at, poll_state_json FROM github_cache WHERE endpoint_id = ?` ); this.selectGithubEtag = this.db.prepare( @@ -701,8 +733,8 @@ class Queries { // ----------------------------------------------------------------------- // GitHub stats cache helpers. Successful writes replace the payload and // reset failure state. Failure writes preserve payload_json, etag, - // fetched_at, completeness, and payload version so readers retain the - // last-known-good snapshot. + // fetched_at, completeness, payload version, and private poll cursor so + // readers and the next attempt retain the last-known-good snapshot. // ----------------------------------------------------------------------- upsertGithubCacheRow( endpointId, @@ -715,8 +747,12 @@ class Queries { isComplete = true, payloadVersion = 1, lastAttemptAt = fetchedAt, - nextRetryAt = null + nextRetryAt = null, + pollState = null ) { + const pollStateJson = pollState === null || pollState === undefined + ? null + : JSON.stringify(pollState); this.upsertGithubCache.run( endpointId, payloadJson, @@ -728,7 +764,8 @@ class Queries { isComplete ? 1 : 0, Number(payloadVersion) || 0, lastAttemptAt, - nextRetryAt + nextRetryAt, + pollStateJson ); } @@ -756,9 +793,63 @@ class Queries { ); } + upsertGithubCacheFailureSnapshotRow( + endpointId, + payloadJson, + fetchedAt, + httpStatus, + rlRemaining, + rlReset, + payloadVersion, + attemptedAt, + nextRetryAt, + pollState = null + ) { + const normalizedStatus = Number.isInteger(Number(httpStatus)) ? Number(httpStatus) : 0; + const pollStateJson = pollState === null || pollState === undefined + ? null + : JSON.stringify(pollState); + this.upsertGithubCacheFailureSnapshot.run( + endpointId, + payloadJson, + fetchedAt, + normalizedStatus, + rlRemaining == null ? null : Number(rlRemaining), + rlReset == null ? null : Number(rlReset), + Number(payloadVersion) || 0, + attemptedAt, + normalizedStatus, + nextRetryAt, + pollStateJson + ); + } + + refreshGithubCacheSnapshotDuringBackoffRow( + endpointId, + payloadJson, + fetchedAt, + payloadVersion, + pollState = null + ) { + const pollStateJson = pollState === null || pollState === undefined + ? null + : JSON.stringify(pollState); + this.refreshGithubCacheSnapshotDuringBackoff.run( + payloadJson, + fetchedAt, + Number(payloadVersion) || 0, + pollStateJson, + endpointId + ); + } + getGithubCachePayload(endpointId) { const row = this.selectGithubCache.get(endpointId); if (!row) return null; + let pollState = null; + if (typeof row.poll_state_json === 'string') { + try { pollState = JSON.parse(row.poll_state_json); } catch { pollState = null; } + } return { payload: row.payload_json, etag: row.etag, @@ -772,6 +863,7 @@ class Queries { lastErrorStatus: row.last_error_status, consecutiveFailures: row.consecutive_failures, nextRetryAt: row.next_retry_at, + pollState, }; } diff --git a/showcase/server/src/db/schema.js b/showcase/server/src/db/schema.js index 56a42e948..39a41599b 100644 --- a/showcase/server/src/db/schema.js +++ b/showcase/server/src/db/schema.js @@ -128,7 +128,8 @@ function scrubLegacyGithubCache(db) { UPDATE github_cache SET payload_json = ?, etag = NULL, is_complete = 0, payload_version = ?, last_attempt_at = COALESCE(last_attempt_at, fetched_at), - last_error_status = NULL, consecutive_failures = 0, next_retry_at = NULL + last_error_status = NULL, consecutive_failures = 0, next_retry_at = NULL, + poll_state_json = NULL WHERE endpoint_id = ? `); const remove = db.prepare('DELETE FROM github_cache WHERE endpoint_id = ?'); @@ -313,6 +314,8 @@ function initializeDatabase(db) { -- the public, field-allowlisted representation used by /stats. A completed -- page walk is committed atomically; failures update attempt metadata while -- preserving the last-known-good payload and fetched_at timestamp. + -- poll_state_json is server-private incremental polling state and is never + -- copied into the public payload or API response. CREATE TABLE IF NOT EXISTS github_cache ( endpoint_id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, @@ -326,7 +329,8 @@ function initializeDatabase(db) { last_attempt_at INTEGER, last_error_status INTEGER, consecutive_failures INTEGER NOT NULL DEFAULT 0, - next_retry_at INTEGER + next_retry_at INTEGER, + poll_state_json TEXT ); `); @@ -366,7 +370,7 @@ function initializeDatabase(db) { // Existing GitHub cache rows predate traversal-completeness metadata. They // intentionally migrate as incomplete so the poller performs a full walk - // before trusting their page-1 ETag as a whole-cache freshness sentinel. + // before seeding an incremental cursor or trusting a conditional validator. try { db.exec(`ALTER TABLE github_cache ADD COLUMN is_complete INTEGER NOT NULL DEFAULT 0`); } catch { /* column already exists */ } @@ -385,6 +389,9 @@ function initializeDatabase(db) { try { db.exec(`ALTER TABLE github_cache ADD COLUMN next_retry_at INTEGER`); } catch { /* column already exists */ } + try { + db.exec(`ALTER TABLE github_cache ADD COLUMN poll_state_json TEXT`); + } catch { /* column already exists */ } // Pre-migration cache rows stored raw GitHub list responses. Scrub them in // the same initialization turn so usernames, profile data, commit metadata, diff --git a/showcase/server/src/routes/public-stats.js b/showcase/server/src/routes/public-stats.js index b398366bc..1a63c6291 100644 --- a/showcase/server/src/routes/public-stats.js +++ b/showcase/server/src/routes/public-stats.js @@ -400,7 +400,10 @@ function createPublicStatsRouter(db, queries) { fetchedAt: effectiveFetchedAt, checkedAt, upstreamStatus: refreshFailed ? row.lastErrorStatus : row.status, - cacheState: !refreshFailed && ageMs <= GITHUB_CACHE_FRESH_MS ? 'fresh' : 'stale', + // Freshness describes the age of the last usable snapshot. A failed + // retry is separate upstream-health metadata and must not make a + // still-current snapshot stale. + cacheState: ageMs <= GITHUB_CACHE_FRESH_MS ? 'fresh' : 'stale', nextRetryAt: row.nextRetryAt, payloadAsOf: typeof publicPayload.as_of === 'string' ? publicPayload.as_of : null, }; diff --git a/showcase/server/src/telemetry/github-poller.js b/showcase/server/src/telemetry/github-poller.js index 69d2ca433..d7551c78d 100644 --- a/showcase/server/src/telemetry/github-poller.js +++ b/showcase/server/src/telemetry/github-poller.js @@ -2,9 +2,10 @@ * Server-side GitHub statistics poller. * * The browser never receives GitHub's raw REST responses. Each successful - * poll writes a compact, allowlisted payload after the complete page walk has - * finished. A failed request records status/backoff metadata without replacing - * the last-known-good payload. + * poll writes a compact, allowlisted payload. Private cursors make the normal + * commit path incremental and let unchanged star totals skip identity walks. + * A failed request records status/backoff metadata without replacing the + * last-known-good commit payload or cursor. */ 'use strict'; @@ -377,7 +378,12 @@ async function fetchOnePage(url, accept, ifNoneMatch, fetchImpl) { try { await res.text(); } catch { /* socket cleanup only */ } return { status: res.status, body: undefined, etag: null, rlRemaining, rlReset, link, retryAfter }; } - const body = await res.json(); + let body; + try { + body = await res.json(); + } catch { + return { status: 502, body: undefined, etag: null, rlRemaining, rlReset, link, retryAfter }; + } return { status: 200, body, etag, rlRemaining, rlReset, link, retryAfter }; } finally { clearTimeout(timeout); @@ -410,11 +416,6 @@ function failureRetryAt(endpointId, queries, nowMs, rlRemaining, rlReset, retryA return nextRetryAt; } -function recordFailure(endpointId, queries, nowMs, status, rlRemaining, rlReset, retryAfter = null) { - const nextRetryAt = failureRetryAt(endpointId, queries, nowMs, rlRemaining, rlReset, retryAfter); - queries.recordGithubCacheFailureRow(endpointId, nowMs, status, rlRemaining, rlReset, nextRetryAt); -} - function paginationKey(endpointId, event) { if (!isObject(event)) return null; if (endpointId === 'commits' && typeof event.sha === 'string') return `sha:${event.sha}`; @@ -437,7 +438,7 @@ function parseCachedPayload(row) { try { return JSON.parse(row.payload); } catch { return null; } } -function repoStarSnapshot(queries, nowMs, minimumFetchedAt = 0) { +function repoSnapshot(queries, nowMs, minimumFetchedAt = 0) { const row = queries.getGithubCachePayload('repo-summary'); if ( !row || row.fetchedAt <= 0 || row.fetchedAt < minimumFetchedAt || @@ -445,7 +446,46 @@ function repoStarSnapshot(queries, nowMs, minimumFetchedAt = 0) { ) return null; const payload = parseCachedPayload(row); const total = isObject(payload) ? nonNegativeInteger(payload.stargazers_count) : null; - return total === null ? null : { total, fetchedAt: row.fetchedAt }; + const branch = isObject(payload) && typeof payload.default_branch === 'string' && payload.default_branch.length > 0 + ? payload.default_branch + : null; + return total === null || branch === null + ? null + : { + total, + branch, + fetchedAt: row.fetchedAt, + checkedAt: row.lastAttemptAt, + lastErrorStatus: row.lastErrorStatus, + status: row.status, + }; +} + +function repoStarSnapshot(queries, nowMs, minimumFetchedAt = 0) { + const snapshot = repoSnapshot(queries, nowMs, minimumFetchedAt); + return snapshot ? { + total: snapshot.total, + fetchedAt: snapshot.fetchedAt, + checkedAt: snapshot.checkedAt, + lastErrorStatus: snapshot.lastErrorStatus, + status: snapshot.status, + } : null; +} + +function commitPollState(row) { + const state = row && row.pollState; + if ( + !isObject(state) || typeof state.branch !== 'string' || state.branch.length === 0 || + typeof state.head_sha !== 'string' || state.head_sha.length === 0 + ) return null; + return { branch: state.branch, head_sha: state.head_sha }; +} + +function starPollState(row) { + const state = row && row.pollState; + return isObject(state) && Number.isFinite(state.last_full_scan_at) && state.last_full_scan_at > 0 + ? { last_full_scan_at: state.last_full_scan_at } + : null; } function writeSuccessfulPayload( @@ -458,7 +498,8 @@ function writeSuccessfulPayload( rlReset, queries, lastAttemptAt = fetchedAt, - nextRetryAt = null + nextRetryAt = null, + pollState = null ) { queries.upsertGithubCacheRow( endpointId, @@ -471,7 +512,8 @@ function writeSuccessfulPayload( true, PAYLOAD_VERSION, lastAttemptAt, - nextRetryAt + nextRetryAt, + pollState ); } @@ -484,214 +526,423 @@ function tryStarsFallback(endpointId, queries, nowMs, status, rlRemaining, rlRes const snapshot = repoStarSnapshot(queries, nowMs, minimumFetchedAt); if (!snapshot) return false; const previous = parseCachedPayload(existing); - const payload = starsFromRepositoryCount(previous, snapshot.total, snapshot.fetchedAt); - writeSuccessfulPayload( + let payload; + try { + payload = starsFromRepositoryCount(previous, snapshot.total, snapshot.fetchedAt); + } catch { + payload = starsFromRepositoryCount(null, snapshot.total, snapshot.fetchedAt); + } + queries.upsertGithubCacheFailureSnapshotRow( endpointId, - payload, - null, + JSON.stringify(payload), snapshot.fetchedAt, status, rlRemaining, rlReset, - queries, + PAYLOAD_VERSION, nowMs, - nextRetryAt + nextRetryAt, + starPollState(existing) ); return true; } -async function pollEndpoint(endpointId, queries, fetchImpl, nowMs) { - const cfg = ENDPOINTS[endpointId]; - if (!cfg) return; +function upstreamError(message, response = null, statusOverride = null) { + const error = new Error(message); + const responseStatus = response && Number.isInteger(response.status) ? response.status : 0; + error.httpStatus = Number.isInteger(statusOverride) ? statusOverride : responseStatus; + error.rlRemaining = response ? response.rlRemaining : null; + error.rlReset = response ? response.rlReset : null; + error.retryAfter = response ? response.retryAfter : null; + return error; +} - const existing = queries.getGithubCachePayload(endpointId); - const attemptStartedAt = Date.now(); - // Keep injected test clocks deterministic at sub-second speed while making - // long production traversals base their backoff on the actual failure time. - const attemptedAt = () => nowMs + Math.floor( - Math.max(0, Date.now() - attemptStartedAt) / 1000 - ) * 1000; +function responseOrThrow(response, message) { + if (response.status !== 200) throw upstreamError(message, response); + return response; +} - try { - // Without a token, the stargazer-list endpoint is restricted and would - // produce a permanent auth failure. The repository resource already gives - // us the authoritative total, so use it without making the doomed request. - if (endpointId === 'stars' && !GITHUB_TOKEN) { - if (!tryStarsFallback(endpointId, queries, nowMs, 200, null, null)) { - recordFailure(endpointId, queries, attemptedAt(), 503, null, null); - } - return; +function cumulativeDayCounts(history) { + const counts = new Map(); + let previousTotal = 0; + for (const point of history) { + const count = point.total - previousTotal; + if (!Number.isInteger(count) || count < 0) { + throw upstreamError('cached commit history is not cumulative', null, 502); } + counts.set(point.day_utc, count); + previousTotal = point.total; + } + return counts; +} + +function cumulativeHistoryFromCounts(counts) { + let total = 0; + return [...counts.keys()].sort().map((day_utc) => { + total += counts.get(day_utc); + return { day_utc, total }; + }); +} - if (existing && existing.nextRetryAt && nowMs < existing.nextRetryAt) return; +function commitAuthorDay(event) { + return isObject(event) && isObject(event.commit) && isObject(event.commit.author) + ? utcDay(event.commit.author.date) + : null; +} - if (!cfg.paginated) { - const existingEtag = existing && existing.isComplete ? existing.etag : null; - const url = `https://api.github.com${cfg.path}${cfg.query ? `?${cfg.query}` : ''}`; - const response = await fetchOnePage(url, cfg.accept, existingEtag, fetchImpl); - if (response.status === 304) { - queries.touchGithubCacheRow(endpointId, nowMs, 304, response.rlRemaining, response.rlReset); - return; - } - if (response.status !== 200) { - recordFailure( - endpointId, - queries, - attemptedAt(), - response.status, - response.rlRemaining, - response.rlReset, - response.retryAfter - ); - console.warn(`[github-poller] ${endpointId} HTTP ${response.status}; preserving last-known-good cache`); - return; +function mergeCommitHistory(payload, commits, nowMs) { + const previous = validateCommitsEnvelope(payload); + if (!previous.history_complete) { + throw upstreamError('incremental merge requires complete commit history', null, 502); + } + const counts = cumulativeDayCounts(previous.history); + for (const event of commits) { + const day = commitAuthorDay(event); + if (!day) throw upstreamError('compare response contains a malformed commit date', null, 502); + counts.set(day, (counts.get(day) || 0) + 1); + } + const history = cumulativeHistoryFromCounts(counts); + const total = previous.total + commits.length; + const finalTotal = history.length > 0 ? history[history.length - 1].total : 0; + if (finalTotal !== total) throw upstreamError('incremental commit total is inconsistent', null, 502); + return { + schema_version: PAYLOAD_VERSION, + total, + last_30_days: rollingCommitCountFromHistory(history, nowMs), + history, + history_complete: true, + as_of: isoAt(nowMs), + }; +} + +async function walkPinnedCommits(headSha, fetchImpl) { + const collected = []; + const seen = new Set(); + let page = 1; + let lastResponse = null; + + while (true) { + const url = `https://api.github.com/repos/${OWNER}/${REPO}/commits?sha=${encodeURIComponent(headSha)}&page=${page}&per_page=${PER_PAGE}`; + const response = responseOrThrow( + await fetchOnePage(url, ENDPOINTS.commits.accept, null, fetchImpl), + `commits page=${page} failed` + ); + if (!Array.isArray(response.body)) { + throw upstreamError(`commits page=${page} is malformed`, response, 502); + } + for (const event of response.body) { + const key = paginationKey('commits', event); + if (key === null || !commitAuthorDay(event)) { + throw upstreamError('commit row is missing its SHA or author date', response, 502); } - const payload = sanitizePublicPayload(endpointId, response.body, { nowMs }); - writeSuccessfulPayload(endpointId, payload, response.etag, nowMs, 200, response.rlRemaining, response.rlReset, queries); - return; + if (seen.has(key)) continue; + seen.add(key); + collected.push(event); } + lastResponse = response; + if (!hasNextPage(response.link) && response.body.length < PER_PAGE) break; + page += 1; + } - // Incomplete/legacy rows never use their page-1 ETag: they must self-heal - // via a complete walk. Oldest-first endpoints always walk because page 1 - // becomes immutable once full. - const existingEtag = existing && existing.isComplete && !cfg.oldestFirst ? existing.etag : null; - const base = `https://api.github.com${cfg.path}`; - const collected = []; - const seenKeys = new Set(); - let page = 1; - let etagToStore = null; - let firstPageFingerprint = null; - let lastRlRemaining = null; - let lastRlReset = null; - - while (true) { - const separator = cfg.query ? '&' : ''; - const url = `${base}?${cfg.query}${separator}page=${page}&per_page=${PER_PAGE}`; - const response = await fetchOnePage(url, cfg.accept, page === 1 ? existingEtag : null, fetchImpl); - - if (page === 1 && response.status === 304) { - if (endpointId === 'commits') { - const cached = parseCachedPayload(existing); - const refreshed = refreshCommitWindow(cached, nowMs); - writeSuccessfulPayload( - endpointId, - refreshed, - existing.etag, - nowMs, - 304, - response.rlRemaining, - response.rlReset, - queries - ); - } else { - queries.touchGithubCacheRow(endpointId, nowMs, 304, response.rlRemaining, response.rlReset); - } - return; - } - if (response.status !== 200 || !Array.isArray(response.body)) { - const failureAt = attemptedAt(); - const fallbackRetryAt = failureRetryAt( - endpointId, - queries, - failureAt, - response.rlRemaining, - response.rlReset, - response.retryAfter - ); - const restrictionRetryAt = endpointId === 'stars' && (response.status === 401 || response.status === 403) - ? Math.max(failureAt + RESTRICTED_STARS_RETRY_MS, fallbackRetryAt) - : fallbackRetryAt; - if (tryStarsFallback( - endpointId, - queries, - failureAt, - response.status || 502, - response.rlRemaining, - response.rlReset, - restrictionRetryAt - )) return; - recordFailure( - endpointId, - queries, - failureAt, - response.status || 502, - response.rlRemaining, - response.rlReset, - response.retryAfter - ); - console.warn(`[github-poller] ${endpointId} page=${page} failed; preserving last-known-good cache`); - return; - } + if (!collected.some((event) => event.sha === headSha)) { + throw upstreamError('pinned commit traversal did not contain its requested head', lastResponse, 502); + } + return { commits: collected, response: lastResponse }; +} - if (page === 1) { - etagToStore = response.etag; - firstPageFingerprint = JSON.stringify(response.body); - } - lastRlRemaining = response.rlRemaining; - lastRlReset = response.rlReset; - for (const event of response.body) { - const key = paginationKey(endpointId, event); - if (key !== null && seenKeys.has(key)) continue; - if (key !== null) seenKeys.add(key); - collected.push(event); - } - if (!hasNextPage(response.link) && response.body.length < PER_PAGE) break; - page += 1; +async function compareCommits(baseSha, headSha, fetchImpl) { + const collected = []; + const seen = new Set(); + let aheadBy = null; + let page = 1; + let lastResponse = null; + + while (true) { + const url = `https://api.github.com/repos/${OWNER}/${REPO}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}?page=${page}&per_page=${PER_PAGE}`; + const response = await fetchOnePage(url, null, null, fetchImpl); + if (response.status === 404 || response.status === 409 || response.status === 422) { + return { rebuild: true, response }; + } + responseOrThrow(response, `compare page=${page} failed`); + if (!isObject(response.body) || !Array.isArray(response.body.commits)) { + throw upstreamError(`compare page=${page} is malformed`, response, 502); } - // New commits reorder the leading pages. Verify page 1 did not change - // while a multi-page traversal was in flight before - // publishing the combined result as complete. A changed sentinel leaves - // the last-known-good row untouched and retries from page 1 next cycle. - if (page > 1 && !cfg.oldestFirst) { - const separator = cfg.query ? '&' : ''; - const firstPageUrl = `${base}?${cfg.query}${separator}page=1&per_page=${PER_PAGE}`; - const sentinel = await fetchOnePage(firstPageUrl, cfg.accept, etagToStore || null, fetchImpl); - const stableByValidator = Boolean(etagToStore) && sentinel.status === 304; - const stableByBody = sentinel.status === 200 && Array.isArray(sentinel.body) && - JSON.stringify(sentinel.body) === firstPageFingerprint; - if (!stableByValidator && !stableByBody) { - recordFailure( - endpointId, - queries, - attemptedAt(), - sentinel.status === 200 ? 409 : (sentinel.status || 502), - sentinel.rlRemaining, - sentinel.rlReset, - sentinel.retryAfter - ); - console.warn(`[github-poller] ${endpointId} changed during pagination; preserving last-known-good cache`); - return; + const pageAheadBy = nonNegativeInteger(response.body.ahead_by); + const pageBehindBy = nonNegativeInteger(response.body.behind_by); + if (response.body.status !== 'ahead' || pageBehindBy !== 0 || pageAheadBy === 0) { + return { rebuild: true, response }; + } + if (pageAheadBy === null || (aheadBy !== null && pageAheadBy !== aheadBy)) { + throw upstreamError('compare pagination changed ahead_by', response, 502); + } + aheadBy = pageAheadBy; + + for (const event of response.body.commits) { + const key = paginationKey('commits', event); + if (key === null || !commitAuthorDay(event)) { + throw upstreamError('compare response contains a malformed commit', response, 502); } - if (stableByBody && sentinel.etag) etagToStore = sentinel.etag; + if (seen.has(key)) continue; + seen.add(key); + collected.push(event); } + if (collected.length > aheadBy) { + throw upstreamError('compare returned more commits than ahead_by', response, 502); + } + lastResponse = response; + if (collected.length === aheadBy) break; + if (!hasNextPage(response.link) && response.body.commits.length < PER_PAGE) { + throw upstreamError('compare returned fewer unique commits than ahead_by', response, 502); + } + page += 1; + } + + if (collected.length !== aheadBy) { + throw upstreamError('compare unique commit count does not equal ahead_by', lastResponse, 502); + } + if (!collected.some((event) => event.sha === headSha)) { + throw upstreamError('compare result does not contain the probed head', lastResponse, 502); + } + return { rebuild: false, commits: collected, response: lastResponse }; +} + +async function pollRepoSummary(existing, queries, fetchImpl, nowMs) { + const existingEtag = existing && existing.isComplete ? existing.etag : null; + const response = await fetchOnePage( + `https://api.github.com${ENDPOINTS['repo-summary'].path}`, + ENDPOINTS['repo-summary'].accept, + existingEtag, + fetchImpl + ); + if (response.status === 304) { + queries.touchGithubCacheRow('repo-summary', nowMs, 304, response.rlRemaining, response.rlReset); + return; + } + responseOrThrow(response, 'repo-summary request failed'); + const payload = sanitizePublicPayload('repo-summary', response.body, { nowMs }); + writeSuccessfulPayload( + 'repo-summary', payload, response.etag, nowMs, 200, + response.rlRemaining, response.rlReset, queries + ); +} + +function usableStarEnvelope(existing) { + if (!existing || !existing.isComplete || existing.payloadVersion !== PAYLOAD_VERSION) return null; + try { return validateStarsEnvelope(parseCachedPayload(existing)); } catch { return null; } +} + +async function pollStars(existing, queries, fetchImpl, nowMs) { + const summary = repoStarSnapshot(queries, nowMs); + if ( + !summary || summary.fetchedAt !== nowMs || summary.checkedAt !== nowMs || + summary.lastErrorStatus !== null || (summary.status !== 200 && summary.status !== 304) + ) { + throw upstreamError('stars require a same-tick repository total', null, 503); + } + + const envelope = usableStarEnvelope(existing); + const state = starPollState(existing); + const reconciledRecently = state && nowMs >= state.last_full_scan_at && + nowMs - state.last_full_scan_at < DAY_MS; + const canUseCountFastPath = envelope && envelope.history_complete && + envelope.source === 'stargazers' && envelope.total === summary.total && reconciledRecently; + if (!GITHUB_TOKEN) { let payload; - if (endpointId === 'stars') { - // A successful terminal page proves the complete current list. Do not - // let an older repository-summary snapshot override that newer walk. - const sameTickSummary = repoStarSnapshot(queries, nowMs); - const expectedTotal = sameTickSummary && sameTickSummary.fetchedAt === nowMs - ? sameTickSummary.total - : collected.length; - payload = aggregateCompleteStars(collected, expectedTotal, nowMs); - if (!payload && sameTickSummary && sameTickSummary.fetchedAt === nowMs) { - if (tryStarsFallback(endpointId, queries, nowMs, 409, lastRlRemaining, lastRlReset)) return; - } - if (!payload) throw new Error('complete stargazer walk contained malformed rows'); - } else { - payload = sanitizePublicPayload(endpointId, collected, { nowMs, historyComplete: true }); + try { + payload = starsFromRepositoryCount(parseCachedPayload(existing), summary.total, nowMs); + } catch { + payload = starsFromRepositoryCount(null, summary.total, nowMs); + } + writeSuccessfulPayload( + 'stars', payload, null, nowMs, 200, + existing ? existing.rateLimitRemaining : null, + existing ? existing.rateLimitReset : null, + queries, nowMs, null, starPollState(existing) + ); + return; + } + + if (existing && existing.nextRetryAt && nowMs < existing.nextRetryAt) { + let payload; + try { + payload = starsFromRepositoryCount(parseCachedPayload(existing), summary.total, nowMs); + } catch { + payload = starsFromRepositoryCount(null, summary.total, nowMs); } + queries.refreshGithubCacheSnapshotDuringBackoffRow( + 'stars', JSON.stringify(payload), nowMs, PAYLOAD_VERSION, starPollState(existing) + ); + return; + } - writeSuccessfulPayload(endpointId, payload, etagToStore, nowMs, 200, lastRlRemaining, lastRlReset, queries); + if (canUseCountFastPath) { + const refreshed = { ...envelope, as_of: isoAt(nowMs) }; + writeSuccessfulPayload( + 'stars', refreshed, existing.etag, nowMs, 200, + existing.rateLimitRemaining, existing.rateLimitReset, queries, + nowMs, null, state + ); + return; + } + + const collected = []; + const seen = new Set(); + let page = 1; + let firstEtag = null; + let lastResponse = null; + while (true) { + const url = `https://api.github.com${ENDPOINTS.stars.path}?page=${page}&per_page=${PER_PAGE}`; + const response = responseOrThrow( + await fetchOnePage(url, ENDPOINTS.stars.accept, null, fetchImpl), + `stars page=${page} failed` + ); + if (!Array.isArray(response.body)) { + throw upstreamError(`stars page=${page} is malformed`, response, 502); + } + if (page === 1) firstEtag = response.etag; + for (const event of response.body) { + const key = paginationKey('stars', event); + if (key !== null && seen.has(key)) continue; + if (key !== null) seen.add(key); + collected.push(event); + } + lastResponse = response; + if (!hasNextPage(response.link) && response.body.length < PER_PAGE) break; + page += 1; + } + + const payload = aggregateCompleteStars(collected, summary.total, nowMs); + if (!payload) { + throw upstreamError('stargazer traversal did not match the repository total', lastResponse, 409); + } + writeSuccessfulPayload( + 'stars', payload, firstEtag, nowMs, 200, + lastResponse.rlRemaining, lastResponse.rlReset, queries, + nowMs, null, { last_full_scan_at: nowMs } + ); +} + +function usableCommitEnvelope(existing) { + if (!existing || !existing.isComplete || existing.payloadVersion !== PAYLOAD_VERSION) return null; + try { + const envelope = validateCommitsEnvelope(parseCachedPayload(existing)); + if (!envelope.history_complete) return null; + cumulativeDayCounts(envelope.history); + return envelope; + } catch { + return null; + } +} + +async function pollCommits(existing, queries, fetchImpl, nowMs) { + const summary = repoSnapshot(queries, nowMs); + // Repo metadata is a dependency, not a commit-upstream failure. Only advance + // the commit snapshot against a default branch verified in this same tick; + // otherwise leave the row and its retry state untouched so recovery can poll + // immediately after the summary succeeds. + if ( + !summary || summary.fetchedAt !== nowMs || summary.checkedAt !== nowMs || + summary.lastErrorStatus !== null || (summary.status !== 200 && summary.status !== 304) + ) return; + + const state = commitPollState(existing); + const cached = usableCommitEnvelope(existing); + const canIncrement = Boolean(state && cached && state.branch === summary.branch); + const headResponse = await fetchOnePage( + `https://api.github.com/repos/${OWNER}/${REPO}/commits?sha=${encodeURIComponent(summary.branch)}&page=1&per_page=1`, + null, + canIncrement ? existing.etag : null, + fetchImpl + ); + + if (headResponse.status === 304) { + if (!canIncrement) throw upstreamError('unexpected conditional head response', headResponse, 502); + const refreshed = refreshCommitWindow(cached, nowMs); + writeSuccessfulPayload( + 'commits', refreshed, existing.etag, nowMs, 304, + headResponse.rlRemaining, headResponse.rlReset, queries, + nowMs, null, state + ); + return; + } + responseOrThrow(headResponse, 'default-branch head probe failed'); + const headCommit = Array.isArray(headResponse.body) ? headResponse.body[0] : null; + const headSha = isObject(headCommit) && typeof headCommit.sha === 'string' && headCommit.sha.length > 0 + ? headCommit.sha + : null; + if (!headSha) throw upstreamError('default-branch head response is malformed', headResponse, 502); + const headEtag = headResponse.etag || (canIncrement && headSha === state.head_sha ? existing.etag : null); + const nextState = { branch: summary.branch, head_sha: headSha }; + + if (canIncrement && headSha === state.head_sha) { + const refreshed = refreshCommitWindow(cached, nowMs); + writeSuccessfulPayload( + 'commits', refreshed, headEtag, nowMs, 200, + headResponse.rlRemaining, headResponse.rlReset, queries, + nowMs, null, nextState + ); + return; + } + + if (canIncrement) { + const compared = await compareCommits(state.head_sha, headSha, fetchImpl); + if (!compared.rebuild) { + const payload = mergeCommitHistory(cached, compared.commits, nowMs); + writeSuccessfulPayload( + 'commits', payload, headEtag, nowMs, 200, + compared.response.rlRemaining, compared.response.rlReset, queries, + nowMs, null, nextState + ); + return; + } + } + + const walked = await walkPinnedCommits(headSha, fetchImpl); + const payload = aggregateCommits(walked.commits, nowMs, true); + writeSuccessfulPayload( + 'commits', payload, headEtag, nowMs, 200, + walked.response.rlRemaining, walked.response.rlReset, queries, + nowMs, null, nextState + ); +} + +async function pollEndpoint(endpointId, queries, fetchImpl, nowMs) { + const cfg = ENDPOINTS[endpointId]; + if (!cfg) return; + + const existing = queries.getGithubCachePayload(endpointId); + const attemptStartedAt = Date.now(); + // Keep injected test clocks deterministic at sub-second speed while making + // long production traversals base their backoff on the actual failure time. + const attemptedAt = () => nowMs + Math.floor( + Math.max(0, Date.now() - attemptStartedAt) / 1000 + ) * 1000; + + try { + if (existing && existing.nextRetryAt && nowMs < existing.nextRetryAt && endpointId !== 'stars') return; + if (endpointId === 'repo-summary') await pollRepoSummary(existing, queries, fetchImpl, nowMs); + else if (endpointId === 'stars') await pollStars(existing, queries, fetchImpl, nowMs); + else if (endpointId === 'commits') await pollCommits(existing, queries, fetchImpl, nowMs); } catch (err) { const failureAt = attemptedAt(); - const fallbackRetryAt = failureRetryAt(endpointId, queries, failureAt, null, null); - if (tryStarsFallback(endpointId, queries, failureAt, 0, null, null, fallbackRetryAt)) { + const status = err && Number.isInteger(err.httpStatus) ? err.httpStatus : 0; + const rlRemaining = !err || err.rlRemaining == null ? null : err.rlRemaining; + const rlReset = !err || err.rlReset == null ? null : err.rlReset; + const fallbackRetryAt = failureRetryAt( + endpointId, queries, failureAt, rlRemaining, rlReset, (err && err.retryAfter) || null + ); + const nextRetryAt = endpointId === 'stars' && (status === 401 || status === 403) + ? Math.max(failureAt + RESTRICTED_STARS_RETRY_MS, fallbackRetryAt) + : fallbackRetryAt; + if (tryStarsFallback(endpointId, queries, failureAt, status, rlRemaining, rlReset, nextRetryAt)) { console.warn(`[github-poller] ${endpointId} request failed; using repository-count fallback`); return; } - recordFailure(endpointId, queries, failureAt, 0, null, null); + queries.recordGithubCacheFailureRow( + endpointId, failureAt, status, rlRemaining, rlReset, nextRetryAt + ); console.error(`[github-poller] ${endpointId} tick failed:`, err && err.message ? err.message : err); } } diff --git a/tests/fsb-telemetry-service.test.js b/tests/fsb-telemetry-service.test.js index eb78135cf..1159ccafc 100644 --- a/tests/fsb-telemetry-service.test.js +++ b/tests/fsb-telemetry-service.test.js @@ -122,9 +122,11 @@ check('service: aborts in-flight fetches and guards route generations', /this\.generation\s*\+=\s*1/.test(serviceCode) && /controller\.abort\(\)/.test(serviceCode), 'AbortController/generation lifecycle guard missing'); -check('service: preserves prior successful data as explicitly stale on refresh failure', - /kind:\s*'stale'/.test(serviceCode) && /fetchedAt:\s*previous\.fetchedAt/.test(serviceCode), - 'stale last-known-good transition missing'); +check('service: preserves prior successful data and records failed refresh metadata', + /data:\s*previous\.data/.test(serviceCode) && + /availabilityAfterFailure\(previous\.availability,\s*err\)/.test(serviceCode) && + /statsFailureMetadata\(err\)/.test(serviceCode), + 'last-known-good availability transition missing'); // ---- ETag round-trip ---- check('service: sends If-None-Match header for ETag round-trip', diff --git a/tests/server-github-cache.test.js b/tests/server-github-cache.test.js index 3738ea67c..c4bfdd1d2 100644 --- a/tests/server-github-cache.test.js +++ b/tests/server-github-cache.test.js @@ -14,7 +14,10 @@ const express = require(require.resolve('express', { paths: [SERVER_NM] })); const { initializeDatabase } = require(path.join(__dirname, '..', 'showcase', 'server', 'src', 'db', 'schema')); const Queries = require(path.join(__dirname, '..', 'showcase', 'server', 'src', 'db', 'queries')); const createPublicStatsRouter = require(path.join(__dirname, '..', 'showcase', 'server', 'src', 'routes', 'public-stats')); -const { GITHUB_CACHE_MAX_STALE_MS } = createPublicStatsRouter; +const { + GITHUB_CACHE_FRESH_MS, + GITHUB_CACHE_MAX_STALE_MS, +} = createPublicStatsRouter; let passed = 0; let failed = 0; @@ -115,7 +118,10 @@ function rawGet(port, requestPath, headers = {}) { queries.upsertGithubCacheRow('commits', JSON.stringify([ commit('a', new Date(now - 60 * 60 * 1000).toISOString()), commit('b', new Date(now - 40 * 24 * 60 * 60 * 1000).toISOString()), - ]), '"commits"', now, 200, 5000, 0); + ]), '"commits"', now, 200, 5000, 0, true, 1, now, null, { + branch: 'private-branch-cursor', + head_sha: 'private-head-cursor', + }); primary.router._resetMemoForTest(); const summaryResponse = await rawGet(primary.port, '/api/public-stats/github/repo-summary'); @@ -166,7 +172,7 @@ function rawGet(port, requestPath, headers = {}) { commitsBody.history_complete === true && commitsBody.history.at(-1).total === 2, commitsResponse.body); check('commit identity/message/sha fields do not escape', - !/(Private Name|private@example|large commit|commit-author|"sha")/.test(commitsResponse.body), commitsResponse.body); + !/(Private Name|private@example|large commit|commit-author|private-branch-cursor|private-head-cursor|"sha")/.test(commitsResponse.body), commitsResponse.body); const notModified = await rawGet(primary.port, '/api/public-stats/github/stars', { 'If-None-Match': starsResponse.headers.etag, @@ -190,12 +196,46 @@ function rawGet(port, requestPath, headers = {}) { lkg.headers['x-fsb-stats-upstream-status'] === '500' && lkg.headers['x-fsb-stats-next-retry-at'] === new Date(now + 301000).toISOString(), JSON.stringify(lkg.headers)); - check('failed refresh immediately marks LKG stale and reports the actual check time', - lkg.headers['x-fsb-stats-cache'] === 'stale' && + check('failed refresh keeps a fresh LKG fresh and reports the actual check time', + lkg.headers['x-fsb-stats-cache'] === 'fresh' && lkg.headers['x-fsb-stats-checked-at'] === new Date(now + 1000).toISOString() && lkg.headers['x-fsb-stats-fetched-at'] === new Date(now).toISOString(), JSON.stringify(lkg.headers)); + const failedNotModified = await rawGet(primary.port, '/api/public-stats/github/commits', { + 'If-None-Match': lkg.headers.etag, + }); + check('failed-refresh metadata survives a 304 response', + failedNotModified.statusCode === 304 && failedNotModified.body === '' && + failedNotModified.headers['x-fsb-stats-cache'] === 'fresh' && + failedNotModified.headers['x-fsb-stats-fetched-at'] === new Date(now).toISOString() && + failedNotModified.headers['x-fsb-stats-checked-at'] === new Date(now + 1000).toISOString() && + failedNotModified.headers['x-fsb-stats-upstream-status'] === '500' && + failedNotModified.headers['x-fsb-stats-next-retry-at'] === new Date(now + 301000).toISOString(), + JSON.stringify(failedNotModified.headers)); + + const staleFetchedAt = now - GITHUB_CACHE_FRESH_MS - 1000; + queries.upsertGithubCacheRow( + 'commits', + commitsResponse.body, + '"stale-commits"', + staleFetchedAt, + 200, + 5000, + 0, + true, + 1 + ); + primary.router._resetMemoForTest(); + const staleUsable = await rawGet(primary.port, '/api/public-stats/github/commits'); + check('snapshot older than 15 minutes remains usable but is classified stale', + staleUsable.statusCode === 200 && + staleUsable.headers['x-fsb-stats-cache'] === 'stale' && + staleUsable.headers['x-fsb-stats-fetched-at'] === new Date(staleFetchedAt).toISOString() && + staleUsable.headers['x-fsb-stats-checked-at'] === new Date(staleFetchedAt).toISOString() && + staleUsable.headers['x-fsb-stats-upstream-status'] === '200', + `${staleUsable.statusCode} ${JSON.stringify(staleUsable.headers)}`); + // Route-time legacy normalization must not let an older repository summary // reduce a newer complete stargazer aggregate. const orderingDb = new Database(':memory:'); diff --git a/tests/server-github-poller.test.js b/tests/server-github-poller.test.js index 5013e3eba..c286bd92a 100644 --- a/tests/server-github-poller.test.js +++ b/tests/server-github-poller.test.js @@ -1,5 +1,5 @@ /** - * GitHub poller/cache regression tests. + * Authenticated incremental GitHub poller/cache regression tests. * * Run: node tests/server-github-poller.test.js */ @@ -14,6 +14,15 @@ const Queries = require(path.join(__dirname, '..', 'showcase', 'server', 'src', const POLLER_PATH = path.join(__dirname, '..', 'showcase', 'server', 'src', 'telemetry', 'github-poller'); const NOW = Date.parse('2026-07-20T12:00:00.000Z'); +const REPO_URL = 'https://api.github.com/repos/fullselfbrowsing/FSB'; +const STARS_URL = `${REPO_URL}/stargazers`; +const COMMITS_URL = `${REPO_URL}/commits`; +const COMPARE_URL = `${REPO_URL}/compare`; + +function headUrl(branch = 'main') { + return `${COMMITS_URL}?sha=${encodeURIComponent(branch)}&page=1&per_page=1`; +} + let passed = 0; let failed = 0; @@ -48,28 +57,31 @@ function response(canned) { return values[name.toLowerCase()] ?? null; }, }, - json: async () => canned.body, + json: async () => { + if (canned.malformedJson) throw new Error('malformed JSON'); + return canned.body; + }, text: async () => JSON.stringify(canned.body ?? ''), }; } -function makeFakeFetch(cannedByPrefix) { +function makeFakeFetch(handler) { const calls = []; - const prefixes = Object.keys(cannedByPrefix).sort((a, b) => b.length - a.length); const fakeFetch = async (url, options) => { calls.push({ url, options }); - const prefix = prefixes.find((candidate) => url.startsWith(candidate)); - if (!prefix) throw new Error(`no canned response for ${url}`); - const value = typeof cannedByPrefix[prefix] === 'function' - ? cannedByPrefix[prefix](url, options) - : cannedByPrefix[prefix]; - if (value && value.__reject) throw new Error(value.__reject); - return response(value); + const canned = await handler(url, options, calls.length); + if (!canned) throw new Error(`no canned response for ${url}`); + if (canned.reject) throw new Error(canned.reject); + return response(canned); }; return { fakeFetch, calls }; } -function repoSummary(stars = 2) { +function ok(body, extras = {}) { + return { status: 200, body, rlRemaining: '4999', rlReset: '0', ...extras }; +} + +function repoSummary(stars = 2, branch = 'main') { return { id: 123, name: 'FSB', @@ -78,7 +90,7 @@ function repoSummary(stars = 2) { forks_count: 4, open_issues_count: 8, subscribers_count: 3, - default_branch: 'main', + default_branch: branch, pushed_at: '2026-07-20T10:00:00Z', owner: { login: 'private-shape-must-not-escape' }, }; @@ -95,36 +107,59 @@ function commit(sha, date) { }; } -function happyCanned() { - return { - 'https://api.github.com/repos/fullselfbrowsing/FSB/stargazers': { - status: 200, - body: [ - { starred_at: '2026-07-18T10:00:00Z', user: { login: 'alice' } }, - { starred_at: '2026-07-19T10:00:00Z', user: { login: 'bob' } }, - ], - etag: '"stars-1"', - rlRemaining: '4998', - rlReset: '0', - }, - 'https://api.github.com/repos/fullselfbrowsing/FSB/commits': { - status: 200, - body: [ - commit('a', '2026-07-01T12:00:00Z'), - commit('b', '2026-07-19T12:00:00Z'), - ], - etag: '"commits-1"', - rlRemaining: '4996', - rlReset: '0', - }, - 'https://api.github.com/repos/fullselfbrowsing/FSB': { - status: 200, - body: repoSummary(2), - etag: '"summary-1"', - rlRemaining: '4999', - rlReset: '0', - }, - }; +function star(id, date) { + return { starred_at: date, user: { id, login: `private-${id}` } }; +} + +function openDb() { + const db = new Database(':memory:'); + initializeDatabase(db); + return { db, queries: new Queries(db) }; +} + +function seedRepo(poller, queries, fetchedAt = NOW, stars = 2, branch = 'main') { + const payload = poller.sanitizePublicPayload('repo-summary', repoSummary(stars, branch), { nowMs: fetchedAt }); + queries.upsertGithubCacheRow( + 'repo-summary', JSON.stringify(payload), '"repo-etag"', fetchedAt, + 200, 5000, 0, true, 1, fetchedAt + ); +} + +function seedCommits(poller, queries, options = {}) { + const fetchedAt = options.fetchedAt ?? NOW - 60_000; + const events = options.events || [ + commit('old-head', '2026-07-19T12:00:00Z'), + commit('old-root', '2026-07-18T12:00:00Z'), + ]; + const payload = poller.aggregateCommits(events, fetchedAt, options.historyComplete ?? true); + queries.upsertGithubCacheRow( + 'commits', JSON.stringify(payload), options.etag ?? '"old-head-etag"', fetchedAt, + 200, 4900, 0, true, 1, fetchedAt, null, + options.pollState === undefined ? { branch: 'main', head_sha: 'old-head' } : options.pollState + ); + return payload; +} + +function seedStars(poller, queries, options = {}) { + const fetchedAt = options.fetchedAt ?? NOW - 60_000; + const events = options.events || [ + star(1, '2026-07-18T10:00:00Z'), + star(2, '2026-07-19T10:00:00Z'), + ]; + let payload = poller.sanitizePublicPayload('stars', events, { + nowMs: fetchedAt, + repoTotal: events.length, + historyComplete: options.historyComplete ?? true, + }); + if (options.historyComplete === false) { + payload = { ...payload, history_complete: false, source: 'repository-count' }; + } + queries.upsertGithubCacheRow( + 'stars', JSON.stringify(payload), '"stars-etag"', fetchedAt, + 200, 4900, 0, true, 1, fetchedAt, null, + options.pollState === undefined ? { last_full_scan_at: fetchedAt } : options.pollState + ); + return payload; } (async function main() { @@ -134,489 +169,494 @@ function happyCanned() { console.warn = () => {}; console.error = () => {}; - delete process.env.GITHUB_TOKEN; + process.env.GITHUB_TOKEN = 'github_pat_test'; let poller = freshPoller(); - check('allowlist contains only the three GitHub datasets used by the stats page', - JSON.stringify(poller.GITHUB_ENDPOINT_IDS) === JSON.stringify(['repo-summary', 'stars', 'commits']), - JSON.stringify(poller.GITHUB_ENDPOINT_IDS)); - check('poll cadence remains five minutes', poller.GITHUB_POLL_INTERVAL_MS === 300000, String(poller.GITHUB_POLL_INTERVAL_MS)); - check('issues endpoint configuration and sanitizer are retired', - poller.ENDPOINTS.issues === undefined && poller.aggregateIssues === undefined); - - // Happy path: every persisted payload is compact, complete, and identity-free. - process.env.GITHUB_TOKEN = 'ghp_happy_path'; - poller = freshPoller(); - const db1 = new Database(':memory:'); - initializeDatabase(db1); - const q1 = new Queries(db1); - const happy = makeFakeFetch(happyCanned()); - await poller.runGithubPollerTick(db1, q1, happy.fakeFetch, NOW); - - for (const endpointId of poller.GITHUB_ENDPOINT_IDS) { - const row = q1.getGithubCachePayload(endpointId); - check(`${endpointId} has a complete v1 cache row`, row && row.isComplete && row.payloadVersion === 1, JSON.stringify(row)); - check(`${endpointId} cached payload contains no raw GitHub identity`, - row && !/(alice|bob|private@example|identity-must-not-escape|"user"|"owner")/.test(row.payload), - row && row.payload); - } - check('poller makes no Issues API request', - !happy.calls.some((call) => call.url.includes('/issues')), - JSON.stringify(happy.calls.map((call) => call.url))); - - const stars1 = JSON.parse(q1.getGithubCachePayload('stars').payload); - check('stars use the aggregate v1 contract', - stars1.schema_version === 1 && stars1.total === 2 && stars1.history.length === 2 && stars1.history_complete === true && stars1.source === 'stargazers', - JSON.stringify(stars1)); - - const commits1 = JSON.parse(q1.getGithubCachePayload('commits').payload); - check('commits use cumulative aggregate contract', - commits1.schema_version === 1 && commits1.total === 2 && commits1.last_30_days === 2 && commits1.history_complete === true && commits1.history.at(-1).total === 2, - JSON.stringify(commits1)); - check('every fetch receives an AbortSignal for timeout enforcement', - happy.calls.length > 0 && happy.calls.every((call) => call.options.signal && typeof call.options.signal.aborted === 'boolean'), - `${happy.calls.length} calls`); - - // A tokenless deployment must not call the restricted stargazer-list API. - delete process.env.GITHUB_TOKEN; - const tokenlessPoller = freshPoller(); - const dbTokenless = new Database(':memory:'); - initializeDatabase(dbTokenless); - const qTokenless = new Queries(dbTokenless); - const tokenlessFetch = makeFakeFetch(happyCanned()); - await tokenlessPoller.runGithubPollerTick(dbTokenless, qTokenless, tokenlessFetch.fakeFetch, NOW); - const tokenlessStars = JSON.parse(qTokenless.getGithubCachePayload('stars').payload); - check('tokenless poll uses repo count without requesting stargazer identities', - tokenlessStars.source === 'repository-count' && - !tokenlessFetch.calls.some((call) => call.url.includes('/stargazers')), - JSON.stringify(tokenlessFetch.calls.map((call) => call.url))); - process.env.GITHUB_TOKEN = 'ghp_happy_path'; - poller = freshPoller(); - - // Conditional refresh preserves the complete last-known-good body. - const beforeSummary = q1.getGithubCachePayload('repo-summary'); - const secondCanned = happyCanned(); - secondCanned['https://api.github.com/repos/fullselfbrowsing/FSB'] = { - status: 304, body: undefined, etag: '"summary-1"', rlRemaining: '4900', rlReset: '0', - }; - const second = makeFakeFetch(secondCanned); - await poller.runGithubPollerTick(db1, q1, second.fakeFetch, NOW + 60000); - const afterSummary = q1.getGithubCachePayload('repo-summary'); - check('304 preserves payload byte-for-byte', afterSummary.payload === beforeSummary.payload, afterSummary.payload); - check('304 advances freshness and keeps cache complete', - afterSummary.fetchedAt === NOW + 60000 && afterSummary.status === 304 && afterSummary.isComplete, - JSON.stringify(afterSummary)); - - // A page-1 304 means no new commits, but the rolling calendar window still - // changes as old days age out. Refresh that derived field from cached history. - const commit304 = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/commits': { - status: 304, - body: undefined, - etag: '"commits-1"', - rlRemaining: '4899', - rlReset: '0', - }, - }); - const commitWindowNow = Date.parse('2026-08-20T12:00:00.000Z'); - await poller.pollEndpoint('commits', q1, commit304.fakeFetch, commitWindowNow); - const commitsAfter304 = JSON.parse(q1.getGithubCachePayload('commits').payload); - check('commit 304 recomputes the rolling 30-calendar-day count', - commitsAfter304.last_30_days === 0 && commitsAfter304.as_of === new Date(commitWindowNow).toISOString(), - JSON.stringify(commitsAfter304)); - - // One cold endpoint failure records retry state without aborting the tick. - const db2 = new Database(':memory:'); - initializeDatabase(db2); - const q2 = new Queries(db2); - const failureCanned = happyCanned(); - failureCanned['https://api.github.com/repos/fullselfbrowsing/FSB/commits'] = { __reject: 'network down' }; - const failureFetch = makeFakeFetch(failureCanned); - await poller.runGithubPollerTick(db2, q2, failureFetch.fakeFetch, NOW); - const failedCommit = q2.getGithubCachePayload('commits'); - check('cold failure records an incomplete placeholder and exponential retry time', - failedCommit && !failedCommit.isComplete && failedCommit.status === 0 && failedCommit.consecutiveFailures === 1 && failedCommit.nextRetryAt === NOW + 300000, - JSON.stringify(failedCommit)); - check('a sibling endpoint still updates after one failure', q2.getGithubCachePayload('repo-summary')?.isComplete === true); - - // Token is captured at module load and sent only when configured. - process.env.GITHUB_TOKEN = 'ghp_test_123'; - poller = freshPoller(); - const db3 = new Database(':memory:'); - initializeDatabase(db3); - const q3 = new Queries(db3); - const authFetch = makeFakeFetch(happyCanned()); - await poller.runGithubPollerTick(db3, q3, authFetch.fakeFetch, NOW); - check('configured token is sent as a bearer token', - authFetch.calls.every((call) => call.options.headers.Authorization === 'Bearer ghp_test_123')); - delete process.env.GITHUB_TOKEN; - poller = freshPoller(); - - // Regression: history larger than the former 30-page/3000-commit cap. - const db4 = new Database(':memory:'); - initializeDatabase(db4); - const q4 = new Queries(db4); - const deepFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/commits': (url, options) => { - if (options.headers['If-None-Match'] === '"deep"') { - return { status: 304, body: undefined, etag: '"deep"', rlRemaining: '4499', rlReset: '0' }; - } - const page = Number(url.match(/[?&]page=(\d+)/)?.[1]); - const length = page <= 32 ? 100 : 7; - return { - status: 200, - body: Array.from({ length }, (_, index) => commit(`${page}-${index}`, '2025-01-01T00:00:00Z')), - etag: page === 1 ? '"deep"' : null, - rlRemaining: '4500', - rlReset: '0', - }; - }, + check('allowlist contains only current public GitHub datasets', + JSON.stringify(poller.GITHUB_ENDPOINT_IDS) === JSON.stringify(['repo-summary', 'stars', 'commits'])); + check('poll cadence remains five minutes', poller.GITHUB_POLL_INTERVAL_MS === 300_000); + + // The additive migration leaves all pre-existing rows without a cursor. + const legacyDb = new Database(':memory:'); + legacyDb.exec(` + CREATE TABLE github_cache ( + endpoint_id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + etag TEXT, + fetched_at INTEGER NOT NULL, + http_status INTEGER NOT NULL, + rate_limit_remaining INTEGER, + rate_limit_reset INTEGER + ); + INSERT INTO github_cache VALUES ( + 'repo-summary', + '${JSON.stringify(repoSummary()).replaceAll("'", "''")}', + '"legacy"', ${NOW}, 200, 60, 0 + ); + `); + initializeDatabase(legacyDb); + const legacyRow = legacyDb.prepare( + 'SELECT poll_state_json FROM github_cache WHERE endpoint_id = ?' + ).get('repo-summary'); + check('migration adds nullable private poll state and migrates existing rows as NULL', + legacyRow && legacyRow.poll_state_json === null, JSON.stringify(legacyRow)); + legacyDb.close(); + + // First authenticated tick performs complete, immutable-head seed walks. + const happyDb = openDb(); + const happyFetch = makeFakeFetch((url) => { + if (url === REPO_URL) return ok(repoSummary(2), { etag: '"repo-1"' }); + if (url.startsWith(`${STARS_URL}?`)) { + return ok([star(1, '2026-07-18T10:00:00Z'), star(2, '2026-07-19T10:00:00Z')], { etag: '"stars-1"' }); + } + if (url === headUrl()) return ok([commit('head-2', '2026-07-19T12:00:00Z')], { etag: '"head-2-etag"' }); + if (url.startsWith(`${COMMITS_URL}?sha=head-2`)) { + return ok([commit('head-2', '2026-07-19T12:00:00Z'), commit('root-1', '2026-07-01T12:00:00Z')]); + } + return null; }); - await poller.pollEndpoint('commits', q4, deepFetch.fakeFetch, NOW); - const deep = JSON.parse(q4.getGithubCachePayload('commits').payload); - check('commit pagination walks past 30 pages to the terminal short page', - deep.total === 3207 && deep.history_complete === true && deepFetch.calls.length === 34, - `total=${deep.total} calls=${deepFetch.calls.length}`); - - // Follow Link rel=next even when a page is unexpectedly short, deduplicate - // boundary rows, and require a stable page-one ETag before committing. - const dbPaging = new Database(':memory:'); - initializeDatabase(dbPaging); - const qPaging = new Queries(dbPaging); - const linkedFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/commits': (url, options) => { - if (options.headers['If-None-Match'] === '"linked"') { - return { status: 304, body: undefined, etag: '"linked"', rlRemaining: '4900', rlReset: '0' }; - } - const page = Number(url.match(/[?&]page=(\d+)/)?.[1]); - return page === 1 - ? { - status: 200, - body: [commit('a', '2026-07-19T00:00:00Z'), commit('b', '2026-07-18T00:00:00Z')], - etag: '"linked"', - link: '; rel="next"', - rlRemaining: '4902', - rlReset: '0', - } - : { - status: 200, - body: [commit('b', '2026-07-18T00:00:00Z'), commit('c', '2026-07-17T00:00:00Z')], - etag: null, - rlRemaining: '4901', - rlReset: '0', - }; - }, + await poller.runGithubPollerTick(happyDb.db, happyDb.queries, happyFetch.fakeFetch, NOW); + const seededStars = happyDb.queries.getGithubCachePayload('stars'); + const seededCommits = happyDb.queries.getGithubCachePayload('commits'); + check('first authenticated star walk seeds reconciliation state', + seededStars.pollState.last_full_scan_at === NOW, JSON.stringify(seededStars)); + check('first authenticated commit walk is pinned to the probed immutable SHA and seeds its cursor', + seededCommits.pollState.branch === 'main' && seededCommits.pollState.head_sha === 'head-2' && + happyFetch.calls.some((call) => call.url.includes('?sha=head-2')), + JSON.stringify(happyFetch.calls.map((call) => call.url))); + check('private cursor and raw identities are absent from public payload JSON', + !/(head-2|main|private-|identity-must-not-escape|private@example|"sha")/.test( + `${seededStars.payload}${seededCommits.payload}` + ), `${seededStars.payload} ${seededCommits.payload}`); + check('all authenticated GitHub requests carry the bearer token', + happyFetch.calls.every((call) => call.options.headers.Authorization === 'Bearer github_pat_test')); + check('every request has timeout cancellation attached', + happyFetch.calls.every((call) => call.options.signal && typeof call.options.signal.aborted === 'boolean')); + + // Unchanged commits use one conditional head probe and still age the rolling window. + const conditionalDb = openDb(); + seedRepo(poller, conditionalDb.queries, NOW); + seedCommits(poller, conditionalDb.queries, { + fetchedAt: Date.parse('2026-06-01T12:00:00Z'), + events: [commit('old-head', '2026-06-01T12:00:00Z')], }); - await poller.pollEndpoint('commits', qPaging, linkedFetch.fakeFetch, NOW); - const linked = JSON.parse(qPaging.getGithubCachePayload('commits').payload); - check('pagination follows Link next, removes boundary duplicates, and revalidates page one', - linked.total === 3 && linkedFetch.calls.length === 3, - `total=${linked.total} calls=${linkedFetch.calls.length}`); - - // GitHub normally supplies an ETag, but completeness must not depend on it. - // Re-read page one and compare its body when the validator is absent. - const dbNoEtag = new Database(':memory:'); - initializeDatabase(dbNoEtag); - const qNoEtag = new Queries(dbNoEtag); - let stablePageOneCalls = 0; - const stableNoEtagFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/commits': (url) => { - const page = Number(url.match(/[?&]page=(\d+)/)?.[1]); - if (page === 1) { - stablePageOneCalls += 1; - return { - status: 200, - body: [commit('a', '2026-07-19T00:00:00Z'), commit('b', '2026-07-18T00:00:00Z')], - etag: null, - link: stablePageOneCalls === 1 ? '; rel="next"' : null, - rlRemaining: '4902', - rlReset: '0', - }; - } + const conditionalFetch = makeFakeFetch((url, options) => { + if (url === headUrl()) { return { - status: 200, - body: [commit('c', '2026-07-17T00:00:00Z')], - etag: null, - rlRemaining: '4901', - rlReset: '0', + status: 304, etag: '"old-head-etag"', rlRemaining: '4800', rlReset: '0', + body: undefined, }; - }, + } + return null; }); - await poller.pollEndpoint('commits', qNoEtag, stableNoEtagFetch.fakeFetch, NOW); - const stableNoEtag = JSON.parse(qNoEtag.getGithubCachePayload('commits').payload); - check('multi-page walk without an ETag rechecks identical page-one content before publishing', - stableNoEtag.total === 3 && stableNoEtag.history_complete === true && stablePageOneCalls === 2, - `total=${stableNoEtag.total} pageOneCalls=${stablePageOneCalls}`); - - const oldNoEtagPayload = JSON.stringify({ - schema_version: 1, - total: 1, - last_30_days: 1, - history: [{ day_utc: '2026-07-16', total: 1 }], - history_complete: true, - as_of: '2026-07-19T12:00:00.000Z', + await poller.pollEndpoint('commits', conditionalDb.queries, conditionalFetch.fakeFetch, NOW); + const conditionalRow = conditionalDb.queries.getGithubCachePayload('commits'); + const conditionalPayload = JSON.parse(conditionalRow.payload); + check('unchanged commit refresh is a single conditional head request', + conditionalFetch.calls.length === 1 && + conditionalFetch.calls[0].options.headers['If-None-Match'] === '"old-head-etag"'); + check('head 304 refreshes snapshot age, keeps cursor, and recalculates rolling 30 days', + conditionalRow.fetchedAt === NOW && conditionalRow.status === 304 && + conditionalRow.pollState.head_sha === 'old-head' && conditionalPayload.last_30_days === 0, + JSON.stringify(conditionalRow)); + + // A failed summary must not let commits refresh against an unverified old + // default branch, and must not back off commit recovery. + const dependencyDb = openDb(); + seedRepo(poller, dependencyDb.queries, NOW - 60_000, 2, 'main'); + seedCommits(poller, dependencyDb.queries); + const beforeDependency = dependencyDb.queries.getGithubCachePayload('commits'); + const summaryFailure = makeFakeFetch((url) => url === REPO_URL + ? { status: 500, body: {}, rlRemaining: '4800', rlReset: '0' } + : null); + await poller.pollEndpoint('repo-summary', dependencyDb.queries, summaryFailure.fakeFetch, NOW); + const dependencyCommitFetch = makeFakeFetch(() => null); + await poller.pollEndpoint('commits', dependencyDb.queries, dependencyCommitFetch.fakeFetch, NOW); + const afterDependency = dependencyDb.queries.getGithubCachePayload('commits'); + check('failed same-tick summary leaves commits untouched and creates no commit backoff', + dependencyCommitFetch.calls.length === 0 && + afterDependency.payload === beforeDependency.payload && + afterDependency.fetchedAt === beforeDependency.fetchedAt && + afterDependency.lastAttemptAt === beforeDependency.lastAttemptAt && + afterDependency.consecutiveFailures === 0 && afterDependency.nextRetryAt === null, + JSON.stringify(afterDependency)); + + const recoveryAt = NOW + 300_000; + const dependencyRecovery = makeFakeFetch((url) => { + if (url === REPO_URL) return ok(repoSummary(2, 'release'), { etag: '"release-summary"' }); + if (url === headUrl('release')) { + return ok([commit('release-recovery', '2026-07-20T12:05:00Z')], { etag: '"release-recovery"' }); + } + if (url.includes('sha=release-recovery&page=1&per_page=100')) { + return ok([commit('release-recovery', '2026-07-20T12:05:00Z')]); + } + return null; }); - qNoEtag.upsertGithubCacheRow('commits', oldNoEtagPayload, null, NOW - 1000, 200, 4900, 0, true, 1); - let changedPageOneCalls = 0; - const changedNoEtagFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/commits': (url) => { - const page = Number(url.match(/[?&]page=(\d+)/)?.[1]); - if (page === 1) { - changedPageOneCalls += 1; - return { - status: 200, - body: changedPageOneCalls === 1 - ? [commit('a', '2026-07-19T00:00:00Z'), commit('b', '2026-07-18T00:00:00Z')] - : [commit('new-head', '2026-07-20T00:00:00Z'), commit('a', '2026-07-19T00:00:00Z')], - etag: null, - link: changedPageOneCalls === 1 ? '; rel="next"' : null, - rlRemaining: '4899', - rlReset: '0', - }; - } - return { - status: 200, - body: [commit('c', '2026-07-17T00:00:00Z')], - etag: null, - rlRemaining: '4898', - rlReset: '0', - }; - }, + await poller.pollEndpoint('repo-summary', dependencyDb.queries, dependencyRecovery.fakeFetch, recoveryAt); + await poller.pollEndpoint('commits', dependencyDb.queries, dependencyRecovery.fakeFetch, recoveryAt); + const recoveredDependency = dependencyDb.queries.getGithubCachePayload('commits'); + check('summary recovery immediately detects a default-branch change and performs a pinned rebuild', + recoveredDependency.fetchedAt === recoveryAt && recoveredDependency.pollState.branch === 'release' && + recoveredDependency.pollState.head_sha === 'release-recovery' && + dependencyRecovery.calls.some((call) => call.url.includes('sha=release-recovery&page=1&per_page=100')), + JSON.stringify(dependencyRecovery.calls.map((call) => call.url))); + + // Normal fast-forward is a head probe plus compare request. + const fastDb = openDb(); + seedRepo(poller, fastDb.queries, NOW); + seedCommits(poller, fastDb.queries); + const fastFetch = makeFakeFetch((url) => { + if (url === headUrl()) return ok([commit('new-head', '2026-07-20T11:00:00Z')], { etag: '"new-head-etag"' }); + if (url.startsWith(`${COMPARE_URL}/old-head...new-head?`)) { + return ok({ status: 'ahead', ahead_by: 1, behind_by: 0, commits: [commit('new-head', '2026-07-20T11:00:00Z')] }); + } + return null; }); - await poller.pollEndpoint('commits', qNoEtag, changedNoEtagFetch.fakeFetch, NOW); - const changedNoEtag = qNoEtag.getGithubCachePayload('commits'); - check('changed page one without an ETag fails closed and preserves the LKG', - changedNoEtag.payload === oldNoEtagPayload && changedNoEtag.lastErrorStatus === 409 && changedPageOneCalls === 2, - JSON.stringify(changedNoEtag)); - - let malformedCommitRejected = false; - try { - poller.aggregateCommits([commit('bad', 'not-a-date')], NOW, true); - } catch { - malformedCommitRejected = true; - } - check('complete commit aggregation rejects malformed dates instead of silently truncating', malformedCommitRejected); - - // A later-page failure must never overwrite a complete LKG with a prefix. - const oldCommitPayload = JSON.stringify({ - schema_version: 1, - total: 2, - last_30_days: 2, - history: [{ day_utc: '2026-07-19', total: 2 }], - history_complete: true, - as_of: '2026-07-19T12:00:00.000Z', + await poller.pollEndpoint('commits', fastDb.queries, fastFetch.fakeFetch, NOW); + const fastRow = fastDb.queries.getGithubCachePayload('commits'); + check('one-commit fast-forward uses two requests and atomically advances payload, ETag, and cursor', + fastFetch.calls.length === 2 && JSON.parse(fastRow.payload).total === 3 && + fastRow.etag === '"new-head-etag"' && fastRow.pollState.head_sha === 'new-head', + JSON.stringify({ calls: fastFetch.calls.map((call) => call.url), row: fastRow })); + + // Paginated compare merge validates unique cardinality and supports backdated author dates. + const pagedDb = openDb(); + seedRepo(poller, pagedDb.queries, NOW); + seedCommits(poller, pagedDb.queries); + const newCommits = Array.from({ length: 101 }, (_, index) => commit( + `new-${index}`, + index === 100 ? '2025-01-01T00:00:00Z' : '2026-07-20T00:00:00Z' + )); + const pagedFetch = makeFakeFetch((url) => { + if (url === headUrl()) return ok([commit('new-100', '2025-01-01T00:00:00Z')], { etag: '"paged-head"' }); + if (url.includes('/compare/old-head...new-100?page=1')) { + return ok( + { status: 'ahead', ahead_by: 101, behind_by: 0, commits: newCommits.slice(0, 100) }, + { link: '; rel="next"' } + ); + } + if (url.includes('/compare/old-head...new-100?page=2')) { + return ok({ status: 'ahead', ahead_by: 101, behind_by: 0, commits: newCommits.slice(100) }); + } + return null; }); - q4.upsertGithubCacheRow('commits', oldCommitPayload, '"old"', NOW - 1000, 200, 4500, 0, true, 1); - const partialFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/commits': (url) => { - const page = Number(url.match(/[?&]page=(\d+)/)?.[1]); - if (page === 1) { - return { status: 200, body: Array.from({ length: 100 }, (_, i) => commit(`new-${i}`, '2026-07-20T00:00:00Z')), etag: '"new"', rlRemaining: '4000', rlReset: '0' }; - } - return { status: 500, body: { message: 'boom' }, etag: null, rlRemaining: '3999', rlReset: '0' }; - }, + await poller.pollEndpoint('commits', pagedDb.queries, pagedFetch.fakeFetch, NOW); + const pagedPayload = JSON.parse(pagedDb.queries.getGithubCachePayload('commits').payload); + check('paginated fast-forward merges every unique compare commit', + pagedPayload.total === 103 && pagedFetch.calls.length === 3, JSON.stringify(pagedPayload)); + check('backdated compare commits are inserted into chronological cumulative history', + pagedPayload.history[0].day_utc === '2025-01-01' && + pagedPayload.history.at(-1).total === 103, JSON.stringify(pagedPayload.history)); + + // Any compare inconsistency fails closed with payload/ETag/cursor/fetched_at untouched. + const atomicDb = openDb(); + seedRepo(poller, atomicDb.queries, NOW); + seedCommits(poller, atomicDb.queries); + const beforeAtomic = atomicDb.queries.getGithubCachePayload('commits'); + const malformedCompare = makeFakeFetch((url) => { + if (url === headUrl()) return ok([commit('bad-head', '2026-07-20T00:00:00Z')], { etag: '"bad-head-etag"' }); + if (url.startsWith(`${COMPARE_URL}/old-head...bad-head?`)) { + return ok({ status: 'ahead', ahead_by: 2, behind_by: 0, commits: [commit('bad-head', '2026-07-20T00:00:00Z')] }); + } + return null; }); - await poller.pollEndpoint('commits', q4, partialFetch.fakeFetch, NOW); - const afterPartial = q4.getGithubCachePayload('commits'); - check('later-page failure preserves LKG payload and fetched_at', - afterPartial.payload === oldCommitPayload && afterPartial.fetchedAt === NOW - 1000 && afterPartial.isComplete, - JSON.stringify(afterPartial)); - check('later-page failure records status and retry metadata', - afterPartial.status === 500 && afterPartial.lastErrorStatus === 500 && afterPartial.consecutiveFailures === 1 && afterPartial.nextRetryAt === NOW + 300000, - JSON.stringify(afterPartial)); - - // A migrated row is incomplete and therefore cannot use its legacy ETag. - const db5 = new Database(':memory:'); - initializeDatabase(db5); - const q5 = new Queries(db5); - q5.upsertGithubCacheRow('commits', JSON.stringify([commit('legacy', '2025-01-01T00:00:00Z')]), '"legacy"', NOW - 1000, 200, 5000, 0, false, 0); - const healFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/commits': { - status: 200, - body: [commit('fresh', '2026-07-20T00:00:00Z')], - etag: '"fresh"', - rlRemaining: '4999', - rlReset: '0', - }, + await poller.pollEndpoint('commits', atomicDb.queries, malformedCompare.fakeFetch, NOW); + const afterAtomic = atomicDb.queries.getGithubCachePayload('commits'); + check('compare cardinality mismatch preserves payload, ETag, cursor, and fetched_at atomically', + afterAtomic.payload === beforeAtomic.payload && afterAtomic.etag === beforeAtomic.etag && + afterAtomic.fetchedAt === beforeAtomic.fetchedAt && + JSON.stringify(afterAtomic.pollState) === JSON.stringify(beforeAtomic.pollState) && + afterAtomic.lastErrorStatus === 502, + JSON.stringify(afterAtomic)); + + const wrongHeadDb = openDb(); + seedRepo(poller, wrongHeadDb.queries, NOW); + seedCommits(poller, wrongHeadDb.queries); + const beforeWrongHead = wrongHeadDb.queries.getGithubCachePayload('commits'); + const wrongHeadCompare = makeFakeFetch((url) => { + if (url === headUrl()) return ok([commit('expected-head', '2026-07-20T00:00:00Z')], { etag: '"expected-head"' }); + if (url.startsWith(`${COMPARE_URL}/old-head...expected-head?`)) { + return ok({ + status: 'ahead', ahead_by: 1, behind_by: 0, + commits: [commit('wrong-but-cardinality-matches', '2026-07-20T00:00:00Z')], + }); + } + return null; }); - await poller.pollEndpoint('commits', q5, healFetch.fakeFetch, NOW); - const healed = q5.getGithubCachePayload('commits'); - check('incomplete legacy cache does not send If-None-Match', - !Object.prototype.hasOwnProperty.call(healFetch.calls[0].options.headers, 'If-None-Match'), - JSON.stringify(healFetch.calls[0].options.headers)); - check('full traversal self-heals legacy cache to complete v1', - healed.isComplete && healed.payloadVersion === 1 && JSON.parse(healed.payload).history_complete === true, - JSON.stringify(healed)); - - // Restricted stargazer endpoint falls back to the authoritative summary and - // retains aggregate history without exposing identities. - const summaryPayload = poller.sanitizePublicPayload('repo-summary', repoSummary(3), { nowMs: NOW }); - q5.upsertGithubCacheRow('repo-summary', JSON.stringify(summaryPayload), '"summary"', NOW, 200, 5000, 0, true, 1); - const oldStars = { - ...stars1, - as_of: '2026-07-15T12:00:00.000Z', - }; - q5.upsertGithubCacheRow('stars', JSON.stringify(oldStars), '"stars-old"', NOW - 1000, 200, 5000, 0, true, 1); - const restrictedFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/stargazers': { - status: 403, - body: { message: 'collaborator access required' }, - etag: null, - rlRemaining: '4998', - rlReset: '0', - }, + await poller.pollEndpoint('commits', wrongHeadDb.queries, wrongHeadCompare.fakeFetch, NOW); + const afterWrongHead = wrongHeadDb.queries.getGithubCachePayload('commits'); + check('right-cardinality compare with the wrong head SHA preserves the complete LKG', + afterWrongHead.payload === beforeWrongHead.payload && + afterWrongHead.etag === beforeWrongHead.etag && + afterWrongHead.fetchedAt === beforeWrongHead.fetchedAt && + JSON.stringify(afterWrongHead.pollState) === JSON.stringify(beforeWrongHead.pollState) && + afterWrongHead.lastErrorStatus === 502, + JSON.stringify(afterWrongHead)); + + const authDb = openDb(); + seedRepo(poller, authDb.queries, NOW); + seedCommits(poller, authDb.queries); + const beforeAuth = authDb.queries.getGithubCachePayload('commits'); + const authFailure = makeFakeFetch((url) => { + if (url === headUrl()) return ok([commit('auth-head', '2026-07-20T00:00:00Z')], { etag: '"auth-head"' }); + if (url.startsWith(`${COMPARE_URL}/old-head...auth-head?`)) return { status: 403, body: {}, rlRemaining: '0', rlReset: '0' }; + return null; }); - process.env.GITHUB_TOKEN = 'ghp_restricted'; - const restrictedPoller = freshPoller(); - await restrictedPoller.pollEndpoint('stars', q5, restrictedFetch.fakeFetch, NOW); - const restricted = JSON.parse(q5.getGithubCachePayload('stars').payload); - check('restricted stars use repository-count fallback with current total', - restricted.source === 'repository-count' && restricted.total === 3 && restricted.history.at(-1).total === 3, - JSON.stringify(restricted)); - check('restricted stars remain aggregate-only', !/(alice|bob|"user"|login)/.test(JSON.stringify(restricted)), JSON.stringify(restricted)); - check('multi-day unknown star-count change downgrades history completeness', - restricted.history_complete === false, - JSON.stringify(restricted)); - const midnightFallback = restrictedPoller.starsFromRepositoryCount({ - ...stars1, - as_of: '2026-07-19T23:59:59.000Z', - }, 3, Date.parse('2026-07-20T00:00:01.000Z')); - check('star-count change across UTC midnight downgrades history completeness even across a short gap', - midnightFallback.history_complete === false, - JSON.stringify(midnightFallback)); - const sameDayFallback = restrictedPoller.starsFromRepositoryCount({ - ...stars1, - as_of: '2026-07-20T00:01:00.000Z', - }, 3, Date.parse('2026-07-20T00:02:00.000Z')); - check('repository-count change on the same day still marks historical distribution incomplete', - sameDayFallback.history_complete === false, - JSON.stringify(sameDayFallback)); - const unchangedFallback = restrictedPoller.starsFromRepositoryCount(stars1, stars1.total, NOW); - check('every repository-count fallback is explicitly history-incomplete, even when net total is unchanged', - unchangedFallback.history_complete === false, - JSON.stringify(unchangedFallback)); - check('401/403 stargazer restriction uses a six-hour retry cadence', - q5.getGithubCachePayload('stars').nextRetryAt === NOW + restrictedPoller.RESTRICTED_STARS_RETRY_MS, - JSON.stringify(q5.getGithubCachePayload('stars'))); - - let invalidStarEnvelopeRejected = false; - try { - restrictedPoller.normalizeStarsPayload({ - schema_version: 1, - total: 99, - history: [{ day_utc: '2026-07-20', total: 3 }], - history_complete: true, - source: 'stargazers', - as_of: '2026-07-20T12:00:00.000Z', - }, 99, NOW); - } catch { - invalidStarEnvelopeRejected = true; + await poller.pollEndpoint('commits', authDb.queries, authFailure.fakeFetch, NOW); + const afterAuth = authDb.queries.getGithubCachePayload('commits'); + check('compare authentication failure preserves the full last-known-good row', + afterAuth.payload === beforeAuth.payload && afterAuth.etag === beforeAuth.etag && + afterAuth.fetchedAt === beforeAuth.fetchedAt && + JSON.stringify(afterAuth.pollState) === JSON.stringify(beforeAuth.pollState) && + afterAuth.lastErrorStatus === 403, + JSON.stringify(afterAuth)); + + // Unavailable bases and rewritten history rebuild from the new immutable head. + for (const scenario of [ + { label: 'unavailable compare base', compare: { status: 404, body: {} } }, + { label: 'rewritten history', compare: ok({ status: 'diverged', ahead_by: 1, behind_by: 1, commits: [] }) }, + { label: 'nonzero compare behind count', compare: ok({ + status: 'ahead', ahead_by: 1, behind_by: 1, + commits: [commit('rewrite-head', '2026-07-20T00:00:00Z')], + }) }, + ]) { + const rebuiltDb = openDb(); + seedRepo(poller, rebuiltDb.queries, NOW); + seedCommits(poller, rebuiltDb.queries); + const rebuiltFetch = makeFakeFetch((url) => { + if (url === headUrl()) return ok([commit('rewrite-head', '2026-07-20T00:00:00Z')], { etag: '"rewrite-etag"' }); + if (url.startsWith(`${COMPARE_URL}/old-head...rewrite-head?`)) { + return { rlRemaining: '4800', rlReset: '0', ...scenario.compare }; + } + if (url.startsWith(`${COMMITS_URL}?sha=rewrite-head`)) { + return ok([ + commit('rewrite-head', '2026-07-20T00:00:00Z'), + commit('rewrite-root', '2026-07-10T00:00:00Z'), + ]); + } + return null; + }); + await poller.pollEndpoint('commits', rebuiltDb.queries, rebuiltFetch.fakeFetch, NOW); + const rebuilt = rebuiltDb.queries.getGithubCachePayload('commits'); + check(`${scenario.label} triggers a complete traversal pinned to the new head`, + JSON.parse(rebuilt.payload).total === 2 && rebuilt.pollState.head_sha === 'rewrite-head' && + rebuiltFetch.calls.some((call) => call.url.includes('?sha=rewrite-head')), + JSON.stringify(rebuiltFetch.calls.map((call) => call.url))); + rebuiltDb.db.close(); } - check('star envelope rejects a final history total inconsistent with total', invalidStarEnvelopeRejected); - - // A complete stargazer traversal is newer and more authoritative than an - // older repository summary. Its own terminal-page cardinality wins. - q5.upsertGithubCacheRow( - 'repo-summary', - JSON.stringify(restrictedPoller.sanitizePublicPayload('repo-summary', repoSummary(2), { nowMs: NOW })), - '"older-summary"', - NOW - 60000, - 200, - 5000, - 0, - true, - 1 - ); - q5.upsertGithubCacheRow('stars', JSON.stringify(oldStars), '"stars-before-new-walk"', NOW - 120000, 200, 5000, 0, true, 1); - const newerStarsFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/stargazers': { - status: 200, - body: [ - { starred_at: '2026-07-18T10:00:00Z' }, - { starred_at: '2026-07-19T10:00:00Z' }, - { starred_at: '2026-07-20T10:00:00Z' }, - ], - etag: '"newer-stars"', - rlRemaining: '4997', - rlReset: '0', - }, + + const branchDb = openDb(); + seedRepo(poller, branchDb.queries, NOW, 2, 'release'); + seedCommits(poller, branchDb.queries); + const branchFetch = makeFakeFetch((url, options) => { + if (url === headUrl('release')) return ok([commit('release-head', '2026-07-20T00:00:00Z')], { etag: '"release-etag"' }); + if (url.startsWith(`${COMMITS_URL}?sha=release-head`)) return ok([commit('release-head', '2026-07-20T00:00:00Z')]); + return null; }); - await restrictedPoller.pollEndpoint('stars', q5, newerStarsFetch.fakeFetch, NOW); - const newerStars = JSON.parse(q5.getGithubCachePayload('stars').payload); - check('newer complete stargazer walk is not overwritten by an older summary count', - newerStars.total === 3 && newerStars.history_complete === true && newerStars.source === 'stargazers', - JSON.stringify(newerStars)); - - const newerStarsRowBeforeFailure = q5.getGithubCachePayload('stars'); - await restrictedPoller.pollEndpoint('stars', q5, restrictedFetch.fakeFetch, NOW + 1000); - const newerStarsRowAfterFailure = q5.getGithubCachePayload('stars'); - check('an older repository summary cannot replace a newer star LKG after a restricted-endpoint failure', - newerStarsRowAfterFailure.payload === newerStarsRowBeforeFailure.payload && - newerStarsRowAfterFailure.fetchedAt === newerStarsRowBeforeFailure.fetchedAt && - newerStarsRowAfterFailure.lastErrorStatus === 403, - JSON.stringify(newerStarsRowAfterFailure)); - - // GitHub secondary rate limits communicate their own retry window. - const dbRetry = new Database(':memory:'); - initializeDatabase(dbRetry); - const qRetry = new Queries(dbRetry); - const retryFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/commits': { - status: 429, - body: { message: 'secondary rate limit' }, - retryAfter: '1200', - rlRemaining: '4999', - rlReset: '0', - }, + await poller.pollEndpoint('commits', branchDb.queries, branchFetch.fakeFetch, NOW); + const branchRow = branchDb.queries.getGithubCachePayload('commits'); + check('default-branch change bypasses compare and performs an unconditional pinned rebuild', + branchRow.pollState.branch === 'release' && branchFetch.calls.length === 2 && + branchFetch.calls[0].options.headers['If-None-Match'] === undefined && + !branchFetch.calls.some((call) => call.url.includes('/compare/')), + JSON.stringify(branchFetch.calls.map((call) => call.url))); + + // Missing state forces a full seed; a failed seed leaves the prior public snapshot available. + const missingStateDb = openDb(); + seedRepo(poller, missingStateDb.queries, NOW); + seedCommits(poller, missingStateDb.queries, { pollState: null }); + const beforeSeedFailure = missingStateDb.queries.getGithubCachePayload('commits'); + const seedFailure = makeFakeFetch((url) => { + if (url === headUrl()) return ok([commit('seed-head', '2026-07-20T00:00:00Z')], { etag: '"seed-etag"' }); + if (url.startsWith(`${COMMITS_URL}?sha=seed-head`)) return { status: 500, body: {}, rlRemaining: '4700', rlReset: '0' }; + return null; }); - await restrictedPoller.pollEndpoint('commits', qRetry, retryFetch.fakeFetch, NOW); - check('Retry-After extends persistent backoff beyond the default failure delay', - qRetry.getGithubCachePayload('commits').nextRetryAt === NOW + 1200000, - JSON.stringify(qRetry.getGithubCachePayload('commits'))); - - qRetry.upsertGithubCacheRow( - 'repo-summary', - JSON.stringify(restrictedPoller.sanitizePublicPayload('repo-summary', repoSummary(3), { nowMs: NOW })), - '"retry-summary"', - NOW, - 200, - 5000, - 0, - true, - 1 - ); - qRetry.upsertGithubCacheRow('stars', JSON.stringify(oldStars), '"retry-stars"', NOW - 1000, 200, 5000, 0, true, 1); - const starRateResetAt = NOW + 1800000; - const starRetryFetch = makeFakeFetch({ - 'https://api.github.com/repos/fullselfbrowsing/FSB/stargazers': { - status: 429, - body: { message: 'secondary rate limit' }, - retryAfter: '1200', - rlRemaining: '0', - rlReset: String(starRateResetAt / 1000), - }, + await poller.pollEndpoint('commits', missingStateDb.queries, seedFailure.fakeFetch, NOW); + const failedSeed = missingStateDb.queries.getGithubCachePayload('commits'); + check('failed first cursor seed preserves the pre-existing aggregate while state remains NULL', + failedSeed.payload === beforeSeedFailure.payload && failedSeed.fetchedAt === beforeSeedFailure.fetchedAt && + failedSeed.pollState === null && failedSeed.lastErrorStatus === 500, + JSON.stringify(failedSeed)); + + const laterPageDb = openDb(); + seedRepo(poller, laterPageDb.queries, NOW); + seedCommits(poller, laterPageDb.queries, { pollState: null }); + const beforeLaterPage = laterPageDb.queries.getGithubCachePayload('commits'); + const firstRebuildPage = [ + commit('long-head', '2026-07-20T00:00:00Z'), + ...Array.from({ length: 99 }, (_, index) => commit(`long-${index}`, '2026-07-19T00:00:00Z')), + ]; + const laterPageFailure = makeFakeFetch((url) => { + if (url === headUrl()) { + return ok([commit('long-head', '2026-07-20T00:00:00Z')], { etag: '"long-head-etag"' }); + } + if (url.includes('sha=long-head&page=1&per_page=100')) { + return ok(firstRebuildPage, { + link: '; rel="next"', + }); + } + if (url.includes('sha=long-head&page=2&per_page=100')) { + return { status: 500, body: {}, rlRemaining: '4600', rlReset: '0' }; + } + return null; }); - await restrictedPoller.pollEndpoint('stars', qRetry, starRetryFetch.fakeFetch, NOW); - const starRetryRow = qRetry.getGithubCachePayload('stars'); - check('repository-count fallback preserves the longest Retry-After/rate-reset instruction', - starRetryRow.nextRetryAt === starRateResetAt && JSON.parse(starRetryRow.payload).source === 'repository-count', - JSON.stringify(starRetryRow)); - - // Persistent backoff avoids hammering an unhealthy endpoint. - q5.recordGithubCacheFailureRow('commits', NOW, 500, null, null, NOW + 600000); - const skippedFetch = makeFakeFetch({}); - await restrictedPoller.pollEndpoint('commits', q5, skippedFetch.fakeFetch, NOW + 1000); - check('endpoint inside backoff window makes no request', skippedFetch.calls.length === 0, String(skippedFetch.calls.length)); - - db1.close(); - dbTokenless.close(); - db2.close(); - db3.close(); - db4.close(); - db5.close(); - dbPaging.close(); - dbNoEtag.close(); - dbRetry.close(); + await poller.pollEndpoint('commits', laterPageDb.queries, laterPageFailure.fakeFetch, NOW); + const afterLaterPage = laterPageDb.queries.getGithubCachePayload('commits'); + check('later-page pinned rebuild failure preserves LKG payload, cursor, ETag, and fetched_at', + afterLaterPage.payload === beforeLaterPage.payload && + afterLaterPage.etag === beforeLaterPage.etag && + afterLaterPage.fetchedAt === beforeLaterPage.fetchedAt && + JSON.stringify(afterLaterPage.pollState) === JSON.stringify(beforeLaterPage.pollState) && + afterLaterPage.lastErrorStatus === 500 && laterPageFailure.calls.length === 3, + JSON.stringify(afterLaterPage)); + + // Star total is the cheap detector; identity traversal is skipped while reconciled. + const starFastDb = openDb(); + seedRepo(poller, starFastDb.queries, NOW, 2); + seedStars(poller, starFastDb.queries, { fetchedAt: NOW - 60_000 }); + const noIdentityFetch = makeFakeFetch(() => null); + await poller.pollEndpoint('stars', starFastDb.queries, noIdentityFetch.fakeFetch, NOW); + const fastStars = starFastDb.queries.getGithubCachePayload('stars'); + check('unchanged complete star total under 24 hours skips the identity-list request', + noIdentityFetch.calls.length === 0 && fastStars.fetchedAt === NOW && + JSON.parse(fastStars.payload).history_complete === true, + JSON.stringify(fastStars)); + + const starChangedDb = openDb(); + seedRepo(poller, starChangedDb.queries, NOW, 3); + seedStars(poller, starChangedDb.queries, { fetchedAt: NOW - 60_000 }); + const changedStarsFetch = makeFakeFetch((url) => url.startsWith(`${STARS_URL}?`) + ? ok([ + star(1, '2026-07-18T00:00:00Z'), + star(2, '2026-07-19T00:00:00Z'), + star(3, '2026-07-20T00:00:00Z'), + ], { etag: '"stars-3"' }) + : null); + await poller.pollEndpoint('stars', starChangedDb.queries, changedStarsFetch.fakeFetch, NOW); + const changedStars = starChangedDb.queries.getGithubCachePayload('stars'); + check('changed repository total triggers a complete star reconciliation and advances state', + changedStarsFetch.calls.length === 1 && JSON.parse(changedStars.payload).total === 3 && + JSON.parse(changedStars.payload).history_complete === true && + changedStars.pollState.last_full_scan_at === NOW, + JSON.stringify(changedStars)); + + for (const scenario of [ + { label: 'missing star state', options: { pollState: null } }, + { label: 'incomplete star history', options: { historyComplete: false } }, + { label: 'daily star reconciliation', options: { fetchedAt: NOW - (24 * 60 * 60 * 1000) } }, + ]) { + const dueDb = openDb(); + seedRepo(poller, dueDb.queries, NOW, 2); + seedStars(poller, dueDb.queries, scenario.options); + const dueFetch = makeFakeFetch((url) => url.startsWith(`${STARS_URL}?`) + ? ok([star(1, '2026-07-18T00:00:00Z'), star(2, '2026-07-19T00:00:00Z')]) + : null); + await poller.pollEndpoint('stars', dueDb.queries, dueFetch.fakeFetch, NOW); + check(`${scenario.label} forces an atomic full stargazer walk`, + dueFetch.calls.length === 1 && dueDb.queries.getGithubCachePayload('stars').pollState.last_full_scan_at === NOW, + JSON.stringify(dueFetch.calls.map((call) => call.url))); + dueDb.db.close(); + } + + // Restricted tokens keep the same-tick total fresh while isolating history quality. + const restrictedDb = openDb(); + seedRepo(poller, restrictedDb.queries, NOW, 3); + seedStars(poller, restrictedDb.queries, { fetchedAt: NOW - (24 * 60 * 60 * 1000) }); + const previousRestrictedState = restrictedDb.queries.getGithubCachePayload('stars').pollState; + const restrictedFetch = makeFakeFetch((url) => url.startsWith(`${STARS_URL}?`) + ? { status: 403, body: {}, rlRemaining: '4990', rlReset: '0' } + : null); + await poller.pollEndpoint('stars', restrictedDb.queries, restrictedFetch.fakeFetch, NOW); + const restrictedRow = restrictedDb.queries.getGithubCachePayload('stars'); + const restrictedPayload = JSON.parse(restrictedRow.payload); + check('restricted token fallback retains the fresh repository total and marks only history incomplete', + restrictedRow.fetchedAt === NOW && restrictedPayload.total === 3 && + restrictedPayload.source === 'repository-count' && restrictedPayload.history_complete === false, + JSON.stringify(restrictedRow)); + check('restricted token fallback preserves private reconciliation state and sets a six-hour retry', + JSON.stringify(restrictedRow.pollState) === JSON.stringify(previousRestrictedState) && + restrictedRow.status === 403 && restrictedRow.lastErrorStatus === 403 && + restrictedRow.consecutiveFailures === 1 && + restrictedRow.nextRetryAt === NOW + poller.RESTRICTED_STARS_RETRY_MS, + JSON.stringify(restrictedRow)); + check('restricted fallback never persists raw stargazer identities', + !/(private-|"user"|login)/.test(restrictedRow.payload), restrictedRow.payload); + + // Real star-list failures retain health metadata while count-only snapshots + // continue to refresh during backoff. Repeated failures then back off + // exponentially from the retained consecutive-failure count. + const repeatedStarDb = openDb(); + seedRepo(poller, repeatedStarDb.queries, NOW, 2); + seedStars(poller, repeatedStarDb.queries, { fetchedAt: NOW - (24 * 60 * 60 * 1000) }); + const star500 = makeFakeFetch((url) => url.startsWith(`${STARS_URL}?`) + ? { status: 500, body: {}, rlRemaining: '4700', rlReset: '0' } + : null); + await poller.pollEndpoint('stars', repeatedStarDb.queries, star500.fakeFetch, NOW); + const firstStarFailure = repeatedStarDb.queries.getGithubCachePayload('stars'); + check('first star 5xx keeps the repository-count snapshot fresh and records failure metadata', + firstStarFailure.fetchedAt === NOW && firstStarFailure.lastAttemptAt === NOW && + firstStarFailure.status === 500 && firstStarFailure.lastErrorStatus === 500 && + firstStarFailure.consecutiveFailures === 1 && firstStarFailure.nextRetryAt === NOW + 300_000 && + JSON.parse(firstStarFailure.payload).source === 'repository-count', + JSON.stringify(firstStarFailure)); + + const insideBackoffAt = NOW + 60_000; + seedRepo(poller, repeatedStarDb.queries, insideBackoffAt, 3); + const noStarRetry = makeFakeFetch(() => null); + await poller.pollEndpoint('stars', repeatedStarDb.queries, noStarRetry.fakeFetch, insideBackoffAt); + const insideStarBackoff = repeatedStarDb.queries.getGithubCachePayload('stars'); + check('star tick inside backoff refreshes count but preserves all failure and retry metadata', + noStarRetry.calls.length === 0 && insideStarBackoff.fetchedAt === insideBackoffAt && + JSON.parse(insideStarBackoff.payload).total === 3 && + insideStarBackoff.lastAttemptAt === NOW && insideStarBackoff.lastErrorStatus === 500 && + insideStarBackoff.consecutiveFailures === 1 && insideStarBackoff.nextRetryAt === NOW + 300_000, + JSON.stringify(insideStarBackoff)); + + const secondFailureAt = NOW + 300_000; + seedRepo(poller, repeatedStarDb.queries, secondFailureAt, 4); + const secondStar500 = makeFakeFetch((url) => url.startsWith(`${STARS_URL}?`) + ? { status: 500, body: {}, rlRemaining: '4600', rlReset: '0' } + : null); + await poller.pollEndpoint('stars', repeatedStarDb.queries, secondStar500.fakeFetch, secondFailureAt); + const secondStarFailure = repeatedStarDb.queries.getGithubCachePayload('stars'); + check('repeated star 5xx increments failure count and applies exponential retry delay', + secondStarFailure.fetchedAt === secondFailureAt && JSON.parse(secondStarFailure.payload).total === 4 && + secondStarFailure.lastAttemptAt === secondFailureAt && secondStarFailure.lastErrorStatus === 500 && + secondStarFailure.consecutiveFailures === 2 && + secondStarFailure.nextRetryAt === secondFailureAt + 600_000, + JSON.stringify(secondStarFailure)); + + // Tokenless deployments use the same count-only fallback without calling the restricted API. + delete process.env.GITHUB_TOKEN; + const tokenlessPoller = freshPoller(); + const tokenlessDb = openDb(); + seedRepo(tokenlessPoller, tokenlessDb.queries, NOW, 4); + const tokenlessFetch = makeFakeFetch(() => null); + await tokenlessPoller.pollEndpoint('stars', tokenlessDb.queries, tokenlessFetch.fakeFetch, NOW); + const tokenless = JSON.parse(tokenlessDb.queries.getGithubCachePayload('stars').payload); + check('tokenless star poll uses repository count without an identity-list request', + tokenlessFetch.calls.length === 0 && tokenless.total === 4 && + tokenless.source === 'repository-count' && tokenless.history_complete === false, + JSON.stringify(tokenless)); + + // Persistent backoff prevents commit endpoint hammering. + process.env.GITHUB_TOKEN = 'github_pat_test'; + poller = freshPoller(); + const backoffDb = openDb(); + seedRepo(poller, backoffDb.queries, NOW); + seedCommits(poller, backoffDb.queries); + backoffDb.queries.recordGithubCacheFailureRow('commits', NOW, 500, null, null, NOW + 600_000); + const skippedFetch = makeFakeFetch(() => null); + await poller.pollEndpoint('commits', backoffDb.queries, skippedFetch.fakeFetch, NOW + 1_000); + check('commit endpoint inside persistent backoff makes no request', skippedFetch.calls.length === 0); + + for (const holder of [ + happyDb, conditionalDb, dependencyDb, fastDb, pagedDb, atomicDb, wrongHeadDb, authDb, branchDb, + missingStateDb, laterPageDb, starFastDb, starChangedDb, restrictedDb, tokenlessDb, backoffDb, + repeatedStarDb, + ]) holder.db.close(); + + delete process.env.GITHUB_TOKEN; console.warn = originalWarn; console.error = originalError; diff --git a/tests/stats-chart-overhaul.test.js b/tests/stats-chart-overhaul.test.js index 1f9e85e0a..495238ff8 100644 --- a/tests/stats-chart-overhaul.test.js +++ b/tests/stats-chart-overhaul.test.js @@ -14,9 +14,9 @@ * exists in stats-page.component.ts.) * * Suite B -- Active Now globe lifecycle: - * Static guards for the Stats page globe: view switches defer redraw by one - * animation frame, pending redraws are canceled on teardown, and the globe - * requestAnimationFrame loop runs outside Angular change detection. + * Static guards for the Stats page globe: view switches defer redraw until + * Angular finishes rendering, pending callbacks are canceled on teardown, + * and visualization work runs outside Angular change detection. * * Suite C -- globe service + sitemaps PRNG (review-fix guards): * Static guards that the coastline GeoJSON is cached across setupGlobe() @@ -226,6 +226,16 @@ function commitPunchcardLocal(commits) { const destroyBlock = destroyStart >= 0 && destroyEnd > destroyStart ? src.slice(destroyStart, destroyEnd) : ''; + const scheduleStart = src.indexOf(' private scheduleViewRedraw('); + const scheduleEnd = src.indexOf(' private cancelPendingViewRedraw(', scheduleStart); + const scheduleBlock = scheduleStart >= 0 && scheduleEnd > scheduleStart + ? src.slice(scheduleStart, scheduleEnd) + : ''; + const cancelStart = scheduleEnd; + const cancelEnd = src.indexOf(' private redrawChart(', cancelStart); + const cancelBlock = cancelStart >= 0 && cancelEnd > cancelStart + ? src.slice(cancelStart, cancelEnd) + : ''; check('stats globe: component source file readable', src.length > 0, @@ -242,12 +252,24 @@ function commitPunchcardLocal(commits) { check('stats globe: setView no longer redraws synchronously', !setViewBlock.includes('this.redrawChart();'), 'setView(...) still calls redrawChart() synchronously'); - check('stats globe: scheduleViewRedraw uses requestAnimationFrame', - /pendingViewRedrawFrame\s*=\s*window\.requestAnimationFrame/.test(src), - 'scheduleViewRedraw() does not use window.requestAnimationFrame'); - check('stats globe: pending frame cancellation uses cancelAnimationFrame', - /window\.cancelAnimationFrame\(\s*this\.pendingViewRedrawFrame\s*\)/.test(src), - 'cancelPendingViewRedraw() does not cancel pendingViewRedrawFrame'); + check('stats visualizations: post-render callback types are imported and Injector is injected', + /\bAfterRenderRef\b/.test(src) && /\bInjector\b/.test(src) && + /inject\(\s*Injector\s*\)/.test(src), + 'AfterRenderRef/Injector import or injected Injector is missing'); + check('stats visualizations: redraw coalescing uses Angular afterNextRender', + scheduleBlock.includes('this.cancelPendingViewRedraw();') && + /pendingViewRedraw\s*=\s*afterNextRender\s*\(/.test(scheduleBlock) && + /injector\s*:\s*this\.injector/.test(scheduleBlock), + 'scheduleViewRedraw() does not replace the prior callback and register afterNextRender with the component Injector'); + check('stats visualizations: post-render work runs outside Angular', + /zone\.runOutsideAngular\(\s*\(\)\s*=>\s*(?:\{\s*)?this\.redrawChart\(\)/.test(scheduleBlock), + 'the afterNextRender callback does not redraw outside Angular'); + check('stats visualizations: raw animation-frame scheduler is absent', + !/window\.(?:request|cancel)AnimationFrame/.test(scheduleBlock + cancelBlock), + 'scheduleViewRedraw()/cancelPendingViewRedraw() still use raw animation-frame APIs'); + check('stats visualizations: pending post-render callback is destroyable', + /pendingViewRedraw(?:\?\.|\.)destroy\(\)/.test(cancelBlock), + 'cancelPendingViewRedraw() does not destroy the pending AfterRenderRef'); check('stats globe: ngOnDestroy cancels pending view redraw', destroyBlock.includes('this.cancelPendingViewRedraw();'), 'ngOnDestroy() does not call cancelPendingViewRedraw()'); @@ -330,15 +352,13 @@ function commitPunchcardLocal(commits) { check('stats FSB summary: stale active snapshots are not labelled right now', /@if\s*\(isFsbActiveSnapshotCurrent\)[\s\S]*SHOWCASE_STATS_FSB_HEADLINE_ACTIVE/.test(html), 'active-right-now copy is not gated on a current active snapshot'); - check('stats FSB metrics: averages use reporting users without visible reported copy', + check('stats FSB metrics: averages use reporting users', src.includes('avg_agents_per_reporting_user') && src.includes('reportingUsers > 0') && src.includes('avg/reporting user') && src.includes('tokens (24h)') && - html.includes('tokens (24h)') && - !/\$localize`[^`]*reported/i.test(src) && - !/>\s*reported/i.test(html), - 'visible Stats copy still contains the reported qualifier'); + html.includes('tokens (24h)'), + 'Stats metrics do not use reporting-user denominators'); check('stats FSB metrics: agent-days use only the active-v2 history', src.includes('agent_days_since_active_v2') && !/formattedFsbAgentDays[\s\S]{0,300}(?:agent_days_lifetime|total_agents_lifetime)/.test(src) && diff --git a/tests/stats-view-state.test.js b/tests/stats-view-state.test.js index 9d0d35025..694b3a91e 100644 --- a/tests/stats-view-state.test.js +++ b/tests/stats-view-state.test.js @@ -16,7 +16,18 @@ const SERVICE_PATH = path.join( '..', 'showcase/angular/src/app/core/stats/github-stats.service.ts' ); +const FSB_SERVICE_PATH = path.join( + __dirname, + '..', + 'showcase/angular/src/app/core/stats/fsb-telemetry.service.ts' +); +const FETCH_ERROR_PATH = path.join( + __dirname, + '..', + 'showcase/angular/src/app/core/stats/stats-fetch-error.ts' +); const serviceSource = fs.readFileSync(SERVICE_PATH, 'utf8'); +const fsbServiceSource = fs.readFileSync(FSB_SERVICE_PATH, 'utf8'); const compiled = ts.transpileModule(fs.readFileSync(MODEL_PATH, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, @@ -30,8 +41,26 @@ new Function('module', 'exports', 'require', compiled.outputText)( production.exports, require ); +const fetchErrorCompiled = ts.transpileModule(fs.readFileSync(FETCH_ERROR_PATH, 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: FETCH_ERROR_PATH, +}); +const fetchErrorProduction = { exports: {} }; +new Function('module', 'exports', 'require', fetchErrorCompiled.outputText)( + fetchErrorProduction, + fetchErrorProduction.exports, + require +); const { + ACTIVE_FRESHNESS_SLA_MS, + AGGREGATE_FRESHNESS_SLA_MS, + GITHUB_FRESHNESS_SLA_MS, + MAX_FUTURE_SKEW_MS, + STATS_HARD_LIMIT_MS, activeSnapshotDatasetState, aggregateDatasetState, initialStatsSourceStates, @@ -42,6 +71,26 @@ const { statsResponseFreshness, updateStatsSourceState, } = production.exports; +const { + availabilityAfterFailure, + httpStatsFetchError, + retryAfterTimestamp, +} = fetchErrorProduction.exports; + +const NOW = Date.parse('2026-07-20T12:00:00.000Z'); + +function ready(data, snapshotAt = NOW, options = {}) { + return { + kind: 'ready', + data, + availability: { + snapshotAt, + checkedAt: options.checkedAt ?? NOW, + upstreamStatus: options.upstreamStatus ?? '200', + ...(options.nextRetryAt === undefined ? {} : { nextRetryAt: options.nextRetryAt }), + }, + }; +} const starPayload = { schema_version: 1, @@ -57,36 +106,16 @@ const starPayload = { test('one successful response cannot mask the selected dataset error', () => { let states = initialStatsSourceStates(); - states = updateStatsSourceState(states, 'commits', { - kind: 'ready', data: { sentinel: 'commits' }, fetchedAt: 20, - }); + states = updateStatsSourceState(states, 'commits', ready({ sentinel: 'commits' })); states = updateStatsSourceState(states, 'stars', { kind: 'error', message: 'stars failed', }); - assert.deepEqual(selectedStatsViewState('stars-cumulative', states), { + assert.deepEqual(selectedStatsViewState('stars-cumulative', states, NOW), { kind: 'error', message: 'stars failed', }); - assert.deepEqual(selectedStatsViewState('commits-cumulative', states), { - kind: 'ready', fetchedAt: 20, - }); -}); - -test('stale prior data is scoped to the GitHub view that consumes its source', () => { - let states = initialStatsSourceStates(); - states = updateStatsSourceState(states, 'stars', { - kind: 'stale', - data: { sentinel: 'old stars' }, - fetchedAt: 10, - message: 'upstream unavailable', - }); - states = updateStatsSourceState(states, 'commits', { - kind: 'ready', data: { sentinel: 'commits' }, fetchedAt: 30, - }); - - assert.equal(selectedStatsViewState('stars-cumulative', states).kind, 'stale'); - assert.deepEqual(selectedStatsViewState('commits-cumulative', states), { - kind: 'ready', fetchedAt: 30, + assert.deepEqual(selectedStatsViewState('commits-cumulative', states, NOW), { + kind: 'ready', snapshotAt: NOW, checkedAt: NOW, }); }); @@ -102,99 +131,196 @@ test('source state has no retired Issues dataset', () => { test('a new route visit resets retained singleton snapshots to loading', () => { let previousVisit = initialStatsSourceStates(); - previousVisit = updateStatsSourceState(previousVisit, 'stars', { - kind: 'ready', data: { sentinel: 'previous visit' }, fetchedAt: 10, - }); - assert.equal(selectedStatsViewState('stars-cumulative', previousVisit).kind, 'ready'); - assert.equal(selectedStatsViewState('stars-cumulative', initialStatsSourceStates()).kind, 'loading'); + previousVisit = updateStatsSourceState( + previousVisit, + 'stars', + ready({ sentinel: 'previous visit' }) + ); + assert.equal(selectedStatsViewState('stars-cumulative', previousVisit, NOW).kind, 'ready'); + assert.equal( + selectedStatsViewState('stars-cumulative', initialStatsSourceStates(), NOW).kind, + 'loading' + ); }); test('independent response ordering keeps a multi-source view honest', () => { let states = initialStatsSourceStates(); - states = updateStatsSourceState(states, 'fsb-headline', { - kind: 'ready', data: { sentinel: 'headline first' }, fetchedAt: 50, - }); - assert.equal(selectedStatsViewState('fsb-tokens', states).kind, 'loading'); + states = updateStatsSourceState(states, 'fsb-headline', ready({ sentinel: 'headline first' })); + assert.equal(selectedStatsViewState('fsb-tokens', states, NOW).kind, 'loading'); states = updateStatsSourceState(states, 'fsb-series', { kind: 'error', message: 'series failed later', }); - assert.deepEqual(selectedStatsViewState('fsb-tokens', states), { + assert.deepEqual(selectedStatsViewState('fsb-tokens', states, NOW), { kind: 'error', message: 'series failed later', }); }); -test('Active view cannot be Live until both the snapshot and regional aggregate are fresh', () => { +test('Active now requires only the current request-time snapshot', () => { let states = initialStatsSourceStates(); - states = updateStatsSourceState(states, 'fsb-active', { - kind: 'ready', data: { sentinel: 'request-time active snapshot' }, fetchedAt: 60, - }); - assert.equal(selectedStatsViewState('fsb-active-now', states).kind, 'loading'); + states = updateStatsSourceState(states, 'fsb-active', ready({ active_users_now: 32 })); - states = updateStatsSourceState(states, 'fsb-headline', { - kind: 'stale', - data: { sentinel: 'old regional aggregate' }, - fetchedAt: 20, - message: 'aggregate is old', - }); - assert.deepEqual(selectedStatsViewState('fsb-active-now', states), { - kind: 'stale', fetchedAt: 20, message: 'aggregate is old', + assert.deepEqual(selectedStatsViewState('fsb-active-now', states, NOW), { + kind: 'ready', snapshotAt: NOW, checkedAt: NOW, }); states = updateStatsSourceState(states, 'fsb-headline', { - kind: 'ready', data: { sentinel: 'fresh regional aggregate' }, fetchedAt: 55, - }); - assert.deepEqual(selectedStatsViewState('fsb-active-now', states), { - kind: 'ready', fetchedAt: 55, + kind: 'error', message: 'regional aggregate missing', }); + assert.equal(selectedStatsViewState('fsb-active-now', states, NOW).kind, 'ready'); }); -test('partial history propagates to the selected view instead of becoming Live', () => { +test('history completeness and a failed retry do not downgrade a fresh GitHub snapshot', () => { let states = initialStatsSourceStates(); - states = updateStatsSourceState(states, 'commits', { + states = updateStatsSourceState(states, 'stars', ready( + { ...starPayload, history_complete: false }, + NOW - 5 * 60 * 1000, + { + checkedAt: NOW, + upstreamStatus: '403', + nextRetryAt: NOW + 60_000, + } + )); + + assert.equal(selectedStatsViewState('stars-cumulative', states, NOW).kind, 'ready'); +}); + +test('GitHub snapshots cross the 15-minute and 24-hour age boundaries', () => { + const stateForAge = (age) => { + let states = initialStatsSourceStates(); + states = updateStatsSourceState(states, 'stars', ready(starPayload, NOW - age)); + return selectedStatsViewState('stars-cumulative', states, NOW).kind; + }; + + assert.equal(stateForAge(GITHUB_FRESHNESS_SLA_MS), 'ready'); + assert.equal(stateForAge(GITHUB_FRESHNESS_SLA_MS + 1), 'partial'); + assert.equal(stateForAge(STATS_HARD_LIMIT_MS), 'partial'); + assert.equal(stateForAge(STATS_HARD_LIMIT_MS + 1), 'error'); +}); + +test('snapshot timestamps over five minutes in the future are unknown and Partial', () => { + const stateForTimestamp = (snapshotAt) => { + let states = initialStatsSourceStates(); + states = updateStatsSourceState(states, 'stars', ready(starPayload, snapshotAt)); + return selectedStatsViewState('stars-cumulative', states, NOW); + }; + + assert.equal(stateForTimestamp(NOW + MAX_FUTURE_SKEW_MS).kind, 'ready'); + assert.deepEqual(stateForTimestamp(NOW + MAX_FUTURE_SKEW_MS + 1), { kind: 'partial', - data: { sentinel: 'truncated history' }, - fetchedAt: 40, - message: 'incomplete', - }); - assert.deepEqual(selectedStatsViewState('commits-cumulative', states), { - kind: 'partial', fetchedAt: 40, message: 'incomplete', + snapshotAt: null, + checkedAt: NOW, + message: 'One or more required snapshots are outside the freshness window.', }); }); -test('FSB aggregate timestamp distinguishes ready, partial, and stale snapshots', () => { - const now = Date.parse('2026-07-20T12:00:00.000Z'); - assert.equal(aggregateDatasetState({ aggregate_updated_at: null }, now, now).kind, 'partial'); - assert.equal(aggregateDatasetState({ - aggregate_updated_at: '2026-07-20T09:59:59.000Z', - }, now, now).kind, 'stale'); - assert.equal(aggregateDatasetState({ - aggregate_updated_at: '2026-07-20T10:00:00.000Z', - }, now, now).kind, 'ready'); +test('an unknown aggregate timestamp remains usable but Partial', () => { + const transformed = aggregateDatasetState( + { aggregate_updated_at: null, tokens_total_lifetime: 0 }, + ready(null).availability + ); + assert.equal(transformed.kind, 'ready'); + assert.equal(transformed.availability.snapshotAt, null); + + let states = initialStatsSourceStates(); + states = updateStatsSourceState(states, 'fsb-headline', transformed); + assert.equal(selectedStatsViewState('fsb-popular-mcp', states, NOW).kind, 'partial'); +}); + +test('Tokens and Popular use the two-hour aggregate boundary', () => { + const stateFor = (view, age) => { + let states = initialStatsSourceStates(); + states = updateStatsSourceState(states, 'fsb-headline', ready({}, NOW - age)); + states = updateStatsSourceState(states, 'fsb-series', ready({}, NOW - age)); + return selectedStatsViewState(view, states, NOW).kind; + }; + + for (const view of ['fsb-tokens', 'fsb-popular-mcp']) { + assert.equal(stateFor(view, AGGREGATE_FRESHNESS_SLA_MS), 'ready'); + assert.equal(stateFor(view, AGGREGATE_FRESHNESS_SLA_MS + 1), 'partial'); + assert.equal(stateFor(view, STATS_HARD_LIMIT_MS + 1), 'error'); + } +}); + +test('Active freshness is age-based and ignores agent-reporting coverage', () => { + const active = { + generated_at: new Date(NOW).toISOString(), + active_users_now: 32, + active_agents_reporting_users_now: 0, + }; + const transformed = activeSnapshotDatasetState( + active, + ready(null, NOW).availability + ); + assert.equal(transformed.kind, 'ready'); + + let states = initialStatsSourceStates(); + states = updateStatsSourceState(states, 'fsb-active', transformed); + assert.equal( + selectedStatsViewState('fsb-active-now', states, NOW + ACTIVE_FRESHNESS_SLA_MS).kind, + 'ready' + ); + assert.equal( + selectedStatsViewState('fsb-active-now', states, NOW + ACTIVE_FRESHNESS_SLA_MS + 1).kind, + 'partial' + ); +}); + +test('Active with a missing or invalid generated_at remains usable but Partial', () => { + for (const generated_at of [undefined, 'not-a-date']) { + const transformed = activeSnapshotDatasetState( + { generated_at, active_users_now: 4 }, + ready(null, null).availability + ); + let states = initialStatsSourceStates(); + states = updateStatsSourceState(states, 'fsb-active', transformed); + assert.equal(selectedStatsViewState('fsb-active-now', states, NOW).kind, 'partial'); + } + assert.match(fsbServiceSource, /snapshotAt:\s*generatedAt\(value\)/); + assert.doesNotMatch(fsbServiceSource, /snapshotAt:\s*generatedAt\(value\)\s*\?\?/); }); -test('v2 active snapshots are Partial until every active user reports an agent count', () => { - const checkedAt = Date.parse('2026-07-20T12:00:00.000Z'); - assert.equal(activeSnapshotDatasetState({ - active_users_now: 4, - active_agents_reporting_users_now: 3, - active_count_version: 2, - active_metric_semantics: 'reported_registry_count_v2', - }, checkedAt).kind, 'partial'); - assert.equal(activeSnapshotDatasetState({ - active_users_now: 4, - active_agents_reporting_users_now: 4, - active_count_version: 2, - active_metric_semantics: 'reported_registry_count_v2', - }, checkedAt).kind, 'ready'); - assert.equal(activeSnapshotDatasetState({ - active_users_now: 4, - active_agents_reporting_users_now: 4, - active_count_version: 2, - }, checkedAt).kind, 'partial'); - assert.equal(activeSnapshotDatasetState({ - active_users_now: 0, - }, checkedAt).kind, 'partial'); +test('failed HTTP refreshes preserve status and Retry-After metadata', () => { + const checkedAt = NOW; + assert.equal(retryAfterTimestamp('45', checkedAt), checkedAt + 45_000); + assert.equal( + retryAfterTimestamp(new Date(checkedAt + 90_000).toUTCString(), checkedAt), + checkedAt + 90_000 + ); + + const failure = httpStatsFetchError('temporarily unavailable', 503, '45', checkedAt); + assert.deepEqual( + availabilityAfterFailure( + { snapshotAt: checkedAt - 60_000, checkedAt: checkedAt - 30_000, upstreamStatus: '200' }, + failure + ), + { + snapshotAt: checkedAt - 60_000, + checkedAt, + upstreamStatus: '503', + nextRetryAt: checkedAt + 45_000, + } + ); +}); + +test('client-side failures use request-error without inventing a retry time', () => { + assert.deepEqual( + availabilityAfterFailure( + { + snapshotAt: NOW, + checkedAt: NOW, + upstreamStatus: '403', + nextRetryAt: NOW + 10_000, + }, + new TypeError('network down'), + NOW + 1_000 + ), + { + snapshotAt: NOW, + checkedAt: NOW + 1_000, + upstreamStatus: 'request-error', + } + ); }); test('rolling seven-day stars uses UTC calendar days and preserves unstars', () => { @@ -228,21 +354,38 @@ test('aggregate normalizers reject inconsistent cross-field totals', () => { }), /inconsistent/); }); -test('freshness metadata preserves the server timestamp for 200/304 handling', () => { +test('freshness headers include checked time, upstream status, and retry time', () => { const values = new Map([ ['x-fsb-stats-cache', 'stale'], ['x-fsb-stats-fetched-at', '2026-07-20T11:00:00.000Z'], + ['x-fsb-stats-checked-at', '2026-07-20T11:05:00.000Z'], + ['x-fsb-stats-upstream-status', '403'], + ['x-fsb-stats-next-retry-at', '2026-07-20T11:10:00.000Z'], ]); assert.deepEqual( statsResponseFreshness({ get: (name) => values.get(name) ?? null }), { cacheState: 'stale', - fetchedAt: Date.parse('2026-07-20T11:00:00.000Z'), + snapshotAt: Date.parse('2026-07-20T11:00:00.000Z'), + checkedAt: Date.parse('2026-07-20T11:05:00.000Z'), + upstreamStatus: '403', + nextRetryAt: Date.parse('2026-07-20T11:10:00.000Z'), } ); }); -test('GitHub service applies freshness metadata on both 200 and 304 paths', () => { +test('freshness parser rejects incomplete metadata', () => { + const values = new Map([ + ['x-fsb-stats-cache', 'fresh'], + ['x-fsb-stats-fetched-at', '2026-07-20T11:00:00.000Z'], + ]); + assert.throws( + () => statsResponseFreshness({ get: (name) => values.get(name) ?? null }), + /freshness metadata/ + ); +}); + +test('GitHub service preserves response metadata on both 200 and 304 paths', () => { assert.match( serviceSource, /response\.status\s*===\s*304[\s\S]*statsResponseFreshness\(response\.headers\)/ @@ -251,8 +394,10 @@ test('GitHub service applies freshness metadata on both 200 and 304 paths', () = serviceSource, /return\s*\{\s*data:\s*parsed,\s*\.\.\.statsResponseFreshness\(response\.headers\)\s*\}/ ); - assert.match(serviceSource, /result\.cacheState\s*===\s*'stale'/); - assert.match(serviceSource, /fetchedAt:\s*result\.fetchedAt/); + assert.match(serviceSource, /availability:\s*availabilityFromResponse\(result\)/); + assert.doesNotMatch(serviceSource, /result\.cacheState\s*===/); + assert.match(serviceSource, /httpStatsFetchError\(/); + assert.match(serviceSource, /availabilityAfterFailure\(previous\.availability, err\)/); }); test('GitHub service fetches only datasets used by the page and resets on start', () => {