From d2a07d598859a49b9d8cba4155f72928bf8407a0 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 28 Sep 2026 14:55:00 -0400 Subject: [PATCH] First public release Co-Authored-By: Claude Opus 5.5 --- .editorconfig | 12 + .gitignore | 8 + .gitlab-ci.yml | 6 + CHANGELOG.md | 15 + CONTRIBUTING.md | 22 + LICENSE | 21 + README.md | 97 +++ SECURITY.md | 6 + package-lock.json | 1504 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 46 ++ src/index.ts | 3 + src/report.ts | 108 ++++ src/sla.ts | 164 +++++ test/sla.test.ts | 243 ++++++++ tsconfig.cjs.json | 9 + tsconfig.json | 8 + 16 files changed, 2272 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/report.ts create mode 100644 src/sla.ts create mode 100644 test/sla.test.ts create mode 100644 tsconfig.cjs.json create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f15441a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fd8e1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env* +!.env.example +.npmrc +dist-cjs/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e5a37d2 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,6 @@ +stages: [test] + +test: + stage: test + image: node:22-alpine + script: [npm ci, npm run build, npm test] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c2e56dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 0.3.0 - 2026-09-28 + +First public release under MIT. + +- `slaReport()` computes availability over a half-open window, charges + downtime per failing sample capped at one probe interval, and excludes + scheduled maintenance. +- Zero samples gives `uptime_pct: null`, never 100. No target gives + `met: null`, never a pass or a failure. +- `toReport()` and `formatSla()` produce an `@extant2000/evidence-record` + report. Zero samples with a target is not assessed, not a missed SLA. +- `describeAvailability()` and `describeTarget()` put the null cases into + plain sentences. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b73fe45 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# Contributing + +Issues and merge requests are welcome at +https://gitlab.com/extant2000/proof-of-up. + +## Ground rules + +- **A test that cannot fail proves nothing.** If you fix a bug, add a test + that fails without your fix, and check that it does fail before you submit. +- Measure, do not assume. Two modules with the same line count can be + different programs. +- Keep dependencies minimal. Every new dependency needs a reason. +- Explain why in comments, not what. + +## Before you open a merge request + +Run both of these and make sure they pass: + +``` +npm run build +npm test +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34e49b1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Extant 2000 LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e0400c --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# ProofOfUp + +Computes availability for uptime and SLA statements from probe samples, and +refuses to report a number the samples do not support. It is used in Hitt +Hosting products. + +## What it does and why + +An uptime figure is only as good as the samples behind it, and the common +failures all flatter the service. ProofOfUp computes availability over +samples you have already loaded and handles the edge cases in the direction +of saying less. + +**Zero samples is not 100%.** With no samples, `uptime_pct` is `null`, and +the report says availability cannot be computed. A statement that answers +"100%" when nothing was measured is worse than one that abstains. It also +does not say the SLA was missed: when a target applies but nothing was +measured, the report is not assessed and states that the target was neither +met nor missed. (The raw `met` field stays `false` in that case.) + +"No SLA target" is not "SLA met". With no target, `met` is `null` and the +report says no SLA target applies to this plan, so a free plan never gets a +statement that reads as a failure. + +The rest of the arithmetic: + +- the window is half-open, `[from, to)`, so adjacent monthly statements never + count a boundary sample twice; +- samples outside the window or with bad timestamps are dropped, not counted; +- each failing sample is charged the gap to the next sample, capped at one + probe interval (300 seconds by default), so a long reporting gap after one + failure cannot inflate downtime; the last sample is charged one interval; +- samples inside scheduled maintenance windows are removed from the math as if + the probe never ran; cancelled windows are ignored, overlapping windows are + merged, and windows are clipped to the period; +- uptime is rounded to one decimal, and the result does not depend on the + order of the input. + +The functions are pure, with no I/O and no clock, so the same samples always +give the same numbers. That is what makes an availability figure evidence and +not a reading taken at a moment. Reports use the `@extant2000/evidence-record` +format. The availability citation carries its sample count, so 100% over zero +samples cannot be mistaken for 100% over thousands, and excluded maintenance +is reported as not applicable with its own measurement so planned downtime +stays visible. + +## Usage + +```ts +import { slaReport, formatSla, describeAvailability, describeTarget, type Check } from '@extant2000/proof-of-up' + +// One probe result every five minutes, loaded from wherever you store them. +const checks: Check[] = [ + { ts: '2026-08-01T00:00:00Z', ok: true }, + { ts: '2026-08-01T00:05:00Z', ok: false }, + // ... +] + +const report = slaReport(checks, { + from: '2026-08-01T00:00:00Z', + to: '2026-09-01T00:00:00Z', + target: 99.9, // omit or null for no SLA + intervalSeconds: 300, + excludeWindows: [{ starts_at: '2026-08-10T02:00:00Z', ends_at: '2026-08-10T03:00:00Z' }], +}) + +describeAvailability(report) // e.g. 'Availability 99.8% over 8916 samples, 100 minutes down.' +describeTarget(report) // e.g. 'Target 99.9% was NOT met.' +console.log(formatSla(report, { subject: 'app.example.com' })) +``` + +For a month of five-minute samples with 20 failures and one hour of +maintenance, `formatSla()` printed this (header line omitted): + +``` + NOT MET · 8916 items examined · 2 findings + Availability 99.8% over 8916 samples, 100 minutes down. + Target 99.9% was NOT met. + +✗ CRITICAL Availability 99.8% over 8916 samples, 100 minutes down. [NOT MET] + Target 99.9% was NOT met. + evidence: Availability = 99.8% (8916 samples, 2026-08-01 → 2026-09-01) +◦ info 60 minutes of scheduled maintenance excluded (12 samples) [N/A] +``` + +With no samples, the same call reports NOT ASSESSED and "No samples in this +period; availability cannot be computed." + +## Install + +``` +npm install @extant2000/proof-of-up +``` + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b88987e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +# Security + +Please report vulnerabilities privately by email to security@extant2000.com. +Do not open a public issue for a security problem. + +We aim to acknowledge every report within 5 business days. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a2fd23a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1504 @@ +{ + "name": "@extant2000/proof-of-up", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@extant2000/proof-of-up", + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "@extant2000/evidence-record": "^0.1.2" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@extant2000/evidence-record": { + "version": "0.1.2", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..290081e --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "@extant2000/proof-of-up", + "version": "0.3.0", + "private": false, + "description": "Backs uptime claims with measured data.", + "license": "MIT", + "type": "module", + "main": "./dist-cjs/index.js", + "files": [ + "dist", + "dist-cjs", + "src", + "README.md", + "LICENSE" + ], + "repository": { + "type": "git", + "url": "https://gitlab.com/extant2000/proof-of-up.git" + }, + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist-cjs/index.js" + } + }, + "scripts": { + "build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + }, + "module": "./dist/index.js", + "dependencies": { + "@extant2000/evidence-record": "^0.1.2" + }, + "author": "Extant 2000 LLC", + "homepage": "https://gitlab.com/extant2000/proof-of-up", + "bugs": { + "url": "https://gitlab.com/extant2000/proof-of-up/-/issues" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9d108c5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from './sla.js' +export { toReport, formatSla, describeAvailability, describeTarget } from './report.js' +export type { SlaReportOptions } from './report.js' diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..5384ae1 --- /dev/null +++ b/src/report.ts @@ -0,0 +1,108 @@ +import { + type CapabilityReport, + type Finding, + type FormatOptions, + evidence, + formatReport as renderReport, +} from '@extant2000/evidence-record' +import type { SlaReport } from './sla.js' + +export interface SlaReportOptions { + /** What the statement is about, e.g. "app.example.com". */ + subject?: string +} + +/** + * Say the null cases out loud. + * + * `uptime null, met=false` was correct and unreadable. Nobody should have to + * know that zero samples is not 100% in order to read the line, and nobody + * should have to know that a null target is not a missed target. The rigour + * was already in the arithmetic; this is the phrasing catching up. + */ +export function describeAvailability(r: SlaReport): string { + if (r.uptime_pct === null) { + return 'No samples in this period; availability cannot be computed.' + } + return `Availability ${r.uptime_pct}% over ${r.total_checks} samples, ${r.downtime_minutes} minutes down.` +} + +export function describeTarget(r: SlaReport): string { + if (r.target_pct === null) return 'No SLA target applies to this plan.' + if (r.uptime_pct === null) { + return `A ${r.target_pct}% target applies, but nothing was measured — the target was neither met nor missed.` + } + return r.met + ? `Target ${r.target_pct}% was met.` + : `Target ${r.target_pct}% was NOT met.` +} + +const window = (r: SlaReport) => + r.from && r.to ? `${r.from.slice(0, 10)} → ${r.to.slice(0, 10)}` : 'unbounded period' + +/** + * Build the portfolio-standard report for one availability statement. + * + * Zero samples with a target set produces `not-assessed`, not `fail`. The + * underlying `met: false` is preserved because the ported arithmetic is + * unchanged, but reporting a MISSED SLA when nothing was measured is an + * overclaim in the opposite direction from reporting 100% — and this library + * exists to avoid exactly that kind of unearned certainty. + */ +export function toReport(r: SlaReport, opts: SlaReportOptions = {}): CapabilityReport { + const subject = opts.subject ?? 'service' + const findings: Finding[] = [] + + const measured = evidence.measurement('Availability', r.uptime_pct, r.total_checks, { + unit: '%', + window: window(r), + }) + + findings.push({ + id: 'availability', + summary: describeAvailability(r), + determination: r.uptime_pct === null + ? 'not-assessed' + : r.target_pct === null + ? 'not-applicable' + : r.met ? 'pass' : 'fail', + severity: r.uptime_pct === null ? 'high' : r.met === false ? 'critical' : 'info', + detail: describeTarget(r), + evidence: r.uptime_pct === null ? [] : [measured], + }) + + if (r.excluded_checks > 0) { + findings.push({ + id: 'maintenance', + summary: `${r.excluded_minutes} minutes of scheduled maintenance excluded (${r.excluded_checks} samples)`, + determination: 'not-applicable', + severity: 'info', + detail: 'Planned downtime inside a declared window is removed from the availability math, as if the probe never ran. A cancelled window does not excuse anything.', + evidence: [evidence.measurement('Excluded maintenance', r.excluded_minutes, r.excluded_checks, { + unit: ' min', + window: window(r), + })], + }) + } + + return { + capability: 'ProofOfUp', + scope: `${subject}, ${window(r)}`, + // The sample count IS the examined count. Zero samples therefore forces + // not-assessed at the report level too, without a special case. + examined: r.total_checks, + findings, + // Both sentences go in the notes, not only on the finding: with zero + // samples the renderer leads with "nothing was assessed" and stops before + // the findings, and the reader still has to be told why. + notes: [describeAvailability(r), describeTarget(r)], + } +} + +/** Render an availability statement for a terminal, CI log or customer PDF. */ +export function formatSla( + r: SlaReport, + opts: SlaReportOptions & FormatOptions = {}, +): string { + return renderReport(toReport(r, opts), opts) +} diff --git a/src/sla.ts b/src/sla.ts new file mode 100644 index 0000000..72782df --- /dev/null +++ b/src/sla.ts @@ -0,0 +1,164 @@ +/** + * Availability math for uptime / SLA statements. + * + * Only the arithmetic lives here. Queries, auth, CSV and printable rendering + * stay in the product that calls it. + */ + +export interface Check { + /** Sample timestamp. */ + ts: Date | string | number + /** Whether the probe succeeded. */ + ok: boolean +} + +export interface MaintenanceWindow { + starts_at?: Date | string | number + ends_at?: Date | string | number + /** Aliases accepted for convenience. */ + start?: Date | string | number + end?: Date | string | number + /** A `cancelled` window is ignored — it did not happen. */ + status?: string +} + +export interface SlaOptions { + from: Date | string | number + to: Date | string | number + /** Marketed availability target, e.g. 99.9. `null`/absent = no SLA. */ + target?: number | null + /** Probe cadence in seconds. Bounds each failing sample's charged downtime. */ + intervalSeconds?: number + excludeWindows?: MaintenanceWindow[] +} + +export interface SlaReport { + from: string | null + to: string | null + total_checks: number + up_checks: number + down_checks: number + /** `null` when there were no samples — NOT 100. */ + uptime_pct: number | null + downtime_minutes: number + excluded_checks: number + excluded_minutes: number + target_pct: number | null + /** `null` when no target is set — "no SLA" is not "SLA met". */ + met: boolean | null +} + +function ms(v: unknown): number { + if (v instanceof Date) return v.getTime() + return new Date(v as string).getTime() +} + +function round1(n: number): number { + return Math.round(n * 10) / 10 +} + +/** + * Compute an availability report over already-loaded probe samples. + * + * Pure: no I/O, no clock. Given the same samples it returns the same numbers, + * which is what makes an availability figure auditable evidence rather than a + * reading taken at a moment. + * + * The window is half-open `[from, to)` — includes `from`, excludes `to` — so + * adjacent monthly statements never double-count a boundary sample. + */ +export function slaReport(checks: Check[], opts: SlaOptions): SlaReport { + const fromMs = ms(opts.from) + const toMs = ms(opts.to) + + const target = opts.target != null && Number.isFinite(Number(opts.target)) + ? Number(opts.target) + : null + + const intervalSec = Number.isFinite(Number(opts.intervalSeconds)) && Number(opts.intervalSeconds) > 0 + ? Number(opts.intervalSeconds) + : 300 + const intervalMs = intervalSec * 1000 + + const fromIso = Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : null + const toIso = Number.isFinite(toMs) ? new Date(toMs).toISOString() : null + + // ── Scheduled-maintenance exclusion ────────────────────────────────────── + // Normalize windows into half-open [s, e) intervals clipped to the report + // period, dropping cancelled ones. A sample inside such an interval is + // removed from the availability math entirely — as if the probe never ran — + // so planned downtime never counts against uptime_pct. + const raw: Array<[number, number]> = [] + if (Array.isArray(opts.excludeWindows) && Number.isFinite(fromMs) && Number.isFinite(toMs)) { + for (const w of opts.excludeWindows) { + if (!w || w.status === 'cancelled') continue + const s = ms(w.starts_at ?? w.start) + const e = ms(w.ends_at ?? w.end) + if (!Number.isFinite(s) || !Number.isFinite(e) || e <= s) continue + const cs = Math.max(s, fromMs) + const ce = Math.min(e, toMs) + if (ce > cs) raw.push([cs, ce]) + } + } + + // Merge overlapping/adjacent intervals so excluded_minutes never + // double-counts two windows that overlap. + raw.sort((a, b) => a[0] - b[0]) + const merged: Array<[number, number]> = [] + for (const iv of raw) { + const last = merged[merged.length - 1] + if (last && iv[0] <= last[1]) last[1] = Math.max(last[1], iv[1]) + else merged.push([iv[0], iv[1]]) + } + const isExcluded = (t: number) => merged.some(([s, e]) => t >= s && t < e) + const excludedMs = merged.reduce((acc, [s, e]) => acc + (e - s), 0) + + // ── Collect in-window samples ──────────────────────────────────────────── + const kept: Array<{ t: number, ok: boolean }> = [] + let excludedChecks = 0 + if (Array.isArray(checks) && Number.isFinite(fromMs) && Number.isFinite(toMs)) { + for (const c of checks) { + if (!c || c.ts == null) continue + const t = ms(c.ts) + if (!Number.isFinite(t) || t < fromMs || t >= toMs) continue + if (isExcluded(t)) { excludedChecks++; continue } + kept.push({ t, ok: !!c.ok }) + } + } + kept.sort((a, b) => a.t - b.t) + + // ── Availability ───────────────────────────────────────────────────────── + let up = 0 + let downtimeMs = 0 + for (let i = 0; i < kept.length; i++) { + const cur = kept[i]! + if (cur.ok) { up++; continue } + // Charge the gap to the next sample, capped at one probe interval, so a + // long reporting gap after a single failure cannot inflate downtime. The + // last sample has no successor and is charged exactly one interval. + const next = kept[i + 1] + const gap = next ? next.t - cur.t : intervalMs + downtimeMs += Math.min(Math.max(gap, 0), intervalMs) + } + + const total = kept.length + // null, not 100: zero samples is no evidence, and an availability report that + // answers "100%" when nothing was measured is worse than one that abstains. + const pct = total === 0 ? null : round1((up / total) * 100) + + return { + from: fromIso, + to: toIso, + total_checks: total, + up_checks: up, + down_checks: total - up, + uptime_pct: pct, + downtime_minutes: round1(downtimeMs / 60000), + excluded_checks: excludedChecks, + excluded_minutes: round1(excludedMs / 60000), + target_pct: target, + // null, not false: "no SLA target" and "SLA missed" are different claims, + // and a free plan must not produce a statement that reads as a failure. + met: target == null ? null : (pct != null && pct >= target), + } +} diff --git a/test/sla.test.ts b/test/sla.test.ts new file mode 100644 index 0000000..20534dd --- /dev/null +++ b/test/sla.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import { slaReport, toReport, formatSla, type Check } from '../src/index.js' +import { overall, validateReport } from '@extant2000/evidence-record' + +const FROM = '2026-07-01T00:00:00.000Z' +const TO = '2026-08-01T00:00:00.000Z' + +/** n samples 5 minutes apart from `start`, `okAt` decides pass/fail. */ +function series(start: string, n: number, okAt: (i: number) => boolean): Check[] { + const t0 = new Date(start).getTime() + return Array.from({ length: n }, (_, i) => ({ + ts: new Date(t0 + i * 300_000).toISOString(), + ok: okAt(i), + })) +} + +describe('slaReport — refusing flattering answers', () => { + it('reports uptime_pct as NULL with no samples, not 100', () => { + // An availability report that answers "100%" when nothing was measured is + // worse than one that abstains. + const r = slaReport([], { from: FROM, to: TO, target: 99.9 }) + expect(r.uptime_pct).toBeNull() + expect(r.total_checks).toBe(0) + }) + + it('reports met as NULL with no target, not false', () => { + // "no SLA target" and "SLA missed" are different claims. A free plan must + // not produce a statement that reads as a failure. + expect(slaReport(series(FROM, 10, () => true), { from: FROM, to: TO }).met).toBeNull() + }) + + it('cannot report met=true from zero evidence', () => { + const r = slaReport([], { from: FROM, to: TO, target: 99.9 }) + expect(r.met).toBe(false) + }) +}) + +describe('slaReport — window semantics', () => { + it('is half-open [from, to) so adjacent months never double-count', () => { + const boundary: Check[] = [ + { ts: FROM, ok: true }, // included + { ts: TO, ok: true }, // excluded — belongs to the NEXT statement + ] + expect(slaReport(boundary, { from: FROM, to: TO }).total_checks).toBe(1) + }) + + it('drops samples outside the window entirely', () => { + const r = slaReport([ + { ts: '2026-06-30T23:59:59.000Z', ok: false }, + { ts: '2026-07-15T00:00:00.000Z', ok: true }, + ], { from: FROM, to: TO }) + expect(r.total_checks).toBe(1) + expect(r.uptime_pct).toBe(100) + }) + + it('ignores malformed samples rather than counting them', () => { + const r = slaReport( + [{ ts: 'not-a-date', ok: false }, { ts: FROM, ok: true }] as Check[], + { from: FROM, to: TO }, + ) + expect(r.total_checks).toBe(1) + }) +}) + +describe('slaReport — availability and downtime', () => { + it('computes uptime to one decimal', () => { + // 999 up, 1 down out of 1000 → 99.9 + const r = slaReport(series(FROM, 1000, i => i !== 500), { from: FROM, to: TO, target: 99.9 }) + expect(r.uptime_pct).toBe(99.9) + expect(r.up_checks).toBe(999) + expect(r.down_checks).toBe(1) + expect(r.met).toBe(true) + }) + + it('misses the target just below it', () => { + // 998/1000 = 99.8 < 99.9 + const r = slaReport(series(FROM, 1000, i => i !== 500 && i !== 600), { + from: FROM, to: TO, target: 99.9, + }) + expect(r.uptime_pct).toBe(99.8) + expect(r.met).toBe(false) + }) + + it('charges a failing sample the gap to its successor, capped at one interval', () => { + // Two failures 5 min apart at a 300s cadence → 5 min each = 10 min. + const r = slaReport(series(FROM, 3, i => i === 2), { + from: FROM, to: TO, intervalSeconds: 300, + }) + expect(r.downtime_minutes).toBe(10) + }) + + it('caps a long reporting gap at one interval', () => { + // A single failure followed by a sample a DAY later must not charge a day. + const r = slaReport([ + { ts: '2026-07-01T00:00:00.000Z', ok: false }, + { ts: '2026-07-02T00:00:00.000Z', ok: true }, + ], { from: FROM, to: TO, intervalSeconds: 300 }) + expect(r.downtime_minutes).toBe(5) + }) + + it('charges the final sample exactly one interval when it has no successor', () => { + const r = slaReport([{ ts: FROM, ok: false }], { + from: FROM, to: TO, intervalSeconds: 600, + }) + expect(r.downtime_minutes).toBe(10) + }) +}) + +describe('slaReport — scheduled maintenance', () => { + const win = [{ starts_at: '2026-07-10T00:00:00.000Z', ends_at: '2026-07-10T01:00:00.000Z' }] + + it('removes samples inside a window from the math entirely', () => { + const r = slaReport([ + { ts: '2026-07-10T00:30:00.000Z', ok: false }, // planned downtime + { ts: '2026-07-11T00:00:00.000Z', ok: true }, + ], { from: FROM, to: TO, excludeWindows: win, target: 99.9 }) + + expect(r.excluded_checks).toBe(1) + expect(r.total_checks).toBe(1) + expect(r.uptime_pct).toBe(100) // planned downtime never counts against it + expect(r.excluded_minutes).toBe(60) + }) + + it('ignores a cancelled window — it did not happen', () => { + const r = slaReport([{ ts: '2026-07-10T00:30:00.000Z', ok: false }], { + from: FROM, to: TO, + excludeWindows: [{ ...win[0]!, status: 'cancelled' }], + }) + expect(r.excluded_checks).toBe(0) + expect(r.uptime_pct).toBe(0) + }) + + it('does NOT double-count overlapping windows', () => { + const r = slaReport([], { + from: FROM, to: TO, + excludeWindows: [ + { starts_at: '2026-07-10T00:00:00.000Z', ends_at: '2026-07-10T02:00:00.000Z' }, + { starts_at: '2026-07-10T01:00:00.000Z', ends_at: '2026-07-10T03:00:00.000Z' }, + ], + }) + expect(r.excluded_minutes).toBe(180) // union, not 120 + 120 + }) + + it('clips a window to the report period', () => { + const r = slaReport([], { + from: FROM, to: TO, + excludeWindows: [{ starts_at: '2026-06-01T00:00:00.000Z', ends_at: '2026-07-01T01:00:00.000Z' }], + }) + expect(r.excluded_minutes).toBe(60) // only the hour inside July + }) + + it('drops a zero-length or inverted window', () => { + const r = slaReport([], { + from: FROM, to: TO, + excludeWindows: [ + { starts_at: '2026-07-10T01:00:00.000Z', ends_at: '2026-07-10T01:00:00.000Z' }, + { starts_at: '2026-07-10T05:00:00.000Z', ends_at: '2026-07-10T04:00:00.000Z' }, + ], + }) + expect(r.excluded_minutes).toBe(0) + }) + + it('accepts start/end aliases', () => { + const r = slaReport([], { + from: FROM, to: TO, + excludeWindows: [{ start: '2026-07-10T00:00:00.000Z', end: '2026-07-10T00:30:00.000Z' }], + }) + expect(r.excluded_minutes).toBe(30) + }) +}) + +describe('slaReport — reproducibility', () => { + it('same input, identical output', () => { + const checks = series(FROM, 200, i => i % 17 !== 0) + const a = slaReport(checks, { from: FROM, to: TO, target: 99 }) + const b = slaReport(checks, { from: FROM, to: TO, target: 99 }) + expect(a).toEqual(b) + }) + + it('is order-independent — unsorted input gives the same answer', () => { + const checks = series(FROM, 50, i => i !== 10) + const shuffled = [...checks].reverse() + expect(slaReport(shuffled, { from: FROM, to: TO, intervalSeconds: 300 })) + .toEqual(slaReport(checks, { from: FROM, to: TO, intervalSeconds: 300 })) + }) +}) + +describe('report — the null cases said out loud', () => { + const period = { from: '2026-07-01T00:00:00Z', to: '2026-08-01T00:00:00Z' } + + it('reads zero samples as uncomputable, not as a missed SLA', () => { + // `uptime null, met=false` is correct arithmetic and an overclaim to + // print: nothing was measured, so nothing was missed. + const r = slaReport([], { ...period, target: 99.9 }) + expect(r.uptime_pct).toBeNull() + expect(r.met).toBe(false) // ported behaviour, unchanged + expect(overall(toReport(r))).toBe('not-assessed') // what a reader is told + const text = formatSla(r) + expect(text).toContain('No samples in this period; availability cannot be computed.') + expect(text).toContain('neither met nor missed') + expect(text).not.toContain('100%') + }) + + it('says plainly when no SLA target applies', () => { + const r = slaReport([{ ts: '2026-07-02T00:00:00Z', ok: true }], period) + expect(r.met).toBeNull() + expect(formatSla(r)).toContain('No SLA target applies to this plan.') + }) + + it('carries the sample count into the citation', () => { + const checks = Array.from({ length: 100 }, (_, i) => ({ + ts: new Date(Date.parse(period.from) + i * 300_000).toISOString(), + ok: i !== 3, + })) + const r = slaReport(checks, { ...period, target: 99.9 }) + const text = formatSla(r, { subject: 'app.example.com', evidence: 'inline' }) + expect(text).toContain('100 samples') + expect(text).toContain('app.example.com') + expect(overall(toReport(r))).toBe('fail') + expect(text).toContain('was NOT met') + }) + + it('reports excluded maintenance as not-applicable, with its own measurement', () => { + const checks = Array.from({ length: 20 }, (_, i) => ({ + ts: new Date(Date.parse(period.from) + i * 300_000).toISOString(), + ok: true, + })) + const r = slaReport(checks, { + ...period, + target: 99.9, + excludeWindows: [{ start: period.from, end: new Date(Date.parse(period.from) + 3600_000).toISOString() }], + }) + expect(r.excluded_checks).toBeGreaterThan(0) + const f = toReport(r).findings.find(x => x.id === 'maintenance')! + expect(f.determination).toBe('not-applicable') + expect(formatSla(r)).toContain('scheduled maintenance excluded') + }) + + it('every conclusion carries a citation', () => { + const r = slaReport([{ ts: '2026-07-02T00:00:00Z', ok: true }], { ...period, target: 99.9 }) + expect(validateReport(toReport(r))).toEqual([]) + }) +}) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..0d99093 --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist-cjs", + "declaration": false + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9f56c30 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "declaration": true, "outDir": "dist", "rootDir": "src", + "strict": true, "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}