From c03dfdf15b9b1d10eb4cd6ea35285b5117a6fc82 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 18 Aug 2026 17:05:39 +1000 Subject: [PATCH 01/14] chore: initialize branch and bump versions for dynamic functional context (tempo/library v3.12.0, ai-plugin v1.1.0) --- package-lock.json | 8 ++++---- package.json | 2 +- packages/library/package.json | 2 +- packages/plugins/ai/package.json | 4 ++-- packages/tempo/package.json | 2 +- packages/tempo/public/esm_sh.index.html | 2 +- packages/tempo/src/tempo.version.ts | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3787141e..27361f27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.11.1", + "version": "3.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.11.1", + "version": "3.12.0", "workspaces": [ "packages/*", "packages/plugins/*" @@ -11415,7 +11415,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.11.1", + "version": "3.12.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11494,7 +11494,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.11.1", + "version": "3.12.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index c44b6f1f..39ab9471 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.11.1", + "version": "3.12.0", "private": true, "engines": { "node": ">=20.0.0" diff --git a/packages/library/package.json b/packages/library/package.json index 9ecbafb8..637cbef6 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.11.1", + "version": "3.12.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index ce53fb84..7bd0db8a 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -1,6 +1,6 @@ { "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", @@ -25,7 +25,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.11.1" + "@magmacomputing/tempo": "^3.12.0" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1" diff --git a/packages/tempo/package.json b/packages/tempo/package.json index a7782350..57d32abb 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.11.1", + "version": "3.12.0", "engines": { "node": ">=20.0.0" }, diff --git a/packages/tempo/public/esm_sh.index.html b/packages/tempo/public/esm_sh.index.html index bdc2ce4c..28994aa8 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@3.12.0" } } diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 04e3729c..afb14bd5 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '3.11.1'; +export const TEMPO_VERSION = '3.12.0'; From 712cf7ed71dda88a21ef24909e220f933ca87ec2 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 19 Aug 2026 08:26:35 +1000 Subject: [PATCH 02/14] chore: configure npm provenance repository directories, update README security notice, and add publish.yml workflow --- .github/workflows/publish.yml | 90 +++++++++++++++++++++++++++ packages/plugins/ai/README.md | 10 +++ packages/plugins/ai/package.json | 5 ++ packages/plugins/astro/package.json | 5 ++ packages/plugins/batch/package.json | 5 ++ packages/plugins/finance/package.json | 5 ++ packages/plugins/snap/package.json | 5 ++ packages/plugins/sync/package.json | 5 ++ 8 files changed, 130 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..b9e51538 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,90 @@ +name: Publish Package with Provenance + +on: + workflow_dispatch: + inputs: + package: + description: 'Package to publish' + required: true + type: choice + options: + - '@magmacomputing/tempo' + - '@magmacomputing/tempo-plugin-ai' + - '@magmacomputing/tempo-fns' + - '@magmacomputing/tempo-plugin-astro' + - '@magmacomputing/tempo-plugin-finance' + - '@magmacomputing/tempo-plugin-batch' + - '@magmacomputing/tempo-plugin-snap' + - '@magmacomputing/tempo-plugin-sync' + - '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 + 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: Build Monorepo Workspaces + run: | + npm run build:library + npm run build:tempo + npm run build:plugins + + - 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 + + if [ "$SELECTED_PKG" = "all" ]; then + echo "🚀 Publishing all workspaces..." + npm publish --workspace=@magmacomputing/tempo $PROVENANCE_FLAG + npm publish --workspace=@magmacomputing/tempo-fns $PROVENANCE_FLAG + npm publish --workspace=@magmacomputing/tempo-plugin-ai $PROVENANCE_FLAG + npm publish --workspace=@magmacomputing/tempo-plugin-astro $PROVENANCE_FLAG + npm publish --workspace=@magmacomputing/tempo-plugin-finance $PROVENANCE_FLAG + npm publish --workspace=@magmacomputing/tempo-plugin-batch $PROVENANCE_FLAG + npm publish --workspace=@magmacomputing/tempo-plugin-snap $PROVENANCE_FLAG + npm publish --workspace=@magmacomputing/tempo-plugin-sync $PROVENANCE_FLAG + else + echo "🚀 Publishing workspace: $SELECTED_PKG" + npm publish --workspace="$SELECTED_PKG" $PROVENANCE_FLAG + fi diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md index adeb6d3c..0f9cc479 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**: All requests are dispatched directly from your application runtime to the official endpoints of your configured AI providers (OpenAI, Google Gemini, Anthropic, Groq, or local Ollama). There are **no intermediary proxies**, **no 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/package.json b/packages/plugins/ai/package.json index 7bd0db8a..abff2492 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/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/ai" + }, "files": [ "dist", "README.md", diff --git a/packages/plugins/astro/package.json b/packages/plugins/astro/package.json index 8aae8108..a63f8a95 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", diff --git a/packages/plugins/batch/package.json b/packages/plugins/batch/package.json index a27780fc..c439c502 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", diff --git a/packages/plugins/finance/package.json b/packages/plugins/finance/package.json index 0cc07b2b..40629941 100644 --- a/packages/plugins/finance/package.json +++ b/packages/plugins/finance/package.json @@ -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", diff --git a/packages/plugins/snap/package.json b/packages/plugins/snap/package.json index c11d7636..b3a23681 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", diff --git a/packages/plugins/sync/package.json b/packages/plugins/sync/package.json index 51022cfd..951c4f15 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", From ff9ec2f14175aa986e280b51001e14b5a9324883 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 19 Aug 2026 12:28:33 +1000 Subject: [PATCH 03/14] pre-evaluate() --- .github/workflows/publish.yml | 2 +- .../.setup/community-plugin-template.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b9e51538..eaf27dc3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,8 +9,8 @@ on: type: choice options: - '@magmacomputing/tempo' - - '@magmacomputing/tempo-plugin-ai' - '@magmacomputing/tempo-fns' + - '@magmacomputing/tempo-plugin-ai' - '@magmacomputing/tempo-plugin-astro' - '@magmacomputing/tempo-plugin-finance' - '@magmacomputing/tempo-plugin-batch' diff --git a/packages/plugins/.setup/community-plugin-template.md b/packages/plugins/.setup/community-plugin-template.md index 6fd43fb8..ed453295 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 automated 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 + ``` + From ddf4975acfbd6bf37fcd93ba2d0a33e63fa25c65 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 19 Aug 2026 13:58:24 +1000 Subject: [PATCH 04/14] PR initial review --- packages/library/CHANGELOG.md | 7 + packages/library/src/common.index.ts | 1 + .../library/src/common/evaluation.library.ts | 112 ++++++++++++ packages/library/src/common/proxy.library.ts | 46 ++++- packages/library/src/common/type.library.ts | 32 ++++ .../library/test/evaluation.library.test.ts | 172 ++++++++++++++++++ packages/plugins/ai/CHANGELOG.md | 7 + packages/plugins/ai/doc/architecture.md | 8 + packages/plugins/ai/doc/init.md | 26 +++ packages/plugins/ai/doc/security.md | 39 ++++ packages/plugins/ai/src/core/discovery.ts | 12 +- packages/plugins/ai/src/core/manifest.ts | 7 +- packages/plugins/ai/src/core/support.ts | 50 +++-- packages/plugins/ai/src/core/transport.ts | 50 +++-- packages/plugins/ai/src/types/base.type.ts | 45 ++--- packages/plugins/ai/test/dynamic.ai.test.ts | 171 +++++++++++++++++ packages/tempo/CHANGELOG.md | 9 + .../doc/1-getting-started/ai-integration.md | 2 +- .../doc/1-getting-started/tempo.cookbook.md | 17 ++ .../tempo/doc/2-core-concepts/tempo.config.md | 53 +++++- .../doc/6-utility-library/tempo.library.md | 32 +++- packages/tempo/public/llms.txt | 2 +- packages/tempo/src/library.index.ts | 13 +- packages/tempo/src/support/support.init.ts | 6 +- packages/tempo/src/tempo.class.ts | 23 ++- packages/tempo/src/tempo.type.ts | 18 +- .../test/core/dynamic_evaluation.test.ts | 96 ++++++++++ 27 files changed, 964 insertions(+), 92 deletions(-) create mode 100644 packages/library/src/common/evaluation.library.ts create mode 100644 packages/library/test/evaluation.library.test.ts create mode 100644 packages/plugins/ai/test/dynamic.ai.test.ts create mode 100644 packages/tempo/test/core/dynamic_evaluation.test.ts diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index df0d106e..ceac6636 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -5,6 +5,13 @@ 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). +## [3.12.0] - 2026-08-19 + +### Added +- **Dynamic Evaluation Utilities (`evaluation.library`)**: Added `#library/evaluation.library.js` containing `evaluate(value, fallback?)`, `evaluateAsync(value, fallback?)`, `evaluateConfig(config)`, and `evaluateConfigAsync(config)`. Supports synchronous and asynchronous value/supplier evaluation with fallback defaults for undefined resolutions. +- **Dynamic Property Proxy (`proxy.library`)**: Added `dynamicProxy(target, overrides)` for zero-overhead dynamic property proxying with on-access evaluation of functional overrides. +- **Standardized Evaluation Types (`type.library`)**: Added `Evaluable`, `AsyncEvaluable`, `EvaluableRecord`, `AsyncEvaluableRecord`, and unified `Resolved` on top of `Awaited`. + ## [3.11.1] - 2026-08-06 ### Added diff --git a/packages/library/src/common.index.ts b/packages/library/src/common.index.ts index 9b5afd12..b32c3ce7 100644 --- a/packages/library/src/common.index.ts +++ b/packages/library/src/common.index.ts @@ -10,6 +10,7 @@ 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/evaluation.library.js'; export * from './common/function.library.js'; export * from './common/international.library.js'; export * from './common/logger.class.js'; diff --git a/packages/library/src/common/evaluation.library.ts b/packages/library/src/common/evaluation.library.ts new file mode 100644 index 00000000..8c2fa853 --- /dev/null +++ b/packages/library/src/common/evaluation.library.ts @@ -0,0 +1,112 @@ +import { isFunction, isNullish, isObject } from '#library/assertion.library.js'; +import type { Evaluable, AsyncEvaluable } from '#library/type.library.js'; + +/** + * Evaluates a synchronous scalar or supplier function. + * If the input is a function, it is invoked with zero arguments and its return value is returned. + * If the resolved value is undefined and a fallback is provided, the fallback is evaluated and returned. + * Otherwise, the scalar value is returned as-is. + * Any exception thrown by the supplier function bubbles directly to the caller. + * + * @param value - The scalar value or synchronous supplier function to evaluate + * @param fallback - Optional default value or supplier to evaluate if `value` is undefined + * @returns The resolved synchronous value + * @example + * ```ts + * evaluate(42); // 42 + * evaluate(() => 'UTC'); // 'UTC' + * evaluate(undefined, 'fallback'); // 'fallback' + * evaluate(undefined, () => 'dynamic-fallback'); // 'dynamic-fallback' + * ``` + */ +export function evaluate(value: Evaluable | undefined, fallback?: Evaluable): T { + const resolved = isFunction(value) ? (value as () => T)() : value; + if (resolved !== undefined) return resolved as T; + return (isFunction(fallback) + ? (fallback as () => T)() + : fallback) as T; +} + +/** + * Evaluates a synchronous or asynchronous scalar, Promise, or supplier function. + * If the input is a function, it is invoked and its result is awaited. + * If the resolved value is undefined and a fallback is provided, the fallback is evaluated and returned. + * Otherwise, the scalar or Promise is resolved and returned. + * Any exception or rejection bubbles directly to the caller. + * + * @param value - The scalar value, Promise, or supplier function to evaluate + * @param fallback - Optional default value, Promise, or async supplier to evaluate if `value` is undefined + * @returns A Promise resolving to the evaluated value + * @example + * ```ts + * await evaluateAsync('apiKey123'); // 'apiKey123' + * await evaluateAsync(async () => fetchSecret()); // 'secret' + * await evaluateAsync(undefined, async () => fetchDefault()); // 'default' + * ``` + */ +export async function evaluateAsync(value: AsyncEvaluable | undefined, fallback?: AsyncEvaluable): Promise { + const resolved = isFunction(value) ? await (value as () => T | Promise)() : await value; + if (resolved !== undefined) return resolved as T; + return (isFunction(fallback) + ? await (fallback as () => T | Promise)() + : await fallback) as T; +} + +/** + * 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): { [K in keyof T]: T[K] extends () => infer R ? R : T[K] } { + 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<{ [K in keyof T]: T[K] extends () => infer R ? Awaited : Awaited }> { + 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/proxy.library.ts b/packages/library/src/common/proxy.library.ts index d73ceafc..d9ad8fe4 100644 --- a/packages/library/src/common/proxy.library.ts +++ b/packages/library/src/common/proxy.library.ts @@ -2,7 +2,7 @@ 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 { isString, isFunction, isSymbol, isDefined, isNumber, isObject } from '#library/assertion.library.js'; import { registerType, type Constructor } from '#library/type.library.js'; const boundMethodCache = new WeakMap>(); @@ -255,3 +255,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): { [K in keyof T]: T[K] extends () => infer R ? R : T[K] } { + 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); + return (isFunction(val) && k !== 'constructor') + ? (val as () => any)() + : val; + }, + has(t, k) { + return Reflect.has(t, k); + }, + ownKeys(t) { + return Reflect.ownKeys(t); + }, + getOwnPropertyDescriptor(t, k) { + const desc = Reflect.getOwnPropertyDescriptor(t, k); + return (!desc) + ? undefined + : { ...desc, configurable: true }; + }, + }) as any; +} + diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index 57fe0c4f..20099683 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -454,3 +454,35 @@ 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 synchronous supplier function, or an asynchronous supplier function. + */ +export type AsyncEvaluable = T | (() => T | Promise); + +/** + * 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; + diff --git a/packages/library/test/evaluation.library.test.ts b/packages/library/test/evaluation.library.test.ts new file mode 100644 index 00000000..a6c416e1 --- /dev/null +++ b/packages/library/test/evaluation.library.test.ts @@ -0,0 +1,172 @@ +import { evaluate, evaluateAsync, evaluateConfig, evaluateConfigAsync } from '#library/evaluation.library.js'; +import { dynamicProxy } from '#library/proxy.library.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 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 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 allow asynchronous rejections to bubble naturally', async () => { + const failingAsyncSupplier = async () => { + throw new Error('Vault timeout'); + }; + await expect(evaluateAsync(failingAsyncSupplier)).rejects.toThrow('Vault timeout'); + }); + }); + + 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']); + }); + }); +}); 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/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/init.md b/packages/plugins/ai/doc/init.md index df628e50..73c3f2c2 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 + +All provider credential fields (`key`, `url`, `model`) as well as global context settings (`timeZone`, `locale`, `calendar`, `sphere`) accept synchronous or asynchronous **supplier functions** (`Evaluable` / `AsyncEvaluable`). + +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..7f53d953 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` and `url` parameters support synchronous and asynchronous supplier functions (`() => Promise | string`). +* **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/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..557d4277 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 = options?.timeZone !== undefined ? evaluate(options.timeZone) : fallbackTempo?.tz ?? evaluate(resolvedOptions.timeZone) ?? evaluate(_state.config.timeZone) ?? 'UTC'; + const tz = String(rawTz || 'UTC'); + + const optLoc = options?.locale !== undefined ? evaluate(options.locale) : undefined; + const fbLoc = fallbackTempo?.locale; + const resLoc = resolvedOptions.locale !== undefined ? evaluate(resolvedOptions.locale) : undefined; + const cfgLoc = _state.config.locale !== undefined ? evaluate(_state.config.locale) : undefined; + + 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 = options?.calendar !== undefined ? evaluate(options.calendar) : fallbackTempo?.cal ?? evaluate(resolvedOptions.calendar) ?? evaluate(_state.config.calendar) ?? 'iso8601'; + const cal = String(rawCal || 'iso8601'); + + const rawSph = options?.sphere !== undefined ? evaluate(options.sphere) : fallbackTempo?.sphere ?? evaluate(resolvedOptions.sphere) ?? evaluate(_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,11 +126,12 @@ export function resolveAnchorTempo( context: ResolvedAiContext, options?: { defaultAnchor?: unknown; operationName?: string } | undefined, ): Tempo { + const evaluatedAnchor = evaluate(anchor 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; + const targetValue = isDefined(evaluatedAnchor) ? evaluatedAnchor : evaluate(options?.defaultAnchor as any); let instance: Tempo; try { instance = new Tempo(targetValue as any, { @@ -128,12 +142,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..59af1fcf 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 { @@ -195,7 +211,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/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 6aa68c5e..bee6f700 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,15 @@ 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). +## [3.12.0] - 2026-08-19 + +### Added +- **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 +- **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/doc/1-getting-started/ai-integration.md b/packages/tempo/doc/1-getting-started/ai-integration.md index 51a2dd1e..568d3bdf 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. ``` diff --git a/packages/tempo/doc/1-getting-started/tempo.cookbook.md b/packages/tempo/doc/1-getting-started/tempo.cookbook.md index d40e8fc2..2bc86fbb 100644 --- a/packages/tempo/doc/1-getting-started/tempo.cookbook.md +++ b/packages/tempo/doc/1-getting-started/tempo.cookbook.md @@ -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..e411675a 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.config.md +++ b/packages/tempo/doc/2-core-concepts/tempo.config.md @@ -188,13 +188,13 @@ 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). | | `extends` | `Plugin \| Plugin[]` | `[]` | Plugins/modules to extend during initialization. `Tempo.init()` applies each plugin with `Tempo.extend(p)`. | @@ -224,6 +224,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/6-utility-library/tempo.library.md b/packages/tempo/doc/6-utility-library/tempo.library.md index 481f2a67..0bf65aaf 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(value, fallback?)`:** Synchronously resolves a static value or zero-argument supplier function (`() => T`). If the result is `undefined`, evaluates and returns the optional fallback. +* **`evaluateAsync(value, fallback?)`:** Asynchronously resolves static values, sync/async suppliers, or Promises (`() => Promise | T`). +* **`evaluateConfig(config)` / `evaluateConfigAsync(config)`:** Deeply resolves all `Evaluable` property suppliers across a configuration dictionary. +* **`dynamicProxy(target, overrides)`:** Wraps a target object with dynamic property traps that evaluate functional overrides 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 fallback +const tz = evaluate(() => process.env.TZ, 'UTC'); // Returns env TZ or 'UTC' + +// Asynchronous supplier evaluation (e.g. secret vault / remote config) +const apiKey = await evaluateAsync(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/public/llms.txt b/packages/tempo/public/llms.txt index 5c80e229..f70eb869 100644 --- a/packages/tempo/public/llms.txt +++ b/packages/tempo/public/llms.txt @@ -4,7 +4,7 @@ ## 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. +- **Strict Immutability**: `Tempo` instances are completely frozen. All mutating operations (`add`, `subtract`, `set`) 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(...)`. diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts index 3acd576b..7b254ecf 100644 --- a/packages/tempo/src/library.index.ts +++ b/packages/tempo/src/library.index.ts @@ -19,5 +19,16 @@ export * from '#library/assertion.library.js'; export * from '#library/temporal.library.js'; export * from '#library/calendar.library.js'; export * from '#library/recurrence.library.js'; +export * from '#library/evaluation.library.js'; -export type { OwnOf, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; +export type { + OwnOf, + KeyOf, + ValueOf, + EntryOf, + Evaluable, + AsyncEvaluable, + EvaluableRecord, + AsyncEvaluableRecord, + Resolved, +} from '#library/type.library.js'; diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 6b378da8..da10295d 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -11,6 +11,7 @@ import { ScopedSet } from '#library/scopedset.class.js'; import { ownEntries } from '#library/primitive.library.js'; import { getStorage } from '#library/storage.library.js'; import { parseLogLevel } from '#library/logger.class.js'; +import { evaluate } from '#library/evaluation.library.js'; import { getRuntime } from './support.runtime.js'; import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError, generateLocalizedSnippets } from './support.util.js'; @@ -226,7 +227,10 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea if (isUndefined(optVal)) return; state.userProvidedKeys.add(optKey); - const arg = asType(optVal); + const evaluatedVal = (['timeZone', 'calendar', 'locale', 'sphere', 'pivot'].includes(optKey)) + ? evaluate(optVal) + : optVal; + const arg = asType(evaluatedVal); switch (optKey) { case 'monthDay': diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 388da95e..bfe0241b 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -14,6 +14,7 @@ import { clone } from '#library/serialize.library.js'; import { isEmpty, isDefined, isUndefined, isString, isObject, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike, isNumber } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale, getISOWeekOfYear } from '#library/international.library.js'; +import { evaluate } from '#library/evaluation.library.js'; import { LOG } from '#library/logger.class.js'; import type { Property, Secure } from '#library/type.library.js'; @@ -255,8 +256,8 @@ export class Tempo { } /** try to infer hemisphere using the timezone's daylight-savings setting */ - static #setSphere = (shape: Internal.State, options: t.Options) => { - if (isDefined(options.sphere)) return options.sphere; + static #setSphere = (shape: Internal.State, options: t.Options): t.COMPASS | undefined => { + if (isDefined(options.sphere)) return evaluate(options.sphere); const tz = options.timeZone; if (isDefined(tz)) { @@ -868,8 +869,8 @@ export class Tempo { // 2. Establish context and keys const sys = getDateTimeFormat(); - const timeZone = options.timeZone ?? sys.timeZone; - const calendar = options.calendar ?? sys.calendar; + const timeZone = evaluate(options.timeZone) ?? sys.timeZone; + const calendar = evaluate(options.calendar) ?? sys.calendar; const config = state.config; let discovery = options.discovery ?? Symbol.keyFor($Tempo) as string; const storeKey = options.store || config.store || Symbol.keyFor($Tempo) as string; @@ -884,7 +885,7 @@ export class Tempo { const userDiscovery = (globalThis as any)[normalizedDiscovery] as Internal.Discovery; // Resolve locale if missing or invalid - const currentLocale = config.locale; + const currentLocale = evaluate(options.locale ?? config.locale); const locale = (!currentLocale || currentLocale === 'en-US') ? Tempo.#locale(currentLocale) : currentLocale; if (!hasOwn(config, 'get')) { @@ -1334,9 +1335,15 @@ export class Tempo { if (isZonedDateTime(this.#tempo)) this.#zdt = this.#tempo; this.#setLocal(this.#options); // parse local options + this.#anchor = evaluate(this.#options.anchor); if (!this.#zdt && isObject(this.#tempo) && isDurationLike(this.#tempo)) { - // relative shorthand for "now plus duration" - this.#zdt = this.#now.toZonedDateTimeISO(this.#local.config.timeZone).add(this.#tempo as Temporal.DurationLike); + // relative shorthand for "anchor (or now) plus duration" + const basis = isTempo(this.#anchor) + ? this.#anchor.toDateTime() + : isZonedDateTime(this.#anchor) + ? this.#anchor + : this.#now.toZonedDateTimeISO(this.#local.config.timeZone); + this.#zdt = basis.add(this.#tempo as Temporal.DurationLike); } const { mode } = this.#local.parse; @@ -1349,8 +1356,6 @@ export class Tempo { else if (isString(this.#tempo) && !isEmpty(input) && guard.test(trimAll(input))) this.#local.parse.lazy = true; // auto-switch to lazy-mode for valid strings - this.#anchor = this.#options.anchor; - // 🧬 Unified State Hand-off (from clone / mutate) const handoff = (this.#options as any)[$Internal]; if (isObject(handoff)) { diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 1f25acc1..cab8dab7 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -9,7 +9,7 @@ import type { Pledge } from '#library/pledge.class.js'; import type { DebugLevel } from '#library/logger.class.js'; import type { ScopedSet } from '#library/scopedset.class.js'; -import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue, RegistryOption, Branded, LooseUnion } from '#library/type.library.js'; +import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue, RegistryOption, Branded, LooseUnion, Evaluable } from '#library/type.library.js'; import { sym, type TempoBrand } from '#tempo/support/support.symbol.js'; import * as enums from '#tempo/support/support.enum.js'; @@ -40,7 +40,7 @@ declare global { export type ISOString = Branded; /** the value that Tempo will attempt to interpret as a valid ISO date / time */ -export type DateTime = ISOString | string | number | bigint | Date | Tempo | TempoBrand | TemporalObject | Temporal.ZonedDateTimeLike | undefined | null; +export type DateTime = ISOString | string | number | bigint | Date | Tempo | TempoBrand | TemporalObject | Temporal.ZonedDateTimeLike | Temporal.DurationLike | undefined | null; export type Pattern = string | RegExp /** @@ -332,11 +332,11 @@ export namespace Internal { /** additional console.log for tracking */ debug: DebugLevel; /** catch or throw Errors */ catch: boolean; /** suppress console output during catch */ silent: boolean; - /** Temporal timeZone */ timeZone: Temporal.TimeZoneLike; - /** Temporal calendar */ calendar: Temporal.CalendarLike; - /** locale (e.g. en-AU) */ locale: string | string[]; + /** Temporal timeZone */ timeZone: Evaluable; + /** Temporal calendar */ calendar: Evaluable; + /** locale (e.g. en-AU) */ locale: Evaluable; /** pivot year for two-digit years */ pivot: number; - /** hemisphere for term.qtr or term.szn */ sphere: enums.COMPASS | undefined; + /** hemisphere for term.qtr or term.szn */ sphere: Evaluable; /** internationalization configuration (relativeTime, etc.) */ intl?: IntlOptions; /** parse planner configuration (layoutOrder, etc.) */ planner?: PlannerOptions; /** Precision to measure timestamps (ms | us) */ timeStamp?: TimeStamp; @@ -436,7 +436,11 @@ export namespace Internal { export type OptionsKeep = Omit /** Instance configuration derived from supply, storage, and discovery. */ - export interface Config extends Required> { + export interface Config extends Required> { + /** Temporal timeZone */ timeZone: Temporal.TimeZoneLike; + /** Temporal calendar */ calendar: Temporal.CalendarLike; + /** locale (e.g. en-AU) */ locale: string | string[]; + /** hemisphere for term.qtr or term.szn */ sphere: enums.COMPASS | undefined; /** license key for premium features */ license?: string; /** scope for configuration mutations */ scope: 'global' | 'local'; /** custom data augmentation registries */ registry: { formats: FormatRegistry, locales: Record>, modifiers?: Record, tokens?: Record }; diff --git a/packages/tempo/test/core/dynamic_evaluation.test.ts b/packages/tempo/test/core/dynamic_evaluation.test.ts new file mode 100644 index 00000000..ade7275e --- /dev/null +++ b/packages/tempo/test/core/dynamic_evaluation.test.ts @@ -0,0 +1,96 @@ +import { Tempo } from '#tempo'; + +describe('Dynamic Context Evaluation in Tempo Core', () => { + beforeEach(() => { + Tempo[Symbol.dispose](); + }); + + test('should dynamically evaluate timezone supplier on instance creation', () => { + let currentTz = 'Australia/Sydney'; + const getTz = () => currentTz; + + const t1 = new Tempo('2026-06-01T12:00:00', { timeZone: getTz }); + expect(t1.tz).toBe('Australia/Sydney'); + expect(t1.config.timeZone).toBe('Australia/Sydney'); + + currentTz = 'America/New_York'; + const t2 = new Tempo('2026-06-01T12:00:00', { timeZone: getTz }); + expect(t2.tz).toBe('America/New_York'); + expect(t2.config.timeZone).toBe('America/New_York'); + }); + + test('should dynamically evaluate locale supplier on instance creation', () => { + let currentLocale = 'fr-FR'; + const getLocale = () => currentLocale; + + const t1 = new Tempo('2026-06-01T12:00:00', { locale: getLocale }); + expect(t1.locale).toBe('fr-FR'); + expect(t1.config.locale).toBe('fr-FR'); + + currentLocale = 'de-DE'; + const t2 = new Tempo('2026-06-01T12:00:00', { locale: getLocale }); + expect(t2.locale).toBe('de-DE'); + expect(t2.config.locale).toBe('de-DE'); + }); + + test('should dynamically evaluate calendar supplier on instance creation', () => { + let currentCal = 'iso8601'; + const getCal = () => currentCal; + + const t1 = new Tempo('2026-06-01T12:00:00', { calendar: getCal }); + expect(t1.cal).toBe('iso8601'); + expect(t1.config.calendar).toBe('iso8601'); + + currentCal = 'gregory'; + const t2 = new Tempo('2026-06-01T12:00:00', { calendar: getCal }); + expect(t2.cal).toBe('gregory'); + expect(t2.config.calendar).toBe('gregory'); + }); + + test('should dynamically evaluate sphere supplier on instance creation', () => { + let currentSphere: 'north' | 'south' = 'south'; + const getSphere = () => currentSphere; + + const t1 = new Tempo('2026-06-01T12:00:00', { sphere: getSphere }); + expect(t1.sphere).toBe('south'); + expect(t1.config.sphere).toBe('south'); + + currentSphere = 'north'; + const t2 = new Tempo('2026-06-01T12:00:00', { sphere: getSphere }); + expect(t2.sphere).toBe('north'); + expect(t2.config.sphere).toBe('north'); + }); + + test('should dynamically evaluate anchor supplier on instance creation', () => { + let anchorYear = 2024; + const getAnchor = () => new Tempo(`${anchorYear}-01-01T00:00:00Z`); + + const t1 = new Tempo({ days: 5 }, { anchor: getAnchor }); + expect(t1.yy).toBe(2024); + expect(t1.dd).toBe(6); + + anchorYear = 2028; + const t2 = new Tempo({ days: 5 }, { anchor: getAnchor }); + expect(t2.yy).toBe(2028); + expect(t2.dd).toBe(6); + }); + + test('should dynamically evaluate suppliers in Tempo.init', () => { + let globalTz = 'Asia/Tokyo'; + Tempo.init({ timeZone: () => globalTz, locale: () => 'ja-JP' }); + + expect(Tempo.config.timeZone).toBe('Asia/Tokyo'); + expect(Tempo.config.locale).toBe('ja-JP'); + }); + + test('should dynamically evaluate suppliers in Tempo.create sandbox', () => { + let sandboxTz = 'Europe/London'; + const Sandbox = Tempo.create({ + timeZone: () => sandboxTz, + locale: () => 'en-GB', + }); + + expect(Sandbox.config.timeZone).toBe('Europe/London'); + expect(Sandbox.config.locale).toBe('en-GB'); + }); +}); From d8f858aeabbe810ad6ee642256741cf9794f00a8 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 19 Aug 2026 16:26:42 +1000 Subject: [PATCH 05/14] PR 1st review --- .github/FUNDING.yml | 1 - .github/workflows/publish.yml | 6 +- packages/library/CHANGELOG.md | 6 +- .../library/src/common/evaluation.library.ts | 69 +++++----- packages/library/src/common/proxy.library.ts | 18 +-- packages/library/src/common/type.library.ts | 18 ++- .../library/test/evaluation.library.test.ts | 62 +++++++++ .../.setup/community-plugin-template.md | 2 +- packages/plugins/ai/README.md | 2 +- packages/plugins/ai/doc/init.md | 2 +- packages/plugins/ai/doc/security.md | 2 +- packages/plugins/ai/src/core/support.ts | 17 ++- packages/plugins/ai/src/core/transport.ts | 1 + .../doc/6-utility-library/tempo.library.md | 22 ++-- .../dynamic-functional-context-evaluation.md | 121 ------------------ packages/tempo/src/support/support.init.ts | 13 +- packages/tempo/src/tempo.class.ts | 42 +++++- .../test/core/dynamic_evaluation.test.ts | 34 +++-- 18 files changed, 216 insertions(+), 222 deletions(-) delete mode 100644 .github/FUNDING.yml delete mode 100644 packages/tempo/plan/dynamic-functional-context-evaluation.md 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 index eaf27dc3..7a855a5c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,8 +12,8 @@ on: - '@magmacomputing/tempo-fns' - '@magmacomputing/tempo-plugin-ai' - '@magmacomputing/tempo-plugin-astro' - - '@magmacomputing/tempo-plugin-finance' - '@magmacomputing/tempo-plugin-batch' + - '@magmacomputing/tempo-plugin-finance' - '@magmacomputing/tempo-plugin-snap' - '@magmacomputing/tempo-plugin-sync' - 'all' @@ -30,6 +30,10 @@ permissions: 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: diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index ceac6636..226b12e6 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -8,9 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.12.0] - 2026-08-19 ### Added -- **Dynamic Evaluation Utilities (`evaluation.library`)**: Added `#library/evaluation.library.js` containing `evaluate(value, fallback?)`, `evaluateAsync(value, fallback?)`, `evaluateConfig(config)`, and `evaluateConfigAsync(config)`. Supports synchronous and asynchronous value/supplier evaluation with fallback defaults for undefined resolutions. -- **Dynamic Property Proxy (`proxy.library`)**: Added `dynamicProxy(target, overrides)` for zero-overhead dynamic property proxying with on-access evaluation of functional overrides. -- **Standardized Evaluation Types (`type.library`)**: Added `Evaluable`, `AsyncEvaluable`, `EvaluableRecord`, `AsyncEvaluableRecord`, and unified `Resolved` on top of `Awaited`. +- **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`. ## [3.11.1] - 2026-08-06 diff --git a/packages/library/src/common/evaluation.library.ts b/packages/library/src/common/evaluation.library.ts index 8c2fa853..59cb8ee3 100644 --- a/packages/library/src/common/evaluation.library.ts +++ b/packages/library/src/common/evaluation.library.ts @@ -1,55 +1,56 @@ import { isFunction, isNullish, isObject } from '#library/assertion.library.js'; -import type { Evaluable, AsyncEvaluable } from '#library/type.library.js'; +import type { Evaluable, AsyncEvaluable, Evaluated, AsyncEvaluated } from '#library/type.library.js'; /** - * Evaluates a synchronous scalar or supplier function. - * If the input is a function, it is invoked with zero arguments and its return value is returned. - * If the resolved value is undefined and a fallback is provided, the fallback is evaluated and returned. - * Otherwise, the scalar value is returned as-is. - * Any exception thrown by the supplier function bubbles directly to the caller. + * 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 value - The scalar value or synchronous supplier function to evaluate - * @param fallback - Optional default value or supplier to evaluate if `value` is undefined - * @returns The resolved synchronous value + * @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, () => 'dynamic-fallback'); // 'dynamic-fallback' + * evaluate(undefined, () => undefined, () => 'dynamic-fallback', 'final'); // 'dynamic-fallback' * ``` */ -export function evaluate(value: Evaluable | undefined, fallback?: Evaluable): T { - const resolved = isFunction(value) ? (value as () => T)() : value; - if (resolved !== undefined) return resolved as T; - return (isFunction(fallback) - ? (fallback as () => T)() - : fallback) as T; +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 a synchronous or asynchronous scalar, Promise, or supplier function. - * If the input is a function, it is invoked and its result is awaited. - * If the resolved value is undefined and a fallback is provided, the fallback is evaluated and returned. - * Otherwise, the scalar or Promise is resolved and returned. - * Any exception or rejection bubbles directly to the caller. + * Evaluates candidate synchronous or asynchronous scalars, Promises, or supplier functions in order, returning the first defined result (async lazy coalesce). + * If a candidate is a function, it is invoked and its result awaited. + * Candidates after the first defined value are never evaluated (short-circuiting). + * If all candidates evaluate to undefined, returns undefined. + * Any exception or rejection thrown by an evaluated supplier bubbles directly to the caller. * - * @param value - The scalar value, Promise, or supplier function to evaluate - * @param fallback - Optional default value, Promise, or async supplier to evaluate if `value` is undefined - * @returns A Promise resolving to the evaluated value + * @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(async () => fetchSecret()); // 'secret' - * await evaluateAsync(undefined, async () => fetchDefault()); // 'default' + * await evaluateAsync(undefined, async () => fetchSecret(), 'defaultKey'); // 'secret' * ``` */ -export async function evaluateAsync(value: AsyncEvaluable | undefined, fallback?: AsyncEvaluable): Promise { - const resolved = isFunction(value) ? await (value as () => T | Promise)() : await value; - if (resolved !== undefined) return resolved as T; - return (isFunction(fallback) - ? await (fallback as () => T | Promise)() - : await fallback) as T; +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 { + for (const val of values) { + const resolved = isFunction(val) ? await (val as () => T | Promise)() : await val; + if (resolved !== undefined) return resolved as T; + } + return undefined; } /** @@ -67,7 +68,7 @@ export async function evaluateAsync(value: AsyncEvaluable | undefined, fal * // { timeZone: 'America/New_York', locale: 'en-US' } * ``` */ -export function evaluateConfig(config: T): { [K in keyof T]: T[K] extends () => infer R ? R : T[K] } { +export function evaluateConfig(config: T): Evaluated { if (isNullish(config) || !isObject(config)) return config as any; const result = { ...config } as any; @@ -94,7 +95,7 @@ export function evaluateConfig(config: T): { [K in keyof T]: T * // { key: 'sk-...', url: 'https://api.openai.com/v1' } * ``` */ -export async function evaluateConfigAsync(config: T): Promise<{ [K in keyof T]: T[K] extends () => infer R ? Awaited : Awaited }> { +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)[]; diff --git a/packages/library/src/common/proxy.library.ts b/packages/library/src/common/proxy.library.ts index d9ad8fe4..9c867ea9 100644 --- a/packages/library/src/common/proxy.library.ts +++ b/packages/library/src/common/proxy.library.ts @@ -3,7 +3,7 @@ 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, isObject } from '#library/assertion.library.js'; -import { registerType, type Constructor } from '#library/type.library.js'; +import { registerType, type Constructor, type Evaluated } from '#library/type.library.js'; const boundMethodCache = new WeakMap>(); @@ -274,15 +274,18 @@ export function indexedArray( * proxy.locale; // 'en-US' * ``` */ -export function dynamicProxy(target: T): { [K in keyof T]: T[K] extends () => infer R ? R : T[K] } { +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); - return (isFunction(val) && k !== 'constructor') - ? (val as () => any)() - : val; + 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); @@ -291,10 +294,7 @@ export function dynamicProxy(target: T): { [K in keyof T]: T[K return Reflect.ownKeys(t); }, getOwnPropertyDescriptor(t, k) { - const desc = Reflect.getOwnPropertyDescriptor(t, k); - return (!desc) - ? undefined - : { ...desc, configurable: true }; + return Reflect.getOwnPropertyDescriptor(t, k); }, }) as any; } diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index 20099683..4a9320fa 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -461,9 +461,9 @@ export type Extend = T & { [P in K]: export type Evaluable = T | (() => T); /** - * Represents a value that can either be a direct scalar, a synchronous supplier function, or an asynchronous supplier function. + * Represents a value that can either be a direct scalar, a Promise, a synchronous supplier function, or an asynchronous supplier function. */ -export type AsyncEvaluable = T | (() => T | Promise); +export type AsyncEvaluable = T | Promise | (() => T | Promise); /** * Maps an object type so that each property value can be provided as an `Evaluable`. @@ -486,3 +486,17 @@ 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/test/evaluation.library.test.ts b/packages/library/test/evaluation.library.test.ts index a6c416e1..3c436be7 100644 --- a/packages/library/test/evaluation.library.test.ts +++ b/packages/library/test/evaluation.library.test.ts @@ -31,6 +31,21 @@ describe('evaluation.library', () => { 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'); @@ -60,12 +75,33 @@ describe('evaluation.library', () => { 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'); @@ -168,5 +204,31 @@ describe('evaluation.library', () => { 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/plugins/.setup/community-plugin-template.md b/packages/plugins/.setup/community-plugin-template.md index ed453295..056bb9e9 100644 --- a/packages/plugins/.setup/community-plugin-template.md +++ b/packages/plugins/.setup/community-plugin-template.md @@ -145,7 +145,7 @@ 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 automated provenance releases: +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: diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md index 0f9cc479..d5ac6a86 100644 --- a/packages/plugins/ai/README.md +++ b/packages/plugins/ai/README.md @@ -77,7 +77,7 @@ For complete API references, architecture guides, and advanced examples: ## 🔒 Security, Privacy & Transparency -* 🌐 **Direct Provider Communication**: All requests are dispatched directly from your application runtime to the official endpoints of your configured AI providers (OpenAI, Google Gemini, Anthropic, Groq, or local Ollama). There are **no intermediary proxies**, **no telemetry**, and **zero tracking**. +* 🌐 **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. diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md index 73c3f2c2..a66a03c3 100644 --- a/packages/plugins/ai/doc/init.md +++ b/packages/plugins/ai/doc/init.md @@ -65,7 +65,7 @@ await initAI({ ### Dynamic Provider Credentials & Context Suppliers -All provider credential fields (`key`, `url`, `model`) as well as global context settings (`timeZone`, `locale`, `calendar`, `sphere`) accept synchronous or asynchronous **supplier functions** (`Evaluable` / `AsyncEvaluable`). +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: diff --git a/packages/plugins/ai/doc/security.md b/packages/plugins/ai/doc/security.md index 7f53d953..5ebedb6b 100644 --- a/packages/plugins/ai/doc/security.md +++ b/packages/plugins/ai/doc/security.md @@ -104,7 +104,7 @@ const rawReasoning = result.reasoning; * 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` and `url` parameters support synchronous and asynchronous supplier functions (`() => Promise | string`). +* 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({ diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 557d4277..a1478577 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -57,13 +57,13 @@ export function resolveFullContext( fallbackTempo?: Tempo | null, ): ResolvedAiContext { const resolvedOptions = (Tempo as any).options ?? {}; - const rawTz = options?.timeZone !== undefined ? evaluate(options.timeZone) : fallbackTempo?.tz ?? evaluate(resolvedOptions.timeZone) ?? evaluate(_state.config.timeZone) ?? 'UTC'; + const rawTz = evaluate(options?.timeZone, fallbackTempo?.tz, resolvedOptions.timeZone, _state.config.timeZone, 'UTC'); const tz = String(rawTz || 'UTC'); - const optLoc = options?.locale !== undefined ? evaluate(options.locale) : undefined; + const optLoc = evaluate(options?.locale); const fbLoc = fallbackTempo?.locale; - const resLoc = resolvedOptions.locale !== undefined ? evaluate(resolvedOptions.locale) : undefined; - const cfgLoc = _state.config.locale !== undefined ? evaluate(_state.config.locale) : undefined; + const resLoc = evaluate(resolvedOptions.locale); + const cfgLoc = evaluate(_state.config.locale); const rawLoc = (optLoc !== undefined && (Array.isArray(optLoc) ? optLoc.length > 0 : Boolean(optLoc))) ? optLoc @@ -73,10 +73,10 @@ export function resolveFullContext( const firstLoc = Array.isArray(rawLoc) ? rawLoc[0] : rawLoc; const loc = asText(firstLoc, 'en-US'); - const rawCal = options?.calendar !== undefined ? evaluate(options.calendar) : fallbackTempo?.cal ?? evaluate(resolvedOptions.calendar) ?? evaluate(_state.config.calendar) ?? 'iso8601'; + const rawCal = evaluate(options?.calendar, fallbackTempo?.cal, resolvedOptions.calendar, _state.config.calendar, 'iso8601'); const cal = String(rawCal || 'iso8601'); - const rawSph = options?.sphere !== undefined ? evaluate(options.sphere) : fallbackTempo?.sphere ?? evaluate(resolvedOptions.sphere) ?? evaluate(_state.config.sphere) ?? 'north'; + 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 }; @@ -126,15 +126,14 @@ export function resolveAnchorTempo( context: ResolvedAiContext, options?: { defaultAnchor?: unknown; operationName?: string } | undefined, ): Tempo { - const evaluatedAnchor = evaluate(anchor as any); + const evaluatedAnchor = evaluate(anchor as any, options?.defaultAnchor as any); const { tz, loc, cal, sph } = context; if (Tempo.isTempo(evaluatedAnchor)) return evaluatedAnchor.tz === tz ? evaluatedAnchor : evaluatedAnchor.set({ timeZone: tz }); - const targetValue = isDefined(evaluatedAnchor) ? evaluatedAnchor : evaluate(options?.defaultAnchor as any); let instance: Tempo; try { - instance = new Tempo(targetValue as any, { + instance = new Tempo(evaluatedAnchor as any, { timeZone: tz, locale: loc, calendar: cal, diff --git a/packages/plugins/ai/src/core/transport.ts b/packages/plugins/ai/src/core/transport.ts index 59af1fcf..b6cf75b0 100644 --- a/packages/plugins/ai/src/core/transport.ts +++ b/packages/plugins/ai/src/core/transport.ts @@ -179,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'; diff --git a/packages/tempo/doc/6-utility-library/tempo.library.md b/packages/tempo/doc/6-utility-library/tempo.library.md index 0bf65aaf..da3f796d 100644 --- a/packages/tempo/doc/6-utility-library/tempo.library.md +++ b/packages/tempo/doc/6-utility-library/tempo.library.md @@ -64,26 +64,26 @@ Tempo provides a specialized wrapper around `Promise.withResolvers()` called `Pl Tempo exports zero-overhead functional evaluation utilities for resolving static values, lazy suppliers, and dynamic object proxies: -* **`evaluate(value, fallback?)`:** Synchronously resolves a static value or zero-argument supplier function (`() => T`). If the result is `undefined`, evaluates and returns the optional fallback. -* **`evaluateAsync(value, fallback?)`:** Asynchronously resolves static values, sync/async suppliers, or Promises (`() => Promise | T`). -* **`evaluateConfig(config)` / `evaluateConfigAsync(config)`:** Deeply resolves all `Evaluable` property suppliers across a configuration dictionary. -* **`dynamicProxy(target, overrides)`:** Wraps a target object with dynamic property traps that evaluate functional overrides lazily on-access. +* **`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 fallback -const tz = evaluate(() => process.env.TZ, 'UTC'); // Returns env TZ or 'UTC' +// 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(async () => await vault.getKey('openai')); +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() } -); +const dynamicSettings = dynamicProxy({ + timeout: 5000, + token: () => getActiveToken() +}); ```
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/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index da10295d..b986005f 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -227,6 +227,7 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea if (isUndefined(optVal)) return; state.userProvidedKeys.add(optKey); + const preserveSupplier = isFunction(optVal) && state.config?.scope !== 'local'; const evaluatedVal = (['timeZone', 'calendar', 'locale', 'sphere', 'pivot'].includes(optKey)) ? evaluate(optVal) : optVal; @@ -240,19 +241,19 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea case 'timeZone': { const zone = String(arg.value).toLowerCase(); const resolvedZone = options.timeZones?.[zone] ?? state.config.timeZones?.[zone] ?? enums.TIMEZONE[zone] ?? normalizeUtcOffset(String(arg.value)); - setProperty(state.config, 'timeZone', resolvedZone); + setProperty(state.config, 'timeZone', preserveSupplier ? optVal : resolvedZone); break; } case 'calendar': - setProperty(state.config, 'calendar', String(arg.value)); + setProperty(state.config, 'calendar', preserveSupplier ? optVal : String(arg.value)); break; case 'locale': { const resolvedLocales = asArray(arg.value).map(l => canonicalLocale(String(l))).filter(Boolean) as string[]; if (resolvedLocales.length > 0) { const finalLocale = resolvedLocales.length === 1 ? resolvedLocales[0] : resolvedLocales; - setProperty(state.config, 'locale', finalLocale); + setProperty(state.config, 'locale', preserveSupplier ? optVal : finalLocale); if (resolvedLocales.every(locale => locale.split('-')[0] === 'en')) clearLocalization(); } break; @@ -345,7 +346,7 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea break; case 'sphere': - setProperty(state.config, 'sphere', arg.value); + setProperty(state.config, 'sphere', preserveSupplier ? optVal : arg.value); break; case 'catch': @@ -431,9 +432,9 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea } }); - const locale = state.config.locale; + const locale = evaluate(state.config.locale); if (locale) { - const locales = asArray(locale); + const locales = asArray(locale).map(l => isString(l) ? l : undefined).filter(Boolean) as string[]; if (locales.length > 0 && !locales.every(l => l.split('-')[0] === 'en')) { const { snippets, monthMap, weekdayMap, events } = generateLocalizedSnippets(locales); state.parse.monthMap = monthMap; diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index bfe0241b..a34ee7fd 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -12,7 +12,7 @@ import { pad, trimAll } from '#library/string.library.js'; import { getType } from '#library/type.library.js'; import { clone } from '#library/serialize.library.js'; import { isEmpty, isDefined, isUndefined, isString, isObject, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike, isNumber } from '#library/assertion.library.js'; -import { instant, getTemporalIds } from '#library/temporal.library.js'; +import { instant, getTemporalIds, normalizeUtcOffset } from '#library/temporal.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale, getISOWeekOfYear } from '#library/international.library.js'; import { evaluate } from '#library/evaluation.library.js'; import { LOG } from '#library/logger.class.js'; @@ -257,7 +257,10 @@ export class Tempo { /** try to infer hemisphere using the timezone's daylight-savings setting */ static #setSphere = (shape: Internal.State, options: t.Options): t.COMPASS | undefined => { - if (isDefined(options.sphere)) return evaluate(options.sphere); + if (isDefined(options.sphere)) { + const evaluatedSphere = evaluate(options.sphere); + if (isDefined(evaluatedSphere)) return evaluatedSphere; + } const tz = options.timeZone; if (isDefined(tz)) { @@ -267,7 +270,7 @@ export class Tempo { if (isDefined(sphere)) return sphere; } - return isDefined(shape.config?.sphere) ? shape.config.sphere : undefined; + return isDefined(shape.config?.sphere) ? evaluate(shape.config.sphere) : undefined; } /** determine if we have a {timeZone} which prefers {mdy} date-order */ @@ -869,9 +872,9 @@ export class Tempo { // 2. Establish context and keys const sys = getDateTimeFormat(); - const timeZone = evaluate(options.timeZone) ?? sys.timeZone; - const calendar = evaluate(options.calendar) ?? sys.calendar; const config = state.config; + const timeZone = options.timeZone ?? config.timeZone ?? sys.timeZone; + const calendar = options.calendar ?? config.calendar ?? sys.calendar; let discovery = options.discovery ?? Symbol.keyFor($Tempo) as string; const storeKey = options.store || config.store || Symbol.keyFor($Tempo) as string; @@ -885,8 +888,8 @@ export class Tempo { const userDiscovery = (globalThis as any)[normalizedDiscovery] as Internal.Discovery; // Resolve locale if missing or invalid - const currentLocale = evaluate(options.locale ?? config.locale); - const locale = (!currentLocale || currentLocale === 'en-US') ? Tempo.#locale(currentLocale) : currentLocale; + const currentLocale = options.locale ?? config.locale; + const locale = currentLocale ?? Tempo.#locale(); if (!hasOwn(config, 'get')) { Object.defineProperty(config, 'get', { @@ -1722,6 +1725,31 @@ export class Tempo { Object.assign(this.#local.config, { scope: 'local' }); + // Evaluate and snapshot dynamic context suppliers for this specific Tempo instance + const evaluatedTz = evaluate(options.timeZone, classState.config.timeZone); + if (isDefined(evaluatedTz)) { + const zone = String(evaluatedTz).toLowerCase(); + const resolvedZone = (this.constructor as any).timeZones?.[zone] ?? classState.config.timeZones?.[zone] ?? enums.TIMEZONE[zone] ?? normalizeUtcOffset(String(evaluatedTz)); + setProperty(this.#local.config, 'timeZone', resolvedZone); + } + + const evaluatedCal = evaluate(options.calendar, classState.config.calendar); + if (isDefined(evaluatedCal)) + setProperty(this.#local.config, 'calendar', String(evaluatedCal)); + + const evaluatedLoc = evaluate(options.locale, classState.config.locale); + if (isDefined(evaluatedLoc)) { + const resolvedLocales = asArray(evaluatedLoc).map(l => canonicalLocale(String(l))).filter(Boolean) as string[]; + if (resolvedLocales.length > 0) { + const finalLocale = resolvedLocales.length === 1 ? resolvedLocales[0] : resolvedLocales; + setProperty(this.#local.config, 'locale', finalLocale); + } + } + + const evaluatedSphere = evaluate(options.sphere, classState.config.sphere); + if (isDefined(evaluatedSphere)) + setProperty(this.#local.config, 'sphere', evaluatedSphere); + this.#local.parse = markConfig(Object.create(classState.parse)); this.#local.parse.event = { ...classState.parse.event }; this.#local.parse.period = { ...classState.parse.period }; diff --git a/packages/tempo/test/core/dynamic_evaluation.test.ts b/packages/tempo/test/core/dynamic_evaluation.test.ts index ade7275e..68c354db 100644 --- a/packages/tempo/test/core/dynamic_evaluation.test.ts +++ b/packages/tempo/test/core/dynamic_evaluation.test.ts @@ -75,22 +75,28 @@ describe('Dynamic Context Evaluation in Tempo Core', () => { expect(t2.dd).toBe(6); }); - test('should dynamically evaluate suppliers in Tempo.init', () => { - let globalTz = 'Asia/Tokyo'; - Tempo.init({ timeZone: () => globalTz, locale: () => 'ja-JP' }); + test('should evaluate global suppliers when creating new Tempo instances after supplier changes', () => { + let dynamicTz = 'Asia/Tokyo'; + let dynamicLocale = 'ja-JP'; + Tempo.init({ + timeZone: () => dynamicTz, + locale: () => dynamicLocale + }); - expect(Tempo.config.timeZone).toBe('Asia/Tokyo'); - expect(Tempo.config.locale).toBe('ja-JP'); - }); + const t1 = new Tempo('2026-06-01T12:00:00'); + expect(t1.tz).toBe('Asia/Tokyo'); + expect(t1.locale).toBe('ja-JP'); - test('should dynamically evaluate suppliers in Tempo.create sandbox', () => { - let sandboxTz = 'Europe/London'; - const Sandbox = Tempo.create({ - timeZone: () => sandboxTz, - locale: () => 'en-GB', - }); + // Change supplier return values + dynamicTz = 'America/Chicago'; + dynamicLocale = 'es-ES'; + + const t2 = new Tempo('2026-06-01T12:00:00'); + expect(t2.tz).toBe('America/Chicago'); + expect(t2.locale).toBe('es-ES'); - expect(Sandbox.config.timeZone).toBe('Europe/London'); - expect(Sandbox.config.locale).toBe('en-GB'); + // Verify existing instance t1 remained immutable + expect(t1.tz).toBe('Asia/Tokyo'); + expect(t1.locale).toBe('ja-JP'); }); }); From 82a196f8c594d239e5168d07f9492ae1d37bdfa3 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 19 Aug 2026 16:51:10 +1000 Subject: [PATCH 06/14] PR 2nd review --- .github/workflows/publish.yml | 60 +++++++++++++++---- .../library/src/common/evaluation.library.ts | 12 ++-- packages/library/src/common/type.library.ts | 4 +- .../library/test/evaluation.library.test.ts | 39 ++++++++++++ packages/tempo/src/tempo.class.ts | 8 ++- .../test/core/dynamic_evaluation.test.ts | 60 +++++++++++++++++++ 6 files changed, 164 insertions(+), 19 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7a855a5c..e123c190 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -57,11 +57,19 @@ jobs: - 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:plugins + npm run build --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 --if-present - name: Run Tests run: npm run test @@ -78,17 +86,47 @@ jobs: echo "🔍 DRY RUN MODE ACTIVATED — Simulating publication..." fi + WORKSPACES=( + "@magmacomputing/tempo" + "@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" + ) + + publish_workspace() { + local pkg="$1" + local ver + ver=$(npm pkg get version --workspace="$pkg" | awk '{print $NF}' | tr -d '"') + 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 8 package versions..." + for pkg in "${WORKSPACES[@]}"; do + ver=$(npm pkg get version --workspace="$pkg" | awk '{print $NF}' | tr -d '"') + echo " - $pkg: $ver" + if [ -z "$ver" ] || [ "$ver" = "null" ]; then + echo "❌ Failed to resolve version for $pkg" + exit 1 + fi + done + echo "🚀 Publishing all workspaces..." - npm publish --workspace=@magmacomputing/tempo $PROVENANCE_FLAG - npm publish --workspace=@magmacomputing/tempo-fns $PROVENANCE_FLAG - npm publish --workspace=@magmacomputing/tempo-plugin-ai $PROVENANCE_FLAG - npm publish --workspace=@magmacomputing/tempo-plugin-astro $PROVENANCE_FLAG - npm publish --workspace=@magmacomputing/tempo-plugin-finance $PROVENANCE_FLAG - npm publish --workspace=@magmacomputing/tempo-plugin-batch $PROVENANCE_FLAG - npm publish --workspace=@magmacomputing/tempo-plugin-snap $PROVENANCE_FLAG - npm publish --workspace=@magmacomputing/tempo-plugin-sync $PROVENANCE_FLAG + for pkg in "${WORKSPACES[@]}"; do + publish_workspace "$pkg" + done else - echo "🚀 Publishing workspace: $SELECTED_PKG" - npm publish --workspace="$SELECTED_PKG" $PROVENANCE_FLAG + publish_workspace "$SELECTED_PKG" fi diff --git a/packages/library/src/common/evaluation.library.ts b/packages/library/src/common/evaluation.library.ts index 59cb8ee3..78918bda 100644 --- a/packages/library/src/common/evaluation.library.ts +++ b/packages/library/src/common/evaluation.library.ts @@ -1,4 +1,4 @@ -import { isFunction, isNullish, isObject } from '#library/assertion.library.js'; +import { isFunction, isNullish, isObject, isPromise } from '#library/assertion.library.js'; import type { Evaluable, AsyncEvaluable, Evaluated, AsyncEvaluated } from '#library/type.library.js'; /** @@ -30,10 +30,10 @@ export function evaluate(...values: (Evaluable | undefined)[]): T | undefi /** * Evaluates candidate synchronous or asynchronous scalars, Promises, or supplier functions in order, returning the first defined result (async lazy coalesce). - * If a candidate is a function, it is invoked and its result awaited. - * Candidates after the first defined value are never evaluated (short-circuiting). + * 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 supplier bubbles directly to the caller. + * 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 @@ -46,6 +46,10 @@ export function evaluate(...values: (Evaluable | undefined)[]): T | undefi 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 { + for (const val of values) + if (!isFunction(val) && isFunction((val as any)?.then)) + (val as PromiseLike).then(undefined, () => {}); + for (const val of values) { const resolved = isFunction(val) ? await (val as () => T | Promise)() : await val; if (resolved !== undefined) return resolved as T; diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index 4a9320fa..227b69f8 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -461,9 +461,9 @@ export type Extend = T & { [P in K]: export type Evaluable = T | (() => T); /** - * Represents a value that can either be a direct scalar, a Promise, a synchronous supplier function, or an asynchronous supplier function. + * 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 | Promise | (() => T | Promise); +export type AsyncEvaluable = T | PromiseLike | (() => T | PromiseLike); /** * Maps an object type so that each property value can be provided as an `Evaluable`. diff --git a/packages/library/test/evaluation.library.test.ts b/packages/library/test/evaluation.library.test.ts index 3c436be7..f269da8b 100644 --- a/packages/library/test/evaluation.library.test.ts +++ b/packages/library/test/evaluation.library.test.ts @@ -1,5 +1,6 @@ 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()', () => { @@ -108,6 +109,44 @@ describe('evaluation.library', () => { }; 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'); + }); }); describe('evaluateConfig()', () => { diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index a34ee7fd..54944923 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -373,7 +373,8 @@ export class Tempo { const parentSphere = Object.getPrototypeOf(shape.config).sphere; if (newSphere !== parentSphere) shape.config.sphere = newSphere; } else { - shape.config.sphere = newSphere; + if (!isFunction(shape.config.sphere)) + shape.config.sphere = newSphere; } const oldLayout = shape.parse.layout; @@ -1746,7 +1747,10 @@ export class Tempo { } } - const evaluatedSphere = evaluate(options.sphere, classState.config.sphere); + const hasExplicitSphere = isDefined(options.sphere) || classState.userProvidedKeys?.has('sphere'); + const evaluatedSphere = hasExplicitSphere + ? evaluate(options.sphere, classState.config.sphere) + : (this.#local.config.timeZone ? getHemisphere(String(this.#local.config.timeZone)) : undefined) ?? evaluate(classState.config.sphere); if (isDefined(evaluatedSphere)) setProperty(this.#local.config, 'sphere', evaluatedSphere); diff --git a/packages/tempo/test/core/dynamic_evaluation.test.ts b/packages/tempo/test/core/dynamic_evaluation.test.ts index 68c354db..55ff9755 100644 --- a/packages/tempo/test/core/dynamic_evaluation.test.ts +++ b/packages/tempo/test/core/dynamic_evaluation.test.ts @@ -99,4 +99,64 @@ describe('Dynamic Context Evaluation in Tempo Core', () => { expect(t1.tz).toBe('Asia/Tokyo'); expect(t1.locale).toBe('ja-JP'); }); + + test('should evaluate global sphere supplier when creating new Tempo instances after supplier changes', () => { + let dynamicSphere: 'north' | 'south' = 'south'; + Tempo.init({ + sphere: () => dynamicSphere, + }); + + expect(typeof (Tempo.config.sphere as any)).toBe('function'); + expect((Tempo.config.sphere as any)()).toBe('south'); + + const t1 = new Tempo('2026-06-01T12:00:00'); + expect(t1.sphere).toBe('south'); + expect(t1.config.sphere).toBe('south'); + + dynamicSphere = 'north'; + expect((Tempo.config.sphere as any)()).toBe('north'); + + const t2 = new Tempo('2026-06-01T12:00:00'); + expect(t2.sphere).toBe('north'); + expect(t2.config.sphere).toBe('north'); + + // Verify existing instance t1 remained immutable + expect(t1.sphere).toBe('south'); + expect(t1.config.sphere).toBe('south'); + }); + + test('should preserve static sphere values during initialization', () => { + Tempo.init({ + sphere: 'south', + }); + + expect(Tempo.config.sphere).toBe('south'); + + const t = new Tempo('2026-06-01T12:00:00'); + expect(t.sphere).toBe('south'); + expect(t.config.sphere).toBe('south'); + }); + + test('should infer sphere from dynamic global timeZone supplier when sphere is not explicitly set', () => { + let dynamicTz = 'Australia/Sydney'; + Tempo.init({ + timeZone: () => dynamicTz, + }); + + const t1 = new Tempo('2026-06-01T12:00:00'); + expect(t1.tz).toBe('Australia/Sydney'); + expect(t1.sphere).toBe('south'); + expect(t1.config.sphere).toBe('south'); + + dynamicTz = 'America/New_York'; + const t2 = new Tempo('2026-06-01T12:00:00'); + expect(t2.tz).toBe('America/New_York'); + expect(t2.sphere).toBe('north'); + expect(t2.config.sphere).toBe('north'); + + // Verify existing instance t1 remained immutable + expect(t1.tz).toBe('Australia/Sydney'); + expect(t1.sphere).toBe('south'); + expect(t1.config.sphere).toBe('south'); + }); }); From f436f0b34a21d95caf40ae4e6c5c8c7cdbe2cdca Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 21 Aug 2026 11:48:35 +1000 Subject: [PATCH 07/14] PR v4.0.0 initial review --- .github/workflows/publish.yml | 10 +- bin/version-sync.mjs | 2 +- package-lock.json | 4 +- package.json | 3 +- packages/functions/doc/functions/index.md | 2 +- packages/functions/package.json | 4 +- packages/library/CHANGELOG.md | 3 +- packages/library/package.json | 2 +- .../library/src/common/evaluation.library.ts | 2 +- .../library/src/common/storage.library.ts | 9 + .../library/src/common/utility.library.ts | 24 +- .../library/test/evaluation.library.test.ts | 11 + packages/plugins/ai/package.json | 2 +- packages/plugins/astro/package.json | 4 +- packages/plugins/batch/package.json | 6 +- packages/plugins/finance/package.json | 8 +- packages/plugins/snap/package.json | 4 +- packages/plugins/sync/package.json | 6 +- packages/plugins/ticker/CHANGELOG.md | 40 ++ packages/plugins/ticker/LICENSE | 21 + packages/plugins/ticker/README.md | 46 ++ packages/plugins/ticker/doc/index.md | 354 ++++++++++++++ packages/plugins/ticker/package.json | 57 +++ packages/plugins/ticker/src/index.ts | 432 ++++++++++++++++++ .../plugins/ticker/test/ticker.hang.test.ts | 34 ++ .../ticker/test/ticker.patterns.test.ts | 119 +++++ .../plugins/ticker/test/ticker.pulse.test.ts | 26 ++ .../plugins/ticker/test/ticker.rrule.test.ts | 49 ++ .../plugins/ticker/test/ticker.stop.test.ts | 41 ++ .../ticker/test/ticker.term.core.test.ts | 86 ++++ .../ticker/test/ticker_cold_start.test.ts | 26 ++ packages/plugins/ticker/test/tsconfig.json | 6 + packages/plugins/ticker/tsconfig.json | 12 + packages/plugins/ticker/tsup.config.ts | 7 + packages/plugins/tsup.shared.ts | 20 - .../tempo/.vitepress/theme/data/catalog.json | 4 +- packages/tempo/CHANGELOG.md | 10 +- packages/tempo/bin/repl.ts | 9 +- .../doc/1-getting-started/installation.md | 5 +- .../tempo/doc/2-core-concepts/tempo.config.md | 5 +- .../doc/3-extending-tempo/tempo.plugin.md | 48 +- .../tempo/doc/3-extending-tempo/tempo.term.md | 4 +- .../doc/8-project-and-support/commercial.md | 25 +- .../8-project-and-support/migration-guide.md | 23 +- .../8-project-and-support/releases/v3.x.md | 21 +- packages/tempo/index.md | 2 +- packages/tempo/package.json | 23 +- packages/tempo/plan/tempo-pro-architecture.md | 73 +++ packages/tempo/public/esm_sh.index.html | 2 +- packages/tempo/rollup.config.js | 109 +---- packages/tempo/src/config/config.index.ts | 2 +- packages/tempo/src/config/config.resolve.ts | 131 +++++- packages/tempo/src/plugin-api.index.ts | 2 - .../tempo/src/plugin/extend/extend.ticker.ts | 28 -- .../src/plugin/license/license.manager.ts | 206 --------- .../src/plugin/license/license.validator.ts | 75 --- packages/tempo/src/plugin/plugin.index.ts | 1 - packages/tempo/src/plugin/term/term.type.ts | 9 - packages/tempo/src/support/support.enum.ts | 14 - packages/tempo/src/support/support.index.ts | 3 +- packages/tempo/src/support/support.init.ts | 24 +- packages/tempo/src/support/support.runtime.ts | 25 - packages/tempo/src/support/support.symbol.ts | 6 +- packages/tempo/src/support/support.util.ts | 9 - packages/tempo/src/tempo.class.ts | 143 +----- packages/tempo/src/tempo.type.ts | 45 +- packages/tempo/src/tempo.version.ts | 2 +- .../test/core/dynamic_evaluation.test.ts | 24 + .../tempo/test/plugins/license.phase1.test.ts | 133 ------ .../tempo/test/plugins/licensing.full.test.ts | 294 ------------ .../test/plugins/licensing.sandbox.test.ts | 220 --------- packages/tempo/vitest.config.ts | 24 +- 72 files changed, 1727 insertions(+), 1538 deletions(-) create mode 100644 packages/plugins/ticker/CHANGELOG.md create mode 100644 packages/plugins/ticker/LICENSE create mode 100644 packages/plugins/ticker/README.md create mode 100644 packages/plugins/ticker/doc/index.md create mode 100644 packages/plugins/ticker/package.json create mode 100644 packages/plugins/ticker/src/index.ts create mode 100644 packages/plugins/ticker/test/ticker.hang.test.ts create mode 100644 packages/plugins/ticker/test/ticker.patterns.test.ts create mode 100644 packages/plugins/ticker/test/ticker.pulse.test.ts create mode 100644 packages/plugins/ticker/test/ticker.rrule.test.ts create mode 100644 packages/plugins/ticker/test/ticker.stop.test.ts create mode 100644 packages/plugins/ticker/test/ticker.term.core.test.ts create mode 100644 packages/plugins/ticker/test/ticker_cold_start.test.ts create mode 100644 packages/plugins/ticker/test/tsconfig.json create mode 100644 packages/plugins/ticker/tsconfig.json create mode 100644 packages/plugins/ticker/tsup.config.ts create mode 100644 packages/tempo/plan/tempo-pro-architecture.md delete mode 100644 packages/tempo/src/plugin/extend/extend.ticker.ts delete mode 100644 packages/tempo/src/plugin/license/license.manager.ts delete mode 100644 packages/tempo/src/plugin/license/license.validator.ts delete mode 100644 packages/tempo/test/plugins/license.phase1.test.ts delete mode 100644 packages/tempo/test/plugins/licensing.full.test.ts delete mode 100644 packages/tempo/test/plugins/licensing.sandbox.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e123c190..767d0a6d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,6 +9,7 @@ on: type: choice options: - '@magmacomputing/tempo' + - '@magmacomputing/tempo-pro' - '@magmacomputing/tempo-fns' - '@magmacomputing/tempo-plugin-ai' - '@magmacomputing/tempo-plugin-astro' @@ -16,6 +17,7 @@ on: - '@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)' @@ -88,6 +90,7 @@ jobs: WORKSPACES=( "@magmacomputing/tempo" + "@magmacomputing/tempo-pro" "@magmacomputing/tempo-fns" "@magmacomputing/tempo-plugin-ai" "@magmacomputing/tempo-plugin-astro" @@ -95,12 +98,13 @@ jobs: "@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" | awk '{print $NF}' | tr -d '"') + 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 "Checking $pkg@$ver on npm..." if [ "$DRY_RUN" != "true" ] && npm view "$pkg@$ver" version >/dev/null 2>&1; then @@ -113,9 +117,9 @@ jobs: } if [ "$SELECTED_PKG" = "all" ]; then - echo "🔎 Preflighting all 8 package versions..." + echo "🔎 Preflighting all ${#WORKSPACES[@]} package versions..." for pkg in "${WORKSPACES[@]}"; do - ver=$(npm pkg get version --workspace="$pkg" | awk '{print $NF}' | tr -d '"') + 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" 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 27361f27..4b92f1b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.12.0", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.12.0", + "version": "4.0.0", "workspaces": [ "packages/*", "packages/plugins/*" diff --git a/package.json b/package.json index 39ab9471..cef66795 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.12.0", + "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..ce27ace3 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.12.0" }, "peerDependenciesMeta": { "@js-temporal/polyfill": { diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index 226b12e6..9f608692 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -5,12 +5,13 @@ 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). -## [3.12.0] - 2026-08-19 +## [4.0.0] - 2026-08-21 ### Added - **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. ## [3.11.1] - 2026-08-06 diff --git a/packages/library/package.json b/packages/library/package.json index 637cbef6..20680bf4 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.12.0", + "version": "4.0.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/evaluation.library.ts b/packages/library/src/common/evaluation.library.ts index 78918bda..9e4ac0f4 100644 --- a/packages/library/src/common/evaluation.library.ts +++ b/packages/library/src/common/evaluation.library.ts @@ -48,7 +48,7 @@ export function evaluateAsync(...values: (AsyncEvaluable | undefined)[]): export async function evaluateAsync(...values: (AsyncEvaluable | undefined)[]): Promise { for (const val of values) if (!isFunction(val) && isFunction((val as any)?.then)) - (val as PromiseLike).then(undefined, () => {}); + Promise.resolve(val).catch(() => {}); for (const val of values) { const resolved = isFunction(val) ? await (val as () => T | Promise)() : await val; diff --git a/packages/library/src/common/storage.library.ts b/packages/library/src/common/storage.library.ts index b075521d..433e32c4 100644 --- a/packages/library/src/common/storage.library.ts +++ b/packages/library/src/common/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/utility.library.ts index 35147e8b..5f717080 100644 --- a/packages/library/src/common/utility.library.ts +++ b/packages/library/src/common/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/test/evaluation.library.test.ts b/packages/library/test/evaluation.library.test.ts index f269da8b..86d1ea3a 100644 --- a/packages/library/test/evaluation.library.test.ts +++ b/packages/library/test/evaluation.library.test.ts @@ -147,6 +147,17 @@ describe('evaluation.library', () => { 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'); + }); }); describe('evaluateConfig()', () => { diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index abff2492..75feada6 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -22,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": { diff --git a/packages/plugins/astro/package.json b/packages/plugins/astro/package.json index a63f8a95..1a8141ff 100644 --- a/packages/plugins/astro/package.json +++ b/packages/plugins/astro/package.json @@ -23,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.12.0" }, "tempo": { "vendorVariantId": "tempo-plugin-astro", diff --git a/packages/plugins/batch/package.json b/packages/plugins/batch/package.json index c439c502..425a0894 100644 --- a/packages/plugins/batch/package.json +++ b/packages/plugins/batch/package.json @@ -22,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": { @@ -30,7 +30,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.12.0" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1" @@ -52,4 +52,4 @@ "import": "./dist/index.js" } } -} +} \ No newline at end of file diff --git a/packages/plugins/finance/package.json b/packages/plugins/finance/package.json index 40629941..3f23050f 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", @@ -43,10 +43,10 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.9.0" + "@magmacomputing/tempo": "^3.12.0" }, "devDependencies": { - "@magmacomputing/tempo": "^3.9.0", + "@magmacomputing/tempo": "^3.12.0", "vitest": "^4.1.10" }, "exports": { @@ -55,4 +55,4 @@ "import": "./dist/index.js" } } -} +} \ No newline at end of file diff --git a/packages/plugins/snap/package.json b/packages/plugins/snap/package.json index b3a23681..0ba01a17 100644 --- a/packages/plugins/snap/package.json +++ b/packages/plugins/snap/package.json @@ -22,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.12.0" }, "tempo": { "vendorVariantId": "tempo-plugin-snap", diff --git a/packages/plugins/sync/package.json b/packages/plugins/sync/package.json index 951c4f15..110bb0ac 100644 --- a/packages/plugins/sync/package.json +++ b/packages/plugins/sync/package.json @@ -22,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": { @@ -30,7 +30,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.6.1" + "@magmacomputing/tempo": "^3.12.0" }, "devDependencies": {}, "keywords": [ @@ -50,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..cfe07852 --- /dev/null +++ b/packages/plugins/ticker/CHANGELOG.md @@ -0,0 +1,40 @@ +# 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 +- **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..ddc0374e --- /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(1000, (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..4646e6d3 --- /dev/null +++ b/packages/plugins/ticker/doc/index.md @@ -0,0 +1,354 @@ +![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 standard `setInterval` or `requestAnimationFrame`, the Ticker plugin leverages Tempo's robust temporal core to ensure exact sub-millisecond precision, making it ideal for games, complex UI animations, 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' +}); +``` + +## 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..9b82c62c --- /dev/null +++ b/packages/plugins/ticker/package.json @@ -0,0 +1,57 @@ +{ + "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.12.0" + }, + "devDependencies": {}, + "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..3e8402ca --- /dev/null +++ b/packages/plugins/ticker/src/index.ts @@ -0,0 +1,432 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { isRRuleString, getNextRRuleEpoch } from '@magmacomputing/library'; +import { + enums, definePlugin, attachStatics, type TempoPlugin, + isObject, isFunction, isDefined, isUndefined, isEmpty, isNumeric, isNumber, Pledge, asArray, instant, normaliseFractionalDurations, +} from '@magmacomputing/tempo/plugin-api'; + +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 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, stopped } = t.info; + return { ticker: t, label, next, ticks, limit, interval, rrule, 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; + 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; + 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; + #current: Tempo; + #until: Tempo | undefined; + #limit: number | undefined; + #ticks = 0; + #stopped = false; + #genFirstYielded = false; + #isForward = true; + #isInstant = false; + #isShorthand = false; + #schedId: any; + #waiter: Pledge | undefined; + #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 isOptions = (obj: any) => isObject(obj) && !('epochMilliseconds' in 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 (typeof arg1 === 'string' && isRRuleString(arg1)) { + rawOptions.rrule = arg1; + } else { + const num = Number(arg1); + if (isNumeric(arg1) && Number.isFinite(num)) 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, ...rest } = rawOptions; + this.#label = label; + this.#limit = lmt; + if (rruleOption) + this.#rrule = typeof rruleOption === 'string' ? rruleOption : rruleOption.rrule; + + const isSeed = isDefined(rawOptions.seed); + const isRRule = isDefined(this.#rrule); + const isInterval = isDefined(rawOptions.seconds) && isNumber(rawOptions.seconds); + + if (isDefined(arg1) && !isInterval && !isSeed && !isRRule && !cb) { + const err = new Error(`Invalid Ticker interval or seed: ${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 (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 === 'ww' || key.startsWith('#'))) + this.#payload[key] = val; + + if (isEmpty(this.#payload) && !isRRule) { + if (isDefined(startAt) && isUndefined(this.#limit)) 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.#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('#') && typeof v === 'string' && /^[<>]/.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)); + } + + #scheduleNext() { + if (this.#stopped || this.#isInstant) return; + this.#schedId = setTimeout(() => { + if (!this.#stopped) { + this.pulse(); + this.#scheduleNext(); + } + }, this.#delayMs()); + } + + #runBootstrap() { + if (this.#listeners.size > 0 && !this.#stopped && !this.#schedId) { + const delay = this.#delayMs(); + if (delay > 0) { + this.#schedId = setTimeout(() => { + if (!this.#stopped) { + this.pulse(); + this.#scheduleNext(); + } + }, delay); + } else { + this.pulse(); + 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.#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(); + if (isDefined(this.#until)) { + const cmp = this.#TempoClass.compare(t, this.#until); + if ((this.#isForward && cmp >= 0) || (!this.#isForward && cmp <= 0)) this.stop(); + } + + 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() { + if (this.#stopped) return; + this.#stopped = true; + ACTIVE_TICKERS.delete(this.#self); + if (this.#schedId) { + clearTimeout(this.#schedId); + this.#schedId = undefined; + } + if (this.#waiter && this.#waiter.isPending) { + this.#waiter.resolve(); + this.#waiter = undefined; + } + 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, + stopped: this.#stopped, + }; + } + + async next(): Promise> { + if (this.#stopped) return { done: true, value: undefined }; + if (!this.#genFirstYielded) { + this.#genFirstYielded = true; + const delay = this.#delayMs(); + if (delay > 0) { + this.#waiter = new Pledge('Ticker.next'); + this.#schedId = setTimeout(() => this.#waiter?.resolve(), delay); + await this.#waiter; + this.#waiter = undefined; + if (this.#stopped) return { done: true, value: undefined }; + } + const t = this.pulse(); + if (this.#stopped && isDefined(this.#limit) && this.#limit === 0) return { done: true, value: undefined }; + return { done: false, value: t }; + } + if (this.#isInstant) return { done: true, value: undefined }; + const delay = this.#delayMs(); + this.#waiter = new Pledge('Ticker.next'); + this.#schedId = setTimeout(() => this.#waiter?.resolve(), delay); + await this.#waiter; + this.#waiter = undefined; + if (this.#stopped) return { done: true, value: undefined }; + const t = this.pulse(); + if (this.#stopped && isDefined(this.#limit) && this.#limit === 0) return { done: true, value: undefined }; + return { done: false, value: t }; + } + + async return(): Promise> { + this.stop(); + return { done: true, value: undefined }; + } + + async throw(e: any): Promise> { + if (this.#waiter && this.#waiter.isPending) { + this.#waiter.reject(e); + this.#waiter = undefined; + } + 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.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..67ba7c86 --- /dev/null +++ b/packages/plugins/ticker/test/ticker.patterns.test.ts @@ -0,0 +1,119 @@ +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: 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..353a23ad --- /dev/null +++ b/packages/plugins/ticker/test/ticker.pulse.test.ts @@ -0,0 +1,26 @@ +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(); + }); +}); 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..43409925 --- /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-07T00:00:00.000Z'); + 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/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 bee6f700..4f982dc4 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,13 +6,21 @@ 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). -## [3.12.0] - 2026-08-19 +## [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 ESM & JSONC Config Discovery (`resolveConfigSync`)**: Introduced zero-`await` static startup configuration discovery (`resolveConfigSync()`) utilizing `parseJSONC` for `.jsonc` / `.json` files. 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 +- **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 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/installation.md b/packages/tempo/doc/1-getting-started/installation.md index c532d0a4..5d15d2ad 100644 --- a/packages/tempo/doc/1-getting-started/installation.md +++ b/packages/tempo/doc/1-getting-started/installation.md @@ -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({ + plugins: [TickerPlugin] + }); ``` diff --git a/packages/tempo/doc/2-core-concepts/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md index e411675a..b4c75012 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: { diff --git a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md index 939af957..53b1f16a 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md @@ -77,27 +77,12 @@ 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 + plugins: [TickerPlugin] // 3. Register and activate plugin during init }); // Ticker is now available on the core Tempo class! @@ -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..bda8f429 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.term.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.term.md @@ -157,9 +157,7 @@ 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 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..5b8baad5 100644 --- a/packages/tempo/doc/8-project-and-support/migration-guide.md +++ b/packages/tempo/doc/8-project-and-support/migration-guide.md @@ -2,29 +2,20 @@ 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 57d32abb..7a801778 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.12.0", + "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/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_sh.index.html b/packages/tempo/public/esm_sh.index.html index 28994aa8..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.12.0" + "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.0.0" } } diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index 1c598c2b..aec1901c 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -4,54 +4,22 @@ import { fileURLToPath } from 'node:url'; import alias from '@rollup/plugin-alias'; import resolve from '@rollup/plugin-node-resolve'; -import { transform } from 'esbuild'; import terser from '@rollup/plugin-terser'; -import JavaScriptObfuscator from 'javascript-obfuscator'; import MagicString from 'magic-string'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const distPath = path.join(__dirname, 'dist'); -const licensePremium = process.env.TEMPO_LICENSE_PATH ? path.resolve(process.env.TEMPO_LICENSE_PATH) : undefined; -const licenseDefault = path.resolve(__dirname, './src/plugin/license/license.validator.ts'); - -const foundTsconfigPath = (() => { - if (!licensePremium) return ''; - let dir = path.dirname(licensePremium); - while (dir !== path.resolve(dir, '..')) { - const p = path.resolve(dir, 'tsconfig.json'); - if (fs.existsSync(p)) return p; - dir = path.resolve(dir, '..'); - } - return ''; -})(); - -if (licensePremium && !foundTsconfigPath) - throw new Error(`TEMPO_LICENSE_PATH is set to ${licensePremium} but no ancestor tsconfig.json was found.`); - -const isPremiumAvailable = Boolean( - licensePremium && - fs.existsSync(licensePremium) && - fs.existsSync(foundTsconfigPath) -); -const licensePath = isPremiumAvailable ? licensePremium : licenseDefault; - -if (isPremiumAvailable) { - console.log('\n\x1b[45m\x1b[37m\x1b[1m =========================================== \x1b[0m'); - console.log('\x1b[45m\x1b[37m\x1b[1m 📦 BUILDING TEMPO: 💎 PREMIUM \x1b[0m'); - console.log('\x1b[45m\x1b[37m\x1b[1m =========================================== \x1b[0m'); - console.log(`\x1b[35m🛡️ Engine: ${licensePath}\x1b[0m\n`); -} else { - console.log('\n\x1b[42m\x1b[30m\x1b[1m =========================================== \x1b[0m'); - console.log('\x1b[42m\x1b[30m\x1b[1m 📦 BUILDING TEMPO: 🍃 COMMUNITY \x1b[0m'); - console.log('\x1b[42m\x1b[30m\x1b[1m =========================================== \x1b[0m\n'); -} +console.log('\n\x1b[42m\x1b[30m\x1b[1m =========================================== \x1b[0m'); +console.log('\x1b[42m\x1b[30m\x1b[1m 📦 BUILDING TEMPO: COMMUNITY \x1b[0m'); +console.log('\x1b[42m\x1b[30m\x1b[1m =========================================== \x1b[0m\n'); /** - * Rollup Configuration for Tempo + * Rollup Configuration for Tempo Community Edition * * 1. Global IIFE Bundle: Single file for