diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 4726d207..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [magmacomputing] diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..22ff9976 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,140 @@ +name: Publish Package with Provenance + +on: + workflow_dispatch: + inputs: + package: + description: 'Package to publish' + required: true + type: choice + options: + - '@magmacomputing/tempo' + - '@magmacomputing/tempo-pro' + - '@magmacomputing/tempo-fns' + - '@magmacomputing/tempo-plugin-ai' + - '@magmacomputing/tempo-plugin-astro' + - '@magmacomputing/tempo-plugin-batch' + - '@magmacomputing/tempo-plugin-finance' + - '@magmacomputing/tempo-plugin-snap' + - '@magmacomputing/tempo-plugin-sync' + - '@magmacomputing/tempo-plugin-ticker' + - 'all' + dry_run: + description: 'Dry run (simulate without uploading to registry)' + required: false + type: boolean + default: false + +permissions: + contents: read + id-token: write # Mandatory for npm Sigstore/OIDC cryptographic provenance + +jobs: + validate-and-publish: + name: Publish with Provenance + if: github.ref == 'refs/heads/main' + environment: + name: npm + url: https://www.npmjs.com/org/magmacomputing + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + TZ: America/New_York + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + TEMPO_LICENSE_KEY: ${{ secrets.TEMPO_LICENSE_KEY || '' }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Setup Tempo License Key + run: | + if [ -n "$TEMPO_LICENSE_KEY" ]; then + LICENSE_FILE="${{ runner.temp }}/tempo.key" + echo "$TEMPO_LICENSE_KEY" > "$LICENSE_FILE" + echo "TEMPO_LICENSE_PATH=$LICENSE_FILE" >> $GITHUB_ENV + fi + + - name: Build Monorepo Workspaces + run: | + npm run build:library + npm run build:tempo + npm run build --workspace=@magmacomputing/tempo-pro --workspace=@magmacomputing/tempo-fns --workspace=@magmacomputing/tempo-plugin-ai --workspace=@magmacomputing/tempo-plugin-astro --workspace=@magmacomputing/tempo-plugin-batch --workspace=@magmacomputing/tempo-plugin-finance --workspace=@magmacomputing/tempo-plugin-snap --workspace=@magmacomputing/tempo-plugin-sync --workspace=@magmacomputing/tempo-plugin-ticker --if-present + + - name: Run Tests + run: npm run test + + - name: Publish Selected Package + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + SELECTED_PKG: ${{ inputs.package }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + PROVENANCE_FLAG="--provenance --access public" + if [ "$DRY_RUN" = "true" ]; then + PROVENANCE_FLAG="$PROVENANCE_FLAG --dry-run" + echo "šŸ” DRY RUN MODE ACTIVATED — Simulating publication..." + fi + + WORKSPACES=( + "@magmacomputing/tempo" + "@magmacomputing/tempo-pro" + "@magmacomputing/tempo-fns" + "@magmacomputing/tempo-plugin-ai" + "@magmacomputing/tempo-plugin-astro" + "@magmacomputing/tempo-plugin-batch" + "@magmacomputing/tempo-plugin-finance" + "@magmacomputing/tempo-plugin-snap" + "@magmacomputing/tempo-plugin-sync" + "@magmacomputing/tempo-plugin-ticker" + ) + + publish_workspace() { + local pkg="$1" + local ver + ver=$(npm pkg get version --workspace="$pkg" --json | node -p "const d=JSON.parse(require('fs').readFileSync(0, 'utf8')); typeof d === 'string' ? d : (d['$pkg']?.version ?? d['$pkg'] ?? d.version ?? '')") + if [ -z "$ver" ] || [ "$ver" = "null" ]; then + echo "āŒ Failed to resolve version for $pkg" + exit 1 + fi + echo "Checking $pkg@$ver on npm..." + + if [ "$DRY_RUN" != "true" ] && npm view "$pkg@$ver" version >/dev/null 2>&1; then + echo "ā© $pkg@$ver is already published on npm. Skipping to support partial release recovery." + return 0 + fi + + echo "šŸš€ Publishing $pkg@$ver..." + npm publish --workspace="$pkg" $PROVENANCE_FLAG + } + + if [ "$SELECTED_PKG" = "all" ]; then + echo "šŸ”Ž Preflighting all ${#WORKSPACES[@]} package versions..." + for pkg in "${WORKSPACES[@]}"; do + ver=$(npm pkg get version --workspace="$pkg" --json | node -p "const d=JSON.parse(require('fs').readFileSync(0, 'utf8')); typeof d === 'string' ? d : (d['$pkg']?.version ?? d['$pkg'] ?? d.version ?? '')") + echo " - $pkg: $ver" + if [ -z "$ver" ] || [ "$ver" = "null" ]; then + echo "āŒ Failed to resolve version for $pkg" + exit 1 + fi + done + + echo "šŸš€ Publishing all workspaces..." + for pkg in "${WORKSPACES[@]}"; do + publish_workspace "$pkg" + done + else + publish_workspace "$SELECTED_PKG" + fi diff --git a/bin/build-plugins.mjs b/bin/build-plugins.mjs new file mode 100644 index 00000000..10d1f0ba --- /dev/null +++ b/bin/build-plugins.mjs @@ -0,0 +1,14 @@ +import fs from 'node:fs'; +import { execSync } from 'node:child_process'; + +const pluginsDir = new URL('../packages/plugins', import.meta.url); +const entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); + +const workspaceArgs = entries + .filter(dirent => dirent.isDirectory() && !dirent.name.startsWith('.bin') && !dirent.name.startsWith('.setup')) + .map(dirent => `--workspace=packages/plugins/${dirent.name}`) + .join(' '); + +if (workspaceArgs) { + execSync(`npm run build ${workspaceArgs}`, { stdio: 'inherit' }); +} diff --git a/bin/version-sync.mjs b/bin/version-sync.mjs index 01e354d4..df52d83c 100644 --- a/bin/version-sync.mjs +++ b/bin/version-sync.mjs @@ -10,7 +10,7 @@ if (!version) { console.log(`\nšŸ”„ Syncing version ${version} to workspaces...`); try { - const workspaces = ['@magmacomputing/tempo', '@magmacomputing/library']; + const workspaces = ['@magmacomputing/tempo', '@magmacomputing/library', '@magmacomputing/tempo-pro']; let syncedCount = 0; for (const ws of workspaces) { try { diff --git a/package-lock.json b/package-lock.json index 3787141e..9ed776ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.11.1", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.11.1", + "version": "4.0.0", "workspaces": [ "packages/*", "packages/plugins/*" @@ -939,68 +939,6 @@ "import-meta-resolve": "^4.2.0" } }, - "node_modules/@inversifyjs/common": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@inversifyjs/common/-/common-1.5.2.tgz", - "integrity": "sha512-WlzR9xGadABS9gtgZQ+luoZ8V6qm4Ii6RQfcfC9Ho2SOlE6ZuemFo7PKJvKI0ikm8cmKbU8hw5UK6E4qovH21w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@inversifyjs/container": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@inversifyjs/container/-/container-1.15.0.tgz", - "integrity": "sha512-U2xYsPrJTz5za2TExi5lg8qOWf8TEVBpN+pQM7B8BVA2rajtbRE9A66SLRHk8c1eGXmg+0K4Hdki6tWAsSQBUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inversifyjs/common": "1.5.2", - "@inversifyjs/core": "9.2.0", - "@inversifyjs/plugin": "0.2.0", - "@inversifyjs/reflect-metadata-utils": "1.4.1" - }, - "peerDependencies": { - "reflect-metadata": "~0.2.2" - } - }, - "node_modules/@inversifyjs/core": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@inversifyjs/core/-/core-9.2.0.tgz", - "integrity": "sha512-Nm7BR6KmpgshIHpVQWuEDehqRVb6GBm8LFEuhc2s4kSZWrArZ15RmXQzROLk4m+hkj4kMXgvMm5Qbopot/D6Sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inversifyjs/common": "1.5.2", - "@inversifyjs/prototype-utils": "0.1.3", - "@inversifyjs/reflect-metadata-utils": "1.4.1" - } - }, - "node_modules/@inversifyjs/plugin": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@inversifyjs/plugin/-/plugin-0.2.0.tgz", - "integrity": "sha512-R/JAdkTSD819pV1zi0HP54mWHyX+H2m8SxldXRgPQarS3ySV4KPyRdosWcfB8Se0JJZWZLHYiUNiS6JvMWSPjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@inversifyjs/prototype-utils": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@inversifyjs/prototype-utils/-/prototype-utils-0.1.3.tgz", - "integrity": "sha512-EzRamZzNgE9Sn3QtZ8NncNa2lpPMZfspqbK6BWFguWnOpK8ymp2TUuH46ruFHZhrHKnknPd7fG22ZV7iF517TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inversifyjs/common": "1.5.2" - } - }, - "node_modules/@inversifyjs/reflect-metadata-utils": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@inversifyjs/reflect-metadata-utils/-/reflect-metadata-utils-1.4.1.tgz", - "integrity": "sha512-Cp77C4d2wLaHXiUB7iH6Cxb7i1lD/YDuTIHLTDzKINqGSz0DCSoL/Dg2wVkW/6Qx03r/yQMLJ+32Agl32N2X8g==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "reflect-metadata": "~0.2.2" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1019,46 +957,6 @@ "node": ">=12" } }, - "node_modules/@javascript-obfuscator/escodegen": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@javascript-obfuscator/escodegen/-/escodegen-2.4.2.tgz", - "integrity": "sha512-3N7EUdYxuldwvtOtZIlAxNwPW1qTUFwWt3PxNkik6hB1uSSXLXj9JMlSyUbTCXK3Osjn0Nu0s/aa4mbvkGouXQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@javascript-obfuscator/estraverse": "^5.3.0", - "esprima": "^4.0.1", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/@javascript-obfuscator/escodegen/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", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@javascript-obfuscator/estraverse": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@javascript-obfuscator/estraverse/-/estraverse-5.4.0.tgz", - "integrity": "sha512-CZFX7UZVN9VopGbjTx4UXaXsi9ewoM1buL0kY7j1ftYdSs7p2spv9opxFjHlQ/QGTgh4UqufYqJJ0WKLml7b6w==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1173,14 +1071,12 @@ "link": true }, "node_modules/@magmacomputing/tempo-plugin-ticker": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@magmacomputing/tempo-plugin-ticker/-/tempo-plugin-ticker-2.2.3.tgz", - "integrity": "sha512-wWXyXocd6feZEH3tl1jzSaVYIuwXJbA83ri36pHrhYmWMV6bug3vTyDtRXnAnJZLnfGFwmKgCjLzOEYQfhJ73A==", - "dev": true, - "license": "Proprietary", - "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" - } + "resolved": "packages/plugins/ticker", + "link": true + }, + "node_modules/@magmacomputing/tempo-pro": { + "resolved": "packages/tempo-pro", + "link": true }, "node_modules/@mermaid-js/mermaid-mindmap": { "version": "9.3.0", @@ -2786,13 +2682,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/minimatch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", - "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "26.2.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", @@ -2832,13 +2721,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/validator": { - "version": "13.15.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", - "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -3232,176 +3114,6 @@ "d3-transition": "^3.0.1" } }, - "node_modules/@vercel/blob": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.8.0.tgz", - "integrity": "sha512-Nu+HWKpkgovCh/ezlG7wCVwF7RErTzLzZMbGKFBdGBCbTKyK+s5VXPLl+0+TpNEQPH8AVaGzOpIsXUOtkqylCQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/oidc": "^3.6.1", - "async-retry": "^1.3.3", - "is-buffer": "^2.0.5", - "is-node-process": "^1.2.0", - "throttleit": "^2.1.0", - "undici": "^6.23.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@vercel/blob/node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/@vercel/cli-config": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@vercel/cli-config/-/cli-config-0.2.3.tgz", - "integrity": "sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "xdg-app-paths": "5", - "zod": "4.1.11" - } - }, - "node_modules/@vercel/cli-exec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@vercel/cli-exec/-/cli-exec-1.0.1.tgz", - "integrity": "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "execa": "5.1.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@vercel/cli-exec/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@vercel/cli-exec/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vercel/cli-exec/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/@vercel/cli-exec/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@vercel/cli-exec/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@vercel/cli-exec/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vercel/cli-exec/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vercel/cli-exec/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@vercel/oidc": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.8.4.tgz", - "integrity": "sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/cli-config": "0.2.3", - "@vercel/cli-exec": "1.0.1", - "jose": "^5.9.6" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/@vitest/browser": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", @@ -4216,16 +3928,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4373,50 +4075,6 @@ "node": ">= 0.4" } }, - "node_modules/array-differ": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", - "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/arrify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/assert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", - "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-nan": "^1.3.2", - "object-is": "^1.1.5", - "object.assign": "^4.1.4", - "util": "^0.12.5" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4447,32 +4105,6 @@ "dev": true, "license": "MIT" }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "retry": "0.13.1" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -4681,75 +4313,25 @@ "node": ">=8" } }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node": ">=18" } }, "node_modules/chalk": { @@ -4765,23 +4347,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chance": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/chance/-/chance-1.1.13.tgz", - "integrity": "sha512-V6lQCljcLznE7tUYUM9EOAnnKXbctE6j/rdQkYOHIWbfGQbrzTsAXNW9CdU5XCo4ArXQCj/rb6HgxPlmGJcaUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -4804,16 +4369,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/cheerio": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", @@ -4874,18 +4429,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/class-validator": { - "version": "0.14.3", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", - "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/validator": "^13.15.3", - "libphonenumber-js": "^1.11.1", - "validator": "^13.15.20" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5038,13 +4581,6 @@ "node": ">= 14" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -5162,16 +4698,6 @@ "node": ">= 8" } }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -5821,13 +5347,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -5858,42 +5377,6 @@ "node": ">=16.0.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -6012,21 +5495,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -6152,32 +5620,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", - "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-safe-filename": "^0.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -6195,19 +5637,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-toolkit": { "version": "1.50.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", @@ -6305,36 +5734,6 @@ "node": ">=0.10.0" } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -6349,19 +5748,6 @@ "node": ">=4" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -6464,13 +5850,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-xml-builder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", @@ -6586,22 +5965,6 @@ "tabbable": "^6.4.0" } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6666,16 +6029,6 @@ "node": ">=20.0.0" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -6686,31 +6039,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-port": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", @@ -6724,20 +6052,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -6791,19 +6105,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6835,48 +6136,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "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.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -7083,18 +6342,6 @@ "node": ">=12" } }, - "node_modules/inversify": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/inversify/-/inversify-7.11.0.tgz", - "integrity": "sha512-yZDprSSr8TyVeMGI/AOV4ws6gwjX22hj9Z8/oHAVpJORY6WRFTcUzhnZtibBUHEw2U8ArvHcR+i863DplQ3Cwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inversifyjs/common": "1.5.2", - "@inversifyjs/container": "1.15.0", - "@inversifyjs/core": "9.2.0" - } - }, "node_modules/ip-address": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", @@ -7105,60 +6352,6 @@ "node": ">= 12" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -7185,26 +6378,6 @@ "node": ">=8" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -7212,30 +6385,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-nan": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -7249,38 +6398,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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-safe-filename": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", - "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -7294,22 +6411,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-unsafe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", @@ -7360,113 +6461,12 @@ "@isaacs/cliui": "^8.0.2" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/javascript-obfuscator": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/javascript-obfuscator/-/javascript-obfuscator-5.5.0.tgz", - "integrity": "sha512-YBkZLadMb0ynLQKKMj8urk+NPsglWa4Upwm/kd6x65gyY6YkNkCJi40OOdGDQa1bVGixgRAmVINwODkLnmiJNw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@javascript-obfuscator/escodegen": "2.4.2", - "@javascript-obfuscator/estraverse": "5.4.0", - "@vercel/blob": ">=0.23.0", - "acorn": "8.15.0", - "acorn-import-attributes": "^1.9.5", - "assert": "2.1.0", - "chalk": "4.1.2", - "chance": "1.1.13", - "class-validator": "0.14.3", - "commander": "12.1.0", - "env-paths": "4.0.0", - "eslint-scope": "8.4.0", - "eslint-visitor-keys": "4.2.1", - "fast-deep-equal": "3.1.3", - "inversify": "7.11.0", - "js-string-escape": "1.0.1", - "md5": "2.3.0", - "multimatch": "5.0.0", - "process": "0.11.10", - "reflect-metadata": "0.2.2", - "string-template": "1.0.0", - "stringz": "2.1.0", - "tslib": "2.8.1" - }, - "bin": { - "javascript-obfuscator": "bin/javascript-obfuscator" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/javascript-obfuscator/node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/javascript-obfuscator/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/javascript-obfuscator/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/javascript-obfuscator/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/javascript-obfuscator/node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -7477,16 +6477,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -7497,16 +6487,6 @@ "node": ">=10" } }, - "node_modules/js-string-escape": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", - "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/jsbi": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", @@ -7646,27 +6626,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/libphonenumber-js": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.10.tgz", - "integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==", - "dev": true, - "license": "MIT" - }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -7893,16 +6852,6 @@ "node": ">= 20" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mathxyjax3": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/mathxyjax3/-/mathxyjax3-0.8.3.tgz", @@ -7910,25 +6859,6 @@ "dev": true, "license": "MIT" }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, - "node_modules/md5/node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true, - "license": "MIT" - }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", @@ -7958,13 +6888,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, "node_modules/mermaid": { "version": "11.16.1", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", @@ -8169,50 +7092,6 @@ "dev": true, "license": "MIT" }, - "node_modules/multimatch": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", - "integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "^3.0.3", - "array-differ": "^3.0.0", - "array-union": "^2.1.0", - "arrify": "^2.0.1", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/multimatch/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/multimatch/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/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -8295,54 +7174,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -8379,34 +7210,6 @@ "regex-recursion": "^6.0.2" } }, - "node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/os-paths": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/os-paths/-/os-paths-4.4.0.tgz", - "integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0" - } - }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -8695,16 +7498,6 @@ "points-on-curve": "0.2.0" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -8796,15 +7589,6 @@ } } }, - "node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -8957,13 +7741,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -9053,16 +7830,6 @@ "node": ">=10" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -9181,24 +7948,6 @@ ], "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/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -9276,24 +8025,6 @@ "node": ">=20.0.0" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -9594,13 +8325,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-template": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz", - "integrity": "sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg==", - "dev": true, - "license": "MIT" - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -9680,16 +8404,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/stringz": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/stringz/-/stringz-2.1.0.tgz", - "integrity": "sha512-KlywLT+MZ+v0IRepfMxRtnSvDCMc3nR1qqCs3m/qIbSOWkNZYT8XHQA31rS3TnKp0c5xjZu3M4GY/2aRKSi/6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2" - } - }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -9904,19 +8618,6 @@ "node": ">=0.8" } }, - "node_modules/throttleit": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", - "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -10099,19 +8800,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -10429,20 +9117,6 @@ "node": ">= 0.8.0" } }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10464,16 +9138,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/validator": { - "version": "13.15.35", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", - "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -10946,28 +9610,6 @@ "node": ">= 8" } }, - "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "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", @@ -10985,16 +9627,6 @@ "node": ">=8" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -11122,33 +9754,6 @@ } } }, - "node_modules/xdg-app-paths": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.5.1.tgz", - "integrity": "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "os-paths": "^4.0.1", - "xdg-portable": "^7.2.0" - }, - "engines": { - "node": ">= 6.0" - } - }, - "node_modules/xdg-portable": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/xdg-portable/-/xdg-portable-7.3.0.tgz", - "integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "os-paths": "^4.0.1" - }, - "engines": { - "node": ">= 6.0" - } - }, "node_modules/xml-naming": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", @@ -11227,16 +9832,6 @@ "node": ">= 14" } }, - "node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -11260,7 +9855,7 @@ }, "peerDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/tempo": "^3.7.0" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" }, "peerDependenciesMeta": { "@js-temporal/polyfill": { @@ -11415,7 +10010,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.11.1", + "version": "4.0.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11433,13 +10028,13 @@ }, "packages/plugins/ai": { "name": "@magmacomputing/tempo-plugin-ai", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@js-temporal/polyfill": "^0.5.1" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.11.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" } }, "packages/plugins/astro": { @@ -11448,7 +10043,7 @@ "license": "MIT", "devDependencies": {}, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" } }, "packages/plugins/batch": { @@ -11459,7 +10054,7 @@ "@js-temporal/polyfill": "^0.5.1" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" } }, "packages/plugins/finance": { @@ -11467,11 +10062,11 @@ "version": "1.0.3", "license": "MIT", "devDependencies": { - "@magmacomputing/tempo": "^3.9.0", + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0", "vitest": "^4.1.10" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.9.0" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" } }, "packages/plugins/snap": { @@ -11480,7 +10075,7 @@ "license": "MIT", "devDependencies": {}, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" } }, "packages/plugins/sync": { @@ -11489,12 +10084,21 @@ "license": "MIT", "devDependencies": {}, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" + } + }, + "packages/plugins/ticker": { + "name": "@magmacomputing/tempo-plugin-ticker", + "version": "2.3.0", + "license": "MIT", + "devDependencies": {}, + "peerDependencies": { + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" } }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.11.1", + "version": "4.0.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11505,11 +10109,9 @@ "devDependencies": { "@js-temporal/polyfill": "^0.5.1", "@magmacomputing/library": "*", - "@magmacomputing/tempo-plugin-ticker": "^2.2.3", "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.3.0", - "javascript-obfuscator": "^5.4.3", "magic-string": "^1.2.0", "mermaid": "^11.16.1", "typedoc": "^0.28.19", @@ -11520,6 +10122,15 @@ "node": ">=20.0.0" } }, + "packages/tempo-pro": { + "name": "@magmacomputing/tempo-pro", + "version": "4.0.0", + "license": "SEE LICENSE IN LICENSE", + "devDependencies": {}, + "peerDependencies": { + "@magmacomputing/tempo": "^4.0.0" + } + }, "packages/tempo/node_modules/@shikijs/types": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", diff --git a/package.json b/package.json index c44b6f1f..cef66795 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.11.1", + "version": "4.0.0", "private": true, "engines": { "node": ">=20.0.0" @@ -25,6 +25,7 @@ "catalog:sync": "node packages/plugins/.bin/catalog-sync.mjs", "providers:sync": "npm run build:library && node bin/sync-providers.mjs", "repl": "npm run repl --workspace=@magmacomputing/tempo", + "repl:pro": "npm run repl --workspace=@magmacomputing/tempo-pro", "repl:plugins": "tsx --import ./packages/plugins/.bin/temporal-polyfill.mts ./packages/plugins/.bin/repl.mts", "repl:dist": "npm run repl:dist --workspace=@magmacomputing/tempo", "core": "npm run core --workspace=@magmacomputing/tempo", diff --git a/packages/functions/doc/functions/index.md b/packages/functions/doc/functions/index.md index 30d1e6a9..9a3aab1e 100644 --- a/packages/functions/doc/functions/index.md +++ b/packages/functions/doc/functions/index.md @@ -4,7 +4,7 @@ Welcome to the `tempo-fns` documentation! This library provides a comprehensive ## Built for Temporal -`tempo-fns` was engineered from the ground up to support modern date and time objects. For standard calendar operations, `tempo-fns` consumes and returns native `Temporal` instances. [Temporal](https://tc39.es/proposal-temporal/docs/) is the new global object coming to JavaScript that brings a modern, robust date and time API to the language, resolving decades of frustration with the legacy `Date` object. +`tempo-fns` was engineered from the ground up to support modern date and time objects. For standard calendar operations, `tempo-fns` consumes and returns native `Temporal` instances. [Temporal](https://tc39.es/proposal-temporal/docs/) is the new global object in modern JavaScript that brings a robust date and time API to the language, resolving decades of frustration with the legacy `Date` object. Because these core utilities expect standard `Temporal` objects (like `Temporal.ZonedDateTime` or `Temporal.PlainDate`), you can use them natively in any modern JavaScript environment without requiring bulky adapters, parsers, or conversion layers. diff --git a/packages/functions/package.json b/packages/functions/package.json index 45eee05a..74cb66e5 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -47,7 +47,7 @@ } }, "scripts": { - "test": "cross-env TEMPO_LICENSE_KEY=\"\" vitest run", + "test": "vitest run", "build": "npm run clean && npm run build:esm && npm run build:global", "build:esm": "node ../../node_modules/typescript-7/bin/tsc -b", "build:global": "rollup -c", @@ -58,7 +58,7 @@ }, "peerDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/tempo": "^3.7.0" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" }, "peerDependenciesMeta": { "@js-temporal/polyfill": { @@ -74,4 +74,4 @@ "vitepress": "^1.6.4", "vue": "^3.5.39" } -} \ No newline at end of file +} diff --git a/packages/functions/rollup.config.js b/packages/functions/rollup.config.js index d4d577a8..aecc0171 100644 --- a/packages/functions/rollup.config.js +++ b/packages/functions/rollup.config.js @@ -1,4 +1,21 @@ import resolve from '@rollup/plugin-node-resolve'; +import alias from '@rollup/plugin-alias'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const domains = ['primitives', 'temporal', 'security', 'runtime', 'scheduling']; +const libraryDist = path.resolve(__dirname, '../library/dist/common'); + +function resolveLibrary(importPath) { + const rel = importPath.replace(/^#library\//, ''); + for (const domain of domains) { + const candidate = path.join(libraryDist, domain, rel); + if (fs.existsSync(candidate)) return candidate; + } + return path.join(libraryDist, rel); +} export default { input: 'dist/index.js', @@ -11,5 +28,20 @@ export default { } }, external: ['@magmacomputing/tempo'], - plugins: [resolve()] + plugins: [ + alias({ + entries: [ + { + find: /^#library\/(.*)$/, + replacement: '$1', + customResolver(source) { + return resolveLibrary(source); + } + }, + { find: '#library', replacement: path.resolve(__dirname, '../library/dist/common.index.js') } + ] + }), + resolve() + ] } + diff --git a/packages/functions/src/index.ts b/packages/functions/src/index.ts index 4e6594fe..35671e10 100644 --- a/packages/functions/src/index.ts +++ b/packages/functions/src/index.ts @@ -8,7 +8,7 @@ export { isFirstDayOfMonth } from './calendar/isFirstDayOfMonth.js'; export { getISOWeekOfYear } from './calendar/getISOWeekOfYear.js'; // --- Scheduling --- -export { nextCron, prevCron } from './scheduling/cron.js'; +export { nextCron, prevCron, isCronString } from './scheduling/cron.js'; export { Interval } from '@magmacomputing/tempo'; // --- Timezone & Location --- diff --git a/packages/functions/src/scheduling/cron.ts b/packages/functions/src/scheduling/cron.ts index 19412d4a..4c1bf4d5 100644 --- a/packages/functions/src/scheduling/cron.ts +++ b/packages/functions/src/scheduling/cron.ts @@ -1,150 +1,50 @@ import type { Tempo } from '@magmacomputing/tempo'; - -type CronField = { allowed: Set; restricted: boolean }; - -interface CronSchedule { - minutes: CronField; - hours: CronField; - daysOfMonth: CronField; - months: CronField; - daysOfWeek: CronField; -} - -function parseCronField(field: string, min: number, max: number): CronField { - const allowed = new Set(); - if (field === '*' || field === '?') { - for (let i = min; i <= max; i++) allowed.add(i); - return { allowed, restricted: false }; - } - - const parts = field.split(','); - for (const part of parts) { - if (part.includes('/')) { - const [range, stepStr] = part.split('/'); - const step = parseInt(stepStr, 10); - if (isNaN(step) || step <= 0) - throw new Error(`[tempo-fns] Invalid step value: ${stepStr}`); - - let start = min; - let end = max; - if (range !== '*') { - const rangeParts = range.split('-'); - start = parseInt(rangeParts[0], 10); - end = rangeParts.length > 1 ? parseInt(rangeParts[1], 10) : start; - if (start > end) - throw new Error(`[tempo-fns] Invalid range: ${range}`); - - } - for (let i = start; i <= end; i += step) { - allowed.add(i); - } - } else if (part.includes('-')) { - const [start, end] = part.split('-').map(Number); - if (start > end) - throw new Error(`[tempo-fns] Invalid range: ${part}`); - - for (let i = start; i <= end; i++) - allowed.add(i); - } else { - allowed.add(parseInt(part, 10)); - } - } - return { allowed, restricted: true }; -} - -export function parseCron(pattern: string): CronSchedule { - const fields = pattern.trim().split(/\s+/); - if (fields.length !== 5) { - throw new Error('[tempo-fns] Invalid cron pattern. Expected 5 fields (min, hr, dom, mon, dow).'); - } - - return { - minutes: parseCronField(fields[0], 0, 59), - hours: parseCronField(fields[1], 0, 23), - daysOfMonth: parseCronField(fields[2], 1, 31), - months: parseCronField(fields[3], 1, 12), - daysOfWeek: parseCronField(fields[4], 0, 7) // 0 and 7 can both be Sunday - }; -} - -function matchesDay(schedule: CronSchedule, current: Temporal.ZonedDateTime): boolean { - const domMatch = schedule.daysOfMonth.allowed.has(current.day); - const dow = current.dayOfWeek; - const dowMatch = schedule.daysOfWeek.allowed.has(dow) || (dow === 7 && schedule.daysOfWeek.allowed.has(0)); - - if (schedule.daysOfMonth.restricted && schedule.daysOfWeek.restricted) { - return domMatch || dowMatch; - } - return domMatch && dowMatch; -} +import { getTemporal, isTempo, isZonedDateTime } from '../support/index.js'; +import { + parseCron, + isCronString, + getNextCronEpoch, + getPrevCronEpoch, + type CronField, + type CronSchedule +} from '#library/cron.library.js'; + +export type { CronField, CronSchedule }; +export { parseCron, isCronString }; /** - * Returns the next occurrence of a Cron pattern, starting from (and excluding) the current minute. + * Returns the next occurrence of a Cron pattern as a new Tempo instance or Temporal.ZonedDateTime. */ -export function nextCron(tempo: Tempo, pattern: string): Tempo { - const schedule = parseCron(pattern); - // Start searching from the next minute, operating directly on Temporal.ZonedDateTime for performance - let current = tempo.toDateTime().add({ minutes: 1 }).with({ second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 }); - - const maxLimit = current.add({ years: 5 }).epochNanoseconds; - for (;;) { // Max iterations to prevent infinite loops (5 years max approx) - if (current.epochNanoseconds > maxLimit) throw new Error('[tempo-fns] Could not find next cron match within 5 years.'); - if (!schedule.months.allowed.has(current.month)) { - current = current.add({ months: 1 }).with({ day: 1, hour: 0, minute: 0 }); - continue; - } - - if (!matchesDay(schedule, current)) { - current = current.add({ days: 1 }).with({ hour: 0, minute: 0 }); - continue; - } - - if (!schedule.hours.allowed.has(current.hour)) { - current = current.add({ hours: 1 }).with({ minute: 0 }); - continue; - } +export function nextCron(input: T, pattern: string): T { + const Temporal = getTemporal(); + if (isTempo(input)) { + const nextMs = getNextCronEpoch(pattern, input.epoch.ms, input.tz); + return input.set(nextMs) as T; + } - if (!schedule.minutes.allowed.has(current.minute)) { - current = current.add({ minutes: 1 }); - continue; - } + const anchorZdt = isZonedDateTime(input) + ? (input as any).toZonedDateTimeISO('UTC') + : Temporal.Instant.fromEpochMilliseconds(typeof input === 'number' ? input : Date.now()).toZonedDateTimeISO('UTC'); - return tempo.set(current); - } + const nextMs = getNextCronEpoch(pattern, anchorZdt.epochMilliseconds, anchorZdt.timeZoneId); + return Temporal.Instant.fromEpochMilliseconds(nextMs).toZonedDateTimeISO(anchorZdt.timeZoneId) as T; } /** - * Returns the previous occurrence of a Cron pattern, starting from (and excluding) the current minute. + * Returns the previous occurrence of a Cron pattern as a new Tempo instance or Temporal.ZonedDateTime. */ -export function prevCron(tempo: Tempo, pattern: string): Tempo { - const schedule = parseCron(pattern); - // Start searching from the previous minute - let current = tempo.toDateTime().subtract({ minutes: 1 }).with({ second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 }); - - const minLimit = current.subtract({ years: 5 }).epochNanoseconds; - for (;;) { - if (current.epochNanoseconds < minLimit) throw new Error('[tempo-fns] Could not find previous cron match within 5 years.'); - if (!schedule.months.allowed.has(current.month)) { - current = current.subtract({ months: 1 }); - current = current.with({ day: current.daysInMonth, hour: 23, minute: 59 }); - continue; - } - - if (!matchesDay(schedule, current)) { - current = current.subtract({ days: 1 }).with({ hour: 23, minute: 59 }); - continue; - } - - if (!schedule.hours.allowed.has(current.hour)) { - current = current.subtract({ hours: 1 }).with({ minute: 59 }); - continue; - } +export function prevCron(input: T, pattern: string): T { + const Temporal = getTemporal(); + if (isTempo(input)) { + const prevMs = getPrevCronEpoch(pattern, input.epoch.ms, input.tz); + return input.set(prevMs) as T; + } - if (!schedule.minutes.allowed.has(current.minute)) { - current = current.subtract({ minutes: 1 }); - continue; - } + const anchorZdt = isZonedDateTime(input) + ? (input as any).toZonedDateTimeISO('UTC') + : Temporal.Instant.fromEpochMilliseconds(typeof input === 'number' ? input : Date.now()).toZonedDateTimeISO('UTC'); - return tempo.set(current); - } + const prevMs = getPrevCronEpoch(pattern, anchorZdt.epochMilliseconds, anchorZdt.timeZoneId); + return Temporal.Instant.fromEpochMilliseconds(prevMs).toZonedDateTimeISO(anchorZdt.timeZoneId) as T; } + diff --git a/packages/functions/test/scheduling/cron.test.ts b/packages/functions/test/scheduling/cron.test.ts index e95fbbfe..34359ee3 100644 --- a/packages/functions/test/scheduling/cron.test.ts +++ b/packages/functions/test/scheduling/cron.test.ts @@ -63,4 +63,13 @@ describe('cron parser', () => { expect(next2.dd).toBe(5); expect(next2.dow).toBe(7); }); + + it('rejects non-numeric and out-of-range cron fields', () => { + expect(() => parseCron('60 9 * * 1-5')).toThrow(); + expect(() => parseCron('foo 9 * * 1-5')).toThrow(); + expect(() => parseCron('0 24 * * 1')).toThrow(); + expect(() => parseCron('0 9 32 * 1')).toThrow(); + expect(() => parseCron('0 9 1 13 1')).toThrow(); + expect(() => parseCron('0 9 1 1 8')).toThrow(); + }); }); diff --git a/packages/functions/vitest.config.ts b/packages/functions/vitest.config.ts index 5be6ce11..432bc48e 100644 --- a/packages/functions/vitest.config.ts +++ b/packages/functions/vitest.config.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import swc from 'unplugin-swc'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(__dirname, '../../'); const polyfill = resolve(__dirname, './test/setup.ts'); export default defineConfig({ @@ -19,8 +20,46 @@ export default defineConfig({ ], test: { globals: true, + pool: 'forks', + maxWorkers: 2, environment: 'node', include: ['test/**/*.test.ts'], setupFiles: [polyfill] + }, + resolve: { + alias: [ + { find: /^@magmacomputing\/tempo-fns$/, replacement: resolve(__dirname, './src/index.ts') }, + { find: /^@magmacomputing\/tempo-fns\/(.*)$/, replacement: resolve(__dirname, './src/$1.ts') }, + { find: /^@magmacomputing\/tempo$/, replacement: resolve(rootDir, './packages/tempo/src/tempo.index.ts') }, + { find: /^@magmacomputing\/tempo\/plugin-api$/, replacement: resolve(rootDir, './packages/tempo/src/plugin-api.index.ts') }, + { find: /^@magmacomputing\/tempo\/term\/(.*)$/, replacement: resolve(rootDir, './packages/tempo/src/plugin/term/term.$1.ts') }, + { find: /^@magmacomputing\/tempo\/(.*)$/, replacement: resolve(rootDir, './packages/tempo/src/$1.ts') }, + { find: /^@magmacomputing\/library$/, replacement: resolve(rootDir, './packages/library/src/common.index.ts') }, + { find: /^@magmacomputing\/library\/(.*)$/, replacement: resolve(rootDir, './packages/library/src/$1.ts') }, + { find: /^#library\/([^/]+)\/index\.js$/, replacement: resolve(rootDir, './packages/library/src/common/$1/index.ts') }, + { find: /^#library\/(browser|server)\/(.*)\.js$/, replacement: resolve(rootDir, './packages/library/src/$1/$2.ts') }, + { find: /^#library\/(array|assertion|coercion|number|object|primitive|string|symbol|type)\.library\.js$/, replacement: resolve(rootDir, './packages/library/src/common/primitives/$1.library.ts') }, + { find: /^#library\/(calendar|temporal)\.library\.js$/, replacement: resolve(rootDir, './packages/library/src/common/temporal/$1.library.ts') }, + { find: /^#library\/temporal\.polyfill\.js$/, replacement: resolve(rootDir, './packages/library/src/common/temporal/temporal.polyfill.ts') }, + { find: /^#library\/(buffer|cipher|webtoken)\.library\.js$/, replacement: resolve(rootDir, './packages/library/src/common/security/$1.library.ts') }, + { find: /^#library\/(cron|rrule|schedule)\.library\.js$/, replacement: resolve(rootDir, './packages/library/src/common/scheduling/$1.library.ts') }, + { find: /^#library\/(.*)\.js$/, replacement: resolve(rootDir, './packages/library/src/common/runtime/$1.ts') }, + { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: resolve(rootDir, './packages/tempo/src/plugin/term/$1.ts') }, + { find: /^#tempo\/plugin\.(util|type)\.js$/, replacement: resolve(rootDir, './packages/tempo/src/plugin/plugin.$1.ts') }, + { find: /^#tempo\/plugin\.(.*)\.js$/, replacement: resolve(rootDir, './packages/tempo/src/plugin/extend/plugin.$1.ts') }, + { find: /^#tempo\/term\/quarter$/, replacement: resolve(rootDir, './packages/tempo/src/plugin/term/term.quarter.ts') }, + { find: /^#tempo\/term$/, replacement: resolve(rootDir, './packages/tempo/src/plugin/term/term.index.ts') }, + { find: /^#tempo\/std$/, replacement: resolve(rootDir, './packages/plugins/.std/src/index.ts') }, + { find: /^#tempo\/core$/, replacement: resolve(rootDir, './packages/tempo/src/core.index.ts') }, + { find: /^#tempo\/config\/(.*)\.js$/, replacement: resolve(rootDir, './packages/tempo/src/config/$1.ts') }, + { find: /^#tempo\/config$/, replacement: resolve(rootDir, './packages/tempo/src/config/config.index.ts') }, + { find: /^#tempo\/(parse|format|mutate|duration)$/, replacement: resolve(rootDir, './packages/tempo/src/module/module.$1.ts') }, + { find: /^#tempo\/support$/, replacement: resolve(rootDir, './packages/tempo/src/support/support.index.ts') }, + { find: /^#tempo\/module$/, replacement: resolve(rootDir, './packages/tempo/src/module/module.index.ts') }, + { find: /^#tempo\/tempo\.class\.js$/, replacement: resolve(rootDir, './packages/tempo/src/tempo.index.ts') }, + { find: /^#tempo\/(.*)\.js$/, replacement: resolve(rootDir, './packages/tempo/src/$1.ts') }, + { find: /^#tempo\/(.*)$/, replacement: resolve(rootDir, './packages/tempo/src/$1.ts') } + ] } }); + diff --git a/packages/library/package.json b/packages/library/package.json index 9ecbafb8..9414acd4 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.11.1", + "version": "4.0.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", @@ -47,6 +47,26 @@ }, "browser": null }, + "./primitives/*.js": { + "development": "./src/common/primitives/*.ts", + "default": "./dist/common/primitives/*.js" + }, + "./temporal/*.js": { + "development": "./src/common/temporal/*.ts", + "default": "./dist/common/temporal/*.js" + }, + "./security/*.js": { + "development": "./src/common/security/*.ts", + "default": "./dist/common/security/*.js" + }, + "./scheduling/*.js": { + "development": "./src/common/scheduling/*.ts", + "default": "./dist/common/scheduling/*.js" + }, + "./runtime/*.js": { + "development": "./src/common/runtime/*.ts", + "default": "./dist/common/runtime/*.js" + }, "./browser/*.js": { "development": "./src/browser/*.ts", "default": "./dist/browser/*.js" @@ -73,6 +93,154 @@ "development": "./src/common.index.ts", "default": "./dist/common.index.js" }, + "#library/primitives/*.js": { + "development": "./src/common/primitives/*.ts", + "default": "./dist/common/primitives/*.js" + }, + "#library/temporal/*.js": { + "development": "./src/common/temporal/*.ts", + "default": "./dist/common/temporal/*.js" + }, + "#library/security/*.js": { + "development": "./src/common/security/*.ts", + "default": "./dist/common/security/*.js" + }, + "#library/scheduling/*.js": { + "development": "./src/common/scheduling/*.ts", + "default": "./dist/common/scheduling/*.js" + }, + "#library/runtime/*.js": { + "development": "./src/common/runtime/*.ts", + "default": "./dist/common/runtime/*.js" + }, + "#library/array.library.js": { + "development": "./src/common/primitives/array.library.ts", + "default": "./dist/common/primitives/array.library.js" + }, + "#library/assertion.library.js": { + "development": "./src/common/primitives/assertion.library.ts", + "default": "./dist/common/primitives/assertion.library.js" + }, + "#library/coercion.library.js": { + "development": "./src/common/primitives/coercion.library.ts", + "default": "./dist/common/primitives/coercion.library.js" + }, + "#library/number.library.js": { + "development": "./src/common/primitives/number.library.ts", + "default": "./dist/common/primitives/number.library.js" + }, + "#library/object.library.js": { + "development": "./src/common/primitives/object.library.ts", + "default": "./dist/common/primitives/object.library.js" + }, + "#library/primitive.library.js": { + "development": "./src/common/primitives/primitive.library.ts", + "default": "./dist/common/primitives/primitive.library.js" + }, + "#library/string.library.js": { + "development": "./src/common/primitives/string.library.ts", + "default": "./dist/common/primitives/string.library.js" + }, + "#library/symbol.library.js": { + "development": "./src/common/primitives/symbol.library.ts", + "default": "./dist/common/primitives/symbol.library.js" + }, + "#library/type.library.js": { + "development": "./src/common/primitives/type.library.ts", + "default": "./dist/common/primitives/type.library.js" + }, + "#library/calendar.library.js": { + "development": "./src/common/temporal/calendar.library.ts", + "default": "./dist/common/temporal/calendar.library.js" + }, + "#library/temporal.library.js": { + "development": "./src/common/temporal/temporal.library.ts", + "default": "./dist/common/temporal/temporal.library.js" + }, + "#library/temporal.polyfill.js": { + "development": "./src/common/temporal/temporal.polyfill.ts", + "default": "./dist/common/temporal/temporal.polyfill.js" + }, + "#library/buffer.library.js": { + "development": "./src/common/security/buffer.library.ts", + "default": "./dist/common/security/buffer.library.js" + }, + "#library/cipher.library.js": { + "development": "./src/common/security/cipher.library.ts", + "default": "./dist/common/security/cipher.library.js" + }, + "#library/webtoken.library.js": { + "development": "./src/common/security/webtoken.library.ts", + "default": "./dist/common/security/webtoken.library.js" + }, + "#library/cron.library.js": { + "development": "./src/common/scheduling/cron.library.ts", + "default": "./dist/common/scheduling/cron.library.js" + }, + "#library/rrule.library.js": { + "development": "./src/common/scheduling/rrule.library.ts", + "default": "./dist/common/scheduling/rrule.library.js" + }, + "#library/schedule.library.js": { + "development": "./src/common/scheduling/schedule.library.ts", + "default": "./dist/common/scheduling/schedule.library.js" + }, + "#library/class.library.js": { + "development": "./src/common/runtime/class.library.ts", + "default": "./dist/common/runtime/class.library.js" + }, + "#library/enumerate.library.js": { + "development": "./src/common/runtime/enumerate.library.ts", + "default": "./dist/common/runtime/enumerate.library.js" + }, + "#library/evaluation.library.js": { + "development": "./src/common/runtime/evaluation.library.ts", + "default": "./dist/common/runtime/evaluation.library.js" + }, + "#library/function.library.js": { + "development": "./src/common/runtime/function.library.ts", + "default": "./dist/common/runtime/function.library.js" + }, + "#library/international.library.js": { + "development": "./src/common/runtime/international.library.ts", + "default": "./dist/common/runtime/international.library.js" + }, + "#library/json.library.js": { + "development": "./src/common/runtime/json.library.ts", + "default": "./dist/common/runtime/json.library.js" + }, + "#library/logger.class.js": { + "development": "./src/common/runtime/logger.class.ts", + "default": "./dist/common/runtime/logger.class.js" + }, + "#library/pledge.class.js": { + "development": "./src/common/runtime/pledge.class.ts", + "default": "./dist/common/runtime/pledge.class.js" + }, + "#library/proxy.library.js": { + "development": "./src/common/runtime/proxy.library.ts", + "default": "./dist/common/runtime/proxy.library.js" + }, + "#library/reflection.library.js": { + "development": "./src/common/runtime/reflection.library.ts", + "default": "./dist/common/runtime/reflection.library.js" + }, + "#library/request.library.js": { + "development": "./src/common/runtime/request.library.ts", + "default": "./dist/common/runtime/request.library.js" + }, + "#library/serialize.library.js": { + "development": "./src/common/runtime/serialize.library.ts", + "default": "./dist/common/runtime/serialize.library.js" + }, + "#library/storage.library.js": { + "development": "./src/common/runtime/storage.library.ts", + "default": "./dist/common/runtime/storage.library.js" + }, + "#library/utility.library.js": { + "development": "./src/common/runtime/utility.library.ts", + "default": "./dist/common/runtime/utility.library.js" + }, "#library/*.js": { "development": "./src/common/*.ts", "default": "./dist/common/*.js" diff --git a/packages/library/src/common.index.ts b/packages/library/src/common.index.ts index 9b5afd12..2b1b6b0e 100644 --- a/packages/library/src/common.index.ts +++ b/packages/library/src/common.index.ts @@ -1,33 +1,9 @@ /** - * These are utilities that are platform-agnostic + * Platform-agnostic utilities organized by domain subdirectories. */ -export * from './common/array.library.js'; -export * from './common/assertion.library.js'; -export * from './common/boundary.library.js'; -export * from './common/buffer.library.js'; -export * from './common/cipher.library.js'; -export * from './common/class.library.js'; -export * from './common/coercion.library.js'; -export * from './common/enumerate.library.js'; -export * from './common/function.library.js'; -export * from './common/international.library.js'; -export * from './common/logger.class.js'; -export * from './common/number.library.js'; -export * from './common/object.library.js'; -export * from './common/pledge.class.js'; -export * from './common/proxy.library.js'; -export * from './common/reflection.library.js'; -export * from './common/request.library.js'; -export * from './common/json.library.js'; -export * from './common/serialize.library.js'; -export * from './common/storage.library.js'; -export * from './common/string.library.js'; -export * from './common/symbol.library.js'; -export * from './common/type.library.js'; -export * from './common/temporal.polyfill.js'; -export * from './common/temporal.library.js'; -export * from './common/calendar.library.js'; -export * from './common/recurrence.library.js'; -export * from './common/utility.library.js'; -export * from './common/webtoken.library.js'; +export * from './common/primitives/index.js'; +export * from './common/temporal/index.js'; +export * from './common/security/index.js'; +export * from './common/runtime/index.js'; +export * from './common/scheduling/index.js'; diff --git a/packages/library/src/common/array.library.ts b/packages/library/src/common/primitives/array.library.ts similarity index 100% rename from packages/library/src/common/array.library.ts rename to packages/library/src/common/primitives/array.library.ts diff --git a/packages/library/src/common/assertion.library.ts b/packages/library/src/common/primitives/assertion.library.ts similarity index 99% rename from packages/library/src/common/assertion.library.ts rename to packages/library/src/common/primitives/assertion.library.ts index 04bcd293..e3fc0635 100644 --- a/packages/library/src/common/assertion.library.ts +++ b/packages/library/src/common/primitives/assertion.library.ts @@ -1,9 +1,6 @@ import { sym } from '#library/symbol.library.js'; import { getType, protoType } from '#library/type.library.js'; import type { Type, Primitive, Nullish, Temporals, Property, GetType } from '#library/type.library.js'; -import { isJSON, isRawJSON } from '#library/json.library.js'; - -export { isJSON, isRawJSON }; /** * Asserts if a value matches one of the provided types from the Type system. diff --git a/packages/library/src/common/coercion.library.ts b/packages/library/src/common/primitives/coercion.library.ts similarity index 100% rename from packages/library/src/common/coercion.library.ts rename to packages/library/src/common/primitives/coercion.library.ts diff --git a/packages/library/src/common/primitives/index.ts b/packages/library/src/common/primitives/index.ts new file mode 100644 index 00000000..78165496 --- /dev/null +++ b/packages/library/src/common/primitives/index.ts @@ -0,0 +1,9 @@ +export * from './array.library.js'; +export * from './assertion.library.js'; +export * from './coercion.library.js'; +export * from './number.library.js'; +export * from './object.library.js'; +export * from './primitive.library.js'; +export * from './string.library.js'; +export * from './symbol.library.js'; +export * from './type.library.js'; diff --git a/packages/library/src/common/number.library.ts b/packages/library/src/common/primitives/number.library.ts similarity index 100% rename from packages/library/src/common/number.library.ts rename to packages/library/src/common/primitives/number.library.ts diff --git a/packages/library/src/common/object.library.ts b/packages/library/src/common/primitives/object.library.ts similarity index 100% rename from packages/library/src/common/object.library.ts rename to packages/library/src/common/primitives/object.library.ts diff --git a/packages/library/src/common/primitive.library.ts b/packages/library/src/common/primitives/primitive.library.ts similarity index 98% rename from packages/library/src/common/primitive.library.ts rename to packages/library/src/common/primitives/primitive.library.ts index 3963e35f..91a0617c 100644 --- a/packages/library/src/common/primitive.library.ts +++ b/packages/library/src/common/primitives/primitive.library.ts @@ -26,7 +26,7 @@ export function unwrap(obj: T): T { // Use direct reads so proxy get-traps can surface synthetic $Target values. while (curr) { - const next = curr[sym.$Target] ?? (curr as any).$Target; + const next = curr[sym.$Target]; if (!next || depth >= maxDepth) break; curr = next; depth++; diff --git a/packages/library/src/common/string.library.ts b/packages/library/src/common/primitives/string.library.ts similarity index 100% rename from packages/library/src/common/string.library.ts rename to packages/library/src/common/primitives/string.library.ts diff --git a/packages/library/src/common/symbol.library.ts b/packages/library/src/common/primitives/symbol.library.ts similarity index 100% rename from packages/library/src/common/symbol.library.ts rename to packages/library/src/common/primitives/symbol.library.ts diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/primitives/type.library.ts similarity index 93% rename from packages/library/src/common/type.library.ts rename to packages/library/src/common/primitives/type.library.ts index 57fe0c4f..d59493d3 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/primitives/type.library.ts @@ -194,8 +194,6 @@ export type OneKey = { [Q in keyof O]: O[Q] } : never }[K] -/** @deprecated natively supported by modern IDEs via hover verbosity. Slated for removal in v4.0.0 */ -export type Prettify = { [K in keyof T]: T[K]; } & {} export type ParseInt = T extends `${infer N extends number}` ? N : never export type Plural = `${T}s`; export type Singular = T extends `${infer S}s` @@ -454,3 +452,49 @@ export type LooseKey = K | LooseProperty /** Extend an object with a generic-signature */ export type Extend = T & { [P in K]: V } + +/** + * Represents a value that can either be a direct scalar or a synchronous supplier function. + */ +export type Evaluable = T | (() => T); + +/** + * Represents a value that can either be a direct scalar, a Promise, a thenable (such as Pledge), a synchronous supplier function, or an asynchronous supplier function. + */ +export type AsyncEvaluable = T | PromiseLike | (() => T | PromiseLike); + +/** + * Maps an object type so that each property value can be provided as an `Evaluable`. + */ +export type EvaluableRecord = { + [K in keyof T]: Evaluable; +}; + +/** + * Maps an object type so that each property value can be provided as an `AsyncEvaluable`. + */ +export type AsyncEvaluableRecord = { + [K in keyof T]: AsyncEvaluable; +}; + +/** + * Unwraps an `Evaluable` or `AsyncEvaluable` to its resolved value type. + */ +export type Resolved = T extends (...args: any[]) => infer R + ? Awaited + : Awaited; + +/** + * Unwraps an object's evaluable properties to their synchronously evaluated values. + */ +export type Evaluated = { + [K in keyof T]: T[K] extends () => infer R ? R : T[K]; +}; + +/** + * Unwraps an object's asynchronous evaluable properties to their resolved values. + */ +export type AsyncEvaluated = { + [K in keyof T]: Resolved; +}; + diff --git a/packages/library/src/common/boundary.library.ts b/packages/library/src/common/runtime/boundary.library.ts similarity index 95% rename from packages/library/src/common/boundary.library.ts rename to packages/library/src/common/runtime/boundary.library.ts index 3361a6b5..c64a89e0 100644 --- a/packages/library/src/common/boundary.library.ts +++ b/packages/library/src/common/runtime/boundary.library.ts @@ -1,4 +1,4 @@ -import { isString } from './assertion.library.js'; +import { isString } from '../primitives/assertion.library.js'; import type { Logger } from './logger.class.js'; export interface BoundaryContext { diff --git a/packages/library/src/common/class.library.ts b/packages/library/src/common/runtime/class.library.ts similarity index 100% rename from packages/library/src/common/class.library.ts rename to packages/library/src/common/runtime/class.library.ts diff --git a/packages/library/src/common/enumerate.library.ts b/packages/library/src/common/runtime/enumerate.library.ts similarity index 98% rename from packages/library/src/common/enumerate.library.ts rename to packages/library/src/common/runtime/enumerate.library.ts index a5e007e5..9da8bfdc 100644 --- a/packages/library/src/common/enumerate.library.ts +++ b/packages/library/src/common/runtime/enumerate.library.ts @@ -93,7 +93,7 @@ export function enumify(this: any, list: T, frozen = true): any { switch (arg.type) { case 'Enumify': case 'Object': - Object.assign(target, arg.value); + Object.defineProperties(target, Object.getOwnPropertyDescriptors(arg.value)); break; case 'Array': diff --git a/packages/library/src/common/runtime/evaluation.library.ts b/packages/library/src/common/runtime/evaluation.library.ts new file mode 100644 index 00000000..cb2f5a87 --- /dev/null +++ b/packages/library/src/common/runtime/evaluation.library.ts @@ -0,0 +1,123 @@ +import { isFunction, isNullish, isObject, isPromise } from '#library/assertion.library.js'; +import type { Evaluable, AsyncEvaluable, Evaluated, AsyncEvaluated } from '#library/type.library.js'; + +/** + * Evaluates candidate synchronous scalars or supplier functions in order, returning the first defined result (lazy coalesce). + * If a candidate is a function, it is invoked with zero arguments. + * Candidates after the first defined value are never evaluated (short-circuiting). + * If all candidates evaluate to undefined, returns undefined. + * Any exception thrown by an evaluated supplier function bubbles directly to the caller. + * + * @param values - One or more scalars or synchronous supplier functions to evaluate in sequence + * @returns The first resolved defined value, or undefined if none resolved + * @example + * ```ts + * evaluate(42); // 42 + * evaluate(() => 'UTC'); // 'UTC' + * evaluate(undefined, 'fallback'); // 'fallback' + * evaluate(undefined, () => undefined, () => 'dynamic-fallback', 'final'); // 'dynamic-fallback' + * ``` + */ +export function evaluate(first: Evaluable | undefined, fallback: Evaluable, ...rest: Evaluable[]): T; +export function evaluate(...values: (Evaluable | undefined)[]): T | undefined; +export function evaluate(...values: (Evaluable | undefined)[]): T | undefined { + for (const val of values) { + const resolved = isFunction(val) ? (val as () => T)() : val; + if (resolved !== undefined) return resolved as T; + } + return undefined; +} + +/** + * Evaluates candidate synchronous or asynchronous scalars, Promises, or supplier functions in order, returning the first defined result (async lazy coalesce). + * Supplier functions represent deferred asynchronous work and are invoked lazily only when reached during evaluation. + * Rejection handlers are attached upfront to direct Promise candidates to prevent unobserved rejections if iteration short-circuits on an earlier defined candidate. + * If all candidates evaluate to undefined, returns undefined. + * Any exception or rejection thrown by an evaluated candidate bubbles directly to the caller. + * + * @param values - One or more scalars, Promises, or async supplier functions to evaluate in sequence + * @returns A Promise resolving to the first defined value, or undefined if none resolved + * @example + * ```ts + * await evaluateAsync('apiKey123'); // 'apiKey123' + * await evaluateAsync(undefined, async () => fetchSecret(), 'defaultKey'); // 'secret' + * ``` + */ +export function evaluateAsync(first: AsyncEvaluable | undefined, fallback: AsyncEvaluable, ...rest: AsyncEvaluable[]): Promise; +export function evaluateAsync(...values: (AsyncEvaluable | undefined)[]): Promise; +export async function evaluateAsync(...values: (AsyncEvaluable | undefined)[]): Promise { + const promises = values.map(val => { + if (!isFunction(val)) { + const p = Promise.resolve(val); + p.catch(() => {}); + return p; + } + return undefined; + }); + + for (let i = 0; i < values.length; i++) { + const val = values[i]; + const resolved = isFunction(val) ? await (val as () => T | Promise)() : await promises[i]; + if (resolved !== undefined) return resolved as T; + } + return undefined; +} + +/** + * Resolves all top-level properties of a configuration object synchronously. + * For each property whose value is a function, executes the function and sets the property to its return value. + * + * @param config - The configuration object to evaluate + * @returns A new object with all properties synchronously resolved + * @example + * ```ts + * const evaluated = evaluateConfig({ + * timeZone: () => 'America/New_York', + * locale: 'en-US' + * }); + * // { timeZone: 'America/New_York', locale: 'en-US' } + * ``` + */ +export function evaluateConfig(config: T): Evaluated { + if (isNullish(config) || !isObject(config)) return config as any; + const result = { ...config } as any; + + for (const key of Object.keys(config) as (keyof T)[]) { + const val = config[key]; + result[key] = isFunction(val) ? (val as () => any)() : val; + } + + return result; +} + +/** + * Resolves all top-level properties of a configuration object asynchronously. + * For each property whose value is a function or Promise, executes/awaits the value and sets the property. + * + * @param config - The configuration object to evaluate + * @returns A Promise resolving to a new object with all properties resolved + * @example + * ```ts + * const evaluated = await evaluateConfigAsync({ + * key: async () => fetchKey(), + * url: 'https://api.openai.com/v1' + * }); + * // { key: 'sk-...', url: 'https://api.openai.com/v1' } + * ``` + */ +export async function evaluateConfigAsync(config: T): Promise> { + if (isNullish(config) || !isObject(config)) return config as any; + const result = { ...config } as any; + const keys = Object.keys(config) as (keyof T)[]; + const values = await Promise.all( + keys.map(key => { + const val = config[key]; + return isFunction(val) ? (val as () => any)() : val; + }) + ); + + for (let i = 0; i < keys.length; i++) + result[keys[i]] = values[i]; + + return result; +} diff --git a/packages/library/src/common/function.library.ts b/packages/library/src/common/runtime/function.library.ts similarity index 100% rename from packages/library/src/common/function.library.ts rename to packages/library/src/common/runtime/function.library.ts diff --git a/packages/library/src/common/runtime/index.ts b/packages/library/src/common/runtime/index.ts new file mode 100644 index 00000000..a8b583ae --- /dev/null +++ b/packages/library/src/common/runtime/index.ts @@ -0,0 +1,16 @@ +export * from './boundary.library.js'; +export * from './class.library.js'; +export * from './enumerate.library.js'; +export * from './evaluation.library.js'; +export * from './function.library.js'; +export * from './international.library.js'; +export * from './json.library.js'; +export * from './logger.class.js'; +export * from './pledge.class.js'; +export * from './proxy.library.js'; +export * from './reflection.library.js'; +export * from './request.library.js'; +export * from './scopedset.class.js'; +export * from './serialize.library.js'; +export * from './storage.library.js'; +export * from './utility.library.js'; diff --git a/packages/library/src/common/international.library.ts b/packages/library/src/common/runtime/international.library.ts similarity index 100% rename from packages/library/src/common/international.library.ts rename to packages/library/src/common/runtime/international.library.ts diff --git a/packages/library/src/common/json.library.ts b/packages/library/src/common/runtime/json.library.ts similarity index 100% rename from packages/library/src/common/json.library.ts rename to packages/library/src/common/runtime/json.library.ts diff --git a/packages/library/src/common/logger.class.ts b/packages/library/src/common/runtime/logger.class.ts similarity index 100% rename from packages/library/src/common/logger.class.ts rename to packages/library/src/common/runtime/logger.class.ts diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/runtime/pledge.class.ts similarity index 100% rename from packages/library/src/common/pledge.class.ts rename to packages/library/src/common/runtime/pledge.class.ts diff --git a/packages/library/src/common/proxy.library.ts b/packages/library/src/common/runtime/proxy.library.ts similarity index 85% rename from packages/library/src/common/proxy.library.ts rename to packages/library/src/common/runtime/proxy.library.ts index d73ceafc..d9dfe32a 100644 --- a/packages/library/src/common/proxy.library.ts +++ b/packages/library/src/common/runtime/proxy.library.ts @@ -2,8 +2,8 @@ import { sym } from '#library/symbol.library.js'; import { allObject } from '#library/reflection.library.js'; import { deepFreeze } from '#library/utility.library.js'; import { unwrap } from '#library/primitive.library.js'; -import { isString, isFunction, isSymbol, isDefined, isNumber } from '#library/assertion.library.js'; -import { registerType, type Constructor } from '#library/type.library.js'; +import { isString, isFunction, isSymbol, isDefined, isNumber, isObject } from '#library/assertion.library.js'; +import { registerType, type Constructor, type Evaluated } from '#library/type.library.js'; const boundMethodCache = new WeakMap>(); @@ -118,9 +118,8 @@ function factory(target: T, options: ProxyOptions = {}): T { } // silent mark to avoid redundant discovery // Only define if object is extensible and not frozen - if (Reflect.isExtensible(t) && !Object.isFrozen(t) && !Reflect.has(t, k)) { + if (Reflect.isExtensible(t) && !Object.isFrozen(t) && !Reflect.has(t, k)) Object.defineProperty(t, k, { value: undefined, writable: true, enumerable: false, configurable: true }); - } } const val = Reflect.get(t, k, r); @@ -255,3 +254,47 @@ export function indexedArray( : undefined; }, readonly) as any; } + +/** + * Wraps an object in a dynamic evaluation Proxy where any property that is a function/supplier + * is transparently invoked upon read access. + * Non-function properties and symbol traps are forwarded as-is. + * + * @param target - The object containing dynamic properties or suppliers + * @returns A Proxy wrapping the target where property reads automatically resolve functions + * @example + * ```ts + * const userContext = { + * timeZone: () => currentSession.tz, + * locale: 'en-US' + * }; + * const proxy = dynamicProxy(userContext); + * proxy.timeZone; // Returns dynamic currentSession.tz + * proxy.locale; // 'en-US' + * ``` + */ +export function dynamicProxy(target: T): Evaluated { + if (!isObject(target)) return target as any; + return new Proxy(unwrap(target), { + get(t, k, r) { + if (k === sym.$Target) return t; + const val = Reflect.get(t, k, r); + if (!isFunction(val) || isSymbol(k) || k === 'constructor') + return val; + const desc = Reflect.getOwnPropertyDescriptor(t, k); + if (desc && !desc.configurable && !desc.writable) + return val; + return (val as () => any)(); + }, + has(t, k) { + return Reflect.has(t, k); + }, + ownKeys(t) { + return Reflect.ownKeys(t); + }, + getOwnPropertyDescriptor(t, k) { + return Reflect.getOwnPropertyDescriptor(t, k); + }, + }) as any; +} + diff --git a/packages/library/src/common/reflection.library.ts b/packages/library/src/common/runtime/reflection.library.ts similarity index 100% rename from packages/library/src/common/reflection.library.ts rename to packages/library/src/common/runtime/reflection.library.ts diff --git a/packages/library/src/common/request.library.ts b/packages/library/src/common/runtime/request.library.ts similarity index 100% rename from packages/library/src/common/request.library.ts rename to packages/library/src/common/runtime/request.library.ts diff --git a/packages/library/src/common/scopedset.class.ts b/packages/library/src/common/runtime/scopedset.class.ts similarity index 100% rename from packages/library/src/common/scopedset.class.ts rename to packages/library/src/common/runtime/scopedset.class.ts diff --git a/packages/library/src/common/serialize.library.ts b/packages/library/src/common/runtime/serialize.library.ts similarity index 100% rename from packages/library/src/common/serialize.library.ts rename to packages/library/src/common/runtime/serialize.library.ts diff --git a/packages/library/src/common/storage.library.ts b/packages/library/src/common/runtime/storage.library.ts similarity index 96% rename from packages/library/src/common/storage.library.ts rename to packages/library/src/common/runtime/storage.library.ts index b075521d..433e32c4 100644 --- a/packages/library/src/common/storage.library.ts +++ b/packages/library/src/common/runtime/storage.library.ts @@ -76,6 +76,11 @@ export function getStorage(key?: string, dflt?: T): T | undefined { store = context.global.PropertiesService?.getUserProperties().getProperty(key); break; + case CONTEXT.WebWorker: + case CONTEXT.Unknown: + store = undefined; + break; + default: throw new Error(`Cannot determine Javascript context: ${context.type}`); } @@ -126,6 +131,10 @@ export function setStorage(key: string, val?: T) { : context.global.PropertiesService?.getUserProperties().deleteProperty(key) break; + case CONTEXT.WebWorker: + case CONTEXT.Unknown: + break; + default: throw new Error(`Cannot determine Javascript context: ${context.type}`); } diff --git a/packages/library/src/common/utility.library.ts b/packages/library/src/common/runtime/utility.library.ts similarity index 86% rename from packages/library/src/common/utility.library.ts rename to packages/library/src/common/runtime/utility.library.ts index 35147e8b..5f717080 100644 --- a/packages/library/src/common/utility.library.ts +++ b/packages/library/src/common/runtime/utility.library.ts @@ -1,5 +1,5 @@ import { ownEntries } from '#library/primitive.library.js'; -import { isDefined, isPrimitive } from '#library/assertion.library.js'; +import { isDefined, isFunction, isPrimitive } from '#library/assertion.library.js'; import { sym } from '#library/symbol.library.js'; import type { Secure, ValueOf } from '#library/type.library.js'; @@ -62,6 +62,7 @@ export const sleep = (msg = 'sleep: timed out', timeout = 2000) => export const CONTEXT = { 'Unknown': 'unknown', 'Browser': 'browser', + 'WebWorker': 'web-worker', 'NodeJS': 'nodejs', 'Deno': 'deno', 'GoogleAppsScript': 'google-apps-script', @@ -83,17 +84,22 @@ type Context = { global: any, type: CONTEXT } export const getContext = (): Context => { const global = globalThis as any; - if (isDefined(global.SpreadsheetApp)) - return { global, type: CONTEXT.GoogleAppsScript }; + try { + if (isDefined(global.SpreadsheetApp)) + return { global, type: CONTEXT.GoogleAppsScript }; - if (isDefined(global.window?.document)) - return { global, type: CONTEXT.Browser }; + if (isDefined(global.window?.document)) + return { global, type: CONTEXT.Browser }; - if (isDefined(global.Deno)) - return { global, type: CONTEXT.Deno }; + if (isFunction(global.importScripts) || (isDefined(global.WorkerGlobalScope) && global instanceof global.WorkerGlobalScope)) + return { global, type: CONTEXT.WebWorker }; - if (isDefined(global.process?.versions?.node)) - return { global, type: CONTEXT.NodeJS }; + if (isDefined(global.Deno)) + return { global, type: CONTEXT.Deno }; + + if (isDefined(global.process?.versions?.node) && global.process?.release?.name === 'node') + return { global, type: CONTEXT.NodeJS }; + } catch { } return { global, type: CONTEXT.Unknown }; } diff --git a/packages/library/src/common/scheduling/cron.library.ts b/packages/library/src/common/scheduling/cron.library.ts new file mode 100644 index 00000000..e634164e --- /dev/null +++ b/packages/library/src/common/scheduling/cron.library.ts @@ -0,0 +1,175 @@ +import '#library/temporal.polyfill.js'; +import { isString } from '#library/assertion.library.js'; + +export interface CronField { + allowed: Set; + restricted: boolean; +} + +export interface CronSchedule { + minutes: CronField; + hours: CronField; + daysOfMonth: CronField; + months: CronField; + daysOfWeek: CronField; +} + +function parseStrictInt(token: string, min: number, max: number): number { + const trimmed = token.trim(); + if (!/^\d+$/.test(trimmed)) + throw new Error(`Invalid numeric cron token: "${token}"`); + + const num = Number(trimmed); + if (num < min || num > max) + throw new Error(`Cron token out of range [${min}-${max}]: "${token}"`); + + return num; +} + +function parseCronField(field: string, min: number, max: number): CronField { + const allowed = new Set(); + if (field === '*' || field === '?') { + for (let i = min; i <= max; i++) allowed.add(i); + return { allowed, restricted: false }; + } + + const parts = field.split(','); + for (const part of parts) { + if (part.includes('/')) { + const [range, stepStr] = part.split('/'); + if (!/^\d+$/.test(stepStr.trim())) + throw new Error(`Invalid step value: ${stepStr}`); + + const step = Number(stepStr.trim()); + if (step <= 0) + throw new Error(`Invalid step value: ${stepStr}`); + + let start = min; + let end = max; + if (range !== '*') { + if (range.includes('-')) { + const rangeParts = range.split('-'); + if (rangeParts.length !== 2) throw new Error(`Invalid range: ${range}`); + start = parseStrictInt(rangeParts[0], min, max); + end = parseStrictInt(rangeParts[1], min, max); + } else { + start = parseStrictInt(range, min, max); + end = start; + } + if (start > end) throw new Error(`Invalid range: ${range}`); + } + for (let i = start; i <= end; i += step) + allowed.add(i); + } else if (part.includes('-')) { + const rangeParts = part.split('-'); + if (rangeParts.length !== 2) throw new Error(`Invalid range: ${part}`); + const start = parseStrictInt(rangeParts[0], min, max); + const end = parseStrictInt(rangeParts[1], min, max); + if (start > end) throw new Error(`Invalid range: ${part}`); + + for (let i = start; i <= end; i++) + allowed.add(i); + } else + allowed.add(parseStrictInt(part, min, max)); + } + + return { allowed, restricted: true }; +} + +export function parseCron(pattern: string): CronSchedule { + const fields = pattern.trim().split(/\s+/); + if (fields.length !== 5) + throw new Error('Invalid cron pattern. Expected 5 fields (min, hr, dom, mon, dow).'); + + return { + minutes: parseCronField(fields[0], 0, 59), + hours: parseCronField(fields[1], 0, 23), + daysOfMonth: parseCronField(fields[2], 1, 31), + months: parseCronField(fields[3], 1, 12), + daysOfWeek: parseCronField(fields[4], 0, 7) + } +} + +export function isCronString(val: unknown): val is string { + if (!isString(val)) return false; + const trimmed = val.trim(); + if (trimmed.startsWith('FREQ=') || trimmed.startsWith('RRULE:')) return false; + const fields = trimmed.split(/\s+/); + if (fields.length !== 5) return false; + try { + parseCron(trimmed); + return true; + } catch { + return false; + } +} + +function matchesDay(schedule: CronSchedule, day: number, dow: number): boolean { + const domMatch = schedule.daysOfMonth.allowed.has(day); + const dowMatch = schedule.daysOfWeek.allowed.has(dow) || (dow === 7 && schedule.daysOfWeek.allowed.has(0)); + + return (schedule.daysOfMonth.restricted && schedule.daysOfWeek.restricted) + ? domMatch || dowMatch + : domMatch && dowMatch +} + +function searchCronEpoch(pattern: string, anchorMs: number, timeZone: string, direction: 1 | -1): number { + const schedule = parseCron(pattern); + const zdt = Temporal.Instant.fromEpochMilliseconds(anchorMs).toZonedDateTimeISO(timeZone); + const forward = direction === 1; + + let current = forward + ? zdt.add({ minutes: 1 }).with({ second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 }) + : zdt.subtract({ minutes: 1 }).with({ second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 }); + + const limitMs = forward + ? current.add({ years: 5 }).epochMilliseconds + : current.subtract({ years: 5 }).epochMilliseconds; + + const errMessage = forward + ? 'Could not find next cron match within 5 years.' + : 'Could not find previous cron match within 5 years.'; + + for (; ;) { + if (forward ? current.epochMilliseconds > limitMs : current.epochMilliseconds < limitMs) + throw new Error(errMessage); + + if (!schedule.months.allowed.has(current.month)) { + current = forward + ? current.add({ months: 1 }).with({ day: 1, hour: 0, minute: 0 }) + : current.subtract({ months: 1 }).with({ day: current.daysInMonth, hour: 23, minute: 59 }); + continue; + } + + if (!matchesDay(schedule, current.day, current.dayOfWeek)) { + current = forward + ? current.add({ days: 1 }).with({ hour: 0, minute: 0 }) + : current.subtract({ days: 1 }).with({ hour: 23, minute: 59 }); + continue; + } + + if (!schedule.hours.allowed.has(current.hour)) { + current = forward + ? current.add({ hours: 1 }).with({ minute: 0 }) + : current.subtract({ hours: 1 }).with({ minute: 59 }); + continue; + } + + if (!schedule.minutes.allowed.has(current.minute)) { + current = forward + ? current.add({ minutes: 1 }) + : current.subtract({ minutes: 1 }); + continue; + } + + return current.epochMilliseconds; + } +} + +export function getNextCronEpoch(pattern: string, anchorMs: number, timeZone = 'UTC'): number { + return searchCronEpoch(pattern, anchorMs, timeZone, 1); +} + +export function getPrevCronEpoch(pattern: string, anchorMs: number, timeZone = 'UTC'): number { + return searchCronEpoch(pattern, anchorMs, timeZone, -1); +} diff --git a/packages/library/src/common/scheduling/index.ts b/packages/library/src/common/scheduling/index.ts new file mode 100644 index 00000000..6e6df0ae --- /dev/null +++ b/packages/library/src/common/scheduling/index.ts @@ -0,0 +1,3 @@ +export * from './cron.library.js'; +export * from './rrule.library.js'; +export * from './schedule.library.js'; diff --git a/packages/library/src/common/recurrence.library.ts b/packages/library/src/common/scheduling/rrule.library.ts similarity index 92% rename from packages/library/src/common/recurrence.library.ts rename to packages/library/src/common/scheduling/rrule.library.ts index 9ecd2805..48f9ad0d 100644 --- a/packages/library/src/common/recurrence.library.ts +++ b/packages/library/src/common/scheduling/rrule.library.ts @@ -1,4 +1,4 @@ -import { isDefined, isNumber } from './assertion.library.js'; +import { isDefined, isNumber, isString } from '#library/assertion.library.js'; import { DAYS_IN_WEEK, DAY_MAP, @@ -11,7 +11,7 @@ import { withUtcParts, type DayKey, type MonthKey, -} from './calendar.library.js'; +} from '#library/calendar.library.js'; const RE_RRULE_FREQ = /^(RRULE:)?FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)(;|$)/i; const RE_FINITE_RRULE = /(?:^|;)(?:RRULE:)?(UNTIL|COUNT)=/i; @@ -21,12 +21,13 @@ const RE_UNTIL_TIMESTAMP = /^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/ const RE_BYDAY_PART = /^([+-]?\d+)?([A-Z]+)$/i; /** - * Tests whether a string is a valid RFC 5545 Recurrence Rule (RRULE). + * Tests whether a value is a valid RFC 5545 Recurrence Rule (RRULE) string. * - * @param input - The candidate string to inspect - * @returns `true` if the string matches an RRULE pattern starting with FREQ=, otherwise `false`. + * @param input - The candidate value to inspect + * @returns `true` if the value is a string matching an RRULE pattern starting with FREQ=, otherwise `false`. */ -export function isRRuleString(input: string): boolean { +export function isRRuleString(input: unknown): input is string { + if (!isString(input)) return false; const trimmed = input.trim(); return RE_RRULE_FREQ.test(trimmed); } @@ -89,6 +90,7 @@ export function parseRRule(rrule: string): ParsedRRule { if (!key || !val) continue; const k = key.toUpperCase(); const trimmedVal = val.trim(); + const trimmedArr = trimmedVal.split(',').map(v => v.trim()); switch (k) { case 'FREQ': @@ -119,18 +121,17 @@ export function parseRRule(rrule: string): ParsedRRule { break; } case 'BYMONTH': { - const items = trimmedVal.split(',').map(v => { - const trimmed = v.trim(); - const num = parseInt(trimmed, 10); + const items = trimmedArr.map(v => { + const num = parseInt(v, 10); if (isNumber(num) && num >= 1 && num <= 12) return num; - const prefix = trimmed.slice(0, 3).toUpperCase(); + const prefix = v.slice(0, 3).toUpperCase(); return prefix in MONTH_MAP ? MONTH_MAP[prefix as MonthKey] : undefined; }).filter((v): v is number => isDefined(v)); if (items.length > 0) byMonth = items; break; } case 'BYDAY': { - const items = trimmedVal.split(',').map(item => { + const items = trimmedArr.map(item => { const m = item.match(RE_BYDAY_PART); const nthVal = m && m[1] ? parseInt(m[1], 10) : undefined; const rawDay = m ? m[2] : item; @@ -141,17 +142,17 @@ export function parseRRule(rrule: string): ParsedRRule { break; } case 'BYHOUR': { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => isNumber(v) && v >= 0 && v <= 23); + const items = trimmedArr.map(v => parseInt(v, 10)).filter(v => isNumber(v) && v >= 0 && v <= 23); if (items.length > 0) byHour = items; break; } case 'BYMINUTE': { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => isNumber(v) && v >= 0 && v <= 59); + const items = trimmedArr.map(v => parseInt(v, 10)).filter(v => isNumber(v) && v >= 0 && v <= 59); if (items.length > 0) byMinute = items; break; } case 'BYSETPOS': { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(isNumber); + const items = trimmedArr.map(v => parseInt(v, 10)).filter(isNumber); if (items.length > 0) bySetPos = items; break; } diff --git a/packages/library/src/common/scheduling/schedule.library.ts b/packages/library/src/common/scheduling/schedule.library.ts new file mode 100644 index 00000000..8084301b --- /dev/null +++ b/packages/library/src/common/scheduling/schedule.library.ts @@ -0,0 +1,35 @@ +import { isCronString, getNextCronEpoch } from './cron.library.js'; +import { isRRuleString, getNextRRuleEpoch } from './rrule.library.js'; + +/** + * Sniffs whether a given value is a valid schedule string (either Cron or RRULE). + * + * @param val - The candidate value to inspect + * @returns `true` if the value is a valid Cron pattern or RRULE string, otherwise `false`. + */ +export function isScheduleString(val: unknown): val is string { + return isCronString(val) || isRRuleString(val); +} + +/** + * Polymorphically computes the next occurrence epoch timestamp (in milliseconds) + * for a schedule expression (Cron pattern or RRULE string). + * + * @param pattern - The schedule pattern (5-field Cron or RFC 5545 RRULE) + * @param anchorMs - The starting anchor timestamp in epoch milliseconds + * @param timeZone - The IANA time zone identifier (defaults to UTC) + * @returns Next epoch timestamp in milliseconds, or `null` if no future occurrences exist + */ +export function getNextScheduleEpoch( + pattern: string, + anchorMs: number, + timeZone = 'UTC' +): number | null { + if (isRRuleString(pattern)) + return getNextRRuleEpoch(pattern, anchorMs); + + if (isCronString(pattern)) + return getNextCronEpoch(pattern, anchorMs, timeZone); + + return null; +} diff --git a/packages/library/src/common/buffer.library.ts b/packages/library/src/common/security/buffer.library.ts similarity index 100% rename from packages/library/src/common/buffer.library.ts rename to packages/library/src/common/security/buffer.library.ts diff --git a/packages/library/src/common/cipher.library.ts b/packages/library/src/common/security/cipher.library.ts similarity index 100% rename from packages/library/src/common/cipher.library.ts rename to packages/library/src/common/security/cipher.library.ts diff --git a/packages/library/src/common/security/index.ts b/packages/library/src/common/security/index.ts new file mode 100644 index 00000000..9e9af7cf --- /dev/null +++ b/packages/library/src/common/security/index.ts @@ -0,0 +1,3 @@ +export * from './buffer.library.js'; +export * from './cipher.library.js'; +export * from './webtoken.library.js'; diff --git a/packages/library/src/common/webtoken.library.ts b/packages/library/src/common/security/webtoken.library.ts similarity index 98% rename from packages/library/src/common/webtoken.library.ts rename to packages/library/src/common/security/webtoken.library.ts index fe5eba3c..3840cb2a 100644 --- a/packages/library/src/common/webtoken.library.ts +++ b/packages/library/src/common/security/webtoken.library.ts @@ -1,5 +1,5 @@ import { base64ToBuffer, bufferToBase64, encodeBuffer, decodeBuffer } from './buffer.library.js'; -import { Logger } from './logger.class.js'; +import { Logger } from '../runtime/logger.class.js'; import { keys } from './cipher.library.js'; const logger = new Logger('WebToken'); diff --git a/packages/library/src/common/calendar.library.ts b/packages/library/src/common/temporal/calendar.library.ts similarity index 100% rename from packages/library/src/common/calendar.library.ts rename to packages/library/src/common/temporal/calendar.library.ts diff --git a/packages/library/src/common/temporal/index.ts b/packages/library/src/common/temporal/index.ts new file mode 100644 index 00000000..b06b788c --- /dev/null +++ b/packages/library/src/common/temporal/index.ts @@ -0,0 +1,3 @@ +export * from './calendar.library.js'; +export * from './temporal.library.js'; +export * from './temporal.polyfill.js'; diff --git a/packages/library/src/common/temporal.library.ts b/packages/library/src/common/temporal/temporal.library.ts similarity index 100% rename from packages/library/src/common/temporal.library.ts rename to packages/library/src/common/temporal/temporal.library.ts diff --git a/packages/library/src/common/temporal.polyfill.ts b/packages/library/src/common/temporal/temporal.polyfill.ts similarity index 95% rename from packages/library/src/common/temporal.polyfill.ts rename to packages/library/src/common/temporal/temporal.polyfill.ts index f7f4505e..aff3f8b3 100644 --- a/packages/library/src/common/temporal.polyfill.ts +++ b/packages/library/src/common/temporal/temporal.polyfill.ts @@ -16,7 +16,7 @@ if (typeof globalThis.Temporal === 'undefined') { ); } -import { asError } from './coercion.library.js'; +import { asError } from '../primitives/coercion.library.js'; // šŸ›”ļø Sane Implementation Check // Some early native implementations (e.g. Node 22.0.x) are incomplete and crash on basic arithmetic. diff --git a/packages/library/src/server/auth.library.ts b/packages/library/src/server/auth.library.ts index e4127dea..0e381f59 100644 --- a/packages/library/src/server/auth.library.ts +++ b/packages/library/src/server/auth.library.ts @@ -1,4 +1,4 @@ -import { decodeJWT } from '../common/webtoken.library.js'; +import { decodeJWT } from '../common/security/webtoken.library.js'; const MAX_TOKEN_LENGTH = 8192; // 8 KB const MAX_PAYLOAD_LENGTH = 4096; // 4 KB diff --git a/packages/library/src/tsconfig.json b/packages/library/src/tsconfig.json index c213dda2..5ab62747 100644 --- a/packages/library/src/tsconfig.json +++ b/packages/library/src/tsconfig.json @@ -20,9 +20,19 @@ "./common.index.ts" ], "#library/*.js": [ + "./common/primitives/*.ts", + "./common/temporal/*.ts", + "./common/security/*.ts", + "./common/runtime/*.ts", + "./common/scheduling/*.ts", "./common/*.ts" ], "#library/*": [ + "./common/primitives/*", + "./common/temporal/*", + "./common/security/*", + "./common/runtime/*", + "./common/scheduling/*", "./common/*" ], "#browser/*.js": [ diff --git a/packages/library/CHANGELOG.md b/packages/library/test/CHANGELOG.md similarity index 73% rename from packages/library/CHANGELOG.md rename to packages/library/test/CHANGELOG.md index df0d106e..2f7c9e82 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/test/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.0] - 2026-08-21 + +### Added +- **Scheduling Domain Engine (`scheduling/`)**: Introduced the `src/common/scheduling/` domain architecture containing `cron.library.ts` (5-field UNIX Cron expression parser & evaluator), `rrule.library.ts` (RFC 5545 RRULE parser & evaluator, replacing `recurrence.library.ts`), and `schedule.library.ts` (polymorphic schedule engine providing `isScheduleString` and `getNextScheduleEpoch`). +- **Dynamic Evaluation Utilities (`evaluation.library`)**: Added `#library/evaluation.library.js` containing `evaluate(...values)`, `evaluateAsync(...values)`, `evaluateConfig(config)`, and `evaluateConfigAsync(config)`. Supports variadic synchronous and asynchronous value/supplier evaluation with lazy, short-circuiting coalescing. +- **Dynamic Property Proxy (`proxy.library`)**: Added `dynamicProxy(target)` for zero-overhead dynamic property proxying with on-access evaluation of function-valued properties on the target object. +- **Standardized Evaluation Types (`type.library`)**: Added `Evaluable`, `AsyncEvaluable`, `EvaluableRecord`, `AsyncEvaluableRecord`, `Evaluated`, `AsyncEvaluated`, and unified `Resolved` on top of `Awaited`. +- **WebWorker Context & Runtime Hardening (`utility.library`)**: Added `CONTEXT.WebWorker` to `getContext()` with `WorkerGlobalScope` / `importScripts` detection and hardened runtime checks against global property tampering. + +### Changed +- **Domain Subdirectory Architecture (`src/common/`)**: Reorganized internal common utilities into domain-focused subdirectories (`primitives/`, `temporal/`, `security/`, `runtime/`, `scheduling/`). Resolution via `#library/*` path aliases and root barrel re-exports (`@magmacomputing/library`) automatically resolve across domain subdirectories without breaking consumers. + ## [3.11.1] - 2026-08-06 ### Added diff --git a/packages/library/test/common/assertion.library.test.ts b/packages/library/test/common/primitives/assertion.library.test.ts similarity index 96% rename from packages/library/test/common/assertion.library.test.ts rename to packages/library/test/common/primitives/assertion.library.test.ts index b3341a71..71a58653 100644 --- a/packages/library/test/common/assertion.library.test.ts +++ b/packages/library/test/common/primitives/assertion.library.test.ts @@ -1,5 +1,5 @@ -import { isNumber, isNumeric, isString, isText, isPrimitive, isArrayLike, isObject, isPlainObject, isInteger, isJSON, isRawJSON, isEmpty } from '#library/assertion.library.js'; -import { rawJSON } from '#library/json.library.js'; +import { isNumber, isNumeric, isText, isArrayLike, isPlainObject, isEmpty } from '#library/assertion.library.js'; +import { isJSON, isRawJSON, rawJSON } from '#library/json.library.js'; describe('Assertion Library', () => { diff --git a/packages/library/test/common/coercion.library.test.ts b/packages/library/test/common/primitives/coercion.library.test.ts similarity index 97% rename from packages/library/test/common/coercion.library.test.ts rename to packages/library/test/common/primitives/coercion.library.test.ts index e15f0023..09caca4b 100644 --- a/packages/library/test/common/coercion.library.test.ts +++ b/packages/library/test/common/primitives/coercion.library.test.ts @@ -1,4 +1,4 @@ -import { ifNumeric, asInteger, asText, asNumber, when } from '../../src/common/coercion.library.js'; +import { ifNumeric, asInteger, asText, asNumber, when } from '#library/coercion.library.js'; describe('Coercion Library', () => { describe('asText', () => { diff --git a/packages/library/test/common/number.library.test.ts b/packages/library/test/common/primitives/number.library.test.ts similarity index 100% rename from packages/library/test/common/number.library.test.ts rename to packages/library/test/common/primitives/number.library.test.ts diff --git a/packages/library/test/common/string.library.test.ts b/packages/library/test/common/primitives/string.library.test.ts similarity index 100% rename from packages/library/test/common/string.library.test.ts rename to packages/library/test/common/primitives/string.library.test.ts diff --git a/packages/library/test/common/type.library.test.ts b/packages/library/test/common/primitives/type.library.test.ts similarity index 100% rename from packages/library/test/common/type.library.test.ts rename to packages/library/test/common/primitives/type.library.test.ts diff --git a/packages/library/test/common/boundary.library.test.ts b/packages/library/test/common/runtime/boundary.library.test.ts similarity index 95% rename from packages/library/test/common/boundary.library.test.ts rename to packages/library/test/common/runtime/boundary.library.test.ts index a4433c50..67da0efd 100644 --- a/packages/library/test/common/boundary.library.test.ts +++ b/packages/library/test/common/runtime/boundary.library.test.ts @@ -1,4 +1,4 @@ -import { raise } from '../../src/common/boundary.library.js'; +import { raise } from '#library/boundary.library.js'; describe('Boundary Library', () => { let consoleSpy: any; diff --git a/packages/library/test/common/class.library.test.ts b/packages/library/test/common/runtime/class.library.test.ts similarity index 95% rename from packages/library/test/common/class.library.test.ts rename to packages/library/test/common/runtime/class.library.test.ts index b806683b..b3ed07bf 100644 --- a/packages/library/test/common/class.library.test.ts +++ b/packages/library/test/common/runtime/class.library.test.ts @@ -1,4 +1,4 @@ -import { Immutable, Securable } from '../../src/common/class.library.js'; +import { Immutable, Securable } from '#library/class.library.js'; describe('Class Decorators: Immutable & Secure', () => { it('Immutable: should throw on mutation (Object.freeze, strict mode)', () => { diff --git a/packages/library/test/common/enumerate.test.ts b/packages/library/test/common/runtime/enumerate.test.ts similarity index 100% rename from packages/library/test/common/enumerate.test.ts rename to packages/library/test/common/runtime/enumerate.test.ts diff --git a/packages/library/test/common/runtime/evaluation.library.test.ts b/packages/library/test/common/runtime/evaluation.library.test.ts new file mode 100644 index 00000000..f4478023 --- /dev/null +++ b/packages/library/test/common/runtime/evaluation.library.test.ts @@ -0,0 +1,310 @@ +import { evaluate, evaluateAsync, evaluateConfig, evaluateConfigAsync } from '#library/evaluation.library.js'; +import { dynamicProxy } from '#library/proxy.library.js'; +import { Pledge } from '#library/pledge.class.js'; + +describe('evaluation.library', () => { + describe('evaluate()', () => { + it('should return scalar values as-is', () => { + expect(evaluate(42)).toBe(42); + expect(evaluate('UTC')).toBe('UTC'); + expect(evaluate(true)).toBe(true); + expect(evaluate(null)).toBe(null); + expect(evaluate(undefined)).toBe(undefined); + const obj = { a: 1 }; + expect(evaluate(obj)).toBe(obj); + }); + + it('should evaluate synchronous supplier functions', () => { + expect(evaluate(() => 100)).toBe(100); + expect(evaluate(() => 'America/New_York')).toBe('America/New_York'); + + let count = 0; + const counter = () => ++count; + expect(evaluate(counter)).toBe(1); + expect(evaluate(counter)).toBe(2); + expect(evaluate(counter)).toBe(3); + }); + + it('should support fallback values and fallback suppliers', () => { + expect(evaluate(undefined, 'default-tz')).toBe('default-tz'); + expect(evaluate(undefined, () => 'supplier-fallback')).toBe('supplier-fallback'); + expect(evaluate('explicit-value', 'fallback')).toBe('explicit-value'); + expect(evaluate(() => 'supplier-value', 'fallback')).toBe('supplier-value'); + }); + + it('should coalesce multiple candidates in sequence and short-circuit', () => { + let thirdCalled = false; + const result = evaluate( + undefined, + () => undefined, + () => 'first-defined', + () => { + thirdCalled = true; + return 'should-not-reach'; + } + ); + expect(result).toBe('first-defined'); + expect(thirdCalled).toBe(false); + }); + + it('should allow exceptions thrown in suppliers to bubble naturally', () => { + const throwingSupplier = () => { + throw new Error('Supplier failed'); + }; + expect(() => evaluate(throwingSupplier)).toThrow('Supplier failed'); + }); + }); + + describe('evaluateAsync()', () => { + it('should resolve scalar values', async () => { + expect(await evaluateAsync(42)).toBe(42); + expect(await evaluateAsync('secret-token')).toBe('secret-token'); + expect(await evaluateAsync(null)).toBe(null); + }); + + it('should resolve synchronous supplier functions', async () => { + expect(await evaluateAsync(() => 'sync-value')).toBe('sync-value'); + }); + + it('should resolve asynchronous supplier functions', async () => { + const asyncSupplier = async () => { + return 'vault-token-xyz'; + }; + expect(await evaluateAsync(asyncSupplier)).toBe('vault-token-xyz'); + + const promiseSupplier = () => Promise.resolve(999); + expect(await evaluateAsync(promiseSupplier)).toBe(999); + }); + + it('should resolve direct Promise values', async () => { + const directPromise: Promise = Promise.resolve('direct-resolved-value'); + const result: string = (await evaluateAsync(directPromise))!; + expect(result).toBe('direct-resolved-value'); + }); + + it('should support async fallbacks and suppliers', async () => { + expect(await evaluateAsync(undefined, 'default-key')).toBe('default-key'); + expect(await evaluateAsync(undefined, async () => 'async-default-key')).toBe('async-default-key'); + expect(await evaluateAsync('explicit-key', 'fallback-key')).toBe('explicit-key'); + }); + + it('should coalesce multiple async candidates in sequence and short-circuit', async () => { + let thirdCalled = false; + const result = await evaluateAsync( + undefined, + async () => undefined, + async () => 'async-first-defined', + async () => { + thirdCalled = true; + return 'should-not-reach'; + } + ); + expect(result).toBe('async-first-defined'); + expect(thirdCalled).toBe(false); + }); + + it('should allow asynchronous rejections to bubble naturally', async () => { + const failingAsyncSupplier = async () => { + throw new Error('Vault timeout'); + }; + await expect(evaluateAsync(failingAsyncSupplier)).rejects.toThrow('Vault timeout'); + }); + + it('should attach rejection handlers to direct Promise candidates to prevent unhandled rejections upon short-circuiting', async () => { + const resolvedPromise = Promise.resolve('early-success'); + const rejectedPromise = Promise.reject(new Error('Unobserved error')); + + const result = await evaluateAsync(resolvedPromise, rejectedPromise); + expect(result).toBe('early-success'); + }); + + it('should await and bubble rejection when an earlier candidate is undefined and a later Promise rejects', async () => { + const rejectedPromise = Promise.reject(new Error('Observed rejection')); + await expect(evaluateAsync(undefined, rejectedPromise)).rejects.toThrow('Observed rejection'); + }); + + it('should keep supplier functions lazy and not invoke them if an earlier Promise candidate resolves', async () => { + let supplierInvoked = false; + const lazySupplier = () => { + supplierInvoked = true; + return 'lazy-value'; + }; + + const result = await evaluateAsync(Promise.resolve('immediate'), lazySupplier); + expect(result).toBe('immediate'); + expect(supplierInvoked).toBe(false); + }); + + it('should resolve Pledge candidates and handle Pledge rejection safely', async () => { + const resolvedPledge = new Pledge(); + resolvedPledge.resolve('pledge-value'); + expect(await evaluateAsync(resolvedPledge)).toBe('pledge-value'); + + const earlyPromise = Promise.resolve('early-val'); + const rejectedPledge = new Pledge(); + rejectedPledge.reject(new Error('pledge error')); + + const coalesced = await evaluateAsync(earlyPromise, rejectedPledge); + expect(coalesced).toBe('early-val'); + }); + + it('should safely return earlier scalar when followed by a throwing thenable', async () => { + const throwingThenable = { + then() { + throw new Error('synchronous then error'); + } + }; + + const result = await evaluateAsync('early-scalar', throwingThenable as any); + expect(result).toBe('early-scalar'); + }); + + it('should safely return earlier scalar when followed by a candidate with a throwing then getter', async () => { + const throwingThenGetter = {}; + Object.defineProperty(throwingThenGetter, 'then', { + get() { + throw new Error('throwing then getter'); + } + }); + + const result = await evaluateAsync('early-scalar', throwingThenGetter as any); + expect(result).toBe('early-scalar'); + }); + + it('should call direct thenable then method only once', async () => { + let thenCalls = 0; + const thenable = { + then(onFulfilled: any) { + thenCalls++; + onFulfilled('thenable-value'); + } + }; + + const result = await evaluateAsync(thenable as any); + expect(result).toBe('thenable-value'); + expect(thenCalls).toBe(1); + }); + }); + + describe('evaluateConfig()', () => { + it('should resolve an object with mixed scalar and functional properties', () => { + let dynamicTz = 'UTC'; + const config = { + timeZone: () => dynamicTz, + locale: 'en-US', + retries: 3, + }; + + const snapshot1 = evaluateConfig(config); + expect(snapshot1).toEqual({ + timeZone: 'UTC', + locale: 'en-US', + retries: 3, + }); + + dynamicTz = 'Europe/London'; + const snapshot2 = evaluateConfig(config); + expect(snapshot2).toEqual({ + timeZone: 'Europe/London', + locale: 'en-US', + retries: 3, + }); + }); + + it('should handle nullish or primitive inputs gracefully', () => { + expect(evaluateConfig(null as any)).toBe(null); + expect(evaluateConfig(undefined as any)).toBe(undefined); + }); + }); + + describe('evaluateConfigAsync()', () => { + it('should resolve an object with mixed async functions and scalars', async () => { + const config = { + key: async () => 'async-api-key', + url: () => 'https://api.openai.com/v1', + model: 'gpt-4o', + }; + + const resolved = await evaluateConfigAsync(config); + expect(resolved).toEqual({ + key: 'async-api-key', + url: 'https://api.openai.com/v1', + model: 'gpt-4o', + }); + }); + + it('should reject if any async supplier in the object rejects', async () => { + const config = { + key: async () => { + throw new Error('IAM auth failed'); + }, + url: 'https://api.openai.com/v1', + }; + + await expect(evaluateConfigAsync(config)).rejects.toThrow('IAM auth failed'); + }); + }); + + describe('dynamicProxy()', () => { + it('should dynamically evaluate functional properties on every read', () => { + let currentTz = 'UTC'; + let activeUser = 'Alice'; + + const rawConfig = { + timeZone: () => currentTz, + user: () => activeUser, + staticFlag: true, + }; + + const proxy = dynamicProxy(rawConfig); + + expect(proxy.timeZone).toBe('UTC'); + expect(proxy.user).toBe('Alice'); + expect(proxy.staticFlag).toBe(true); + + currentTz = 'Asia/Tokyo'; + activeUser = 'Bob'; + + expect(proxy.timeZone).toBe('Asia/Tokyo'); + expect(proxy.user).toBe('Bob'); + }); + + it('should reflect keys and in operator', () => { + const proxy = dynamicProxy({ + a: () => 1, + b: 2, + }); + + expect('a' in proxy).toBe(true); + expect('b' in proxy).toBe(true); + expect('c' in proxy).toBe(false); + expect(Object.keys(proxy)).toEqual(['a', 'b']); + }); + + it('should preserve Proxy invariants for non-configurable and symbol properties', () => { + const symKey = Symbol('customSymbol'); + const target = { + regular: () => 'computed', + [symKey]: () => 'symbol-func', + frozenProp: undefined as any, + }; + + Object.defineProperty(target, 'frozenProp', { + value: () => 'frozen-supplier', + writable: false, + configurable: false, + }); + + const proxy = dynamicProxy(target); + + expect(proxy.regular).toBe('computed'); + expect(typeof proxy[symKey]).toBe('function'); + expect(typeof proxy.frozenProp).toBe('function'); + expect(proxy.frozenProp()).toBe('frozen-supplier'); + + const desc = Object.getOwnPropertyDescriptor(proxy, 'frozenProp'); + expect(desc?.configurable).toBe(false); + expect(desc?.writable).toBe(false); + }); + }); +}); diff --git a/packages/library/test/common/json.library.test.ts b/packages/library/test/common/runtime/json.library.test.ts similarity index 100% rename from packages/library/test/common/json.library.test.ts rename to packages/library/test/common/runtime/json.library.test.ts diff --git a/packages/library/test/common/logger.class.test.ts b/packages/library/test/common/runtime/logger.class.test.ts similarity index 93% rename from packages/library/test/common/logger.class.test.ts rename to packages/library/test/common/runtime/logger.class.test.ts index bc796e21..83f6394b 100644 --- a/packages/library/test/common/logger.class.test.ts +++ b/packages/library/test/common/runtime/logger.class.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { Logger, LOG, parseLogLevel } from '../../src/common/logger.class.js'; -import { sym } from '../../src/common/symbol.library.js'; +import { Logger, LOG, parseLogLevel } from '#library/logger.class.js'; +import { sym } from '#library/symbol.library.js'; describe('Logger Class', () => { let consoleSpy: any; diff --git a/packages/library/test/common/pledge.class.test.ts b/packages/library/test/common/runtime/pledge.class.test.ts similarity index 100% rename from packages/library/test/common/pledge.class.test.ts rename to packages/library/test/common/runtime/pledge.class.test.ts diff --git a/packages/library/test/common/proxy.library.test.ts b/packages/library/test/common/runtime/proxy.library.test.ts similarity index 100% rename from packages/library/test/common/proxy.library.test.ts rename to packages/library/test/common/runtime/proxy.library.test.ts diff --git a/packages/library/test/common/reflection.library.test.ts b/packages/library/test/common/runtime/reflection.library.test.ts similarity index 100% rename from packages/library/test/common/reflection.library.test.ts rename to packages/library/test/common/runtime/reflection.library.test.ts diff --git a/packages/library/test/common/request.library.test.ts b/packages/library/test/common/runtime/request.library.test.ts similarity index 98% rename from packages/library/test/common/request.library.test.ts rename to packages/library/test/common/runtime/request.library.test.ts index f029ccb8..bc313d6a 100644 --- a/packages/library/test/common/request.library.test.ts +++ b/packages/library/test/common/runtime/request.library.test.ts @@ -1,4 +1,4 @@ -import { fetchRequest, fetchHead, HttpError } from '../../src/common/request.library.js'; +import { fetchRequest, fetchHead, HttpError } from '#library/request.library.js'; describe('request.library', () => { const mockFetch = vi.fn(); diff --git a/packages/library/test/common/serialize.library.test.ts b/packages/library/test/common/runtime/serialize.library.test.ts similarity index 100% rename from packages/library/test/common/serialize.library.test.ts rename to packages/library/test/common/runtime/serialize.library.test.ts diff --git a/packages/library/test/common/serialize.test.ts b/packages/library/test/common/runtime/serialize.test.ts similarity index 100% rename from packages/library/test/common/serialize.test.ts rename to packages/library/test/common/runtime/serialize.test.ts diff --git a/packages/library/test/common/scheduling/cron.library.test.ts b/packages/library/test/common/scheduling/cron.library.test.ts new file mode 100644 index 00000000..dde9a97c --- /dev/null +++ b/packages/library/test/common/scheduling/cron.library.test.ts @@ -0,0 +1,53 @@ +import { + isCronString, + parseCron, + getNextCronEpoch, + getPrevCronEpoch +} from '#library/cron.library.js'; + +describe('cron.library', () => { + test('isCronString identifies valid 5-part cron expressions', () => { + expect(isCronString('0 0 * * *')).toBe(true); + expect(isCronString('*/15 9-17 * * 1-5')).toBe(true); + expect(isCronString('0 0 1 1 *')).toBe(true); + expect(isCronString('FREQ=DAILY')).toBe(false); + expect(isCronString('invalid cron')).toBe(false); + expect(isCronString('60 0 * * *')).toBe(false); + expect(isCronString('foo * * * *')).toBe(false); + }); + + test('parseCron correctly parses fields into allowed sets', () => { + const parsed = parseCron('15 9 1,15 * 1-5'); + expect(parsed.minutes.allowed.has(15)).toBe(true); + expect(parsed.hours.allowed.has(9)).toBe(true); + expect(parsed.daysOfMonth.allowed.has(1)).toBe(true); + expect(parsed.daysOfMonth.allowed.has(15)).toBe(true); + expect(parsed.daysOfWeek.allowed.has(1)).toBe(true); + expect(parsed.daysOfWeek.allowed.has(5)).toBe(true); + }); + + test('getNextCronEpoch calculates next occurrence timestamp in ms', () => { + // 2026-08-20 12:00:00 UTC (Thursday) + const anchorMs = Date.UTC(2026, 7, 20, 12, 0, 0); + // Next occurrence of "0 13 * * *" -> 2026-08-20 13:00:00 UTC + const nextMs = getNextCronEpoch('0 13 * * *', anchorMs, 'UTC'); + expect(new Date(nextMs).toISOString()).toBe('2026-08-20T13:00:00.000Z'); + }); + + test('getPrevCronEpoch calculates previous occurrence timestamp in ms', () => { + // 2026-08-20 12:00:00 UTC + const anchorMs = Date.UTC(2026, 7, 20, 12, 0, 0); + // Previous occurrence of "0 11 * * *" -> 2026-08-20 11:00:00 UTC + const prevMs = getPrevCronEpoch('0 11 * * *', anchorMs, 'UTC'); + expect(new Date(prevMs).toISOString()).toBe('2026-08-20T11:00:00.000Z'); + }); + + test('rejects non-numeric, out of range, or invalid cron field tokens', () => { + expect(() => parseCron('60 0 * * *')).toThrow(); + expect(() => parseCron('0 24 * * *')).toThrow(); + expect(() => parseCron('0 0 32 * *')).toThrow(); + expect(() => parseCron('0 0 * 13 *')).toThrow(); + expect(() => parseCron('0 0 * * 8')).toThrow(); + expect(() => parseCron('foo * * * *')).toThrow(); + }); +}); diff --git a/packages/library/test/common/recurrence.library.test.ts b/packages/library/test/common/scheduling/rrule.library.test.ts similarity index 98% rename from packages/library/test/common/recurrence.library.test.ts rename to packages/library/test/common/scheduling/rrule.library.test.ts index c41b6826..cee10c04 100644 --- a/packages/library/test/common/recurrence.library.test.ts +++ b/packages/library/test/common/scheduling/rrule.library.test.ts @@ -4,10 +4,10 @@ import { parseRRule, expandRRuleEpochs, getNextRRuleEpoch, -} from '../../src/common/recurrence.library.js'; -import { DAY_MAP } from '../../src/common/calendar.library.js'; +} from '#library/rrule.library.js'; +import { DAY_MAP } from '#library/calendar.library.js'; -describe('recurrence.library', () => { +describe('rrule.library', () => { test('isRRuleString identifies valid RRULE patterns', () => { expect(isRRuleString('FREQ=DAILY')).toBe(true); expect(isRRuleString('RRULE:FREQ=WEEKLY;BYDAY=MO')).toBe(true); diff --git a/packages/library/test/common/scheduling/schedule.library.test.ts b/packages/library/test/common/scheduling/schedule.library.test.ts new file mode 100644 index 00000000..102efefb --- /dev/null +++ b/packages/library/test/common/scheduling/schedule.library.test.ts @@ -0,0 +1,32 @@ +import { + isScheduleString, + getNextScheduleEpoch +} from '#library/schedule.library.js'; + +describe('schedule.library', () => { + test('isScheduleString recognizes both Cron expressions and RRULE strings', () => { + expect(isScheduleString('0 0 * * *')).toBe(true); + expect(isScheduleString('FREQ=DAILY')).toBe(true); + expect(isScheduleString('RRULE:FREQ=WEEKLY;BYDAY=MO')).toBe(true); + expect(isScheduleString('not a schedule')).toBe(false); + }); + + test('getNextScheduleEpoch handles Cron pattern accurately', () => { + const anchorMs = Date.UTC(2026, 7, 20, 10, 0, 0); + const nextCronMs = getNextScheduleEpoch('0 11 * * *', anchorMs, 'UTC'); + expect(nextCronMs).not.toBeNull(); + expect(new Date(nextCronMs!).toISOString()).toBe('2026-08-20T11:00:00.000Z'); + }); + + test('getNextScheduleEpoch handles RRULE string accurately', () => { + const anchorMs = Date.UTC(2026, 7, 20, 10, 0, 0); + const nextRRuleMs = getNextScheduleEpoch('FREQ=DAILY;INTERVAL=1', anchorMs); + expect(nextRRuleMs).not.toBeNull(); + expect(new Date(nextRRuleMs!).toISOString()).toBe('2026-08-21T10:00:00.000Z'); + }); + + test('getNextScheduleEpoch returns null for invalid pattern', () => { + const anchorMs = Date.UTC(2026, 7, 20, 10, 0, 0); + expect(getNextScheduleEpoch('invalid pattern', anchorMs)).toBeNull(); + }); +}); diff --git a/packages/library/test/common/calendar.library.test.ts b/packages/library/test/common/temporal/calendar.library.test.ts similarity index 98% rename from packages/library/test/common/calendar.library.test.ts rename to packages/library/test/common/temporal/calendar.library.test.ts index 350e98ae..de3e282b 100644 --- a/packages/library/test/common/calendar.library.test.ts +++ b/packages/library/test/common/temporal/calendar.library.test.ts @@ -10,7 +10,7 @@ import { isValidDate, addUtcDays, withUtcParts, -} from '../../src/common/calendar.library.js'; +} from '#library/calendar.library.js'; import type { UtcPartsOptions, DayKey, @@ -19,7 +19,7 @@ import type { MonthValue, IsoWeekdayNumber, IsoWeekdayName -} from '../../src/common/calendar.library.js'; +} from '#library/calendar.library.js'; describe('calendar.library', () => { test('DAYS_IN_WEEK constant is 7', () => { diff --git a/packages/library/test/common/temporal.library.test.ts b/packages/library/test/common/temporal/temporal.library.test.ts similarity index 100% rename from packages/library/test/common/temporal.library.test.ts rename to packages/library/test/common/temporal/temporal.library.test.ts diff --git a/packages/library/test/common/temporal_guards.test.ts b/packages/library/test/common/temporal/temporal_guards.test.ts similarity index 100% rename from packages/library/test/common/temporal_guards.test.ts rename to packages/library/test/common/temporal/temporal_guards.test.ts diff --git a/packages/library/test/tsconfig.json b/packages/library/test/tsconfig.json index 84f58900..298ad0ff 100644 --- a/packages/library/test/tsconfig.json +++ b/packages/library/test/tsconfig.json @@ -7,12 +7,22 @@ ], "paths": { "#library": [ - "../src/common/index.ts" + "../src/common.index.ts" ], "#library/*.js": [ + "../src/common/primitives/*.ts", + "../src/common/temporal/*.ts", + "../src/common/security/*.ts", + "../src/common/runtime/*.ts", + "../src/common/scheduling/*.ts", "../src/common/*.ts" ], "#library/*": [ + "../src/common/primitives/*", + "../src/common/temporal/*", + "../src/common/security/*", + "../src/common/runtime/*", + "../src/common/scheduling/*", "../src/common/*" ], "#browser/*.js": [ diff --git a/packages/library/tsconfig.json b/packages/library/tsconfig.json index 7c513882..8958af16 100644 --- a/packages/library/tsconfig.json +++ b/packages/library/tsconfig.json @@ -8,9 +8,19 @@ "./src/common.index.ts" ], "#library/*.js": [ + "./src/common/primitives/*.ts", + "./src/common/temporal/*.ts", + "./src/common/security/*.ts", + "./src/common/runtime/*.ts", + "./src/common/scheduling/*.ts", "./src/common/*.ts" ], "#library/*": [ + "./src/common/primitives/*", + "./src/common/temporal/*", + "./src/common/security/*", + "./src/common/runtime/*", + "./src/common/scheduling/*", "./src/common/*" ], "#browser": [ diff --git a/packages/library/vitest.config.ts b/packages/library/vitest.config.ts index 5f4c091a..ed87d0d4 100644 --- a/packages/library/vitest.config.ts +++ b/packages/library/vitest.config.ts @@ -29,12 +29,24 @@ export default defineConfig({ }, resolve: { alias: isDist ? [ - { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, './dist/common/$1.js') }, + { find: /^#library\/([^/]+)\/index\.js$/, replacement: resolve(__dirname, './dist/common/$1/index.js') }, + { find: /^#library\/(array|assertion|coercion|number|object|primitive|string|symbol|type)\.library\.js$/, replacement: resolve(__dirname, './dist/common/primitives/$1.library.js') }, + { find: /^#library\/(calendar|temporal)\.library\.js$/, replacement: resolve(__dirname, './dist/common/temporal/$1.library.js') }, + { find: /^#library\/temporal\.polyfill\.js$/, replacement: resolve(__dirname, './dist/common/temporal/temporal.polyfill.js') }, + { find: /^#library\/(buffer|cipher|webtoken)\.library\.js$/, replacement: resolve(__dirname, './dist/common/security/$1.library.js') }, + { find: /^#library\/(cron|rrule|schedule)\.library\.js$/, replacement: resolve(__dirname, './dist/common/scheduling/$1.library.js') }, + { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, './dist/common/runtime/$1.js') }, { find: /^#library$/, replacement: resolve(__dirname, './dist/common.index.js') }, { find: /^#browser\/(.*)\.js$/, replacement: resolve(__dirname, './dist/browser/$1.js') }, { find: /^#server\/(.*)\.js$/, replacement: resolve(__dirname, './dist/server/$1.js') }, ] : [ - { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, './src/common/$1.ts') }, + { find: /^#library\/([^/]+)\/index\.js$/, replacement: resolve(__dirname, './src/common/$1/index.ts') }, + { find: /^#library\/(array|assertion|coercion|number|object|primitive|string|symbol|type)\.library\.js$/, replacement: resolve(__dirname, './src/common/primitives/$1.library.ts') }, + { find: /^#library\/(calendar|temporal)\.library\.js$/, replacement: resolve(__dirname, './src/common/temporal/$1.library.ts') }, + { find: /^#library\/temporal\.polyfill\.js$/, replacement: resolve(__dirname, './src/common/temporal/temporal.polyfill.ts') }, + { find: /^#library\/(buffer|cipher|webtoken)\.library\.js$/, replacement: resolve(__dirname, './src/common/security/$1.library.ts') }, + { find: /^#library\/(cron|rrule|schedule)\.library\.js$/, replacement: resolve(__dirname, './src/common/scheduling/$1.library.ts') }, + { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, './src/common/runtime/$1.ts') }, { find: /^#library$/, replacement: resolve(__dirname, './src/common.index.ts') }, { find: /^#browser\/(.*)\.js$/, replacement: resolve(__dirname, './src/browser/$1.ts') }, { find: /^#server\/(.*)\.js$/, replacement: resolve(__dirname, './src/server/$1.ts') }, diff --git a/packages/plugins/.setup/community-plugin-template.md b/packages/plugins/.setup/community-plugin-template.md index 6fd43fb8..056bb9e9 100644 --- a/packages/plugins/.setup/community-plugin-template.md +++ b/packages/plugins/.setup/community-plugin-template.md @@ -26,6 +26,14 @@ Ensure the plugin's `package.json` contains the correct community configuration: "access": "public" } ``` +- **Repository**: Required for npm provenance and source linking. Must include the exact sub-directory path: + ```json + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/plugins/[name]" + } + ``` - **Exports**: Define exports with types and import entrypoints: ```json "exports": { @@ -134,3 +142,14 @@ All exported components (functions, interfaces, classes, and types) must be prop */ export function myExportedFunction(input: string): string { ... } ``` + +## 7. Release & CI Configuration (`.github/workflows/publish.yml`) + +When adding a new plugin to the monorepo, update `.github/workflows/publish.yml` to enable manual `workflow_dispatch` provenance releases: + +1. **Add to Package Selector**: Add `@magmacomputing/tempo-plugin-[name]` to the `options` array under `inputs.package`. +2. **Add to Bulk Publish**: Add the workspace to the `all` branch in the publishing step: + ```bash + npm publish --workspace=@magmacomputing/tempo-plugin-[name] $PROVENANCE_FLAG + ``` + diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index b303f3f6..8858d767 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the `@magmacomputing/tempo-plugin-ai` project will be doc The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-08-19 + +### Added +- **Dynamic Context & Lazy Provider Resolution**: Upgraded `AiConfig` and all AI handlers (`parseAI`, `formatAI`, `diffAI`, `extractAI`, `recurrenceAI`, `scheduleAI`, `contextAI`) to support `Evaluable` and `AsyncEvaluable` configuration suppliers (`T | (() => T | Promise)`). +- **Dynamic Secret Rotation & Custom Endpoints**: AI provider configurations (`AiProvider`) now accept dynamic functions for `key`, `url`, and `model`, enabling automated secret vault rotation and dynamic proxy routing evaluated just-in-time on each request dispatch. +- **Provider Fallback & Default Hierarchy**: Hardened `fetchFromProvider` in `transport.ts` with automated fallback defaults via `evaluate`/`evaluateAsync`, cleanly resolving `DEFAULT_PROVIDERS` templates and environment keys when explicit provider fields are omitted. + ## [1.0.0] - 2026-08-15 ### Added diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md index adeb6d3c..d5ac6a86 100644 --- a/packages/plugins/ai/README.md +++ b/packages/plugins/ai/README.md @@ -75,6 +75,16 @@ For complete API references, architecture guides, and advanced examples: --- +## šŸ”’ Security, Privacy & Transparency + +* 🌐 **Direct Provider Communication**: By default, requests are dispatched directly from your application runtime to official provider endpoints (OpenAI, Google Gemini, Anthropic, Groq, or local Ollama). When custom endpoint URLs or AI Gateways are configured, requests route directly to your specified destination. There are **no hidden intermediary services**, **no third-party telemetry**, and **zero tracking**. +* šŸ›”ļø **Zero Data Retention**: Prompts, input expressions, and temporal context are processed ephemerally and are never stored, logged, or retained outside of your own runtime memory or explicitly configured cache adapters. +* šŸ”‘ **Scoped Environment Lookups**: Auto-discovery only reads standard, documented provider variables (`OPENAI_API_KEY`, `GROQ_API_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `TEMPO_AI_KEY`). No other system environment variables are inspected. +* šŸ“¦ **Client-Side Safety**: BYOK API keys are designed exclusively for server, edge runtime, or secure container environments and should never be exposed in client-side browser bundles. + +--- + ## āš–ļø Licensing This is a **Community** plugin. It is completely free and open-source for personal and commercial use under the MIT license. + diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index 6502ff88..cfd13c82 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -49,6 +49,14 @@ initAI({ }); ``` +### Per-Request Lazy Resolution & Fallback Defaults + +When dispatching requests via `transport.ts`, all provider fields (`key`, `url`, `model`) and execution context (`timeZone`, `locale`, `calendar`, `sphere`) are resolved lazily just-in-time using functional evaluation (`evaluate` / `evaluateAsync`): + +1. **Explicit Dynamic Suppliers**: If a supplier function was provided (e.g. `key: async () => await getRotatedKey()`), it is called per-dispatch. +2. **Built-in Fallbacks**: If a property is omitted or resolves to `undefined`, the transport layer seamlessly cascades to the compiled `DEFAULT_PROVIDERS` templates, remote manifest endpoints, and auto-discovered environment variables. +3. **No Configuration Mutation**: The dynamic resolution runs ephemerally per HTTP dispatch without mutating or locking shared global provider state. + ### Dynamic Provider Manifests & Remote Endpoint Trust By default, `@magmacomputing/tempo-plugin-ai` lazily fetches provider defaults (model IDs, endpoints, token parameter keys) from `https://tempo.magmacomputing.com.au/providers.v1.json` once per application lifecycle. diff --git a/packages/plugins/ai/doc/context.md b/packages/plugins/ai/doc/context.md index 663d4560..521ba178 100644 --- a/packages/plugins/ai/doc/context.md +++ b/packages/plugins/ai/doc/context.md @@ -12,19 +12,12 @@ This is highly useful for user onboarding settings, automatic context mapping fo ## Basic Usage > [!NOTE] -> Like all Tempo AI functions, `contextAI` requires prior initialization with at least one active provider via `initAI()`. +> `@magmacomputing/tempo-plugin-ai` features zero-config auto-discovery. If provider keys exist in your environment (`GROQ_API_KEY`, `OPENAI_API_KEY`, etc.) or `tempo.config.json`, calling `initAI()` is **completely optional**. ```typescript -import { initAI, contextAI } from '@magmacomputing/tempo-plugin-ai'; +import { contextAI } from '@magmacomputing/tempo-plugin-ai'; -// 1. Configure the AI provider farm -await initAI({ - providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY } - ] -}); - -// 2. Infer contextual settings from unstructured text +// 1. Infer contextual settings directly from unstructured text (auto-discovers provider keys) const context = await contextAI("I'm a photographer based in Sydney, Australia."); console.log(context.timeZone); // "Australia/Sydney" diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md index df628e50..a66a03c3 100644 --- a/packages/plugins/ai/doc/init.md +++ b/packages/plugins/ai/doc/init.md @@ -63,6 +63,32 @@ await initAI({ > **Tip**: `initAI` returns a `Promise` and is fully re-callable! Calling it synchronously without `await` instantly initializes local configurations so you can call `parseAI` immediately, while `await initAI()` guarantees that remote provider manifest defaults are fetched and applied before proceeding (with explicit provider configuration values always taking precedence over remote manifest defaults). +### Dynamic Provider Credentials & Context Suppliers + +The provider `key` configuration supports asynchronous or synchronous supplier functions (`AsyncEvaluable`), allowing automated secret vault retrieval and dynamic token refreshing. Provider attributes (`url`, `model`) as well as global context settings (`timeZone`, `locale`, `calendar`, `sphere`) accept synchronous supplier functions (`Evaluable`). + +This enables automated secret vault rotation, dynamic AI gateways, and multi-tenant context resolution evaluated just-in-time on every HTTP dispatch: + +```typescript +import { initAI, parseAI } from '@magmacomputing/tempo-plugin-ai'; + +// Initialize with dynamic async key resolver and per-request context +initAI({ + providers: [ + { + id: 'openai', + // Resolved dynamically per-request: enables secret vault rotation without restarting + key: async () => await secretVault.getApiKey('openai'), + // Dynamic proxy endpoint + url: () => getActiveGatewayUrl() + } + ], + // Dynamic timezone / locale resolution + timeZone: () => currentRequestContext.timeZone, + locale: () => currentRequestContext.locale +}); +``` + ## Execution Modes & Multi-Provider Options The AI plugin supports six multi-provider execution strategies (`fallback`, `race`, `consensus`, `adaptive`, `hedged`, `roundrobin`): diff --git a/packages/plugins/ai/doc/security.md b/packages/plugins/ai/doc/security.md index 5b2dc8ac..5ebedb6b 100644 --- a/packages/plugins/ai/doc/security.md +++ b/packages/plugins/ai/doc/security.md @@ -103,6 +103,45 @@ const rawReasoning = result.reasoning; * Calling `getAiConfig()` returns a sanitized, read-only configuration snapshot. * All provider `key` values, authorization tokens, and shared secrets are permanently replaced with `[REDACTED]`, ensuring secrets cannot be leaked via diagnostic endpoints or error monitors. +### Dynamic Secret Vaults & Automated Key Rotation +* Provider `key` parameters support synchronous and asynchronous supplier functions (`AsyncEvaluable` / `() => Promise | string`), while `url`, `model`, and temporal context fields accept synchronous suppliers (`Evaluable`). +* **Enterprise Secret Vaults**: Instead of pinning long-lived static API keys in memory, applications can integrate cloud key vaults (e.g. AWS Secrets Manager, HashiCorp Vault, Azure Key Vault, Doppler): + ```typescript + initAI({ + providers: [ + { + id: 'openai', + // Evaluated just-in-time on every provider HTTP dispatch + key: async () => await secretVault.getSecret('OPENAI_API_KEY') + } + ] + }); + ``` +* **Multi-Tenant / Per-Request Key Isolation**: In SaaS applications where each tenant supplies their own BYOK credentials, resolve keys dynamically from the active request context without re-initializing global AI state: + ```typescript + initAI({ + providers: [ + { + id: 'openai', + // Pulls tenant-specific key from AsyncLocalStorage or request session + key: () => tenantStore.getStore()?.openaiApiKey + } + ] + }); + ``` +* **Short-Lived & OAuth Token Refreshers**: Dynamic suppliers allow automatic token refresh for short-lived credentials (e.g. Google Cloud Vertex AI / Azure Entra ID OAuth tokens) without service disruption: + ```typescript + initAI({ + providers: [ + { + id: 'gemini', + key: async () => (await authClient.getAccessToken()).token + } + ] + }); + ``` +* Keys are fetched just-in-time prior to the HTTP request and never stored in plain text in persistent global state, enabling zero-downtime key rotation. + ### Frontend Zero-Storage Principle * **No Client-Side Secrets**: LLM API keys must **never** be bundled into client-side single-page applications (React, Vue, Svelte) or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`). * **Proxy Architecture**: Public frontend web applications must route requests through a self-hosted backend proxy or secure AI Gateway (Cloudflare Worker, Next.js API Route) where private API keys are kept server-side. diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index ce53fb84..346ada7d 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -1,10 +1,15 @@ { "name": "@magmacomputing/tempo-plugin-ai", - "version": "1.0.0", + "version": "1.1.0", "description": "Tempo community plugin for LLM-powered natural language parsing.", "main": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/plugins/ai" + }, "files": [ "dist", "README.md", @@ -17,7 +22,7 @@ }, "scripts": { "build": "tsup", - "test": "cross-env TEMPO_LICENSE_KEY=\"\" vitest run -c ../vitest.shared.ts", + "test": "vitest run -c ../vitest.shared.ts", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" }, "tempo": { @@ -25,7 +30,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.11.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1" diff --git a/packages/plugins/ai/src/core/discovery.ts b/packages/plugins/ai/src/core/discovery.ts index f8cfe56a..12b176ba 100644 --- a/packages/plugins/ai/src/core/discovery.ts +++ b/packages/plugins/ai/src/core/discovery.ts @@ -1,4 +1,5 @@ -import { getContext, CONTEXT, isObject, isPlainObject, isString, isArray, isMap, isDefined, asText } from '@magmacomputing/tempo/library'; +import { getContext, CONTEXT, isObject, isPlainObject, isString, isArray, isMap, isDefined, isFunction, asText } from '@magmacomputing/tempo/library'; +import type { AsyncEvaluable } from '@magmacomputing/tempo/library'; import { Tempo } from '@magmacomputing/tempo'; import { DEFAULT_PROVIDERS } from './config.js'; @@ -98,15 +99,16 @@ export const WELL_KNOWN_ENV_MAP: Record = { * Resolves an API key for a provider, falling back to well-known environment variables if not provided. * * @param id - The provider identifier (e.g. 'groq', 'openai') - * @param explicitKey - Optional explicit API key + * @param explicitKey - Optional explicit API key or dynamic supplier * @param env - The environment variables map to inspect - * @returns The resolved API key string, or undefined if not found + * @returns The resolved API key string or supplier, or undefined if not found */ export function resolveProviderApiKey( id: string, - explicitKey?: string, + explicitKey?: AsyncEvaluable, env: Record = getRuntimeEnv() -): string | undefined { +): AsyncEvaluable | undefined { + if (isFunction(explicitKey)) return explicitKey; const key = asText(explicitKey); if (key) return key; diff --git a/packages/plugins/ai/src/core/manifest.ts b/packages/plugins/ai/src/core/manifest.ts index fb0ff4b3..4063c22d 100644 --- a/packages/plugins/ai/src/core/manifest.ts +++ b/packages/plugins/ai/src/core/manifest.ts @@ -1,4 +1,4 @@ -import { asText, fetchRequest, isObject, isString, parseJSONC } from '@magmacomputing/tempo/library'; +import { asText, evaluate, fetchRequest, isObject, isString, parseJSONC } from '@magmacomputing/tempo/library'; import { DEFAULT_PROVIDERS } from './config.js'; import type { AiProvider } from '../types/index.js'; @@ -121,9 +121,10 @@ export function getResolvedProviderDefaults( // Validate manifest-derived URL origin: must be HTTPS or localhost HTTP if (manifestEntry.url) { - if (!isValidManifestUrl(manifestEntry.url)) { + const evaluatedUrl = asText(evaluate(manifestEntry.url)); + if (evaluatedUrl && !isValidManifestUrl(evaluatedUrl)) { if (debug) - console.warn(`[tempo-plugin-ai] Rejected manifest provider URL '${manifestEntry.url}' - invalid HTTPS origin.`); + console.warn(`[tempo-plugin-ai] Rejected manifest provider URL '${evaluatedUrl}' - invalid HTTPS origin.`); delete manifestEntry.url; } } diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 29bb8c80..a1478577 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -1,5 +1,6 @@ import { Tempo } from '@magmacomputing/tempo'; -import { asText, asNumber, isDefined, isFunction, isNumber } from '@magmacomputing/tempo/library'; +import { asText, asNumber, isDefined, isFunction, isNumber, evaluate } from '@magmacomputing/tempo/library'; +import type { Evaluable } from '@magmacomputing/tempo/library'; import { TempoAiError } from './error.js'; import { AiMode } from './config.js'; import { _state } from './init.js'; @@ -52,27 +53,39 @@ export interface ResolvedAiContext { * @returns Resolved context fields and context configuration object */ export function resolveFullContext( - options?: { timeZone?: string | undefined; locale?: string | string[] | undefined; calendar?: string | undefined; sphere?: 'north' | 'south' | string | undefined;[key: string]: any } | undefined, + options?: { timeZone?: Evaluable | undefined; locale?: Evaluable | undefined; calendar?: Evaluable | undefined; sphere?: Evaluable<'north' | 'south' | string> | undefined;[key: string]: any } | undefined, fallbackTempo?: Tempo | null, ): ResolvedAiContext { const resolvedOptions = (Tempo as any).options ?? {}; - const tz = String(options?.timeZone || fallbackTempo?.tz || resolvedOptions.timeZone || _state.config.timeZone || 'UTC'); - const rawLoc = (options?.locale !== undefined && (Array.isArray(options.locale) ? options.locale.length > 0 : Boolean(options.locale))) - ? options.locale - : (fallbackTempo?.locale !== undefined && (Array.isArray(fallbackTempo.locale) ? fallbackTempo.locale.length > 0 : Boolean(fallbackTempo.locale))) - ? fallbackTempo.locale - : resolvedOptions.locale || _state.config.locale || 'en-US'; + const rawTz = evaluate(options?.timeZone, fallbackTempo?.tz, resolvedOptions.timeZone, _state.config.timeZone, 'UTC'); + const tz = String(rawTz || 'UTC'); + + const optLoc = evaluate(options?.locale); + const fbLoc = fallbackTempo?.locale; + const resLoc = evaluate(resolvedOptions.locale); + const cfgLoc = evaluate(_state.config.locale); + + const rawLoc = (optLoc !== undefined && (Array.isArray(optLoc) ? optLoc.length > 0 : Boolean(optLoc))) + ? optLoc + : (fbLoc !== undefined && (Array.isArray(fbLoc) ? fbLoc.length > 0 : Boolean(fbLoc))) + ? fbLoc + : resLoc || cfgLoc || 'en-US'; const firstLoc = Array.isArray(rawLoc) ? rawLoc[0] : rawLoc; const loc = asText(firstLoc, 'en-US'); - const cal = String(options?.calendar || fallbackTempo?.cal || resolvedOptions.calendar || _state.config.calendar || 'iso8601'); - const sph = String(options?.sphere || fallbackTempo?.sphere || resolvedOptions.sphere || _state.config.sphere || 'north'); + + const rawCal = evaluate(options?.calendar, fallbackTempo?.cal, resolvedOptions.calendar, _state.config.calendar, 'iso8601'); + const cal = String(rawCal || 'iso8601'); + + const rawSph = evaluate(options?.sphere, fallbackTempo?.sphere, resolvedOptions.sphere, _state.config.sphere, 'north'); + const sph = String(rawSph || 'north'); + const contextConfig = { timeZone: tz, locale: loc, calendar: cal, sphere: sph }; return { tz, loc, cal, sph, contextConfig }; } export function resolveTzAndLocale( - options?: { timeZone?: string | undefined; locale?: string | string[] | undefined } | undefined, + options?: { timeZone?: Evaluable | undefined; locale?: Evaluable | undefined } | undefined, fallbackTempo?: Tempo | null, ): { tz: string; loc: string } { const { tz, loc } = resolveFullContext(options, fallbackTempo); @@ -102,7 +115,7 @@ export function validateMinConfidence(minConfidence?: number, targetFnName?: str /** * Resolves an anchor Tempo instance, applying timeZone, locale, calendar, and sphere. * - * @param anchor - Optional anchor instance, ISO string, epoch, or undefined + * @param anchor - Optional anchor instance, ISO string, epoch, supplier function, or undefined * @param context - Resolved context fields from resolveFullContext * @param options - Optional default anchor fallback and operation name for error messaging * @returns Standardized anchor Tempo instance @@ -113,14 +126,14 @@ export function resolveAnchorTempo( context: ResolvedAiContext, options?: { defaultAnchor?: unknown; operationName?: string } | undefined, ): Tempo { + const evaluatedAnchor = evaluate(anchor as any, options?.defaultAnchor as any); const { tz, loc, cal, sph } = context; - if (Tempo.isTempo(anchor)) - return anchor.tz === tz ? anchor : anchor.set({ timeZone: tz }); + if (Tempo.isTempo(evaluatedAnchor)) + return evaluatedAnchor.tz === tz ? evaluatedAnchor : evaluatedAnchor.set({ timeZone: tz }); - const targetValue = isDefined(anchor) ? anchor : options?.defaultAnchor; let instance: Tempo; try { - instance = new Tempo(targetValue as any, { + instance = new Tempo(evaluatedAnchor as any, { timeZone: tz, locale: loc, calendar: cal, @@ -128,12 +141,12 @@ export function resolveAnchorTempo( }); } catch (err: any) { const op = options?.operationName ? ` to ${options.operationName}` : ''; - throw new TempoAiError(`Invalid anchor date provided${op}: "${String(anchor)}"`, 400, undefined, { cause: err }); + throw new TempoAiError(`Invalid anchor date provided${op}: "${String(evaluatedAnchor)}"`, 400, undefined, { cause: err }); } - if (!instance.isValid && isDefined(anchor)) { + if (!instance.isValid && isDefined(evaluatedAnchor)) { const op = options?.operationName ? ` to ${options.operationName}` : ''; - throw new TempoAiError(`Invalid anchor date provided${op}: "${String(anchor)}"`, 400); + throw new TempoAiError(`Invalid anchor date provided${op}: "${String(evaluatedAnchor)}"`, 400); } return instance; diff --git a/packages/plugins/ai/src/core/transport.ts b/packages/plugins/ai/src/core/transport.ts index 384cbfe4..b6cf75b0 100644 --- a/packages/plugins/ai/src/core/transport.ts +++ b/packages/plugins/ai/src/core/transport.ts @@ -1,9 +1,10 @@ import { TempoAiError } from './error.js'; -import { RESERVED_PROVIDER_IDS } from './config.js'; +import { DEFAULT_PROVIDERS, RESERVED_PROVIDER_IDS } from './config.js'; +import { resolveProviderApiKey } from './discovery.js'; import { updateRateLimitsFromResponse, _state } from './init.js'; import { logDebug } from './logger.js'; import type { AiProvider, AiBaseOptions } from '../types/index.js'; -import { asNumber, asText, isObject, isString, isText } from '@magmacomputing/tempo/library'; +import { asNumber, asText, isNumber, isObject, isString, isText, evaluate, evaluateAsync } from '@magmacomputing/tempo/library'; export interface FetchFromProviderOptions extends AiBaseOptions { /** AbortSignal for early cancellation / timeout handling */ @@ -28,9 +29,8 @@ export function assertNoReservedProviderId(providers: Partial[]): vo /** * Resolves available AI providers from options or global state, asserts validity, and ensures no reserved IDs. * - * @param options - Execution options optionally containing provider list - * @returns Array of valid AI provider configs - * @throws TempoAiError(400) if no providers configured or reserved IDs found + * @param options - Operation options containing potential per-request provider overrides + * @returns Array of valid provider configurations */ export function getAvailableProviders(options?: AiBaseOptions): AiProvider[] { const customProviders = options?.providers; @@ -80,18 +80,21 @@ export function resolveProviderModel( provider: AiProvider, tier?: 'fast' | 'reasoning' | 'large' | 'default', ): string { - const explicitModel = asText(provider.model); + const defaultTemplate = DEFAULT_PROVIDERS[provider.id]; + const explicitModel = asText(evaluate(provider.model, defaultTemplate?.model)); if (explicitModel) return explicitModel; - if (typeof provider.models === 'string') return provider.models; - if (Array.isArray(provider.models)) { - const first = asText(provider.models[0]); + const models = provider.models ?? defaultTemplate?.models; + + if (isString(models)) return models; + if (Array.isArray(models)) { + const first = asText(models[0]); if (first) return first; } - if (isObject(provider.models)) { - if (tier && provider.models[tier]) return provider.models[tier]; - if (provider.models.default) return provider.models.default; - const values = Object.values(provider.models); + if (isObject(models)) { + if (tier && (models as any)[tier]) return (models as any)[tier]; + if (models.default) return models.default; + const values = Object.values(models); for (const val of values) { const textVal = asText(val); if (textVal) return textVal; @@ -118,8 +121,21 @@ export async function fetchFromProvider( contextString: string, options?: FetchFromProviderOptions, ): Promise<{ rawContent: string; providerId: string; rateLimits: ReturnType }> { - const url = provider.url; - const model = resolveProviderModel(provider, provider.tier as any); + let url: string | undefined; + let model: string | undefined; + let key: string | undefined; + + const defaultUrl = DEFAULT_PROVIDERS[provider.id]?.url; + const defaultKey = resolveProviderApiKey(provider.id); + + try { + url = asText(evaluate(provider.url, defaultUrl)); + model = resolveProviderModel(provider, provider.tier as any); + key = asText(await evaluateAsync(provider.key, defaultKey)); + } catch (err: any) { + if (err instanceof TempoAiError) throw err; + throw new TempoAiError(`Failed to resolve dynamic configuration for provider ${provider.id}: ${err?.message ?? err}`, 500, undefined, { cause: err }); + } if (!isText(url)) throw new TempoAiError(`Provider ${provider.id} missing valid endpoint URL.`, 400); @@ -127,7 +143,7 @@ export async function fetchFromProvider( if (!isText(model)) throw new TempoAiError(`Provider ${provider.id} missing valid model identifier.`, 400); - if (!isText(provider.key)) + if (!isText(key)) throw new TempoAiError(`Provider ${provider.id} missing valid API key.`, 400); try { @@ -163,6 +179,7 @@ Do not include markdown blocks or any text outside the JSON.`; logDebug('tempo-plugin-ai', `Querying provider '${provider.id}' (model: ${model})...`, undefined, { debug: isDebug }); const tokenParam = provider.tokenParam + || DEFAULT_PROVIDERS[provider.id]?.tokenParam || (provider.options?.max_completion_tokens !== undefined ? 'max_completion_tokens' : undefined) || (provider.options?.max_tokens !== undefined ? 'max_tokens' : undefined) || 'max_tokens'; @@ -195,7 +212,7 @@ Do not include markdown blocks or any text outside the JSON.`; redirect: 'error', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${provider.key}` + 'Authorization': `Bearer ${key}` }, body: JSON.stringify({ model: model, diff --git a/packages/plugins/ai/src/types/base.type.ts b/packages/plugins/ai/src/types/base.type.ts index 6d9947f7..fe4268f8 100644 --- a/packages/plugins/ai/src/types/base.type.ts +++ b/packages/plugins/ai/src/types/base.type.ts @@ -1,4 +1,5 @@ import type { Tempo } from '@magmacomputing/tempo'; +import type { Evaluable, AsyncEvaluable } from '@magmacomputing/tempo/library'; import type { AiMode } from '../core/config.js'; /** @@ -44,16 +45,16 @@ export interface AiBaseOptions { * Base options for operations requiring relative anchor dates, timezone, and calendar grounding. */ export interface AiDateContextOptions extends AiBaseOptions { - /** Reference anchor date for relative calculations (defaults to current time). */ - anchor?: TempoDateInput | undefined; - /** Target IANA timezone. */ - timeZone?: string | undefined; - /** Target BCP 47 locale or language tag. */ - locale?: string | string[] | undefined; - /** Preferred calendar system (e.g. 'gregory', 'islamic', 'hebrew'). */ - calendar?: string | undefined; - /** Hemisphere ('north' | 'south') for seasonal and environmental calculations. */ - sphere?: 'north' | 'south' | string | undefined; + /** Reference anchor date for relative calculations (defaults to current time). Accepts static value or dynamic supplier. */ + anchor?: Evaluable | undefined; + /** Target IANA timezone. Accepts static value or dynamic supplier. */ + timeZone?: Evaluable | undefined; + /** Target BCP 47 locale or language tag. Accepts static value or dynamic supplier. */ + locale?: Evaluable | undefined; + /** Preferred calendar system (e.g. 'gregory', 'islamic', 'hebrew'). Accepts static value or dynamic supplier. */ + calendar?: Evaluable | undefined; + /** Hemisphere ('north' | 'south') for seasonal and environmental calculations. Accepts static value or dynamic supplier. */ + sphere?: Evaluable<'north' | 'south' | string> | undefined; /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */ region?: string | undefined; } @@ -147,12 +148,12 @@ export interface AiModelTiers { export interface AiProvider { /** The provider identifier (e.g., 'groq', 'gemini', 'openai', 'mistral', 'custom') */ id: string; - /** The raw API key for the respective provider */ - key?: string | undefined; - /** Optional custom API endpoint URL (e.g., for local Ollama or Azure OpenAI) */ - url?: string | undefined; - /** Optional custom model identifier (e.g., to override the provider's default model) */ - model?: string | undefined; + /** The raw API key for the respective provider (supports static string, sync supplier, or async supplier e.g. for IAM/Vault tokens) */ + key?: AsyncEvaluable | undefined; + /** Optional custom API endpoint URL (supports static string or dynamic supplier) */ + url?: Evaluable | undefined; + /** Optional custom model identifier (supports static string or dynamic supplier) */ + model?: Evaluable | undefined; /** Tiered model dictionary (e.g. { default: '...', fast: '...', reasoning: '...' }) */ models?: AiModelTiers | undefined; /** Model tier preference ('default' | 'fast' | 'reasoning' | 'large' | string) */ @@ -192,14 +193,14 @@ export interface AiConfig { cache?: Map | boolean | undefined; /** Optional custom cache storage engine (e.g., Redis, KV store) for storing parsed strings */ cacheAdapter?: AiCacheAdapter | undefined; - /** Optional default IANA timezone for AI operations */ - timeZone?: string | undefined; - /** Optional default BCP 47 locale for AI operations */ - locale?: string | string[] | undefined; + /** Optional default IANA timezone for AI operations (accepts static string or dynamic supplier) */ + timeZone?: Evaluable | undefined; + /** Optional default BCP 47 locale for AI operations (accepts static string/array or dynamic supplier) */ + locale?: Evaluable | undefined; /** Optional default calendar system for AI operations (e.g. 'iso8601', 'gregory', 'islamic', 'hebrew') */ - calendar?: string | undefined; + calendar?: Evaluable | undefined; /** Optional default hemisphere ('north' | 'south') for seasonal and environmental calculations */ - sphere?: 'north' | 'south' | string | undefined; + sphere?: Evaluable<'north' | 'south' | string> | undefined; /** Optional global cache TTL in milliseconds for AI parsing entries (default: 3600000ms / 1 hour) */ ttl?: number | undefined; /** Optional global timeout in milliseconds for AI requests (default: 15000ms) */ diff --git a/packages/plugins/ai/test/dynamic.ai.test.ts b/packages/plugins/ai/test/dynamic.ai.test.ts new file mode 100644 index 00000000..f4c1e826 --- /dev/null +++ b/packages/plugins/ai/test/dynamic.ai.test.ts @@ -0,0 +1,171 @@ +import { fetchFromProvider, resolveAnchorTempo, resolveFullContext } from '../src/core/support.js'; +import { TempoAiError } from '../src/core/error.js'; +import { resetAI, initAI } from '../src/core/init.js'; +import { Tempo } from '@magmacomputing/tempo'; + +describe('AI Dynamic Evaluation Infrastructure', () => { + const originalFetch = globalThis.fetch; + + beforeEach(async () => { + resetAI(); + await initAI({ + remoteConfigUrl: false, + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe('Dynamic API Key Rotation & Async Suppliers', () => { + it('should invoke async key supplier on every provider dispatch', async () => { + let counter = 0; + const keySupplier = vi.fn().mockImplementation(async () => { + return `ephemeral-token-${++counter}`; + }); + + const authHeaders: string[] = []; + globalThis.fetch = vi.fn().mockImplementation(async (url: any, init: any) => { + authHeaders.push(init?.headers?.Authorization); + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: JSON.stringify({ iso: '2026-05-01T00:00:00', confidence: 0.95 }) } }], + }), + headers: new Headers(), + } as any; + }); + + const provider = { + id: 'custom-provider', + url: 'https://api.openai.com/v1/chat/completions', + model: 'gpt-4o', + key: keySupplier, + }; + + const res1 = await fetchFromProvider(provider, 'next Friday', 'Context'); + expect(res1.providerId).toBe('custom-provider'); + expect(authHeaders[0]).toBe('Bearer ephemeral-token-1'); + + const res2 = await fetchFromProvider(provider, 'next Monday', 'Context'); + expect(res2.providerId).toBe('custom-provider'); + expect(authHeaders[1]).toBe('Bearer ephemeral-token-2'); + + expect(keySupplier).toHaveBeenCalledTimes(2); + }); + + it('should propagate errors when an async key supplier rejects', async () => { + const failingKeySupplier = vi.fn().mockImplementation(async () => { + throw new Error('Vault connection timed out'); + }); + + const provider = { + id: 'vault-provider', + url: 'https://api.openai.com/v1/chat/completions', + model: 'gpt-4o', + key: failingKeySupplier, + }; + + await expect(fetchFromProvider(provider, 'today', 'Context')).rejects.toThrow(TempoAiError); + }); + }); + + describe('Dynamic URL and Model Resolution', () => { + it('should evaluate dynamic URL and Model suppliers per request', async () => { + let activeModel = 'gpt-4o-mini'; + let activeUrl = 'https://api.openai.com/v1/chat/completions'; + + const capturedUrls: string[] = []; + const capturedBodies: any[] = []; + + globalThis.fetch = vi.fn().mockImplementation(async (url: any, init: any) => { + capturedUrls.push(String(url)); + capturedBodies.push(JSON.parse(init.body)); + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: JSON.stringify({ iso: '2026-05-01T00:00:00', confidence: 0.95 }) } }], + }), + headers: new Headers(), + } as any; + }); + + const provider = { + id: 'dynamic-endpoint', + url: () => activeUrl, + model: () => activeModel, + key: 'static-key', + }; + + await fetchFromProvider(provider, 'test 1', 'Context'); + expect(capturedUrls[0]).toBe('https://api.openai.com/v1/chat/completions'); + expect(capturedBodies[0].model).toBe('gpt-4o-mini'); + + activeUrl = 'https://api.groq.com/openai/v1/chat/completions'; + activeModel = 'llama-3.3-70b-versatile'; + + await fetchFromProvider(provider, 'test 2', 'Context'); + expect(capturedUrls[1]).toBe('https://api.groq.com/openai/v1/chat/completions'); + expect(capturedBodies[1].model).toBe('llama-3.3-70b-versatile'); + }); + + it('should fall back to built-in provider defaults when url is omitted', async () => { + const capturedUrls: string[] = []; + globalThis.fetch = vi.fn().mockImplementation(async (url: any) => { + capturedUrls.push(String(url)); + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: JSON.stringify({ iso: '2026-05-01T00:00:00', confidence: 0.95 }) } }], + }), + headers: new Headers(), + } as any; + }); + + const provider = { + id: 'groq', + key: 'static-key', + }; + + await fetchFromProvider(provider, 'test default url', 'Context'); + expect(capturedUrls[0]).toBe('https://api.groq.com/openai/v1/chat/completions'); + }); + }); + + describe('Dynamic Anchor Dates & Context Resolution', () => { + it('should resolve dynamic anchor functions at evaluation time', () => { + let simulatedDate = '2026-01-01T09:00:00Z'; + const anchorSupplier = () => simulatedDate; + + const context = resolveFullContext(); + const anchor1 = resolveAnchorTempo(anchorSupplier, context); + expect(anchor1.format('{yyyy}-{mm}-{dd}')).toBe('2026-01-01'); + + simulatedDate = '2026-12-25T18:30:00Z'; + const anchor2 = resolveAnchorTempo(anchorSupplier, context); + expect(anchor2.format('{yyyy}-{mm}-{dd}')).toBe('2026-12-25'); + }); + + it('should resolve dynamic timeZone and locale suppliers in resolveFullContext', () => { + let currentTz = 'America/New_York'; + let currentLocale = 'en-US'; + + const options = { + timeZone: () => currentTz, + locale: () => currentLocale, + }; + + const ctx1 = resolveFullContext(options); + expect(ctx1.tz).toBe('America/New_York'); + expect(ctx1.loc).toBe('en-US'); + + currentTz = 'Asia/Tokyo'; + currentLocale = 'ja-JP'; + + const ctx2 = resolveFullContext(options); + expect(ctx2.tz).toBe('Asia/Tokyo'); + expect(ctx2.loc).toBe('ja-JP'); + }); + }); +}); diff --git a/packages/plugins/astro/package.json b/packages/plugins/astro/package.json index 8aae8108..b8355ba0 100644 --- a/packages/plugins/astro/package.json +++ b/packages/plugins/astro/package.json @@ -5,6 +5,11 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/plugins/astro" + }, "files": [ "dist", "src", @@ -18,11 +23,11 @@ }, "scripts": { "build": "tsup && tsc", - "test": "cross-env TEMPO_LICENSE_KEY=\"\" vitest run -c ../vitest.shared.ts", + "test": "vitest run -c ../vitest.shared.ts", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" }, "tempo": { "vendorVariantId": "tempo-plugin-astro", diff --git a/packages/plugins/astro/test/astro.test.ts b/packages/plugins/astro/test/astro.test.ts index 871b9f0d..f9a8e601 100644 --- a/packages/plugins/astro/test/astro.test.ts +++ b/packages/plugins/astro/test/astro.test.ts @@ -6,7 +6,7 @@ describe('Astro Plugin (Term Implementation)', () => { beforeEach(() => { // Bypass monorepo dual-package (src vs dist) type hazard for test plugins - Tempo.init({ plugins: [ParseModule as any, AstroTerm as any] }); + Tempo.init({ extends: [ParseModule, AstroTerm] }); }); it('should register "astro" and "astronomy" terms', () => { diff --git a/packages/plugins/batch/CHANGELOG.md b/packages/plugins/batch/CHANGELOG.md index 3a7bebe8..296d7fb2 100644 --- a/packages/plugins/batch/CHANGELOG.md +++ b/packages/plugins/batch/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to the `@magmacomputing/tempo-plugin-batch` project will be documented in this file. +## [1.0.1] - 2026-08-20 + +### Fixed +- **Peer Dependencies**: Expanded peerDependency range to support Tempo v4.0.0. + ## [1.0.0] - 2026-06-29 ### Added diff --git a/packages/plugins/batch/package.json b/packages/plugins/batch/package.json index a27780fc..984f98de 100644 --- a/packages/plugins/batch/package.json +++ b/packages/plugins/batch/package.json @@ -5,6 +5,11 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/plugins/batch" + }, "files": [ "dist", "README.md", @@ -17,7 +22,7 @@ }, "scripts": { "build": "tsup && tsc", - "test": "cross-env TEMPO_LICENSE_KEY=\"\" vitest run -c ../vitest.shared.ts", + "test": "vitest run -c ../vitest.shared.ts", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" }, "tempo": { @@ -25,7 +30,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1" @@ -47,4 +52,4 @@ "import": "./dist/index.js" } } -} +} \ No newline at end of file diff --git a/packages/plugins/finance/CHANGELOG.md b/packages/plugins/finance/CHANGELOG.md index bf3eabb4..68ad22a3 100644 --- a/packages/plugins/finance/CHANGELOG.md +++ b/packages/plugins/finance/CHANGELOG.md @@ -1,5 +1,10 @@ # @magmacomputing/tempo-plugin-finance +## [1.0.3] - 2026-08-20 + +### Fixed +- **Peer Dependencies**: Expanded peerDependency range to support Tempo v4.0.0. + ## 1.0.0 ### Major Changes diff --git a/packages/plugins/finance/package.json b/packages/plugins/finance/package.json index 0cc07b2b..309bba6b 100644 --- a/packages/plugins/finance/package.json +++ b/packages/plugins/finance/package.json @@ -9,7 +9,7 @@ "scripts": { "build": "tsup && tsc", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build", - "test": "cross-env TEMPO_LICENSE_KEY=\"\" vitest run -c ../vitest.shared.ts" + "test": "vitest run -c ../vitest.shared.ts" }, "keywords": [ "tempo", @@ -22,6 +22,11 @@ ], "author": "Magma Computing", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/plugins/finance" + }, "files": [ "dist", "src", @@ -38,10 +43,10 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.9.0" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" }, "devDependencies": { - "@magmacomputing/tempo": "^3.9.0", + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0", "vitest": "^4.1.10" }, "exports": { @@ -50,4 +55,4 @@ "import": "./dist/index.js" } } -} +} \ No newline at end of file diff --git a/packages/plugins/snap/CHANGELOG.md b/packages/plugins/snap/CHANGELOG.md index 03ae0863..218c4c5c 100644 --- a/packages/plugins/snap/CHANGELOG.md +++ b/packages/plugins/snap/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.2] - 2026-08-20 + +### Fixed +- **Peer Dependencies**: Expanded peerDependency range to support Tempo v4.0.0. + ## [1.3.1] - 2026-07-15 ### Fixed diff --git a/packages/plugins/snap/package.json b/packages/plugins/snap/package.json index c11d7636..6a572a95 100644 --- a/packages/plugins/snap/package.json +++ b/packages/plugins/snap/package.json @@ -5,6 +5,11 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/plugins/snap" + }, "files": [ "dist", "README.md", @@ -17,11 +22,11 @@ }, "scripts": { "build": "tsup && tsc", - "test": "cross-env TEMPO_LICENSE_KEY=\"\" vitest run -c ../vitest.shared.ts", + "test": "vitest run -c ../vitest.shared.ts", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" }, "tempo": { "vendorVariantId": "tempo-plugin-snap", diff --git a/packages/plugins/sync/package.json b/packages/plugins/sync/package.json index 51022cfd..b19a4c6f 100644 --- a/packages/plugins/sync/package.json +++ b/packages/plugins/sync/package.json @@ -5,6 +5,11 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/plugins/sync" + }, "files": [ "dist", "README.md", @@ -17,7 +22,7 @@ }, "scripts": { "build": "tsup && tsc", - "test": "cross-env TEMPO_LICENSE_KEY=\"\" vitest run -c ../vitest.shared.ts", + "test": "vitest run -c ../vitest.shared.ts", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" }, "tempo": { @@ -25,7 +30,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" }, "devDependencies": {}, "keywords": [ @@ -45,4 +50,4 @@ "import": "./dist/index.js" } } -} +} \ No newline at end of file diff --git a/packages/plugins/ticker/CHANGELOG.md b/packages/plugins/ticker/CHANGELOG.md new file mode 100644 index 00000000..c7fa675d --- /dev/null +++ b/packages/plugins/ticker/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to the `@magmacomputing/tempo-plugin-ticker` project will be documented in this file. + +## [2.3.0] - 2026-08-20 + +### Changed +- **Community Edition Transition**: Open-sourced under the MIT license as a native Community plugin within the Magma monorepo. +- Removed obfuscation and proprietary license key requirement. + +### Added +- **5-Field Cron Expression Support**: Added native support for standard 5-part cron syntax (e.g. `Tempo.ticker("0 9 * * 1-5")` or `{ cron: "*/15 * * * *" }`) powered by `@magmacomputing/tempo-fns`. +- **RFC 5545 RRULE Support**: Integrated native RRULE string and `TempoRecurrenceRule` support into `Ticker` and `Ticker.Options`. Tickers can now step using deterministic calendar recurrence rules (e.g. `FREQ=DAILY;INTERVAL=1`, `FREQ=WEEKLY;BYDAY=MO`). +- **Snapshot Alignment**: Included `rrule?: string` on `Ticker.Snapshot` and `info` properties so active tickers list their active recurrence rule. + +## [2.2.1] - 2026-07-18 + +### Changed +- **Ticker Plugin Documentation**: Expanded the Ticker documentation to explicitly clarify the difference between snapping (using directional shorthands like `>`) and relative shifting (using numeric values). + +## [2.2.0] - 2026-07-06 + +### Added +- **Shorthand Duration Keys**: `Ticker.Options` now natively supports Tempo shorthand keys (e.g., `yy`, `mm`, `ww`, `dd`, `hh`, `mi`, `ss`, etc.). This enables writing compact and highly ergonomic intervals (e.g., `Tempo.ticker({ mi: 5, ss: 30 })`), achieving total API consistency with the broader v3.6.0 Tempo ecosystem. + +## [1.0.4] - 2026-06-11 + +### Added +- **Label Identification**: Added the `label?: string` property to `Ticker.Options`. Developers can now easily tag and group tickers without needing external memory-tracking structures (like `WeakMap`). This label is natively exposed on `Tempo.tickers` snapshots. + +### Fixed +- **NPM Registry Metadata**: Explicitly added `README.md`, `CHANGELOG.md`, and `LICENSE` to the `files` array in `package.json` to ensure the npmjs.com registry correctly renders package documentation. +- **Test Integrity**: Updated internal test fixtures to correctly align with Tempo Core's standard capitalization and the recent rename of the `period` term to `timeOfDay`. + +## [1.0.0] - 2026-06-01 + +### Added +- Initial release of the Ticker plugin. +- A highly accurate and CPU-efficient periodic timer designed as an alternative to native `setInterval`. +- Features pause, resume, reset, and configurable jitter compensation. +- Supports both `EventEmitter` and callback-based listener patterns. diff --git a/packages/plugins/ticker/LICENSE b/packages/plugins/ticker/LICENSE new file mode 100644 index 00000000..dd7db2d9 --- /dev/null +++ b/packages/plugins/ticker/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Magma Computing + +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/packages/plugins/ticker/README.md b/packages/plugins/ticker/README.md new file mode 100644 index 00000000..93d760b0 --- /dev/null +++ b/packages/plugins/ticker/README.md @@ -0,0 +1,46 @@ +![Tempo Plugin](https://raw.githubusercontent.com/magmacomputing/magma/main/packages/tempo/public/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-ticker + +
+ npm version + npm peer dependency version + License + TypeScript Ready + Documentation +
+ +This is a Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that provides a high-performance continuous execution loop (Ticker) based on temporal mathematics. + +šŸ‘‰ **[View the full documentation on our GitHub Pages](https://magmacomputing.github.io/magma/doc/9-plugins/ticker.index.html)** + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-ticker +``` + +## Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; + +Tempo.init({ + plugins: [TickerPlugin] +}); + +// You can now access Ticker-based execution loops through the Tempo API: +const ticker = Tempo.ticker({ seconds: 1 }, (t, stop) => { + console.log('Tick:', t.format('isoTime')); +}); +``` + +## Documentation + +For full API reference, advanced options, and detailed usage patterns, please visit the official **[Ticker Plugin Documentation ↗](https://magmacomputing.github.io/magma/doc/9-plugins/ticker.index.html)**. + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. + diff --git a/packages/plugins/ticker/doc/index.md b/packages/plugins/ticker/doc/index.md new file mode 100644 index 00000000..2b556b6d --- /dev/null +++ b/packages/plugins/ticker/doc/index.md @@ -0,0 +1,371 @@ +![Tempo Plugin](/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-ticker + +

+ npm version npm peer dependency version License TypeScript Ready +

+ +This is a Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that provides a high-performance continuous execution loop (Ticker) based on temporal mathematics. + +::: info High Performance Loop +Unlike raw `setInterval`, the Ticker plugin leverages Tempo's temporal core to provide best-effort scheduling with millisecond resolution, making it ideal for standard UI updates, periodic tasks, and accurate state synchronization. +::: + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-ticker +``` + +## Usage + +To use the Ticker, pass the plugin to `Tempo.init` or `Tempo.extend`: + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; + +Tempo.init({ + plugins: [TickerPlugin] +}); + +// You can access Ticker-based execution loops through the Tempo API: +const ticker = Tempo.ticker({ seconds: 1 }); +``` + +### Direct Access +If you need to access the [Reporting & Registry](#reporting-registry) API (like `Ticker.active`), you should import the `Ticker` namespace: + +```typescript +import { Ticker } from '@magmacomputing/tempo-plugin-ticker'; + +console.log(Ticker.active); +``` + +## šŸš€ Key Features + +The Ticker supports a unified **Options** object, enabling professional resource management and semantic duration-based intervals. + +### 1. Semantic Intervals (Duration Objects) +Instead of raw numeric seconds, you can use `DurationLike` objects or shorthand keys for clarity. This is especially powerful for variable-length intervals like **months**. + +```typescript +// Pulse exactly once a month +await using monthly = Tempo.ticker({ months: 1 }); + +// You can also use highly compact shorthand keys +await using concise = Tempo.ticker({ hh: 1, mi: 30 }); // every 1h 30m + +// Pulse every time a new #quarter begins +await using quarterly = Tempo.ticker({ '#quarter': 1 }); +``` + +### 2. Term-Based Intervals +Ticker intervals can be driven by any registered **Term**. This is powerful for syncing with business cycles or daily shifts. + +> **Snapping vs Shifting:** Use directional shorthands (like `>`) to snap pulses exactly to the **boundaries** of the term (e.g., the very start of the morning). Using numeric values (like `1`) performs a relative shift, which preserves your current time-offset into the next period (e.g. two hours into a time-period will always be two hours into the next time-period). + +```typescript +// Snap and pulse exactly at the start of every 'morning', 'afternoon', etc. +using shiftTicker = Tempo.ticker({ '#timeOfDay': '>' }, (t) => { + console.log(`New period started: ${t.term.tod}`); +}); +``` + +### 3. Stop Conditions (Resource Management) +Prevent memory leaks and runaway processes by setting a built-in termination condition. + +```typescript +// Pattern A: Stop after exactly 5 ticks (defaults to 1-second interval) +using tickerA = Tempo.ticker({ limit: 5 }, (t) => console.log(t)); + +// Pattern B: Stop when a specific virtual time is reached (Inclusive) +using tickerB = Tempo.ticker({ + seconds: 10, // Plural DurationLike property + until: '2024-12-25T12:00:00' +}, (t) => console.log(t)); + +// Pattern C: Stop immediately (Limit: 0 is strictly honored) +using tickerC = Tempo.ticker({ limit: 0 }); +``` + +### 4. Virtual Clock (Seeding) +To create a **Virtual Clock** that increments from a specific point rather than using the system time, use the `seed` option: + +```typescript +// Starts at '2024-01-01', then increments by 1 day per pulse +await using daily = Tempo.ticker({ + days: 1, + seed: '2024-01-01' +}); +``` + +### 5. Backwards Tickers (Countdowns) +By providing a **negative** interval, you can create a Ticker that moves backwards in time. + +```typescript +// Count down from 10 seconds, moving backwards 1s at a time +using countdown = Tempo.ticker({ seconds: -1, seed: "00:00:10" }, (t, stop) => { + console.log(t.format('{ss}')); + if (t.ss === 0) stop(); +}); +``` + +### 6. Recurrence Rules (RRULE) +Ticker natively supports standard RFC 5545 RRULE strings or options objects with an `rrule` property. + +```typescript +// Pulse on deterministic calendar recurrences (e.g. daily) +await using dailySync = Tempo.ticker('FREQ=DAILY;INTERVAL=1'); + +// Or via options object with additional properties +await using weeklyMeeting = Tempo.ticker({ + rrule: 'FREQ=WEEKLY;BYDAY=MO', + label: 'Weekly Monday Sync' +}); +``` + +### 7. Cron Expressions (Standard 5-Field Syntax) +Ticker natively accepts 5-part cron expressions (`min hr dom mon dow`) powered by `@magmacomputing/tempo-fns`. You can pass cron strings directly or via the `cron` configuration option. + +```typescript +// Pattern A: Positional 5-field cron string (e.g., 9am Monday–Friday) +await using weekdaySync = Tempo.ticker('0 9 * * 1-5', (t) => { + console.log(`Workday morning pulse: ${t.format('isoTime')}`); +}); + +// Pattern B: Options object with cron schedule and boundary limits +await using healthCheck = Tempo.ticker({ + cron: '*/15 * * * *', // Every 15 minutes + label: '15-Minute Healthcheck', + limit: 10 +}); +``` + +## Usage Patterns + +### 1. Resource Management (Recommended) + +Using the `using` and `await using` keywords ensures that Tickers are automatically stopped when they go out of scope. + +```typescript +// Pattern A: Automatic cleanup for callback-based ticker +{ + using ticker = Tempo.ticker((t) => render(t)); // Defaults to a 1-second pulse +} // interval stops automatically here + +// Pattern B: Automatic cleanup for async generator +{ + await using ticker = Tempo.ticker(1); + for await (const t of ticker) { + if (done) break; + } +} // generator is closed and interval stops here +``` + +### 2. Manual Control (Programmatic Stop) + +If you are not using the `using` or `await using` keywords, or if you need to stop the Ticker from outside its own loop (e.g., in a separate event handler), you can manually call the `stop()` method on the Ticker object. + +```typescript +// Pattern A: Stop a callback-based ticker +const tickerA = Tempo.ticker(1, (t) => console.log(t)); +// ... later +tickerA.stop(); + +// Pattern B: Stop an async generator externally +const tickerB = Tempo.ticker(1); + +(async () => { + for await (const t of tickerB) { + console.log(t.toString()); + } + console.log('Ticker has been gracefully stopped.'); +})(); + +// Close the generator from somewhere else +setTimeout(() => { + tickerB.stop(); +}, 5000); +``` +### 3. Event Listeners (.on) +Instead of (or in addition to) the constructor callback, you can register listeners for the `'pulse'`, `'stop'`, and `'catch'` events. +All listeners use the same callback signature: `(t, stop) => {}`. + +```typescript +const ticker = Tempo.ticker(1); +ticker.on('pulse', (t) => console.log('Listener A:', t.fmt.weekTime)); +ticker.on('pulse', (t) => console.log('Listener B:', t.fmt.weekTime)); +ticker.on('stop', (t) => console.log('Ticker stopped at:', t.fmt.weekTime)); +``` +For `'stop'` listeners, the `stop` callback argument is included for signature consistency; however, invoking it after stop has already occurred is a no-op. + +### 4. Manual Pulsing (.pulse) +In some scenarios, you may want to drive a Ticker manually (e.g., from a UI event or a WebSocket message) while still benefiting from the Ticker's internal state management and listeners. + +```typescript +const ticker = Tempo.ticker({ seconds: 1 }); // Still has a 1s duration logic +// ... +ticker.pulse(); // Manually advance and notify listeners +``` + +## 🧟 Zombie Tickers (Warning) {#zombie-tickers-warning} + +In a Node.js environment, `Tempo.ticker()` uses background timers (`setTimeout`) to drive its pulses. If you do not explicitly stop a Ticker, it becomes a **"Zombie Ticker"** that continues to run indefinitely, even if the variable that created it has gone out of scope. + +### The Risks: +- **Process Hangs**: Node.js will not exit a process if there are active timers. Undisposed Tickers are a common cause of "mysterious hangs" at the end of test runs. +- **Test Inconsistency**: Leaked Tickers can continue to fire while subsequent tests are running, leading to flaky assertions and "impossible" state changes. +- **Memory Leaks**: Each active Ticker maintains closures that prevent garbage collection of the `Tempo` instance and its listeners. + +### The Solution: +Always use the **Disposer Pattern** (`using` or `await using`) or a `try...finally` block to guarantee cleanup: + +```typescript +// āœ…āœ… BEST: Automatic cleanup via 'using' +{ + using ticker = Tempo.ticker(1); + // ... logic ... +} // Stays clean: ticker stopped automatically here + +// āœ… GOOD: Manual cleanup in finally block (Required for captured variables) +let ticker; +try { + ticker = Tempo.ticker(1, (t) => { ... }); + // ... assertions ... +} finally { + ticker?.stop(); // Prevents "Zombie Tickers" even if assertions fail +} +``` + +::: warning +If you are using `const` or `let` without a `finally` block, an assertion failure will skip the `stop()` call, leaving a live timer in the event loop. Always prefer the `using` keyword or `try...finally` for industrial-grade resource management. +::: + +### `Ticker` Object +The object returned by `Tempo.ticker()` (or an instance of the `Ticker` class) implements the following interface: + +| Method / Property | Description | +| :--- | :--- | +| `on(event, cb)` | Registers a listener for the `'pulse'`, `'stop'`, or `'catch'` events. | +| `pulse()` | Manually triggers a pulse, advances state, and notifies listeners. Returns the new `Tempo`. | +| `info` | Read-only getter returning `{ next, ticks, limit, interval, stopped }`. | +| `stop()` | Stops the Ticker, clears active timers, and immediately resolves any pending async iteration Promises. | +| `[Symbol.dispose]` | Standard cleanup for `using` blocks. | +| `[Symbol.asyncDispose]` | Standard async cleanup for `await using` blocks. | +| `[Symbol.asyncIterator]` | Standard async iteration support (for `for await` loops). | + +## Reporting & Registry {#reporting-registry} + +The `Ticker` class maintains a static registry of all currently active Tickers. This is useful for debugging, monitoring, or cleanup checks. + +### `Ticker.active` +A static getter that returns an array of [`Ticker.Snapshot`](#tickersnapshot) objects for all active (non-stopped) Tickers. + +```typescript +import { Ticker } from '@magmacomputing/tempo-plugin-ticker'; + +// Get a report of all running tickers +const reports = Ticker.active; + +reports.forEach(({ ticker, next, ticks }) => { + console.log(`Ticker ${ticker} next pulse: ${next}, ticks so far: ${ticks}`); +}); +``` + +#### `Ticker.Snapshot` +```typescript +type Snapshot = { + ticker: Instance; // The Ticker instance (Proxy) itself + next: Tempo; // The next Tempo value to be emitted + ticks: number; // Number of pulses emitted so far + limit?: number; // The configured limit (if any) + interval: object; // The duration-based interval + stopped: boolean; // Whether the ticker is stopped +} +``` + +## šŸŽÆ One-Shot Ticker (Meeting Alerts) + +You can use the Ticker as a "one-shot" timer for specific events by simply specifying a **seed** value. This is perfect for setting up a single alert (e.g., for a meeting) that cleans itself up immediately after firing. + +::: tip +**Seed-Only Logic**: Providing a `seed` (as a string or in an options object) without any other duration-based keys (`seconds`, `minutes`, etc.) or a `limit` implies a `limit: 1`. + +Effectively, `Tempo.ticker('Fri 10am')` and `Tempo.ticker({ seed: 'Fri 10am' })` and `Tempo.ticker({ seed: 'Fri 10am', limit: 1 })` are all treated as one-shot Tickers. + +**Inclusive Boundaries**: Termination conditions (`limit` and `until`) are **inclusive**. A Ticker with `limit: 1` will pulse exactly once before stopping. +::: + +```typescript +// Pattern A: Implicit one-shot via string seed +Tempo.ticker('Friday 10am', (t) => { + console.log(`Meeting alert: ${t.format('{hh}:{mi}')}`); +}); + +// Pattern B: Explicit one-shot via options +const event = { meeting: 'Friday 10am' }; + +Tempo.ticker({ + seed: { value: 'meeting', event } +}, (t) => { + console.log(`Meeting alert: ${t.format('{hh}:{mi}')}`); +}); +``` + +::: warning +**Future Seeds**: If the `seed` is in the future, the Ticker will remain dormant (waiting) until that time is reached. **Most Tickers emit an initial pulse immediately** (at the `seed` time or "now"), but a future seed will delay that first pulse until the specified time. +::: + +::: danger +**Persistence**: Ticker timers exist only **in-memory**. If the driving process (e.g., Node.js) terminates, any scheduled future pulses (including those from future seeds) are lost. For critical long-term scheduling, consider an external persistent job runner. +::: + +::: warning +While `limit: 1` handles the stop condition automatically, always remember that if you are using long-running Tickers without a limit, you **must** use the [Disposer Pattern](#zombie-tickers-warning) or manual `stop()` to avoid memory leaks and zombie processes. +::: + +## 🧭 Advanced: Syncing Multiple Clocks + +If you need to show multiple timezones on a dashboard, avoid creating multiple Tickers. Instead, use a single **Master Ticker** to drive all views. This prevents "drift" between the clocks and is much more efficient. + +### Using Signals (Recommended) + +Signals (from Preact, Solid, or Vue) are perfect for this "one source, many views" pattern. + +```typescript +// 1. Master source of truth +const now = signal(new Tempo()); + +// 2. Drive the master from a single ticker +using _ = Tempo.ticker(1, (t) => now.value = t); + +// 3. Derived timezones update automatically and stay 100% in sync +const sydney = computed(() => now.value.set({ timeZone: 'Australia/Sydney' })); +const london = computed(() => now.value.set({ timeZone: 'Europe/London' })); +``` + +### Using Async Generators (Framework-Agnostic) + +If you are not using a reactive framework, you can use the same pattern with an `AsyncGenerator` to derive all clocks from a single pulse. + +```typescript +// One generator, one interval, zero drift. +await using master = Tempo.ticker(1); + +for await (const t of master) { + const clocks = { + sydney: t.set({ timeZone: 'Australia/Sydney' }), + ny: t.set({ timeZone: 'America/New_York' }), + london: t.set({ timeZone: 'Europe/London' }) + }; + + renderDashboard(clocks); +} +``` + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. + diff --git a/packages/plugins/ticker/package.json b/packages/plugins/ticker/package.json new file mode 100644 index 00000000..0061fd31 --- /dev/null +++ b/packages/plugins/ticker/package.json @@ -0,0 +1,59 @@ +{ + "name": "@magmacomputing/tempo-plugin-ticker", + "version": "2.3.0", + "description": "Tempo plugin that provides a high-performance continuous execution loop (Ticker) based on temporal mathematics.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/magmacomputing/magma.git", + "directory": "packages/plugins/ticker" + }, + "files": [ + "dist", + "src", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "scripts": { + "build": "tsup && tsc", + "test": "vitest run -c ../vitest.shared.ts", + "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" + }, + "tempo": { + "vendorVariantId": "tempo-plugin-ticker", + "plan": "community" + }, + "peerDependencies": { + "@magmacomputing/tempo": "^3.11.1 || ^4.0.0" + }, + "devDependencies": { + "@magmacomputing/tempo-fns": "*" + }, + "keywords": [ + "tempo", + "tempo-plugin", + "magmacomputing", + "temporal", + "ticker", + "interval", + "loop", + "animation", + "date", + "datetime", + "typescript" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} diff --git a/packages/plugins/ticker/src/index.ts b/packages/plugins/ticker/src/index.ts new file mode 100644 index 00000000..ee162717 --- /dev/null +++ b/packages/plugins/ticker/src/index.ts @@ -0,0 +1,470 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { isRRuleString, getNextRRuleEpoch, isString } from '@magmacomputing/library'; +import { nextCron, isCronString } from '@magmacomputing/tempo-fns'; +import { + enums, definePlugin, attachStatics, type TempoPlugin, + isObject, isFunction, isDefined, isEmpty, isNumeric, isNumber, Pledge, asArray, instant, normaliseFractionalDurations, +} from '@magmacomputing/tempo/plugin-api'; + +export { isCronString }; + +declare module '@magmacomputing/tempo' { + namespace Tempo { + /** An array of snapshots for all currently active tickers. */ + const tickers: Ticker.Snapshot[]; + + /** + * Creates a new Ticker instance to schedule recurring events. + * + * @param interval - The ticker interval, cron string, rrule, or options configuration + * @param callback - Optional callback to execute on each tick + * @returns A Ticker.Instance that can be awaited, iterated, or listened to + */ + function ticker(options: Ticker.Options): Ticker.Instance; + function ticker(interval?: Ticker.Interval): Ticker.Instance; + function ticker(callback: Ticker.Callback): Ticker.Instance; + function ticker(interval: Ticker.Interval, callback: Ticker.Callback): Ticker.Instance; + function ticker(options: Ticker.Options, callback: Ticker.Callback): Ticker.Instance; + function ticker(options: Ticker.Options, extraOptions: Ticker.Options): Ticker.Instance; + } +} + +/** + * ## Ticker + * Ticker namespace object. + * Provides access to currently active tickers. + */ +export const Ticker = { + get active() { + return asArray(ACTIVE_TICKERS) + .map((t): Ticker.Snapshot => { + const { label, next, ticks, limit, interval, rrule, cron, stopped } = t.info; + return { ticker: t, label, next, ticks, limit, interval, rrule, cron, stopped }; + }); + }, +}; + +/** + * Unified namespace for Ticker types and public API. + */ +export namespace Ticker { + /** ticker interval allowed types (interpreted as seconds) */ + export type Interval = number | string | bigint; + + /** ticker configuration and stop conditions */ + export type Options = { + label?: string; + cron?: string; + rrule?: string | { rrule: string;[key: string]: any }; + years?: number; months?: number; weeks?: number; days?: number; + hours?: number; minutes?: number; seconds?: number; + milliseconds?: number; microseconds?: number; nanoseconds?: number; + yy?: number; mm?: number; ww?: number; dd?: number; + hh?: number; mi?: number; ss?: number; + ms?: number; us?: number; ns?: number; + limit?: number; + until?: Tempo.DateTime | Tempo.Options; + seed?: Tempo.DateTime | Tempo.Options; + catch?: boolean; + [key: `#${string}`]: number | string; + }; + + /** callback function for Tempo.ticker() */ + export type Callback = (t: Tempo, stop: () => void) => void; + + /** Internal descriptor for Ticker methods and properties */ + export interface Descriptor extends AsyncGenerator, AsyncDisposable, Disposable { + pulse(): Tempo; + on(event: 'pulse' | 'catch' | 'stop', cb: (t: Tempo, stop: () => void) => void): this; + stop(): void; + readonly info: { + label: string | undefined; + next: Tempo; + ticks: number; + limit: number | undefined; + interval: Record; + rrule: string | undefined; + cron: string | undefined; + stopped: boolean; + }; + } + + /** Unified Ticker interface supporting generators, events, and manual pulsing (callable as stop()) */ + export interface Instance extends Descriptor { + (): void; + } + + /** Summary of an active ticker */ + export type Snapshot = Descriptor['info'] & { ticker: Instance }; +} + +/** + * ### ACTIVE_TICKERS + * Internal registry for all active tickers. + */ +const ACTIVE_TICKERS = new Set(); + +/** + * Stateful internal class for Tempo.Ticker instances. + * Implements the AsyncGenerator and EventEmitter patterns. + */ +class TickerInstance implements Ticker.Descriptor { + #TempoClass: typeof Tempo; + #label: string | undefined; + #payload: Record = {}; + #rrule: string | undefined; + #cron: string | undefined; + #current: Tempo; + #until: Tempo | undefined; + #limit: number | undefined; + #ticks = 0; + #stopped = false; + #genFirstYielded = false; + #isForward = true; + #isInstant = false; + #isShorthand = false; + #schedId: any; + #waiters: Pledge[] = []; + #listeners = new Set(); + #catchListeners = new Set(); + #stopListeners = new Set(); + #self!: Ticker.Instance; + + constructor(TempoClass: typeof Tempo, arg1: any, arg2?: any) { + this.#TempoClass = TempoClass; + + // ── Overload Parsing ───────────────────────────────────────────────── + let rawOptions: any = {}; + let cb: Ticker.Callback | undefined; + + const isDateLike = (obj: any) => isObject(obj) && ('epoch' in obj || 'epochMilliseconds' in obj || 'toZonedDateTimeISO' in obj || 'getTime' in obj); + const isOptions = (obj: any) => isObject(obj) && !isDateLike(obj); + + switch (true) { + case isFunction(arg1): + cb = arg1; + break; + case isOptions(arg1): + Object.assign(rawOptions, arg1); + if (isFunction(arg2)) cb = arg2; + else if (isOptions(arg2)) Object.assign(rawOptions, arg2); + break; + default: + if (isDefined(arg1)) { + if (isCronString(arg1)) { + rawOptions.cron = arg1; + } else if (isRRuleString(arg1)) { + rawOptions.rrule = arg1; + } else { + const num = Number(arg1); + if (isNumeric(arg1)) rawOptions.seconds = num; + else rawOptions.seed = arg1; + } + } + if (isFunction(arg2)) cb = arg2; + else if (isOptions(arg2)) Object.assign(rawOptions, arg2); + } + + // ── Initialization ─────────────────────────────────────────────────── + const { label, limit: lmt, until: stopAt, seed: startAt, rrule: rruleOption, cron: cronOption, ...rest } = rawOptions; + this.#label = label; + this.#limit = lmt; + if (rruleOption) + this.#rrule = isString(rruleOption) ? rruleOption : rruleOption.rrule; + if (cronOption) + this.#cron = cronOption; + + if (cb) this.#listeners.add(cb); + + const durationKeys = new Set(Object.keys(enums.DURATIONS)); + for (const [key, val] of Object.entries(rest)) + if (isDefined(val) && (durationKeys.has(key) || key in enums.ELEMENT || key.startsWith('#'))) + this.#payload[key] = val; + + const isSeed = isDefined(rawOptions.seed); + const isRRule = isDefined(this.#rrule); + const isCron = isDefined(this.#cron); + const isInterval = !isEmpty(this.#payload) || (isDefined(rawOptions.seconds) && isNumber(rawOptions.seconds)); + + if (isDefined(arg1) && !isInterval && !isSeed && !isRRule && !isCron && !cb) { + const err = new Error(`Invalid Ticker interval, seed, cron, or rrule: ${String(arg1)}`); + if (!this.#TempoClass.config?.catch) throw err; + console.error(err.message); + } + + this.#until = stopAt ? new this.#TempoClass(isOptions(stopAt) ? undefined : stopAt, isOptions(stopAt) ? { ...rest, ...stopAt } : rest) : undefined; + + if (isEmpty(this.#payload) && !isRRule && !isCron) { + if (isDefined(startAt)) this.#limit ??= 1; + else this.#payload.seconds = 1; + } + + normaliseFractionalDurations(this.#payload); + this.#current = new this.#TempoClass(isOptions(startAt) ? undefined : startAt, isOptions(startAt) ? { ...rest, ...startAt } : rest); + } + + /** explicitly set the proxy-self (called by factory) */ + bootstrap(proxy: Ticker.Instance) { + this.#self = proxy; + + // ── Validation ─────────────────────────────────────────────── + if (!this.#current.isValid) { + this.stop(); + const err = new Error(`Invalid Ticker seed: ${String(this.#current)}`); + if (!this.#current.config?.catch) throw err; + console.error(err.message); + } else if (this.#until && !this.#until.isValid) { + this.stop(); + const err = new Error(`Invalid Ticker boundary: ${String(this.#until)}`); + if (!this.#current.config?.catch) throw err; + console.error(err.message); + } else { + try { + if (this.#cron || this.#rrule) { + this.#isForward = true; + this.#isInstant = false; + ACTIVE_TICKERS.add(this.#self); + this.#runBootstrap(); + } else { + // ── Mode Detection ────────────────────────────────────────── + // Directional shorthand ('>', '<') implies absolute snapping via .set() + // Numeric durations or named ranges imply relative shifting via .add() + const hasShorthand = Object.entries(this.#payload).some(([k, v]) => + k.startsWith('#') && isString(v) && /^[<>]/.test(v.trim()) + ); + const hasRelative = Object.keys(this.#payload).some(k => !k.startsWith('#')); + + if (hasShorthand && hasRelative) + throw new Error(`Ambiguous Ticker payload: cannot mix directional shorthand terms (e.g. '>') with relative durations (e.g. 'hours'). Use one or the other.`); + + this.#isShorthand = hasShorthand; + const hasTermKey = Object.keys(this.#payload).some(k => k.startsWith('#')); + const firstStep = this.#isShorthand ? this.#current.set(this.#payload) : this.#current.add(this.#payload); + if (!firstStep.isValid) throw new Error(`Invalid Ticker payload resolution for ${JSON.stringify(this.#payload)}`); + this.#isForward = this.#TempoClass.compare(firstStep, this.#current) >= 0; + this.#isInstant = firstStep.epoch.ns === this.#current.epoch.ns; + if (hasTermKey) this.#current = firstStep; + + ACTIVE_TICKERS.add(this.#self); + this.#runBootstrap(); + } + } catch (e: any) { + this.stop(); + const msg = `Invalid Ticker payload resolution for ${JSON.stringify(this.#payload)}`; + if (!this.#current.config?.catch) throw new Error(msg); + console.error(msg, e); + queueMicrotask(() => this.#catchListeners.forEach(l => l(this.#current, () => this.stop()))); + this.#isForward = true; + this.#isInstant = false; + } + } + return this.#self; + } + + #delayMs() { + return Math.max(0, Math.round(this.#current.epoch.ms - instant().epochMilliseconds)); + } + + #safePulse(): Tempo { + try { + const t = this.pulse(); + const queue = this.#waiters; + this.#waiters = []; + for (const w of queue) + if (w.isPending) w.resolve(t); + + return t; + } catch (e: any) { + this.stop(); + if (this.#catchListeners.size > 0) { + this.#catchListeners.forEach(l => l(this.#current, () => this.stop())); + } else if (!this.#TempoClass.config?.catch) { + throw e; + } + return this.#current; + } + } + + #scheduleNext() { + if (this.#stopped || this.#isInstant) return; + this.#schedId = setTimeout(() => { + if (!this.#stopped) { + this.#safePulse(); + this.#scheduleNext(); + } + }, this.#delayMs()); + } + + #runBootstrap() { + if ((this.#listeners.size > 0 || this.#waiters.length > 0) && !this.#stopped && !this.#schedId) { + const delay = this.#delayMs(); + if (delay > 0) { + this.#schedId = setTimeout(() => { + if (!this.#stopped) { + this.#safePulse(); + this.#scheduleNext(); + } + }, delay); + } else { + this.#safePulse(); + this.#scheduleNext(); + } + } + } + + pulse(): Tempo { + if (this.#stopped) return new (this.#TempoClass as any)(null, this.#current.config); + + const t = this.#current; + if (!t.isValid) { + this.stop(); + this.#catchListeners.forEach(l => l(t, () => this.stop())); + return t; + } + + if (this.#cron) { + this.#current = nextCron(t, this.#cron); + } else if (this.#rrule) { + const nextMs = getNextRRuleEpoch(this.#rrule, t.epoch.ms); + this.#current = new (this.#TempoClass as any)(nextMs, t.config); + } else { + this.#current = this.#isInstant ? t : (this.#isShorthand ? t.set(this.#payload) : t.add(this.#payload)); + } + + this.#ticks++; + + if (isDefined(this.#limit) && this.#ticks >= this.#limit) this.stop(t); + if (isDefined(this.#until)) { + const cmp = this.#TempoClass.compare(t, this.#until); + if ((this.#isForward && cmp >= 0) || (!this.#isForward && cmp <= 0)) this.stop(t); + } + + if (this.#stopped && isDefined(this.#limit) && this.#limit === 0) return t; + + this.#listeners.forEach(l => l(t, () => this.stop())); + return t; + } + + on(event: 'pulse' | 'catch' | 'stop', cb: Ticker.Callback) { + if (event === 'pulse') { + this.#listeners.add(cb); + this.#runBootstrap(); + } + if (event === 'catch') this.#catchListeners.add(cb); + if (event === 'stop') this.#stopListeners.add(cb); + return this; + } + + stop(terminalValue?: Tempo) { + if (this.#stopped) return; + this.#stopped = true; + ACTIVE_TICKERS.delete(this.#self); + if (this.#schedId) { + clearTimeout(this.#schedId); + this.#schedId = undefined; + } + const queue = this.#waiters; + this.#waiters = []; + for (const w of queue) + if (w.isPending) w.resolve(terminalValue as any); + + this.#stopListeners.forEach(l => l(this.#current, () => undefined)); + } + + get info() { + return { + label: this.#label, + next: this.#current.clone(), + ticks: this.#ticks, + limit: this.#limit, + interval: { ...this.#payload }, + rrule: this.#rrule, + cron: this.#cron, + stopped: this.#stopped, + } + } + + async next(): Promise> { + if (this.#stopped || this.#isInstant) return { done: true, value: undefined }; + + const waiter = new Pledge('Ticker.next'); + this.#waiters.push(waiter); + + if (!this.#genFirstYielded) { + this.#genFirstYielded = true; + const delay = this.#delayMs(); + if (delay > 0) { + this.#runBootstrap(); + } else { + queueMicrotask(() => { + if (!this.#stopped || this.#waiters.length > 0) { + this.#safePulse(); + this.#scheduleNext(); + } + }); + } + } else { + this.#runBootstrap(); + } + + const res = await waiter; + if (res && res.isValid) return { done: false, value: res }; + return { done: true, value: undefined }; + } + + async return(): Promise> { + this.stop(); + return { done: true, value: undefined }; + } + + async throw(e: any): Promise> { + const queue = this.#waiters; + this.#waiters = []; + for (const w of queue) + if (w.isPending) w.reject(e); + + this.stop(); + throw e; + } + + async [Symbol.asyncDispose]() { this.stop(); } + [Symbol.asyncIterator]() { return this.#self; } + [Symbol.dispose]() { this.stop(); } +} + +/** + * ## TickerPlugin + * The Community Ticker Plugin. + * Exposes the `Tempo.ticker()` factory and `Tempo.tickers` registry. + */ +export const TickerPlugin: TempoPlugin = definePlugin({ + name: 'ticker', + install(this: typeof Tempo, TempoClass: typeof Tempo) { + attachStatics(TempoClass, { + ticker: function (arg1: any, arg2?: any): Ticker.Instance { + const instance = new TickerInstance(TempoClass, arg1, arg2); + const proxy = new Proxy((() => instance.stop()) as any, { + get: (_, prop) => { + if (prop === 'pulse') return instance.pulse.bind(instance); + if (prop === 'on') return instance.on.bind(instance); + if (prop === 'stop') return instance.stop.bind(instance); + if (prop === 'next') return instance.next.bind(instance); + if (prop === 'return') return instance.return.bind(instance); + if (prop === 'throw') return instance.throw.bind(instance); + if (prop === 'info') return instance.info; + if (prop === Symbol.asyncIterator) return () => proxy; + if (prop === Symbol.asyncDispose) return instance[Symbol.asyncDispose].bind(instance); + if (prop === Symbol.dispose) return instance[Symbol.dispose].bind(instance); + return (instance as any)[prop]; + }, + apply: (target) => target(), + }) as unknown as Ticker.Instance; + + return instance.bootstrap(proxy); + }, + tickers: { + get: () => Ticker.active, + }, + }); + }, +}); diff --git a/packages/plugins/ticker/test/ticker.cron.test.ts b/packages/plugins/ticker/test/ticker.cron.test.ts new file mode 100644 index 00000000..b4c774ad --- /dev/null +++ b/packages/plugins/ticker/test/ticker.cron.test.ts @@ -0,0 +1,73 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin, isCronString } from '../src/index.js'; + +Tempo.extend(TickerPlugin); + +describe('Ticker Cron Support', () => { + beforeEach(() => { + Tempo.init(); + }); + + test('identifies 5-field cron strings using isCronString helper', () => { + expect(isCronString('0 9 * * 1-5')).toBe(true); + expect(isCronString('*/15 * * * *')).toBe(true); + expect(isCronString('0 0 1 1 *')).toBe(true); + expect(isCronString('FREQ=DAILY;BYHOUR=9')).toBe(false); + expect(isCronString('5s')).toBe(false); + expect(isCronString('2026-04-25 10:30')).toBe(false); + }); + + test('creates Ticker from positional cron string overload', () => { + const t = Tempo.ticker('0 9 * * 1-5'); + expect(t.info.cron).toBe('0 9 * * 1-5'); + t.stop(); + }); + + test('creates Ticker from options object with cron property', () => { + const t = Tempo.ticker({ cron: '*/15 * * * *', label: 'Quarterly Check' }); + expect(t.info.cron).toBe('*/15 * * * *'); + expect(t.info.label).toBe('Quarterly Check'); + t.stop(); + }); + + test('exposes .cron in Tempo.tickers active snapshots', () => { + const t = Tempo.ticker('0 12 * * *'); + const activeSnapshots = Tempo.tickers; + expect(activeSnapshots.length).toBeGreaterThan(0); + const match = activeSnapshots.find(s => s.cron === '0 12 * * *'); + expect(match).toBeDefined(); + expect(match?.cron).toBe('0 12 * * *'); + t.stop(); + }); + + test('stepping via pulse() follows Cron schedule deterministically', () => { + // Seed at 2026-08-22 08:45:00 + const seed = new Tempo('2026-08-22 08:45'); + // Cron for 9am every day: '0 9 * * *' + const t = Tempo.ticker({ cron: '0 9 * * *', seed }); + + const step1 = t.pulse(); + expect(step1.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-22 08:45'); + + const step2 = t.pulse(); + expect(step2.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-22 09:00'); + + const step3 = t.pulse(); + expect(step3.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-23 09:00'); + + t.stop(); + }); + + test('respects limit boundary when pulsing cron tickers', () => { + const seed = new Tempo('2026-08-22 00:00'); + const t = Tempo.ticker({ cron: '0 0 * * *', seed, limit: 2 }); + + const p1 = t.pulse(); + expect(p1.format('{yyyy}-{mm}-{dd}')).toBe('2026-08-22'); + expect(t.info.stopped).toBe(false); + + const p2 = t.pulse(); + expect(p2.format('{yyyy}-{mm}-{dd}')).toBe('2026-08-23'); + expect(t.info.stopped).toBe(true); + }); +}); diff --git a/packages/plugins/ticker/test/ticker.hang.test.ts b/packages/plugins/ticker/test/ticker.hang.test.ts new file mode 100644 index 00000000..5c36a4af --- /dev/null +++ b/packages/plugins/ticker/test/ticker.hang.test.ts @@ -0,0 +1,34 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '../src/index.js'; + +Tempo.extend(TickerPlugin); + +describe('Ticker Pledge Refactor Verification', () => { + beforeEach(() => { + Tempo.init(); + }); + + test('should terminate async iteration immediately when stop() is called (Pledge)', async () => { + const t = Tempo.ticker({ seconds: 1 }); + let count = 0; + + const loopPromise = (async () => { + for await (const tick of t) { + count++; + if (count === 1) { + // Stop the ticker while it's waiting for the NEXT tick (1s delay) + t.stop(); + } + } + return count; + })(); + + const start = Date.now(); + await loopPromise; + const duration = Date.now() - start; + + // Duration should be low (the first pulse is immediate, but the stop should happen immediately) + expect(duration).toBeLessThan(500); + expect(count).toBe(1); + }); +}); diff --git a/packages/plugins/ticker/test/ticker.patterns.test.ts b/packages/plugins/ticker/test/ticker.patterns.test.ts new file mode 100644 index 00000000..8bb21317 --- /dev/null +++ b/packages/plugins/ticker/test/ticker.patterns.test.ts @@ -0,0 +1,131 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '../src/index.js'; + +Tempo.extend(TickerPlugin); + +const label = 'ticker:'; + +describe(`${label}`, () => { + beforeEach(() => { + Tempo.init({ silent: true }); + }); + + test(`${label} callback pattern`, async () => { + let count = 0; + let lastTick: any; + + { + using _stop = Tempo.ticker(0.25, (t) => { + count++; + lastTick = t; + }); + + await new Promise(resolve => setTimeout(resolve, 0)); // check immediate + expect(count).toBe(1); + expect(lastTick).toBeDefined(); + + await new Promise(resolve => setTimeout(resolve, 1_000)); // wait for ~4 total ticks + } // stop() is called automatically here + + expect(count).toBeGreaterThanOrEqual(3); + expect(lastTick).toBeDefined(); + expect(lastTick instanceof Tempo).toBe(true); + + const finalCount = count; + await new Promise(resolve => setTimeout(resolve, 100)); + expect(count).toBe(finalCount); // check it stopped + }); + + test(`${label} async generator pattern`, async () => { + const results: any[] = []; + let i = 0; + + { + await using ticker = Tempo.ticker(0.02); + for await (const t of ticker) { + results.push(t); + if (++i === 3) break; + } + } // asyncDispose() is called automatically here + + expect(results.length).toBe(3); + expect(results[0]).toBeDefined(); + expect(results[0] instanceof Tempo).toBe(true); + }); + + test(`${label} backwards ticker`, async () => { + const results: number[] = []; + const start = new Tempo('2024-01-01T00:00:10Z'); + + { + Tempo.ticker({ seed: start, seconds: -1 }, (t, stop) => { + results.push(t.ss); + if (results.length === 3) stop(); + }); + + await new Promise(resolve => setTimeout(resolve, 2500)); + } + + expect(results).toEqual([10, 9, 8]); + }); + + test(`${label} immediate stop`, async () => { + let count = 0; + Tempo.ticker(0.05, (t, stop) => { + count++; + stop(); // stop immediately on first tick + }); + + await new Promise(resolve => setTimeout(resolve, 200)); + expect(count).toBe(1); // should only have the immediate tick + }); + + test('ticker: flexible numeric intervals', async () => { + let count = 0; + // Test String + const stop1 = Tempo.ticker('0.05', () => count++); + await new Promise(resolve => setTimeout(resolve, 75)); + stop1(); + expect(count).toBeGreaterThanOrEqual(2); + + // Test BigInt + count = 0; + const stop2 = Tempo.ticker(0n, () => count++); + await new Promise(resolve => setTimeout(resolve, 75)); + stop2(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test('ticker: emit-once (zero interval)', async () => { + let count = 0; + const stop = Tempo.ticker(0, () => count++); + await new Promise(resolve => setTimeout(resolve, 100)); + stop(); + expect(count).toBe(1); // Only initial emit + }); + + test('ticker: non-second duration and term intervals without callback', () => { + expect(() => { + using t = Tempo.ticker({ months: 1 }); + t.stop(); + }).not.toThrow(); + + expect(() => { + using t = Tempo.ticker({ '#quarter': 1 }); + t.stop(); + }).not.toThrow(); + }); + + test('ticker: validation', () => { + // @ts-ignore + expect(() => Tempo.ticker(NaN)).toThrow(/Invalid Tempo number: NaN/); + // @ts-ignore + expect(() => Tempo.ticker(NaN, { catch: true })).not.toThrow(); + + // @ts-ignore + expect(() => Tempo.ticker(Infinity)).toThrow(/Invalid Tempo number: Infinity/); + // @ts-ignore + expect(() => Tempo.ticker('not a number')).toThrow(/Unrecognized or invalid ISO 8601 string: "not a number"/); + }); + +}); diff --git a/packages/plugins/ticker/test/ticker.pulse.test.ts b/packages/plugins/ticker/test/ticker.pulse.test.ts new file mode 100644 index 00000000..506e16cb --- /dev/null +++ b/packages/plugins/ticker/test/ticker.pulse.test.ts @@ -0,0 +1,54 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '../src/index.js'; + +Tempo.extend(TickerPlugin); + +describe('Ticker Pulse Behavior', () => { + beforeEach(() => { + Tempo.init(); + }); + + test('limit: 1 should result in 1 pulse currently', async () => { + let count = 0; + const t = Tempo.ticker({ seconds: 0.1, limit: 1 }, () => count++); + await new Promise(r => setTimeout(r, 200)); + expect(count).toBe(1); + t.stop(); + }); + + test('limit: 0 should result in 0 pulses currently', async () => { + let count = 0; + const t = Tempo.ticker({ seconds: 0.1, limit: 0 }, () => count++); + await new Promise(r => setTimeout(r, 200)); + expect(count).toBe(0); + t.stop(); + }); + + test('should support concurrent next() calls without dropping requests', async () => { + const t = Tempo.ticker({ seconds: 0.05, limit: 1 }); + const [res1, res2] = await Promise.all([t.next(), t.next()]); + expect(res1.done).toBe(false); + expect(res2.done).toBe(false); + expect(res1.value?.epoch.ms).toBe(res2.value?.epoch.ms); + + const res3 = await t.next(); + expect(res3.done).toBe(true); + t.stop(); + }); + + test('should return terminal pulse as done: false before completing remaining queued requests', async () => { + const t = Tempo.ticker({ seconds: 0.05, limit: 1 }); + const p1 = t.next(); + const p2 = t.next(); + + const [res1, res2] = await Promise.all([p1, p2]); + expect(res1.done).toBe(false); + expect(res1.value).toBeDefined(); + expect(res2.done).toBe(false); + expect(res2.value).toBeDefined(); + + const res3 = await t.next(); + expect(res3.done).toBe(true); + expect(res3.value).toBeUndefined(); + }); +}); diff --git a/packages/plugins/ticker/test/ticker.rrule.test.ts b/packages/plugins/ticker/test/ticker.rrule.test.ts new file mode 100644 index 00000000..f841795c --- /dev/null +++ b/packages/plugins/ticker/test/ticker.rrule.test.ts @@ -0,0 +1,49 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '../src/index.js'; + +Tempo.extend(TickerPlugin); + +describe('Ticker RRULE Support', () => { + beforeEach(() => { + Tempo.init(); + }); + + test('creates Ticker from raw RRULE string overload', () => { + const t = Tempo.ticker('FREQ=DAILY;INTERVAL=1'); + expect(t.info.rrule).toBe('FREQ=DAILY;INTERVAL=1'); + t.stop(); + }); + + test('creates Ticker from options object with rrule property', () => { + const t = Tempo.ticker({ rrule: 'FREQ=WEEKLY;BYDAY=MO', label: 'Weekly Sync' }); + expect(t.info.rrule).toBe('FREQ=WEEKLY;BYDAY=MO'); + expect(t.info.label).toBe('Weekly Sync'); + t.stop(); + }); + + test('exposes .rrule in Tempo.tickers active snapshots', () => { + const t = Tempo.ticker('FREQ=MONTHLY;INTERVAL=1'); + const activeSnapshots = Tempo.tickers; + expect(activeSnapshots.length).toBeGreaterThan(0); + const match = activeSnapshots.find(s => s.rrule === 'FREQ=MONTHLY;INTERVAL=1'); + expect(match).toBeDefined(); + expect(match?.rrule).toBe('FREQ=MONTHLY;INTERVAL=1'); + t.stop(); + }); + + test('stepping via pulse() follows RRULE logic deterministically', () => { + const seed = new Tempo('2026-08-07'); + const t = Tempo.ticker({ rrule: 'FREQ=DAILY;INTERVAL=2', seed }); + + const step1 = t.pulse(); + expect(step1.format('{yyyy}-{mm}-{dd}')).toBe('2026-08-07'); + + const step2 = t.pulse(); + expect(step2.format('{yyyy}-{mm}-{dd}')).toBe('2026-08-09'); + + const step3 = t.pulse(); + expect(step3.format('{yyyy}-{mm}-{dd}')).toBe('2026-08-11'); + + t.stop(); + }); +}); diff --git a/packages/plugins/ticker/test/ticker.stop.test.ts b/packages/plugins/ticker/test/ticker.stop.test.ts new file mode 100644 index 00000000..84b777fc --- /dev/null +++ b/packages/plugins/ticker/test/ticker.stop.test.ts @@ -0,0 +1,41 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '../src/index.js'; + +Tempo.extend(TickerPlugin); + +describe('Ticker Stop Listener', () => { + beforeEach(() => { + Tempo.init(); + }); + + it('should register and invoke stop listeners with pulse callback signature', () => { + let calls = 0; + let receivedTempo: any; + let receivedStop: any; + + const ticker = Tempo.ticker({ seconds: 1, limit: 1 }); + ticker.on('stop', (t, stop) => { + calls++; + receivedTempo = t; + receivedStop = stop; + }); + + ticker.pulse(); + ticker.stop(); + + expect(calls).toBe(1); + expect(receivedTempo).toBeDefined(); + expect(typeof receivedStop).toBe('function'); + }); + + it('should only invoke stop listeners once when stop is called multiple times', () => { + let calls = 0; + const ticker = Tempo.ticker({ seconds: 1 }); + ticker.on('stop', () => calls++); + + ticker.stop(); + ticker.stop(); + + expect(calls).toBe(1); + }); +}); diff --git a/packages/plugins/ticker/test/ticker.term.core.test.ts b/packages/plugins/ticker/test/ticker.term.core.test.ts new file mode 100644 index 00000000..d0287b61 --- /dev/null +++ b/packages/plugins/ticker/test/ticker.term.core.test.ts @@ -0,0 +1,86 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin, type Ticker } from '../src/index.js'; + +Tempo.extend(TickerPlugin); + +describe('Ticker with Terms', () => { + beforeEach(() => { + Tempo.init({ sphere: 'north' }); + }); + + test.each<{ name: string; interval: Ticker.Options; seed: string; expected: string[] }>([ + { + name: 'once-per-quarter using #quarter term', + interval: { '#quarter': 1 }, + seed: '2020-01-01T00:00:00', + expected: [ + '2020-04-01T00:00:00', + '2020-07-01T00:00:00', + '2020-10-01T00:00:00', + '2021-01-01T00:00:00', + ], + }, + { + name: 'every morning using #timeOfDay term', + interval: { '#timeOfDay': 'Morning' }, + seed: '2020-01-01T00:00:00', + expected: [ + '2020-01-01T08:00:00', + '2020-01-02T08:00:00', + '2020-01-03T08:00:00', + '2020-01-04T08:00:00', + ], + }, + { + name: 'every morning using shorthand literal key', + interval: { '#timeOfDay.Morning': 1 }, + seed: '2020-01-01T00:00:00', + expected: [ + '2020-01-01T08:00:00', + '2020-01-02T08:00:00', + '2020-01-03T08:00:00', + '2020-01-04T08:00:00', + ], + }, + ])('should pulse $name', ({ interval, seed, expected }) => { + const pulses: string[] = []; + const ticker = Tempo.ticker(interval, { seed }); + try { + const callback = vi.fn((t: Tempo) => { + pulses.push(t.toString().substring(0, 19)); + }); + + ticker.on('pulse', callback); + + // Manual pulses to simulate time passing (after the initial bootstrap pulse) + ticker.pulse(); + ticker.pulse(); + ticker.pulse(); + + expect(callback).toHaveBeenCalledTimes(4); + expect(pulses).toEqual(expected); + } finally { + ticker.stop(); + } + }); + + it('should refuse to launch with an invalid #term', async () => { + const seed = '2020-01-01'; + const payload = { '#invalid': 1 }; + + // 1. should throw by default (catch: false) + expect(() => Tempo.ticker(payload, { seed })).toThrow(/Invalid Ticker payload resolution/); + + // 2. should catch and inhibit start if (catch: true) + const errorCallback = vi.fn(); + const ticker = Tempo.ticker(payload, { seed, catch: true }); + ticker.on('catch', errorCallback); + + // Pulse-manual should not work meaningfully as ticker was inhibited + expect(ticker.pulse().isValid).toBe(false); + + // the catch event is emitted via queueMicrotask during bootstrap + await Promise.resolve(); + expect(errorCallback).toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/ticker/test/ticker_cold_start.test.ts b/packages/plugins/ticker/test/ticker_cold_start.test.ts new file mode 100644 index 00000000..06fdc41b --- /dev/null +++ b/packages/plugins/ticker/test/ticker_cold_start.test.ts @@ -0,0 +1,26 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '../src/index.js'; + +Tempo.extend(TickerPlugin); + +describe('Ticker Cold-Start Resolution', () => { + beforeEach(() => { Tempo.init(); }); + + test('should start pulsing when a listener is added post-creation', async () => { + // 1. Create a ticker without a callback (should remain idle) + const t = Tempo.ticker({ seconds: 0.1 }); + let count = 0; + + // 2. Wait to ensure it remains idle + await new Promise(resolve => setTimeout(resolve, 250)); + expect(count).toBe(0); + + // 3. Add a listener (should trigger bootstrap) + t.on('pulse', () => { count++; }); + + // 4. Verify pulsing has started + await new Promise(resolve => setTimeout(resolve, 250)); + expect(count).toBeGreaterThan(0); + t.stop(); + }); +}); diff --git a/packages/plugins/ticker/test/tsconfig.json b/packages/plugins/ticker/test/tsconfig.json new file mode 100644 index 00000000..642ee082 --- /dev/null +++ b/packages/plugins/ticker/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.test.json", + "include": [ + "**/*.ts" + ] +} diff --git a/packages/plugins/ticker/tsconfig.json b/packages/plugins/ticker/tsconfig.json new file mode 100644 index 00000000..2e994979 --- /dev/null +++ b/packages/plugins/ticker/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.shared.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "emitDeclarationOnly": true + }, + "include": [ + "src" + ] +} diff --git a/packages/plugins/ticker/tsup.config.ts b/packages/plugins/ticker/tsup.config.ts new file mode 100644 index 00000000..62bd4b2b --- /dev/null +++ b/packages/plugins/ticker/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsup'; +import { sharedConfig } from '../tsup.shared.js'; + +export default defineConfig({ + ...sharedConfig, + entry: ['src/index.ts'], +}); diff --git a/packages/plugins/tsup.shared.ts b/packages/plugins/tsup.shared.ts index 9b867627..42ebf812 100644 --- a/packages/plugins/tsup.shared.ts +++ b/packages/plugins/tsup.shared.ts @@ -101,27 +101,7 @@ export const sharedConfig: Options = { }); } }, - { - name: 'license-alias', - setup(build) { - build.onResolve({ filter: /^@magmacomputing\/tempo\/(plugin|plugin-api)$/ }, (args) => { - if (args.importer.includes('internal/license/src/plugin.api.ts')) return; - - // Dynamically check the local package.json to determine if this is a Premium plugin - const pkgPath = path.resolve(process.cwd(), 'package.json'); - if (fs.existsSync(pkgPath)) { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - const plan = pkg?.tempo?.plan; - - // If the package explicitly declares plan: 'community', skip the license wrapper - if (plan === 'community') - return; // Community plugin, do not apply license wrapper - } - return { path: path.resolve(__dirname, 'internal/license/src/plugin.api.ts') }; - }); - } - }, { // For ESM builds, keep @magmacomputing/tempo/plugin* external so all plugins // share one runtime singleton and avoid registerSerializable collisions. diff --git a/packages/plugins/vitest.shared.ts b/packages/plugins/vitest.shared.ts index 9a3a534d..48c6777d 100644 --- a/packages/plugins/vitest.shared.ts +++ b/packages/plugins/vitest.shared.ts @@ -24,8 +24,14 @@ export default defineConfig({ include: ['test/**/*.{test,spec}.ts'], setupFiles: [polyfill, spy], alias: [ - { find: /^#library\/(browser|server|common)\/(.*)\.js$/, replacement: resolve(__dirname, '../library/src/$1/$2.ts') }, - { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, '../library/src/common/$1.ts') }, + { find: /^#library\/([^/]+)\/index\.js$/, replacement: resolve(__dirname, '../library/src/common/$1/index.ts') }, + { find: /^#library\/(browser|server)\/(.*)\.js$/, replacement: resolve(__dirname, '../library/src/$1/$2.ts') }, + { find: /^#library\/(array|assertion|coercion|number|object|primitive|string|symbol|type)\.library\.js$/, replacement: resolve(__dirname, '../library/src/common/primitives/$1.library.ts') }, + { find: /^#library\/(calendar|temporal)\.library\.js$/, replacement: resolve(__dirname, '../library/src/common/temporal/$1.library.ts') }, + { find: /^#library\/temporal\.polyfill\.js$/, replacement: resolve(__dirname, '../library/src/common/temporal/temporal.polyfill.ts') }, + { find: /^#library\/(buffer|cipher|webtoken)\.library\.js$/, replacement: resolve(__dirname, '../library/src/common/security/$1.library.ts') }, + { find: /^#library\/(cron|rrule|schedule)\.library\.js$/, replacement: resolve(__dirname, '../library/src/common/scheduling/$1.library.ts') }, + { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, '../library/src/common/runtime/$1.ts') }, { find: /^#tempo\/plugin\.(util|type)\.js$/, replacement: resolve(__dirname, '../tempo/src/plugin/plugin.$1.ts') }, { find: /^#tempo\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, '../tempo/src/plugin/extend/plugin.$1.ts') }, { find: /^#tempo\/(parse|format|mutate|duration)$/, replacement: resolve(__dirname, '../tempo/src/module/module.$1.ts') }, @@ -38,6 +44,10 @@ export default defineConfig({ { find: /^#tempo\/(.*)\.js$/, replacement: resolve(__dirname, '../tempo/src/$1.ts') }, { find: /^#tempo\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/$1.ts') }, { find: /^#tempo$/, replacement: resolve(__dirname, '../tempo/src/tempo.index.ts') }, + { find: /^@magmacomputing\/tempo-fns$/, replacement: resolve(__dirname, '../functions/src/index.ts') }, + { find: /^@magmacomputing\/tempo-fns\/(.*)$/, replacement: resolve(__dirname, '../functions/src/$1.ts') }, + { find: /^@magmacomputing\/library$/, replacement: resolve(__dirname, '../library/src/common.index.ts') }, + { find: /^@magmacomputing\/library\/(.*)$/, replacement: resolve(__dirname, '../library/src/$1.ts') }, { find: /^@magmacomputing\/tempo\/plugin-api$/, replacement: resolve(__dirname, '../tempo/src/plugin-api.index.ts') }, { find: /^@magmacomputing\/tempo\/library$/, replacement: resolve(__dirname, '../tempo/src/library.index.ts') }, { find: /^@magmacomputing\/tempo$/, replacement: resolve(__dirname, '../tempo/src/tempo.index.ts') }, diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index ab7b8e9a..70b55132 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -51,7 +51,7 @@ "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", "status": "active", - "version": "1.0.0" + "version": "1.1.0" }, { "id": "ticker", @@ -60,6 +60,6 @@ "packageName": "@magmacomputing/tempo-plugin-ticker", "plan": "pro", "status": "active", - "version": "2.2.3" + "version": "2.3.0" } ] diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 6aa68c5e..d5e176cd 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.0] - 2026-08-21 + +### Breaking Changes +- **100% Open-Source Community Core**: Completely removed commercial licensing, JWT validation, JWS verification, domain-locking, and premium plugin gating (`license.manager.ts`, `license.validator.ts`, `Tempo.license`, `#formatLicense`, `#isBlocked`, `validateLicenseState`, `LICENSE` enum, `$updateScopeStatus`, and `#tempo/license` export subpath). +- **Commercial Package Decoupling**: Relocated all enterprise licensing hooks and commercial plugin management to the `@magmacomputing/tempo-pro` wrapper package. +- **Config Auto-Discovery Modernization**: Dropped `.cjs` configuration discovery support in favor of modern ES-preferred module extensions (`.mts`, `.ts`, `.mjs`, `.js`, `.jsonc`, `.json`). +- **`Tempo.ready()` Return Signature**: Simplified `Tempo.ready()` return value to a static `'none'` status for community core compatibility. + +### Added +- **Synchronous JSON & JSONC Config Discovery (`resolveConfigSync`)**: Introduced zero-`await` static startup configuration discovery (`resolveConfigSync()`) utilizing `parseJSONC` for `.jsonc` / `.json` files. ESM/TypeScript extensions such as `.mts`, `.ts`, `.mjs`, and `.js` require the asynchronous `resolveConfig()` path. Automatically executed inside `Tempo`'s `static { ... }` initialization block at module load time. +- **Lazy Dynamic Context & Options Evaluation**: Upgraded `BaseOptions` and `Tempo` options (`timeZone`, `locale`, `calendar`, `sphere`) to support `Evaluable` suppliers (`T | (() => T)`). This enables dynamic, per-request context evaluation (such as multi-tenant timezone or locale resolution) without rebuilding configuration state. +- **Evaluation Utilities Export (`@magmacomputing/tempo/library`)**: Re-exported `Evaluable`, `AsyncEvaluable`, `evaluate`, `evaluateAsync`, `evaluateConfig`, `evaluateConfigAsync`, and `dynamicProxy` from the `#library` surface for downstream plugins and custom extensions. + +### Changed & Fixed +- **API Standardization for Boundary & Mutation Payloads (`{Term: Value}`)**: Standardized mutation object signatures across `.set()`, `.add()`, and plugin payloads to follow the unified `{Term: Value}` pattern (e.g., `.set({ year: 'start' })`, `.set({ month: 'end' })`, `.set({ '#qtr': 1 })`). The legacy positional syntax (e.g., `.set({ start: 'year' })`) remains fully supported transparently for complete backwards compatibility. +- **Term Registry Simplification**: Simplified `Tempo.terms` getter to operate purely on open-source term plugins, removing legacy licensing metadata mapping and synthetic uninstalled scope claims. +- **Documentation & LLM Corpus Alignment**: Corrected mutating method descriptions in `ai-integration.md` and `public/llms.txt` to strictly reference supported immutable methods (`.add()`, `.subtract()`, and `.set()`). + ## [3.11.1] - 2026-08-10 ### Added diff --git a/packages/tempo/README.md b/packages/tempo/README.md index 99907125..0f04f2cd 100644 --- a/packages/tempo/README.md +++ b/packages/tempo/README.md @@ -73,7 +73,7 @@ For standard usage natively in the browser, use the pre-optimized **Global ESM B { "imports": { "@js-temporal/polyfill": "https://cdn.jsdelivr.net/npm/@js-temporal/polyfill@0.5.1/dist/index.esm.js", - "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@3/dist/tempo.bundle.esm.js" + "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@4/dist/tempo.bundle.esm.js" } } diff --git a/packages/tempo/bench/bench.v3.3.0.ts b/packages/tempo/bench/bench.v3.3.0.ts index 48251fe7..977de78d 100644 --- a/packages/tempo/bench/bench.v3.3.0.ts +++ b/packages/tempo/bench/bench.v3.3.0.ts @@ -89,7 +89,7 @@ function timedRun(label: string, data: string[], iterations: number, tempoOption // ── Run ────────────────────────────────────────────────────────────────────── -const ITERATIONS = 500; +const ITERATIONS = 100; console.log(`\nā± Tempo v3.3.0 Post-Refactor Benchmark (${ITERATIONS} iterations Ɨ corpus size)\n`); diff --git a/packages/tempo/bench/bench.v4.0.0.ts b/packages/tempo/bench/bench.v4.0.0.ts new file mode 100644 index 00000000..a9faadd9 --- /dev/null +++ b/packages/tempo/bench/bench.v4.0.0.ts @@ -0,0 +1,164 @@ +/** + * Tempo v4.0.0 Production Benchmark + * + * Evaluates v4.0.0 engine performance across: + * A. Baseline: stock Tempo core parser + * B. BenchmarkModule evaluation across auto/defer/strict modes + * C. Localized registry modifier resolution + */ +import '../bin/temporal-polyfill.js'; +import { Tempo } from '../src/tempo.index.js'; +import { BenchmarkModule } from '../src/module/module.benchmark.js'; +import { performance } from 'node:perf_hooks'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ── Corpus ────────────────────────────────────────────────────────────────── + +/** 20-entry representative parser corpus */ +const baseCorpus: string[] = [ + '04012026', + '310559', + '590531', + '09:30', + 'monday', + '2 days ago', + '+6', + '1234567890123', + '2026-04-25', + '2026/04/25 10:30', + '11:45pm', + 'tomorrow', + '2026-04-25T10:30:00Z', + '2026-04-25T10:30:00+05:30', + 'next Friday at 5pm', + 'last Monday', + 'in 3 weeks', + 'yesterday', + 'noon', + 'midnight', +]; + +/** French localized modifier corpus via nested v4 registry.modifiers */ +const frCorpus: string[] = [ + 'vendredi prochain', + 'lundi dernier', + 'mercredi prochain', + '3 jours', + 'vendredi', + 'jeudi dernier', + 'mardi suivant', + 'dimanche prochain', +]; + +// ── Benchmark helpers ─────────────────────────────────────────────────────── + +function timedRun(label: string, data: string[], iterations: number, tempoOptions: any) { + // Warm-up pass + for (const d of data) new Tempo(d, { catch: true, ...tempoOptions }); + + let success = 0, failure = 0; + const startHeap = process.memoryUsage().heapUsed; + const start = performance.now(); + + for (let i = 0; i < iterations; i++) { + for (const d of data) { + const t = new Tempo(d, { catch: true, ...tempoOptions }); + if (t.isValid) success++; else failure++; + } + } + + const elapsed = performance.now() - start; + const endHeap = process.memoryUsage().heapUsed; + const ops = iterations * data.length; + + return { + label, + totalTimeMs: Number(elapsed.toFixed(2)), + opsPerSec: Math.round(ops / (elapsed / 1000)), + microSecPerOp: Number(((elapsed * 1000) / ops).toFixed(2)), + successCount: success, + failureCount: failure, + successRate: ((success / ops) * 100).toFixed(1) + '%', + heapDeltaMb: Number(((endHeap - startHeap) / 1024 / 1024).toFixed(2)), + }; +} + +// ── Run ────────────────────────────────────────────────────────────────────── + +const ITERATIONS = 100; + +console.log(`\nā± Tempo v4.0.0 Production Benchmark (${ITERATIONS} iterations Ɨ corpus size)\n`); + +// A. Baseline — stock config, base corpus +Tempo.init({ debug: 0, catch: true, timeZone: 'UTC' }); +const baseline = timedRun('A. Baseline (v4.0.0 stock, 20-entry corpus)', baseCorpus, ITERATIONS, { timeZone: 'UTC' }); + +// B. Module-based comparison across modes using BenchmarkModule +Tempo.init({ debug: 0, catch: true, timeZone: 'UTC' }); +const moduleResults = BenchmarkModule.run(Tempo, { + data: baseCorpus, + iterations: ITERATIONS, + modes: ['auto', 'defer', 'strict'], + baseline: true, +}); + +// C. Localized modifier corpus via nested v4.0.0 registry configuration +const localizedConfig = { + locale: 'fr-FR', + debug: 0, + catch: true, + timeZone: 'UTC', + registry: { + modifiers: { + '>': ['prochain', 'suivant'], + '<': ['dernier', 'passĆ©'], + '=': ['ce', 'cette'], + } + } +}; +Tempo.init(localizedConfig as any); +const localized = timedRun('C. Localized fr-FR modifiers (v4 nested registry)', frCorpus, ITERATIONS, {}); + +// ── Output ─────────────────────────────────────────────────────────────────── + +const output = { + runAt: new Date().toISOString(), + version: '4.0.0', + iterations: ITERATIONS, + baselineRaw: baseline, + moduleResults, + localizedModifiers: localized, +}; + +// Console output +console.log('── Module-based comparison (v4.0.0 modes) ──'); +BenchmarkModule.printTable(moduleResults); + +console.log('\n── Baseline (v4.0.0 stock) ──'); +console.table([{ + 'Engine': baseline.label, + 'Total Time (ms)': baseline.totalTimeMs, + 'µs / Op': baseline.microSecPerOp, + 'ops/sec': baseline.opsPerSec, + 'Success Rate': baseline.successRate, + 'Heap Delta (MB)': baseline.heapDeltaMb, +}]); + +console.log('\n── Localized Modifier Throughput (v4 registry) ──'); +console.table([{ + 'Engine': localized.label, + 'Total Time (ms)': localized.totalTimeMs, + 'µs / Op': localized.microSecPerOp, + 'ops/sec': localized.opsPerSec, + 'Success Rate': localized.successRate, + 'Heap Delta (MB)': localized.heapDeltaMb, +}]); + +// Save to JSON artifact +const outPath = path.join(__dirname, 'benchmark-results-v4.0.0.json'); +fs.writeFileSync(outPath, JSON.stringify(output, null, 2)); +console.log(`\nāœ… Results saved to ${outPath}\n`); diff --git a/packages/tempo/bench/benchmark-results-v3.3.0.json b/packages/tempo/bench/benchmark-results-v3.3.0.json index 1082ac5f..65b8c152 100644 --- a/packages/tempo/bench/benchmark-results-v3.3.0.json +++ b/packages/tempo/bench/benchmark-results-v3.3.0.json @@ -1,63 +1,63 @@ { - "runAt": "2026-06-21T03:09:07.682Z", + "runAt": "2026-08-22T01:05:41.379Z", "version": "3.3.0", "iterations": 500, "baselineRaw": { "label": "A. Baseline (stock, 20-entry corpus)", - "totalTimeMs": 15517.8, - "opsPerSec": 644, - "microSecPerOp": 1551.78, + "totalTimeMs": 21717.38, + "opsPerSec": 460, + "microSecPerOp": 2171.74, "successCount": 10000, "failureCount": 0, "successRate": "100.0%", - "heapDeltaMb": 113.02 + "heapDeltaMb": 109.34 }, "moduleResults": [ { "name": "Native Date", - "totalTimeMs": 2.92, + "totalTimeMs": 2.85, "microSecPerOp": 0.29, "successCount": 2500, "failureCount": 7500, "successRate": "25.0%", - "heapUsedDeltaMb": "1.08" + "heapUsedDeltaMb": "1.09" }, { "name": "Tempo (mode: auto)", - "totalTimeMs": 15524.01, - "microSecPerOp": 1552.4, + "totalTimeMs": 21984.82, + "microSecPerOp": 2198.48, "successCount": 10000, "failureCount": 0, "successRate": "100.0%", - "heapUsedDeltaMb": "10.34" + "heapUsedDeltaMb": "26.21" }, { "name": "Tempo (mode: defer)", - "totalTimeMs": 15089.25, - "microSecPerOp": 1508.93, + "totalTimeMs": 22889.07, + "microSecPerOp": 2288.91, "successCount": 10000, "failureCount": 0, "successRate": "100.0%", - "heapUsedDeltaMb": "-22.66" + "heapUsedDeltaMb": "-15.66" }, { "name": "Tempo (mode: strict)", - "totalTimeMs": 15784.87, - "microSecPerOp": 1578.49, + "totalTimeMs": 20701.7, + "microSecPerOp": 2070.17, "successCount": 10000, "failureCount": 0, "successRate": "100.0%", - "heapUsedDeltaMb": "56.53" + "heapUsedDeltaMb": "-79.06" } ], "localizedModifiers": { "label": "C. Localized fr-FR modifiers (8-entry corpus)", - "totalTimeMs": 11330.81, - "opsPerSec": 353, - "microSecPerOp": 2832.7, + "totalTimeMs": 10310.03, + "opsPerSec": 388, + "microSecPerOp": 2577.51, "successCount": 4000, "failureCount": 0, "successRate": "100.0%", - "heapDeltaMb": -9.07 + "heapDeltaMb": 11.8 } } \ No newline at end of file diff --git a/packages/tempo/bench/benchmark-results-v4.0.0.json b/packages/tempo/bench/benchmark-results-v4.0.0.json new file mode 100644 index 00000000..676610de --- /dev/null +++ b/packages/tempo/bench/benchmark-results-v4.0.0.json @@ -0,0 +1,63 @@ +{ + "runAt": "2026-08-22T01:06:39.794Z", + "version": "4.0.0", + "iterations": 100, + "baselineRaw": { + "label": "A. Baseline (v4.0.0 stock, 20-entry corpus)", + "totalTimeMs": 4737.82, + "opsPerSec": 422, + "microSecPerOp": 2368.91, + "successCount": 2000, + "failureCount": 0, + "successRate": "100.0%", + "heapDeltaMb": 24.74 + }, + "moduleResults": [ + { + "name": "Native Date", + "totalTimeMs": 0.65, + "microSecPerOp": 0.32, + "successCount": 500, + "failureCount": 1500, + "successRate": "25.0%", + "heapUsedDeltaMb": "0.23" + }, + { + "name": "Tempo (mode: auto)", + "totalTimeMs": 4324.63, + "microSecPerOp": 2162.31, + "successCount": 2000, + "failureCount": 0, + "successRate": "100.0%", + "heapUsedDeltaMb": "43.23" + }, + { + "name": "Tempo (mode: defer)", + "totalTimeMs": 4219.86, + "microSecPerOp": 2109.93, + "successCount": 2000, + "failureCount": 0, + "successRate": "100.0%", + "heapUsedDeltaMb": "89.55" + }, + { + "name": "Tempo (mode: strict)", + "totalTimeMs": 4176.84, + "microSecPerOp": 2088.42, + "successCount": 2000, + "failureCount": 0, + "successRate": "100.0%", + "heapUsedDeltaMb": "-104.64" + } + ], + "localizedModifiers": { + "label": "C. Localized fr-FR modifiers (v4 nested registry)", + "totalTimeMs": 2120.45, + "opsPerSec": 377, + "microSecPerOp": 2650.57, + "successCount": 800, + "failureCount": 0, + "successRate": "100.0%", + "heapDeltaMb": 36.81 + } +} \ No newline at end of file diff --git a/packages/tempo/bin/repl.ts b/packages/tempo/bin/repl.ts index b080b4c1..7654b780 100644 --- a/packages/tempo/bin/repl.ts +++ b/packages/tempo/bin/repl.ts @@ -1,15 +1,8 @@ import { Tempo, enums } from '#tempo'; import { stringify, objectify, enumify, getType, Pledge } from '#library'; -const mockToken = process.env.TEMPO_LICENSE_KEY || undefined; - -if (mockToken) { - if (!process.env.TEMPO_REVOCATION_URL) process.env.TEMPO_REVOCATION_URL = 'mock'; - if (!process.env.TEMPO_REVOCATION_JWS) process.env.TEMPO_REVOCATION_JWS = '{"revoked":[]}'; -} - // pre-load Tempo to the global scope for ease of use in the REPL -Object.assign(globalThis, { Tempo, getType, stringify, objectify, enumify, enums, Pledge, mockToken }); +Object.assign(globalThis, { Tempo, getType, stringify, objectify, enumify, enums, Pledge }); console.log(`\n\x1b[38;2;252;194;1m\x1b[1m ā³ Tempo \x1b[0m\x1b[38;2;45;212;191mREPL initialized.\x1b[0m\n`); diff --git a/packages/tempo/doc/1-getting-started/ai-integration.md b/packages/tempo/doc/1-getting-started/ai-integration.md index 51a2dd1e..de92dd11 100644 --- a/packages/tempo/doc/1-getting-started/ai-integration.md +++ b/packages/tempo/doc/1-getting-started/ai-integration.md @@ -28,7 +28,7 @@ In VS Code, configure GitHub Copilot Chat by adding a `.github/copilot-instructi # Tempo AI Rules - Always use `Tempo` from `@magmacomputing/tempo`. - Never instantiate legacy JavaScript `Date`. Tempo expects native `Temporal` or polyfill. -- All mutating methods (`.add()`, `.subtract()`, `.with()`) return a brand-new, frozen `Tempo` instance. +- All mutating methods (`.add()`, `.subtract()`, `.set()`) return a brand-new, frozen `Tempo` instance. - Refer to https://tempo.magmacomputing.com.au/llms.txt for full layout token grammar. ``` @@ -56,21 +56,21 @@ For web-based LLM interfaces, reference or copy-paste the full, un-truncated doc When asking AI assistants to generate custom layout patterns for parsing unique date-time formats, instruct the model to use Tempo's configuration syntax (`Tempo.init({ registry: { layouts: { ... } } })`) and named capture tokens (`{yy}`, `{mm}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`). ### Sample Prompt: -> *"Using the rules from https://tempo.magmacomputing.com.au/llms.txt, register a custom Tempo layout for fiscal quarters (e.g., 'Q3 2026') using `Tempo.init({ registry: { layouts: { ... } } })` and instantiate a date with the layout option."* +> *"Using the rules from https://tempo.magmacomputing.com.au/llms.txt, register a custom Tempo layout for dot-delimited dates (e.g., '04.08.2026') using `Tempo.init({ registry: { layouts: { ... } } })` and parse the date string using `new Tempo(...)`."* ### Generated Code (Actual Tempo Syntax): ```typescript import { Tempo } from '@magmacomputing/tempo'; -// 1. Register custom layout pattern using snippet tokens +// 1. Register custom layout pattern using layout tokens Tempo.init({ registry: { layouts: { - fiscal_quarter: 'Q{nbr} {yy}' + dot_date: '{dd}.{mm}.{yy}' } } }); -// 2. Parse date string using the registered layout -const date = new Tempo('Q3 2026', { layout: 'fiscal_quarter' }); +// 2. Parse date string matching the custom layout +const date = new Tempo('04.08.2026'); ``` diff --git a/packages/tempo/doc/1-getting-started/installation.md b/packages/tempo/doc/1-getting-started/installation.md index c532d0a4..85b372d4 100644 --- a/packages/tempo/doc/1-getting-started/installation.md +++ b/packages/tempo/doc/1-getting-started/installation.md @@ -101,7 +101,7 @@ The easiest way to use Tempo natively in the browser is via the pre-optimized ES { "imports": { "@js-temporal/polyfill": "https://cdn.jsdelivr.net/npm/@js-temporal/polyfill@0.5.1/dist/index.esm.js", - "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@3/dist/tempo.bundle.esm.js" + "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@4/dist/tempo.bundle.esm.js" } } @@ -129,7 +129,7 @@ While you *could* import directly from the URL everywhere, the best practice is { "imports": { "@js-temporal/polyfill": "https://esm.sh/@js-temporal/polyfill@0.5.1", - "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@3", + "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4", "@magmacomputing/tempo-plugin-ticker": "https://esm.sh/@magmacomputing/tempo-plugin-ticker@2" } } @@ -143,8 +143,9 @@ While you *could* import directly from the URL everywhere, the best practice is import { Tempo } from '@magmacomputing/tempo'; import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; - Tempo.init({ license: 'YOUR_JWT_KEY' }); - Tempo.extend(TickerPlugin); + Tempo.init({ + extends: [TickerPlugin] + }); ``` @@ -172,8 +173,8 @@ To use **Tempo Plugins** via a static CDN, you simply need to explicitly map the { "imports": { "@js-temporal/polyfill": "https://cdn.jsdelivr.net/npm/@js-temporal/polyfill@0.5.1/dist/index.esm.js", - "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@3/dist/tempo.index.js", - "@magmacomputing/tempo/plugin-api": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@3/dist/plugin-api.index.js", + "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@4/dist/tempo.index.js", + "@magmacomputing/tempo/plugin-api": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@4/dist/plugin-api.index.js", "@magmacomputing/tempo-plugin-astro": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo-plugin-astro@2/dist/index.js", "@magmacomputing/tempo-plugin-ticker": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo-plugin-ticker@2/dist/index.js" @@ -183,7 +184,7 @@ To use **Tempo Plugins** via a static CDN, you simply need to explicitly map the ``` > [!WARNING] Cache Busting -> The jsdelivr CDN aggressively caches major version tags (like `@3`). When relying on precise module resolution for plugins, it is highly recommended to use explicit patch versions (like `@3.0.1`) to avoid fetching mismatched or outdated sub-modules. +> The jsdelivr CDN aggressively caches major version tags (like `@4`). When relying on precise module resolution for plugins, it is highly recommended to use explicit patch versions (like `@4.0.0`) to avoid fetching mismatched or outdated sub-modules. --- @@ -196,7 +197,7 @@ If you aren't using ESM or just want a simple ` - + @@ -240,7 +241,7 @@ When using the Lite build, the `Tempo` class will have almost no methods (like ` We recommend pinning your versions in production environments to ensure stability. -* **JSDelivr**: `https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@3/...` (Locks to major version 3) +* **JSDelivr**: `https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@4/...` (Locks to major version 4) * **Latest**: `https://cdn.jsdelivr.net/npm/@magmacomputing/tempo/...` (Omit the version string to always receive the latest release. Note that JSDelivr will resolve a missing version tag to the latest published release). --- diff --git a/packages/tempo/doc/1-getting-started/tempo.cookbook.md b/packages/tempo/doc/1-getting-started/tempo.cookbook.md index d40e8fc2..48349b72 100644 --- a/packages/tempo/doc/1-getting-started/tempo.cookbook.md +++ b/packages/tempo/doc/1-getting-started/tempo.cookbook.md @@ -103,13 +103,13 @@ const t2 = t1.add({ '#quarter': 1 }); // Middle of Q3: "2024-08-14" (approx) The `.set()` method allows you to jump to the boundaries of native units (like months or years) or semantic Terms (using the `#` prefix). You can specify whether to land on the inclusive start, inclusive end, or the exact center. ```typescript // Native Units -const monthStart = new Tempo().set({ start: 'month' }); +const monthStart = new Tempo().set({ month: 'start' }); // Semantic Terms (Lands on 30-Sep 23:59:59.999... Inclusive End) -const qtrEnd = new Tempo().set({ end: '#quarter' }); +const qtrEnd = new Tempo('2024-07-15').set({ '#quarter': 'end' }); // Lands on the arithmetic nanosecond midpoint of the period -const qtrMid = new Tempo().set({ mid: '#quarter' }); +const qtrMid = new Tempo().set({ '#quarter': 'mid' }); ``` ### Slick Object Mutations @@ -150,6 +150,23 @@ console.log(nyc.format('{hh}:{mi}')); // "10:00" console.log(london.format('{hh}:{mi}')); // "15:00" ``` +### Dynamic / Multi-Tenant Context (Functional Options) +Context options (`timeZone`, `locale`, `calendar`, `sphere`) accept supplier functions (`() => string`). Tempo resolves suppliers at instantiation time to construct an immutable, frozen instance: + +```typescript +import { AsyncLocalStorage } from 'node:async_hooks'; + +const requestContext = new AsyncLocalStorage<{ timeZone: string; locale: string }>(); + +// Configure dynamic suppliers that evaluate against current request context +const t = new Tempo('now', { + timeZone: () => requestContext.getStore()?.timeZone || 'UTC', + locale: () => requestContext.getStore()?.locale || 'en-US' +}); +``` + +šŸ‘‰ **Learn More:** See the [Configuration Guide](../2-core-concepts/tempo.config.md#dynamic--functional-context-evaluation) for details on functional options and immutability guarantees. + --- ## Business Logic and Terms diff --git a/packages/tempo/doc/2-core-concepts/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md index e2626cb3..03d5b493 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.config.md +++ b/packages/tempo/doc/2-core-concepts/tempo.config.md @@ -27,13 +27,12 @@ This mirrors modern ecosystem standards (like `vite.config.ts` or `tailwind.conf ```typescript // tempo.config.ts import { defineConfig } from '@magmacomputing/tempo'; -import { FinanceNamespace } from '@magmacomputing/tempo-plugin-finance'; +import { AstroTerm } from '@magmacomputing/tempo-plugin-astro'; import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; export default defineConfig({ timeZone: 'Australia/Sydney', // Set your baseline timezone - license: 'eyJhbGciOiJIUzI1...', // JWT License Key for Premium Plugins - extends: [FinanceNamespace, TickerPlugin], // Register executable plugins + extends: [AstroTerm, TickerPlugin], // Register executable plugins plugins: { // Plugin configuration dictionaries ai: { @@ -162,10 +161,9 @@ Tempo looks for the following structure: | `options` | `Options \| (() => Options)` | Configuration options merged into global state. | | `intl` | `IntlOptions` | Internationalization configuration grouping `relativeTimeFormat`, `numberFormat`, `durationFormat`, and `dateTimeFormat`. | | `extends` | `Plugin \| Plugin[]` | Modular plugin(s) (including `TermPlugin`s) to be extended onto Tempo automatically. | -| `plugins` | `Record` | Plugin configuration dictionary. *(Note: Passing an array to `plugins` is `@deprecated` and scheduled for removal in v4.0.0; use `extends` instead)*. | +| `plugins` | `Record` | Plugin configuration dictionary. | | `timeZones` | `Record` | Custom timezone aliases to be merged. | -| `numbers` | `Record` | Custom number-word aliases merged into the NUMBER registry. | -| `registry` | `{ formats?, locales?, events?, periods?, snippets?, layouts?, ignores?, modifiers?, tokens? }` | Custom configuration for internal dictionary registries. | +| `registry` | `{ formats?, locales?, numbers?, events?, periods?, snippets?, layouts?, ignores?, modifiers?, tokens? }` | Custom configuration for internal dictionary registries. | --- @@ -188,17 +186,17 @@ Tempo.init({ | Option | Type | Default | Description | | :--- | :--- | :--- | :--- | -| `timeZone` | `string` | System Zone | Default IANA time zone or alias. | -| `locale` | `string` | System Locale | Default BCP 47 language tag. used in .since() method | -| `calendar` | `string` | `'iso8601'` | Default calendar system. | +| `timeZone` | `Evaluable` | System Zone | Default IANA time zone, alias, or dynamic supplier (`() => string`). | +| `locale` | `Evaluable` | System Locale | Default BCP 47 language tag(s) or dynamic supplier. | +| `calendar` | `Evaluable` | `'iso8601'` | Default calendar system or dynamic supplier. | | `pivot` | `number` | `75` | Cutoff for parsing two-digit years. | | `monthDay` | `MonthDay \| boolean` | `undefined` | Regional date-parsing configuration (grouped). Includes `active`, `locales`, `layouts`, and `timezones`. | | `timeStamp`| `'ss' \| 'ms' \| 'us' \| 'ns'` | `'ms'` | Precision for numeric inputs and the `.ts` property. | -| `sphere` | `'north' \| 'south'`| Auto-inferred | Hemisphere for seasonal plugins. | +| `sphere` | `Evaluable<'north' \| 'south'>`| Auto-inferred | Hemisphere for seasonal plugins or dynamic supplier. | | `intl` | `IntlOptions` | `undefined` | Internationalization configuration grouping `relativeTimeFormat`, `numberFormat`, and `durationFormat`. | -| `registry` | `{ formats?, locales?, events?, periods?, snippets?, layouts?, ignores?, modifiers? }` | Built-in registries | Custom data augmentation registries (e.g., format aliases, parsing logic, localization). | +| `registry` | `{ formats?, locales?, numbers?, events?, periods?, snippets?, layouts?, ignores?, modifiers? }` | Built-in registries | Custom data augmentation registries (e.g., format aliases, number-to-word mappings, parsing logic, localization). | | `extends` | `Plugin \| Plugin[]` | `[]` | Plugins/modules to extend during initialization. `Tempo.init()` applies each plugin with `Tempo.extend(p)`. | -| `plugins` | `Record` | `{}` | Plugin configuration dictionaries (e.g. `plugins: { ai: { ... } }`). *(Note: Passing an array of plugins to `plugins` is `@deprecated` and scheduled for removal in v4.0.0; use `extends` instead)*. | +| `plugins` | `Record` | `{}` | Plugin configuration dictionaries (e.g. `plugins: { ai: { ... } }`). | | `store` | `string` | `'$Tempo'` | Persistent storage key used by `readStore`/`writeStore`. | | `discovery` | `string \| symbol` | `'$Tempo'` symbol key | Discovery slot used to resolve global discovery config. | | `debug` | `number \| string` | `'info'` | Controls log verbosity via direct `LOG` levels (`0=Off ... 5=Trace`) or string labels (`'trace'`, `'info'`, etc). | @@ -224,6 +222,51 @@ const t = new Tempo('now', { timeZone: 'UTC' }); --- +## 4.1 Dynamic & Functional Context Evaluation + +In modern multi-tenant, serverless, or micro-service architectures (such as Next.js, Express, or Fastify), user timezone and locale preferences frequently change on a per-request basis. + +To eliminate repetitive instance options and prevent global configuration mutation churn (`Tempo.init()`), Tempo supports **`Evaluable`** (`T | (() => T)`) suppliers across all core context properties (`timeZone`, `locale`, `calendar`, `sphere`): + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +interface UserSession { + tenantId: string; + timeZone: string; + locale: string; +} + +export const sessionContext = new AsyncLocalStorage(); + +// Initialize global baseline with dynamic supplier functions once: +Tempo.init({ + timeZone: () => sessionContext.getStore()?.timeZone || 'UTC', + locale: () => sessionContext.getStore()?.locale || 'en-US' +}); + +// In request handlers, instantiate Tempo without manual option boilerplate: +app.get('/api/report', (req, res) => { + sessionContext.run({ tenantId: 'tenant-123', timeZone: 'America/Chicago', locale: 'en-US' }, () => { + const t = new Tempo(); // Automatically resolves to 'America/Chicago' + res.json({ formatted: t.format('{mon} {dd}, {yyyy}') }); + }); +}); +``` + +### Determinism and Immutability Guarantees + +When a `Tempo` instance is constructed: +1. All functional suppliers (`timeZone`, `locale`, `calendar`, `sphere`) are **evaluated synchronously** at the moment of instantiation. +2. The resolved scalar values are locked into the instance's immutable `Temporal.ZonedDateTime` engine and `#state` record. +3. The instance is **strictly frozen** (`Object.freeze`). + +> [!NOTE] +> **Zero Configuration Drift**: Because suppliers are resolved at creation time into an immutable snapshot, an existing `Tempo` instance will never drift or become out-of-sync if external session state changes later in the request lifecycle. Subsequent `new Tempo()` or plugin calls will cleanly evaluate contemporary session state anew. + +--- + ## 5. Advanced Parsing Rules Beyond basic settings, Tempo's parsing engine can be extended with custom rules and behaviors to handle specialized natural language or high-volume processing requirements. diff --git a/packages/tempo/doc/2-core-concepts/tempo.mutate.md b/packages/tempo/doc/2-core-concepts/tempo.mutate.md index bfa58003..2e7b55bc 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.mutate.md +++ b/packages/tempo/doc/2-core-concepts/tempo.mutate.md @@ -39,11 +39,18 @@ t.set({ year: 2026, month: 1 }); // Sets to January 2026 ### Navigating to Boundaries -You can snap to boundaries using native units, or use [Slick Structural Keys](../4-advanced-reference/tempo.shorthand.md) to navigate custom terminology cycles: +You can snap to boundaries using intuitive property-based `{ Term: Value }` assignment, or use [Slick Structural Keys](../4-advanced-reference/tempo.shorthand.md) to navigate custom terminology cycles: ```typescript -t.set({ start: 'month' }); // Native: Start of the current month -t.set({ end: '#qtr' }); // Slick: End of the current quarter +t.set({ month: 'start' }); // Native: Start of the current month +t.set({ '#qtr': 'end' }); // Slick: End of the current quarter +``` + +Snippet shorthand keys (`mm`, `yy`, `dd`) are also supported for boundary snapping: + +```typescript +t.set({ mm: 'start' }); // Snaps to start of current month +t.set({ yy: 'end' }); // Snaps to end of current year ``` ### Slick Object Mutations @@ -86,10 +93,10 @@ Because all mutations return a new instance, you can safely chain `.add()` and ` ```typescript const endOfQ1 = t - .set({ start: 'year' }) // Snap to January 1st + .set({ year: 'start' }) // Snap to January 1st .add({ months: 3 }) // Shift forward 3 months (to April 1st) .subtract({ days: 1 }) // Step back exactly one day (March 31st) - .set({ end: 'month' }); // Snap to March 31st at 23:59:59.999 + .set({ month: 'end' }); // Snap to March 31st at 23:59:59.999 ``` ## Relational vs. Navigation Shifting diff --git a/packages/tempo/doc/3-extending-tempo/tempo.layout.md b/packages/tempo/doc/3-extending-tempo/tempo.layout.md index 98ae8d0a..33fa48cf 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.layout.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.layout.md @@ -71,6 +71,22 @@ Tempo.init({ const t = new Tempo('20-05-2024'); // Parsed using 'myCustomFormat' ``` +> [!WARNING] +> **Escaping Regex Meta-Characters in Custom Delimiters** +> +> Layout strings are compiled directly into regular expressions by the Tempo engine. If your custom layout uses non-standard separators that are regex meta-characters (such as `*`, `|`, `+`, `?`, `.`, `^`, `$`), you **must escape them** in layout strings (e.g., `\\*`, `\\|`) or pass a pre-compiled `RegExp` object with escaped delimiters. +> +> ```typescript +> // āŒ Incorrect: '*' is interpreted as a regex quantifier +> Tempo.init({ registry: { layouts: { starDate: '{mm}*{dd}*{yy}' } } }); +> +> // āœ… Correct: escape meta-characters in layout strings +> Tempo.init({ registry: { layouts: { starDate: '{mm}\\*{dd}\\*{yy}' } } }); +> +> // āœ… Correct: using RegExp with escaped delimiters +> Tempo.init({ registry: { layouts: { starDate: /(?\d{2})\*(?
\d{2})\*(?\d{4})/ } } }); +> ``` + ### Instance-Specific Layout ```typescript @@ -109,7 +125,7 @@ When prompting AI assistants (Cursor, GitHub Copilot, ChatGPT, Claude) to write 2. **Explicit Token Request**: Ask the LLM to use Tempo's standard snippet tokens (`{yy}`, `{mm}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`) rather than raw, un-anchored regular expressions. 3. **Example AI Prompt**: ```text - "Using https://tempo.magmacomputing.com.au/llms.txt, register a custom layout for strings like 'Q3 2026' using Tempo.init({ registry: { layouts: { ... } } }) and snippet tokens." + "Using https://tempo.magmacomputing.com.au/llms.txt, register a custom layout for strings like '20-05-2024' using Tempo.init({ registry: { layouts: { ... } } }) and snippet tokens." ``` --- diff --git a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md index 939af957..d5ebf528 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md @@ -77,26 +77,11 @@ declare module '@magmacomputing/tempo/core' { Modern Tempo plugins are designed to be "plug-and-play." By using the `definePlugin` factory, a plugin registers itself with the global Tempo registry as soon as it's imported. -::: warning -**Premium Plugin Example**: The example below uses the `@magmacomputing/tempo-plugin-ticker` plugin, which is a premium plugin. You must provide a valid `license` key during initialization to activate it. - -
- - Tempo License Registry - -
- šŸ‘‰ Go to the Tempo License Registry šŸ‘ˆ
- Manage your subscriptions and retrieve your license key. -
-
-::: - ```typescript import { Tempo } from '@magmacomputing/tempo/core'; // 1. Load the `lite` engine import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; // 2. Import the plugin Tempo.init({ - license: 'YOUR_JWT_KEY', extends: [TickerPlugin] // 3. Register and activate plugin during init }); @@ -192,36 +177,9 @@ export const MyFeaturePlugin = definePlugin((TempoClass, options) => { }); ``` -### Premium Plugins - -If you wish to distribute a Premium Plugin, you do not need to implement your own licensing engine. Build your plugin using the standard `definePlugin` wrappers. - -Once your plugin is ready for the marketplace, **[Contact Magma Computing Solutions](https://github.com/magmacomputing)**. We can inject our proprietary licensing and cryptographic verification engine directly into your build pipeline, ensuring your plugin is securely gated and protected from unauthorized use. - -### Safely Loading Premium Plugins - -When using a licensed premium plugin, the cryptographic verification of your license key happens securely in the background. Because of this, you should always wait for the validation engine to settle before executing premium features, especially during application boot. - -Use `Tempo.ready()` to safely wait for the cryptographic engine: - -```typescript -import { Tempo } from '@magmacomputing/tempo'; -import { PremiumPlugin } from 'tempo-plugin-premium'; +### Commercial & Enterprise Extensions -// 1. Initialize Tempo with your license key -Tempo.init({ - license: process.env.TEMPO_LICENSE, - extends: [PremiumPlugin] -}); - -async function boot() { - // 2. Wait for the background engine to verify the signature - await Tempo.ready(); - - // 3. 100% safe to execute the premium plugin synchronously - const result = Tempo.premiumFeature(); -} -``` +If you require custom commercial plugins, domain-specific extensions, or enterprise-grade features with dedicated support, see our **[Commercial & Professional Services](../8-project-and-support/commercial.md)** guide. --- diff --git a/packages/tempo/doc/3-extending-tempo/tempo.term.md b/packages/tempo/doc/3-extending-tempo/tempo.term.md index 87d518fa..a122a339 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.term.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.term.md @@ -157,14 +157,12 @@ Tempo.extend(QuarterTerm); ## How to Define a Term Plugin -A Term plugin is ideally created using the **`defineTerm`** factory function provided by the library. This ensures correct type-inference and automatically handles registration during the discovery phase. - -If you are developing a commercial plugin and require license enforcement, simply build your logic using the standard `defineTerm` factory. Once ready for the marketplace, **[contact Magma Computing Solutions](https://github.com/magmacomputing)** to have our proprietary licensing and cryptographic verification engine wrapped around your plugin prior to distribution. +A Term plugin is created using the **`defineTerm`** factory function provided by the library (`@magmacomputing/tempo/plugin-api`). This ensures correct type-inference and automatically handles registration during the discovery phase. ### Plugin Definition ```ts -import { defineTerm, defineRange, getTermRange, resolveCycleWindow } from '@magmacomputing/tempo/plugin'; +import { defineTerm, defineRange, getTermRange, resolveCycleWindow } from '@magmacomputing/tempo/plugin-api'; import { enums, type Tempo } from '@magmacomputing/tempo/core'; /** 1. The range boundaries (grouped by sphere) */ @@ -292,7 +290,7 @@ To unlock Tempo's advanced **Term Traversal** (e.g., `t.add({ '#quarter': 1 })`) The library provides a specialized helper that calculates these boundaries automatically based on your `ranges` array. ```ts -import { getTermRange } from '@magmacomputing/tempo/plugin'; +import { getTermRange } from '@magmacomputing/tempo/plugin-api'; export function define(this: Tempo, keyOnly?: boolean) { // Finds the current range, then injects 'start' and 'end' (as Tempo instances) diff --git a/packages/tempo/doc/6-utility-library/tempo.library.md b/packages/tempo/doc/6-utility-library/tempo.library.md index 481f2a67..da3f796d 100644 --- a/packages/tempo/doc/6-utility-library/tempo.library.md +++ b/packages/tempo/doc/6-utility-library/tempo.library.md @@ -60,7 +60,35 @@ Tempo provides a specialized wrapper around `Promise.withResolvers()` called `Pl
-## 5. Exhaustive API Reference +## 5. Functional Evaluation (`evaluate`, `dynamicProxy`) + +Tempo exports zero-overhead functional evaluation utilities for resolving static values, lazy suppliers, and dynamic object proxies: + +* **`evaluate(...values)`:** Synchronously resolves candidate values or zero-argument supplier functions (`() => T`) in order, returning the first defined result (lazy coalesce with short-circuiting). +* **`evaluateAsync(...values)`:** Asynchronously resolves static values, sync/async suppliers, or Promises (`() => Promise | T`) in order with short-circuiting. +* **`evaluateConfig(config)` / `evaluateConfigAsync(config)`:** Resolves `Evaluable` property suppliers on the top-level properties of a configuration dictionary. +* **`dynamicProxy(target)`:** Wraps a target object with dynamic property traps that evaluate function-valued properties lazily on-access. +* **`Evaluable` / `AsyncEvaluable`:** TypeScript utility types representing values that can be provided directly or supplied lazily via functions. + +```typescript +import { evaluate, evaluateAsync, dynamicProxy } from '@magmacomputing/tempo/library'; + +// Synchronous supplier evaluation with lazy cascading fallback (short-circuited) +const tz = evaluate(options.timeZone, () => process.env.TZ, 'UTC'); + +// Asynchronous supplier evaluation (e.g. secret vault / remote config) +const apiKey = await evaluateAsync(provider.key, async () => await vault.getKey('openai')); + +// Dynamic proxy with lazy on-access getters +const dynamicSettings = dynamicProxy({ + timeout: 5000, + token: () => getActiveToken() +}); +``` + +
+ +## 6. Exhaustive API Reference > [!NOTE] > These are isolated, standalone utility functions and classes developed internally to support our various applications. They are entirely free to use and are documented here as a convenience reference for our users. @@ -70,6 +98,6 @@ While some of these utilities may be used internally by the Tempo library, many The library is split into domain-specific modules: - **Browser**: Functions and classes that rely on browser APIs (e.g., `window`, `localStorage`, `Geolocation`). - **Server**: Node.js specific utilities (e.g., file system access, server-side JWT decoding). -- **Common** *(coming soon)*: Runtime-agnostic utilities shared across all environments. +- **Common**: Runtime-agnostic utilities shared across all environments (`evaluation`, `assertion`, `coercion`, `cipher`, `json`, `calendar`, `recurrence`, `proxy`). You can browse the full API reference in the sidebar below this section. diff --git a/packages/tempo/doc/8-project-and-support/commercial.md b/packages/tempo/doc/8-project-and-support/commercial.md index 3c2c6aa1..40597494 100644 --- a/packages/tempo/doc/8-project-and-support/commercial.md +++ b/packages/tempo/doc/8-project-and-support/commercial.md @@ -10,32 +10,17 @@ Need a specialized plugin for your industry? We design and implement high-perfor - **Production Timelines**: Complex shift-based scheduling and resource allocation generators. - **Custom Terms**: Domain-specific date ranges (e.g., academic years, retail seasons, or medical billing cycles). -
- - Tempo License Registry - -
- šŸ‘‰ Go to the Tempo License Registry šŸ‘ˆ
- Manage your subscriptions and retrieve your license key. -
-
- ### šŸ›ļø Architecture & Migration Consulting Transitioning from legacy libraries like **Moment.js** or **Luxon**? Our team can: - Audit your existing date-time logic for `Temporal` compatibility. - Perform high-fidelity migrations of complex "relative time" calculations. - Optimize performance for large-scale data processing. -### šŸ›”ļø Enterprise Support -For mission-critical applications, we provide priority support, security auditing, and private bug-fix releases tailored to your deployment schedule. - ---- - -## šŸ’Ž Premium Extensions - -In addition to our open-source core, we offer a suite of **Premium Plugins** published directly to the standard public NPM registry (`npmjs.com`), secured by a cryptographic License Key. These extensions provide advanced, proprietary logic for enterprise-scale requirements. - -For details on how to unlock and use these features, see our [License Key Guide](../../../plugins/.setup/doc/index.md). +### šŸ›”ļø Enterprise Support & Commercial Extensions +For mission-critical applications, we provide: +- Priority commercial support and SLA guarantees. +- Security auditing and bespoke feature engineering. +- Custom enterprise plugins tailored to private infrastructures. --- diff --git a/packages/tempo/doc/8-project-and-support/migration-guide.md b/packages/tempo/doc/8-project-and-support/migration-guide.md index 898fc25d..d310a440 100644 --- a/packages/tempo/doc/8-project-and-support/migration-guide.md +++ b/packages/tempo/doc/8-project-and-support/migration-guide.md @@ -1,30 +1,80 @@ +# āš ļø Migrating to Tempo v4.0.0 + +Tempo v4.0.0 introduces a standardized plugin & registry architecture, strict configuration namespaces, and excises legacy v3.x deprecated interfaces. + +## šŸ”Œ Plugin Registration (`extends` vs `plugins`) + +In v3.x, passing plugin modules to install via `plugins: [PluginA]` was supported. In v4.0.0, plugin module installation is strictly separated from plugin runtime configuration. + +- **Plugin Installation**: Use `extends: [PluginA, PluginB]` or `Tempo.extend(PluginA, PluginB)`. +- **Plugin Configuration**: The top-level `plugins` option is now reserved exclusively as a configuration dictionary (`plugins: { ticker: { interval: 1000 }, ai: { provider: 'groq' } }`). + +### Example Migration: +```javascript +// āŒ v3.x (Deprecated) +Tempo.init({ + plugins: [TickerPlugin] +}); + +// āœ… v4.0.0 +Tempo.init({ + extends: [TickerPlugin], + plugins: { + ticker: { interval: 500 } + } +}); +``` + +## šŸ“‚ Data Augmentation (`registry: { ... }`) + +All custom format definitions, locale mappings, modifiers, tokens, snippets, layouts, events, periods, ignores, and number-word definitions are consolidated under the `registry` namespace. + +- **Number-Word Mappings**: Top-level `numbers: { ... }` has been moved to `registry: { numbers: { ... } }`. +- **Custom Formats**: Top-level `formats: { ... }` has been moved to `registry: { formats: { ... } }`. +- **Global Discovery**: Discovery objects (`Symbol.for('$Tempo')`) use `registry: { numbers: { ... }, formats: { ... } }` instead of top-level `discovery.numbers` or `discovery.formats`. + +### Example Migration: +```javascript +// āŒ v3.x (Deprecated) +Tempo.init({ + numbers: { un: 1, deux: 2 }, + formats: { customDate: '{yyyy}-{mm}-{dd}' } +}); + +// āœ… v4.0.0 +Tempo.init({ + registry: { + numbers: { un: 1, deux: 2 }, + formats: { customDate: '{yyyy}-{mm}-{dd}' } + } +}); +``` + +## 🧹 Deprecated Type & API Cleanup + +- **`Tempo.extend` vs `Tempo.extends`**: The legacy alias `Tempo.extends` has been excised. Use `Tempo.extend()`. +- **`TempoInstance` Type**: `TempoInstance` interface alias has been removed in favor of standard `Tempo` class/instance types. + +--- + # āš ļø Migrating to Tempo v3.x Tempo v3.x finalizes the plugin ecosystem by extracting advanced features into standalone, licensed packages. -## šŸ” Migrating from version 2.x to 3.0.0 (Ticker Extraction) +## šŸ” Migrating to Tempo v3.x (Ticker Extraction) -The `TickerPlugin` has been extracted from the core open-source repository into a standalone premium plugin. +The `TickerPlugin` has been extracted from the core engine into a standalone open-source Community plugin (`@magmacomputing/tempo-plugin-ticker`). **Action Required**: -1. If you use `Tempo.ticker()`, you must now install `@magmacomputing/tempo-plugin-ticker` alongside `@magmacomputing/tempo`. -2. **Activate your License**: Obtain your JWT license key. -
- - Tempo License Registry - -
- šŸ‘‰ Go to the Tempo License Registry šŸ‘ˆ
- Manage your subscriptions and retrieve your license key. -
-
-3. Import and register the plugin in your application initialization: +1. If you use `Tempo.ticker()`, install `@magmacomputing/tempo-plugin-ticker` alongside `@magmacomputing/tempo`. +2. Import and register the plugin in your application initialization: ```javascript import { Tempo } from '@magmacomputing/tempo'; import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; - Tempo.init({ license: 'YOUR_JWT_KEY' }); - Tempo.extend(TickerPlugin); + Tempo.init({ + plugins: [TickerPlugin] + }); ``` # āš ļø Migrating to Tempo v2.x diff --git a/packages/tempo/doc/8-project-and-support/releases/v3.x.md b/packages/tempo/doc/8-project-and-support/releases/v3.x.md index fcf60ebf..f3fbb7f6 100644 --- a/packages/tempo/doc/8-project-and-support/releases/v3.x.md +++ b/packages/tempo/doc/8-project-and-support/releases/v3.x.md @@ -409,7 +409,7 @@ This mirrors how `next Friday` works: from any non-Friday you get the very next - `relativeTime` shorthand has been removed; use `intl.relativeTime` instead. - `term` shorthand has been removed; use `terms` instead. - **Strict Parsing Mode**: The parser now enforces a stricter `guard` check by default, reducing the likelihood of "false positive" matches on ambiguous strings. -- **Ticker Module Extraction**: To lighten the core bundle, the `TickerPlugin` has been extracted into its own standalone premium plugin (`@magmacomputing/tempo-plugin-ticker`). It is protected by a License Key via the Tempo Registry. +- **Ticker Module Extraction**: To lighten the core bundle, the `TickerPlugin` has been extracted into its own standalone Community plugin (`@magmacomputing/tempo-plugin-ticker`). ### ✨ What's New - **Formatting Module Additions**: Added new compact date tokens (`{dmy}`, `{mdy}`, `{ymd}`) for generating 8-digit compact date strings (e.g. `24102026`). `{hhmiss}` has been renamed to `{hms}` for consistency. @@ -418,26 +418,15 @@ This mirrors how `next Friday` works: from any non-Friday you get the very next ### šŸ“¦ Migration Path for `Tempo.ticker()` Users If you are upgrading from v2.x and your application relies on `Tempo.ticker()`, you will need to update your integration: 1. **Install the Plugin**: `npm install @magmacomputing/tempo-plugin-ticker` -2. **Activate your License**: Obtain your free JWT license key. -
- - Tempo License Registry - -
- šŸ‘‰ Go to the Tempo License Registry šŸ‘ˆ
- Manage your subscriptions and retrieve your license key. -
-
-3. **Register the Plugin**: Wire the key into your application and extend Tempo: +2. **Register the Plugin**: Wire the plugin into your application during initialization: ```javascript import { Tempo } from '@magmacomputing/tempo'; import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; - // Wire your license key - Tempo.init({ license: 'YOUR_JWT_KEY' }); - // Register the extracted plugin - Tempo.extend(TickerPlugin); + Tempo.init({ + plugins: [TickerPlugin] + }); ``` ### šŸ—ļø Internal Refactoring diff --git a/packages/tempo/index.md b/packages/tempo/index.md index c8d285c9..fe997969 100644 --- a/packages/tempo/index.md +++ b/packages/tempo/index.md @@ -56,7 +56,7 @@ const features = [ { title: 'tomorrow at noon', details: 'Semantic parsing for events and periods. Resolve human-readable strings with zero configuration.', icon: 'šŸŽÆ' }, { title: 'Cycle Persistence', details: 'Shift by semantic terms while preserving your relative day-of-period offset.', icon: 'šŸ”„' }, { title: 'Tempo.ticker()', details: 'Premium Plugin: State-of-the-art timing engine with AsyncGenerator support and native Daylight Saving Time resolution.', icon: 'ā±ļø' }, - { title: 'Temporal Inside', details: 'Built on the ECMAScript Temporal API. Inherit the reliability of the future standard.', icon: 'šŸ—ļø' }, + { title: 'Temporal Inside', details: 'Built on the ECMAScript Temporal API. Inherit the reliability of the modern standard.', icon: 'šŸ—ļø' }, { title: 'Monorepo Resilient', details: 'Built for stability in complex environments with proxy-protected registries.', icon: 'šŸ›”ļø' }, { title: 'Tree-Shakable', details: 'Keep your bundle light. Only import the modules you need—from Fiscal calendars to pulsing Tickers.', icon: 'šŸ“¦' }, { title: 'Business Aware', details: 'Native support for fiscal quarters, zodiac signs, and meteorological seasons. Perfect for financial applications or astrology buffs or meteorologists !', icon: 'šŸ“ˆ' } diff --git a/packages/tempo/package.json b/packages/tempo/package.json index a7782350..7a801778 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.11.1", + "version": "4.0.0", "engines": { "node": ">=20.0.0" }, @@ -110,9 +110,6 @@ "#tempo/module": { "default": "./dist/module/module.index.js" }, - "#tempo/license": { - "default": "./dist/plugin/license/license.validator.js" - }, "#tempo/config": { "default": "./dist/config/config.index.js" }, @@ -204,11 +201,6 @@ "import": "./dist/module/module.format.js", "default": "./dist/module/module.format.js" }, - "./ticker": { - "types": "./dist/plugin/extend/extend.ticker.d.ts", - "import": "./dist/plugin/extend/extend.ticker.js", - "default": "./dist/plugin/extend/extend.ticker.js" - }, "./parse": { "types": "./dist/module/module.parse.d.ts", "import": "./dist/module/module.parse.js", @@ -252,11 +244,12 @@ } }, "scripts": { - "test": "cross-env TEMPO_LICENSE_KEY=\"\" vitest run", - "test:dist": "cross-env TEMPO_LICENSE_KEY=\"\" TEST_DIST=true vitest run", + "test": "vitest run", + "test:dist": "TEST_DIST=true vitest run", "test:browser": "vitest run -c vitest.browser.config.ts", - "test:ci": "cross-env TEMPO_LICENSE_KEY=\"\" TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 vitest run", - "repl": "cross-env TEMPO_LICENSE_KEY=\"\" tsx --tsconfig ./src/tsconfig.repl.json -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", + "test:ci": "TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 vitest run", + "repl": "tsx --tsconfig ./src/tsconfig.repl.json -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", + "repl:pro": "cross-env TEMPO_LICENSE_KEY=\"mock-pro-token\" tsx --tsconfig ./src/tsconfig.repl.json -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", "repl:dist": "tsx -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", "repl:node": "tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal --import ./bin/repl.ts", "repl:bare": "tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal", @@ -268,7 +261,7 @@ "build:version": "node bin/update-version.mjs", "prebuild": "npm run build:version", "clean": "magma-cli rm dist && (node ../../node_modules/typescript-7/bin/tsc -b --clean || true)", - "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && if [ -z \"$TEMPO_LICENSE_PATH\" ] || [ ! -f \"$TEMPO_LICENSE_PATH\" ]; then echo '🚨 ERROR: TEMPO_LICENSE_PATH is missing or invalid. Cannot publish Premium build.'; exit 1; fi && npm run build", + "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build", "docs:api": "typedoc && typedoc --options typedoc.library.json && node bin/expand-typedoc.mjs", "docs:dev": "npm run build && npm run docs:api && node bin/build-catalog.mjs && node bin/harvest-plugins.mjs && node bin/generate-llms-txt.mjs && vitepress dev", "docs:build": "npm run build && npm run docs:api && node bin/build-catalog.mjs && node bin/harvest-plugins.mjs && node bin/generate-llms-txt.mjs && vitepress build", @@ -290,11 +283,9 @@ "devDependencies": { "@js-temporal/polyfill": "^0.5.1", "@magmacomputing/library": "*", - "@magmacomputing/tempo-plugin-ticker": "^2.2.3", "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.3.0", - "javascript-obfuscator": "^5.4.3", "magic-string": "^1.2.0", "mermaid": "^11.16.1", "typedoc": "^0.28.19", diff --git a/packages/tempo/plan/dynamic-functional-context-evaluation.md b/packages/tempo/plan/dynamic-functional-context-evaluation.md deleted file mode 100644 index ea031bdb..00000000 --- a/packages/tempo/plan/dynamic-functional-context-evaluation.md +++ /dev/null @@ -1,121 +0,0 @@ -# Dynamic Functional Context & Lazy Evaluation Strategy - -**Target**: Tempo `v3.12.0` & AI Plugin `v1.1.0` -**Status**: Planned / Shelved for Next Minor Point-Release -**Pattern**: `T | (() => T)` and `T | (() => Promise)` Lazy Evaluation - ---- - -## 1. Executive Summary & Release Timing - -The `T | (() => T)` lazy evaluation pattern allows configurations, inputs, and context bindings to accept both static scalar values and dynamic evaluation hooks. While simple on the surface, integrating lazy and asynchronous resolution touches core dispatch pipelines, constructor argument normalization, multi-tenant state isolation, and secret management lifecycles. - -### Why Defer to the Next Point-Release (`v1.1.0` / `v3.12.0`)? -1. **Release Stability**: Tempo `v3.11.1` and AI Plugin `v1.0.0` have stabilized with 100% green coverage across 131 test files (1,021 passing tests). -2. **Dispatch & Lifecycle Architecture**: Supporting async key getters (`() => Promise`) requires moving key resolution from `Tempo.init()` discovery time into request dispatch time (`executeProviderRequest`), ensuring tokens are refreshed per-request without being prematurely flattened into static strings at boot time. -3. **Constructor & Instantiation Pipeline**: Supporting `new Tempo(() => DateTime)` requires updating `#swap()`, `#resolve()`, and argument overloading in `tempo.class.ts` so supplier functions aren't confused with options objects or eagerly parsed in defer mode. -4. **Multi-Tenant Testing**: Testing thread-local and `AsyncLocalStorage` binding for dynamic `timeZone` / `locale` requires dedicated concurrent test fixtures. -5. **Conclusion**: Deferring this to `v1.1.0` / `v3.12.0` ensures we deliver a production-grade, hardened implementation with full async secret provider, lazy instantiation, and multi-tenant test suites without delaying the `v1.0.0` milestone. - ---- - -## 2. Core Architecture & Target Enhancements - -### Track 1: AI Plugin (`@magmacomputing/tempo-ai` v1.1.0) - -#### A. Dynamic / Rotating API Keys -* **Current Signature**: `key?: string` -* **Target Signature**: `key?: string | (() => string | Promise)` -* **Use Cases**: - * **Short-Lived Cloud IAM / STS Tokens**: Google Cloud Vertex AI and Azure OpenAI access tokens that expire after 60 minutes. - * **Secret Vaults**: On-demand retrieval from AWS Secrets Manager, HashiCorp Vault, Doppler, or GCP Secret Manager. - * **Zero Plaintext In-Memory Persistence**: Sensitive credentials are evaluated ephemerally per request and immediately cleared from scope. -* **Pipeline Change**: - * Update `discovery.ts`: Preserve functional `p.key` as a callable hook during discovery merging instead of eagerly flattening via `resolveProviderApiKey`. - * Update `dispatch.ts`: Await `typeof p.key === 'function' ? await p.key() : p.key` immediately before dispatching the HTTP fetch request. - -#### B. Dynamic Anchor Grounding -* **Current Signature**: `anchor?: TempoDateInput` -* **Target Signature**: `anchor?: TempoDateInput | (() => TempoDateInput)` -* **Use Cases**: - * Long-lived configuration objects and reusable query presets (e.g., `const options = { anchor: () => Tempo.now }`). - * Prevents "anchor drift" where relative expressions (*"tomorrow"*, *"next Friday"*) calculate against the stale process startup timestamp rather than the invocation moment. -* **Pipeline Change**: - * Update `resolveFullContext()` in `support.ts` to unwrap `typeof options.anchor === 'function' ? options.anchor() : options.anchor`. - -#### C. Dynamic URLs & Model Routing -* **Target Signatures**: `url?: string | (() => string)`, `model?: string | (() => string)` -* **Use Cases**: - * **Ephemeral Test Ports**: Dynamic URLs for local mock servers and WireMock containers in CI environments. - * **Time-of-Day / Budget Routing**: Switch between high-speed models (`llama-3.1-8b-instant`) and large reasoning models (`llama-3.3-70b-versatile`) based on load, quota, or time. - ---- - -### Track 2: Tempo Core (`@magmacomputing/tempo` v3.12.0) - -#### A. Dynamic Instantiation Targets (`new Tempo( () => DateTime )`) -* **Target Signatures**: - * `constructor(tempo?: t.DateTime | (() => t.DateTime), options?: t.Options | (() => t.Options))` - * `Tempo.from(value: t.DateTime | (() => t.DateTime), options?: t.Options | (() => t.Options))` -* **Use Cases**: - * **True Lazy / Deferral Construction**: `const t = new Tempo(() => getLatestDatabaseTimestamp());` - Constructing the instance incurs zero parsing or database access overhead until a property or format (`t.iso`, `t.ts`, `t.format()`) is accessed. - * **Live Dynamic / Reactive Anchors**: `const clock = new Tempo(() => Tempo.now);` - * **Functional Argument Disambiguation**: Updating `#swap(tempo, options)` so supplier functions are cleanly distinguished from functional options or format mutators. -* **Pipeline Change**: - * In `tempo.class.ts`: - * Update `#swap()` to treat `isFunction(tempo)` as a `DateTimeSupplier` when it does not return an `Options` dictionary. - * In `#resolve()`, evaluate `const raw = isFunction(this.#tempo) ? this.#tempo() : this.#tempo;` before passing to `#parse()`. - -#### B. Multi-Tenant Request Isolation (`timeZone` & `locale`) -* **Target Signatures**: - * `timeZone?: Temporal.TimeZoneLike | (() => Temporal.TimeZoneLike)` - * `locale?: string | string[] | (() => string | string[])` -* **Use Cases**: - * **SSR / Server Multi-Tenancy**: In Next.js, Fastify, Express, and Remix, concurrent requests share a single runtime process. - * **Zero-Overhead Binding**: Instead of generating a new `Tempo.create({...})` sandbox on every incoming HTTP request, developers can configure Tempo globally once: - ```ts - Tempo.init({ - timeZone: () => asyncLocalStorage.getStore()?.userTimeZone ?? 'UTC', - locale: () => asyncLocalStorage.getStore()?.userLocale ?? 'en-US', - }); - ``` - * Every standard call to `Tempo.now`, `Tempo.today`, or `.format()` automatically resolves against the active request context. - -#### C. Dynamic Enterprise Licensing -* **Target Signature**: `license?: string | (() => string | Promise)` -* **Use Cases**: - * Fetching signed JWS license tokens from remote license servers, Kubernetes secrets, or cloud vaults upon renewal without restarting the Node.js process. - ---- - -## 3. Impact Analysis & Blast Radius - -| Component | Files Affected | Complexity | Risk Level | -| :--- | :--- | :--- | :--- | -| **Dynamic Instantiation** | `tempo/src/tempo.type.ts`
`tempo/src/tempo.class.ts` | Low-Medium | Low (backward-compatible overload) | -| **AI Provider Keys** | `ai/src/types/base.type.ts`
`ai/src/core/discovery.ts`
`ai/src/core/dispatch.ts` | Low-Medium | Low (backward-compatible union) | -| **AI Anchor Grounding** | `ai/src/types/base.type.ts`
`ai/src/core/support.ts` | Low | Very Low | -| **AI URLs & Models** | `ai/src/types/base.type.ts`
`ai/src/core/dispatch.ts` | Low | Very Low | -| **Core Multi-Tenant Context** | `tempo/src/tempo.type.ts`
`tempo/src/tempo.class.ts`
`tempo/src/engine/engine.normalizer.ts` | Medium | Medium (performance in hot parsing loops) | - ---- - -## 4. Verification & Testing Strategy - -1. **Lazy Instantiation Test**: - * Instantiate `new Tempo(() => { supplierCalled = true; return '2026-08-17'; }, { mode: Tempo.MODE.Defer })`. - * Assert `supplierCalled === false` at instantiation. - * Access `.iso` and assert `supplierCalled === true` and `.iso === '2026-08-17T00:00:00Z'`. -2. **AI Dynamic Key Rotation Test**: - * Mock a provider key generator that yields an expired token on request #1 and a refreshed token on request #2. Assert automatic resolution. -3. **AI Dynamic Anchor Drift Test**: - * Create a shared options fixture with `anchor: () => Tempo.now`. Advance simulated timers with `vi.advanceTimersByTime()` and assert relative parsing outputs shift accordingly. -4. **Multi-Tenant Concurrent Context Test**: - * Execute parallel `Promise.all()` workers across 50 simulated requests with distinct `AsyncLocalStorage` stores; verify zero context bleed between concurrent `Tempo.now` calls. - ---- - -## 5. Conclusion & Recommendation - -The lazy evaluation pattern aligns with Tempo’s modern developer ergonomics and production-readiness philosophy. Scheduling this for **Tempo v3.12.0** and **AI Plugin v1.1.0** allows for thorough multi-tenant concurrency testing, async credential provider integration, constructor disambiguation, and documentation updates without putting the pending `v1.0.0` / `v3.11.1` release at risk. diff --git a/packages/tempo/plan/tempo-pro-architecture.md b/packages/tempo/plan/tempo-pro-architecture.md new file mode 100644 index 00000000..4c35ab23 --- /dev/null +++ b/packages/tempo/plan/tempo-pro-architecture.md @@ -0,0 +1,73 @@ +# TempoPro Architecture & Commercial Ecosystem Strategy + +## Overview + +This document outlines the architectural blueprint for `@magmacomputing/tempo-pro`, the commercial extension wrapper and enterprise license management package for the Tempo ecosystem. + +--- + +## Key Principles & Integration + +### 1. Ultra-Lean Core Parity (`@magmacomputing/tempo-pro/core`) +- `tempo-pro` provides 1:1 sub-path export symmetry with community core via `./core`. +- Developers using ultra-lean setups can swap between community core and commercial core by simply changing sub-paths: + +```typescript +// Community Core (Ultra-lean, un-extended) +import { Tempo } from '@magmacomputing/tempo/core'; + +// Commercial Core (Ultra-lean with license validation hooks) +import { Tempo } from '@magmacomputing/tempo-pro/core'; +``` + +Behind the scenes in `packages/tempo-pro/src/core.ts`: +```typescript +import { Tempo as BaseTempoCore } from '@magmacomputing/tempo/core'; + +export class TempoCore extends BaseTempoCore { + static override init(options?: Record) { + if (options?.licenseKey) { + setLicense(ensureLicenseState(TempoCore), options.licenseKey); + } + return super.init(options); + } +} + +export { TempoCore as Tempo }; +export default TempoCore; +``` + +### 2. Sub-Classing + Sandbox Composition (Option 1 + Option 2) +`TempoPro` extends `Tempo` directly, preserving 100% API parity while supporting isolated sandbox creation: + +```typescript +import { TempoPro } from '@magmacomputing/tempo-pro'; + +// 1. Standard Instance (Sub-classing DX) +const t = TempoPro.init({ licenseKey: '...' }); + +// 2. Multi-tenant Enterprise Sandbox (Option 2 via Parent Sandbox Engine) +const tenantSandbox = TempoPro.create({ + licenseKey: 'tenant-enterprise-key', + timeZone: 'America/New_York', +}); +``` + +### 3. Stashed Enterprise Infrastructure +- **`license.manager.ts`**: JWT state decoding, pledge tracking, expiry warning loops, and scope updating (`updateScopeStatus`). +- **`license.validator.ts`**: Cryptographic JWS verification engine and commercial plugin tags (`defineCommercialPlugin`, `defineCommercialTerm`). +- **`license.enum.ts`**: Unified `LICENSE` status enumeration (`None`, `Pending`, `Active`, `Expired`, `Revoked`, `Invalid`). + +--- + +## Package Versioning Strategy + +### Monorepo Version Synchronization vs. Independent SemVer + +- **Synchronized Release Line (`tempo-pro@4.0.0`)**: + - In modern monorepos (similar to Angular `@angular/core@17` or Babel `@babel/core@7`), companion core wrappers synchronize version numbers (`v4.0.0`) with the primary core engine. + - **Advantage**: Completely eliminates consumer matrix confusion ("Is `tempo-pro@1.0.0` compatible with `tempo@4.0.0`? Yes, because both share `v4.0.0`!"). +- **Independent SemVer (`tempo-pro@1.0.0`)**: + - Useful if `tempo-pro` matures on an independent lifecycle with broad `peerDependencies: { "@magmacomputing/tempo": "^4.0.0" }`. + +**Conclusion**: Synchronizing `tempo-pro` to `4.0.0` alongside `@magmacomputing/tempo@4.0.0` is standard industry practice for core monorepo companions. diff --git a/packages/tempo/public/esm_core.index.html b/packages/tempo/public/esm_core.index.html index a1d060d6..95c5d34d 100644 --- a/packages/tempo/public/esm_core.index.html +++ b/packages/tempo/public/esm_core.index.html @@ -234,8 +234,8 @@

Tempo

"imports": { "jsbi": "https://cdn.jsdelivr.net/npm/jsbi@4.3.0/dist/jsbi.mjs", "@js-temporal/polyfill": "https://cdn.jsdelivr.net/npm/@js-temporal/polyfill@0.5/dist/index.esm.js", - "@magmacomputing/tempo/core": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@2/dist/core.index.js", - "@magmacomputing/tempo/mutate": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@2/dist/module/module.mutate.js" + "@magmacomputing/tempo/core": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@4/dist/core.index.js", + "@magmacomputing/tempo/mutate": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@4/dist/module/module.mutate.js" } } diff --git a/packages/tempo/public/esm_full.index.html b/packages/tempo/public/esm_full.index.html index 514bfe61..13f30542 100644 --- a/packages/tempo/public/esm_full.index.html +++ b/packages/tempo/public/esm_full.index.html @@ -232,7 +232,7 @@

Tempo

"imports": { "jsbi": "https://cdn.jsdelivr.net/npm/jsbi@4.3.0/dist/jsbi.mjs", "@js-temporal/polyfill": "https://cdn.jsdelivr.net/npm/@js-temporal/polyfill@0.5/dist/index.esm.js", - "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@2/dist/tempo.bundle.esm.js" + "@magmacomputing/tempo": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@4/dist/tempo.bundle.esm.js" } } diff --git a/packages/tempo/public/esm_sh.index.html b/packages/tempo/public/esm_sh.index.html index bdc2ce4c..603dc98f 100644 --- a/packages/tempo/public/esm_sh.index.html +++ b/packages/tempo/public/esm_sh.index.html @@ -270,7 +270,7 @@

Tempo

{ "imports": { "@js-temporal/polyfill": "https://esm.sh/@js-temporal/polyfill@0.5.1", - "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@3.11.1" + "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.0.0" } } diff --git a/packages/tempo/public/llms.txt b/packages/tempo/public/llms.txt index 5c80e229..560eac15 100644 --- a/packages/tempo/public/llms.txt +++ b/packages/tempo/public/llms.txt @@ -1,12 +1,13 @@ # Tempo: Immutable Date-Time Engine & AI Syntax Rules -> Tempo is a lightweight, immutable JavaScript/TypeScript date-time library built around the native ECMAScript Temporal API proposal. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments. +> Tempo is an immutable TypeScript date-time engine built around the ECMAScript Temporal API. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments. ## Core Architectural Rules & Philosophy -- **Temporal Engine**: Tempo expects native `Temporal` in modern runtimes or uses `@js-temporal/polyfill` when necessary. Never instantiate legacy JavaScript `Date`. -- **Strict Immutability**: `Tempo` instances are completely frozen. All mutating operations (`add`, `subtract`, `with`, `startOf`, `endOf`) return a brand-new `Tempo` object. -- **Zero-Cost Getter Proxies**: Properties like `.year`, `.month`, `.day`, `.hour`, `.minute`, `.second`, `.millisecond`, `.microsecond`, `.nanosecond` are live getters proxying the underlying `Temporal` state. -- **Plugin Architecture**: Core functions can be extended via `Tempo.extend(Plugin)`. License validation occurs via `Tempo.init(...)`. +- **Temporal Engine**: Tempo uses native `Temporal` in modern runtimes or `@js-temporal/polyfill`. Never instantiate legacy JavaScript `Date`. +- **Strict Immutability**: `Tempo` instances are completely frozen (`Object.freeze`). All mutating operations (`add`, `subtract`, `set`) return a new `Tempo` instance. +- **Constructor Instantiation**: Instantiation and string parsing ALWAYS use the `new Tempo(...)` constructor. (There are no static `Tempo.from` or `Tempo.parse` functions; `Tempo.parse` is a static configuration object getter). +- **Live Getters**: Core property accessors use short layout tokens: `.yy` (year), `.mm` (month), `.dd` or `.day` (day of month), `.hh` (hour), `.mi` (minute), `.ss` (second), `.ms` (millisecond), `.us` (microsecond), `.ns` (nanosecond), `.dow` (day of week 1-7), `.doy` (day of year 1-366), `.wy` (week of year), `.tz` (timeZone ID), `.cal` (calendar ID), `.ts` (timestamp ms), `.iso` (ISO 8601 UTC string), `.isValid`. *(Note: Long getters like `.year`, `.month`, `.hour`, `.minute`, `.second` do not exist directly on Tempo instances).* +- **Configuration & Plugins**: System options and plugin registration are configured via `Tempo.init({ timeZone: 'UTC', monthDay: true, extends: [Plugin], registry: { layouts: { ... }, formats: { ... }, numbers: { ... } } })`. Core functionality can also be extended dynamically via `Tempo.extend(Plugin)`. ## Formatting & Parsing Tokens | Token | Description | Sample Output | @@ -30,46 +31,65 @@ - [Plugin Development](https://github.com/magmacomputing/magma/tree/main/packages/tempo/doc/3-extending-tempo/tempo.plugin.md): Rules for extending Tempo via plugins. - [Utility Library](https://github.com/magmacomputing/magma/tree/main/packages/tempo/doc/6-utility-library/tempo.library.md): Type detection, serialization (`stringify`/`objectify`), and `Pledge`. -## Common Code Snippets +## Common Code Snippets & Patterns -### Quick Setup & Basic Usage +### 1. Basic Instantiation & Formatting ```typescript import { Tempo } from '@magmacomputing/tempo'; -// Create from current instant or ISO string -const t = Tempo.from('2026-08-04T15:30:00Z'); +// Create from current instant or ISO 8601 string +const t = new Tempo('2026-08-04T15:30:00Z'); // Format using layout tokens -t.format('{mon} {dd}, {yy}'); // "August 04, 2026" +t.format('{mon} {dd}, {yyyy}'); // "August 04, 2026" // Immutably manipulate date const nextWeek = t.add({ days: 7 }); ``` -### Smart Parsing & Shorthand Expressions +### 2. Relative Shorthand & Arithmetic ```typescript import { Tempo } from '@magmacomputing/tempo'; -// 1. Natural language & relative shorthand expressions -const t1 = Tempo.from('next friday'); -const t2 = Tempo.from('start of month'); -const t3 = Tempo.from('+3 days'); +// Instantiation with relative expressions or durations +const t1 = new Tempo('next Friday'); +const t2 = new Tempo('in 3 days'); -// 2. Flexible date parsing -const t4 = Tempo.parse('2026-08-04T15:30:00+10:00'); +// Immutable boundary & arithmetic helpers via .set() +const startOfMonth = new Tempo().set({ month: 'start' }); +const endOfYear = new Tempo().set({ year: 'end' }); +const inTwoHours = new Tempo().add({ hours: 2 }); ``` -### Custom Layout Registration & Parsing +### 3. Instance Options (Timezone & Locale) ```typescript import { Tempo } from '@magmacomputing/tempo'; -// Register custom snippet & layout pattern via config -Tempo.config({ - layouts: { - us_date: '{mm}/{dd}/{yy}' - } +// ISO 8601 strings parse directly via constructor +const tIso = new Tempo('2026-08-04T15:30:00+10:00'); + +// Pass instance options as the second parameter +const tSydney = new Tempo('2026-08-04T15:30:00', { timeZone: 'Australia/Sydney' }); +``` + +### 4. System Initialization & Month-Day / Custom Layout Configuration +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +// 1. Configure US Month-First Parsing for ambiguous slash dates ('08/04/2026' -> August 4th) +Tempo.init({ + timeZone: 'UTC', + monthDay: true // or locale: 'en-US' }); +const usDate = new Tempo('08/04/2026'); // Month: 8 (August), Day: 4 -// Parse string using registered layout -const parsed = Tempo.parse('08/04/2026', 'us_date'); +// 2. Register custom layout patterns for non-standard formats via registry (escape regex meta-characters as needed, e.g. '\\*') +Tempo.init({ + registry: { + layouts: { + star_date: '{mm}\\*{dd}\\*{yy}' + } + } +}); +const customDate = new Tempo('08*04*2026'); // Automatically matched against star_date layout ``` diff --git a/packages/tempo/public/pro-logo.svg b/packages/tempo/public/pro-logo.svg new file mode 100644 index 00000000..d0e3bc6f --- /dev/null +++ b/packages/tempo/public/pro-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/tempo/public/script.index.html b/packages/tempo/public/script.index.html index 4ca30a7b..fffc947b 100644 --- a/packages/tempo/public/script.index.html +++ b/packages/tempo/public/script.index.html @@ -222,7 +222,7 @@

Tempo

- +