From 6e88b525069d4377087ab63a7f11ed8260b03b11 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 27 May 2026 13:02:50 +1000 Subject: [PATCH 01/20] branch --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5664835..11cef43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "2.11.0", + "version": "2.11.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "2.11.0", + "version": "2.11.3", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 934e23d..b2b65e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.11.2", + "version": "2.11.3", "private": true, "description": "Magma Computing Monorepo", "repository": { @@ -43,4 +43,4 @@ "overrides": { "esbuild": "^0.25.0" } -} \ No newline at end of file +} From f60140f0234e96052ddd251038a730678db9c1b5 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 27 May 2026 13:04:33 +1000 Subject: [PATCH 02/20] version sync --- packages/library/package.json | 4 ++-- packages/tempo/package.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/library/package.json b/packages/library/package.json index 0857b9c..1b30ce6 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.11.2", + "version": "2.11.3", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", @@ -99,4 +99,4 @@ "optionalDependencies": { "@js-temporal/polyfill": "^0.5.1" } -} \ No newline at end of file +} diff --git a/packages/tempo/package.json b/packages/tempo/package.json index bde5bfe..1de040b 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "2.11.2", + "version": "2.11.3", "description": "The Tempo core library", "author": "Magma Computing Solutions", "license": "MIT", @@ -207,7 +207,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.11.2", + "@magmacomputing/library": "2.11.3", "@rollup/plugin-alias": "^6.0.0", "esbuild": "^0.25.12", "javascript-obfuscator": "^5.4.2", @@ -222,4 +222,4 @@ "doc": "doc", "test": "test" } -} \ No newline at end of file +} From 322fca6b76b4267fc5c9944dd34bec2b74cddc44 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 28 May 2026 09:34:30 +1000 Subject: [PATCH 03/20] implement Intl.DurationFormat --- .../src/common/international.library.ts | 14 ++++ packages/tempo/doc/migration-guide.md | 10 +++ packages/tempo/doc/tempo.config.md | 5 +- packages/tempo/doc/tempo.cookbook.md | 2 +- packages/tempo/doc/tempo.duration.md | 21 +++--- packages/tempo/src/module/module.duration.ts | 69 ++++++++++--------- packages/tempo/src/support/support.intl.ts | 14 +++- packages/tempo/src/tempo.class.ts | 21 ++++-- packages/tempo/src/tempo.type.ts | 11 +-- .../test/plugins/duration.balance.test.ts | 4 +- 10 files changed, 107 insertions(+), 64 deletions(-) diff --git a/packages/library/src/common/international.library.ts b/packages/library/src/common/international.library.ts index 42a32d6..3672300 100644 --- a/packages/library/src/common/international.library.ts +++ b/packages/library/src/common/international.library.ts @@ -21,6 +21,11 @@ const getNF = memoizeFunction((locale?: string, options?: Intl.NumberFormatOptio return new Intl.NumberFormat(locale, options); }); +/** memoized helper for Intl.DurationFormat instances */ +const getDF = memoizeFunction((locale?: string, options?: any) => { + return new (Intl as any).DurationFormat(locale, options); +}); + /** * International Cookbook * (using 'Intl' namespace objects) @@ -58,6 +63,15 @@ export function formatList(list: string[], locale?: string, type: Intl.ListForma } } +/** return a localized duration string natively (using Intl.DurationFormat) */ +export function formatDuration(duration: any, locale?: string, options?: any) { + try { + return getDF(locale, options).format(duration); + } catch (e) { + return ''; // This shouldn't be relied on if calling code does a feature check first, but it's safe + } +} + /** return a localized number string */ export function formatNumber(value: number, locale?: string, options?: Intl.NumberFormatOptions) { try { diff --git a/packages/tempo/doc/migration-guide.md b/packages/tempo/doc/migration-guide.md index c90e212..eb157c5 100644 --- a/packages/tempo/doc/migration-guide.md +++ b/packages/tempo/doc/migration-guide.md @@ -137,5 +137,15 @@ If you previously relied on `BigInt` being treated as nanoseconds, you must now new Tempo(1000n, { timeStamp: 'ns' }); ``` +## ๐Ÿ” Upcoming Deprecations (v3.0.0) + +### Internationalization Naming +To better align with ECMAScript standards (specifically `Intl.RelativeTimeFormat`), the `relativeTime` configuration option inside `intl` is deprecated. + +- **Deprecated:** `new Tempo({ intl: { relativeTime: { style: 'long' } } })` +- **Recommended:** `new Tempo({ intl: { relativeTimeFormat: { style: 'long' } } })` + +Currently, `relativeTime` is still supported and will automatically sync with `relativeTimeFormat`, but it will be entirely removed in Tempo v3.0.0. Please update your configurations. + ## ๐Ÿงช Testing and Stability v2.x has been hardened with a 100% pass rate on our regression suite. If you were relying on undocumented "quirks" or bugs in v1.x parsing, you may find that v2.x is more strict and deterministic. diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 23a068b..6b88203 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -136,8 +136,7 @@ Tempo.init({ | `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. | -| `relativeTime` | `RelativeTime` | `undefined` | Relative time formatting configuration (grouped). | -| `intl` | `IntlOptions` | `undefined` | Internationalization configuration grouping both `relativeTime` and `numberFormat`. | +| `intl` | `IntlOptions` | `undefined` | Internationalization configuration grouping `relativeTimeFormat`, `numberFormat`, and `durationFormat`. | | `event` | `Record` | Built-in aliases | Custom date aliases merged into the event registry. | | `period` | `Record` | Built-in aliases | Custom time aliases merged into the period registry. | | `snippet` | `Record` | Built-in snippets | Custom snippet patterns used to compose parse layouts. | @@ -151,8 +150,6 @@ Tempo.init({ | `mode` | `'auto' \| 'strict' \| 'defer'` | `'auto'` | Controls the hydration strategy (e.g., `defer` for Zero-Cost creation). | | `silent` | `boolean` | `false` | Suppresses console output. Combined with `catch: true` for silent failover. | | `ignore` | `string \| string[]` | `['at']` | List of noise words to be stripped before parsing. | -| `layoutOrder` | `string[]` | Built-in Order | The sequence in which layouts are attempted during parsing. | -| `preFilter` | `boolean` | `false` | Enables the Parse Planner to skip irrelevant layouts based on input classification. | | `planner` | `PlannerOptions` | `undefined` | Grouped configuration for `layoutOrder` and `preFilter`. | --- diff --git a/packages/tempo/doc/tempo.cookbook.md b/packages/tempo/doc/tempo.cookbook.md index 27a4a60..6087436 100644 --- a/packages/tempo/doc/tempo.cookbook.md +++ b/packages/tempo/doc/tempo.cookbook.md @@ -169,7 +169,7 @@ console.log(t.since()); // "1d ago" (narrow style) const rtf = new Intl.RelativeTimeFormat('fr', { style: 'long' }); for (const entry of logEntries) { // Use the new grouped API: pass the formatter's format function - console.log(new Tempo(entry.ts).since(null, { relativeTime: rtf.format.bind(rtf) })); + console.log(new Tempo(entry.ts).since(null, { relativeTimeFormat: rtf.format.bind(rtf) })); } ``` diff --git a/packages/tempo/doc/tempo.duration.md b/packages/tempo/doc/tempo.duration.md index 4b16f9d..966437e 100644 --- a/packages/tempo/doc/tempo.duration.md +++ b/packages/tempo/doc/tempo.duration.md @@ -56,16 +56,12 @@ anchor.since(birthday, 'days'); // โ†’ "13,149d ago" (deterministic) // 2. Pass a custom formatter for natural language output (e.g. "yesterday") const yesterday = anchor.add({ days: -1 }); const autoFormat = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' }); -anchor.since(yesterday, { unit: 'days', intl: { relativeTime: { format: autoFormat } } }); // โ†’ "yesterday" +anchor.since(yesterday, { unit: 'days', intl: { relativeTimeFormat: { format: autoFormat } } }); // โ†’ "yesterday" // 3. Returns an ISO 8601 Duration String if no unit is provided anchor.since(birthday); // โ†’ "-P36Y..." ``` -::: info Return Type -Because `.since()` automatically renders a localized string, it returns a primitive JavaScript `String`. Therefore, chaining `.balance()` or `.format()` (see below) onto `.since()` is not possible and will throw an error. -::: - ## The Duration Object (EDO) When you call `until()` (or `Tempo.duration()`), Tempo returns an Extended Data Object (EDO) representing the exact duration. @@ -134,27 +130,28 @@ console.log(exactDays.days); // 366 (2024 is a leap year!) Once you have a balanced duration, you can instantly render it as a highly localized, plural-aware string using the `.format()` method. -`.format()` automatically looks for the largest non-zero unit and uses `Intl.NumberFormat` to translate it perfectly into the user's language. +`.format()` natively uses `Intl.DurationFormat` (or a robust multi-unit polyfill) to render all non-zero units perfectly into the user's language. ```javascript -// Perfect for SaaS Pricing Cards! -const formatted = Tempo.duration({ days: 365 }) +// Perfect for detailed countdowns or SaaS dashboards! +const formatted = Tempo.duration({ days: 395, hours: 4 }) + // 'nominal: true' uses 365d/yr and 30d/mo math, so no relativeTo anchor is needed .balance({ nominal: true }) .format(); -console.log(formatted); // "1 year" (or "1 aรฑo", "1 an" depending on navigator.language) +console.log(formatted); // "1 year, 1 month, and 4 hours" (or localized equivalent) ``` ### Global Configuration -You can also define default formatting options globally by adding `numberFormat` into your `Tempo.init` configuration. +You can also define default formatting options globally by adding `durationFormat` into your `Tempo.init` configuration. ```javascript Tempo.init({ intl: { - numberFormat: { unitDisplay: 'short' } // e.g. "1 yr" instead of "1 year" + durationFormat: { style: 'short' } // e.g. "1 yr, 1 mth, 4 hr" instead of long-form } }); // Now, all format calls will automatically use 'short' display -const shortDur = Tempo.duration('P1Y').format(); // "1 yr" +const shortDur = Tempo.duration('P1Y1M').format(); // "1 yr, 1 mth" ``` diff --git a/packages/tempo/src/module/module.duration.ts b/packages/tempo/src/module/module.duration.ts index 2038312..eeb1d79 100644 --- a/packages/tempo/src/module/module.duration.ts +++ b/packages/tempo/src/module/module.duration.ts @@ -3,7 +3,7 @@ import { isString, isObject, isDefined, isUndefined, isFunction } from '#library import { singular } from '#library/string.library.js'; import { getAccessors } from '#library/reflection.library.js'; import { ifDefined } from '#library/object.library.js'; -import { getRelativeTime, formatNumber } from '#library/international.library.js'; +import { getRelativeTime, formatNumber, formatDuration, formatList } from '#library/international.library.js'; import { defineInterpreterModule, interpret, type TempoModule } from '../plugin/plugin.util.js'; import { enums, isTempo } from '#tempo/support'; @@ -37,7 +37,7 @@ declare module '#library/type.library.js' { /** * Convert a Temporal.Duration to a full Tempo.Duration object (EDO). */ -function toDuration(dur: Temporal.Duration, ctx: { relativeTo?: any, locale?: string, numberFormat?: any } = {}): Tempo.Duration { +function toDuration(dur: Temporal.Duration, ctx: { relativeTo?: any, locale?: string, numberFormat?: any, durationFormat?: any } = {}): Tempo.Duration { const edo = getAccessors(dur) .reduce((acc, d) => Object.assign(acc, ifDefined({ [d]: (dur as any)[d] })), { @@ -90,34 +90,38 @@ function toDuration(dur: Temporal.Duration, ctx: { relativeTo?: any, locale?: st Object.defineProperty(edo, 'format', { value: function (opts: any = {}) { const { locales, ...intlOpts } = opts; + const locale = locales || ctx.locale; + + if (isFunction(ctx.durationFormat)) + return ctx.durationFormat(this); + + // 1. Native Intl.DurationFormat + if ('DurationFormat' in Intl) + return formatDuration(this, locale, { ...(ctx.durationFormat || {}), ...intlOpts }); + + // 2. Fallback Polyfill (combine all non-zero units) + const units = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds', 'microseconds', 'nanoseconds'] as const; + const parts: string[] = []; + + for (const u of units) { + const val = this[u]; + if (val) { + const unitName = singular(u); // singularize unit name (e.g., 'years' -> 'year') + parts.push( + formatNumber(val, locale, { + style: 'unit', + unit: unitName, + unitDisplay: 'long', + ...(ctx.numberFormat || {}), + ...intlOpts + }) + ); + } + } - // Find the largest non-zero unit to format. - let val = 0; - let u = ''; - if (this.years) { val = this.years; u = 'year'; } - else if (this.months) { val = this.months; u = 'month'; } - else if (this.weeks) { val = this.weeks; u = 'week'; } - else if (this.days) { val = this.days; u = 'day'; } - else if (this.hours) { val = this.hours; u = 'hour'; } - else if (this.minutes) { val = this.minutes; u = 'minute'; } - else if (this.seconds) { val = this.seconds; u = 'second'; } - else if (this.milliseconds) { val = this.milliseconds; u = 'millisecond'; } - else if (this.microseconds) { val = this.microseconds; u = 'microsecond'; } - else if (this.nanoseconds) { val = this.nanoseconds; u = 'nanosecond'; } - - if (!u) return '0'; // or some fallback - - if (isFunction(ctx.numberFormat)) - return ctx.numberFormat(val, u); + if (parts.length === 0) return '0'; // fallback for completely empty duration - const locale = locales || ctx.locale; - return formatNumber(val, locale, { - style: 'unit', - unit: u, - unitDisplay: 'long', - ...(ctx.numberFormat || {}), - ...intlOpts - }); + return formatList(parts, locale, 'conjunction', 'long'); }, enumerable: false }); @@ -183,7 +187,8 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) if (isUndefined(unit) || since) { const locale = (this as any)?.config?.locale; const numberFormat = opts['intl']?.numberFormat || (this as any)?.config?.intl?.numberFormat; - const res = toDuration(dur, { relativeTo: selfZdt, locale, numberFormat }); + const durationFormat = opts['intl']?.durationFormat || (this as any)?.config?.intl?.durationFormat; + const res = toDuration(dur, { relativeTo: selfZdt, locale, numberFormat, durationFormat }); if (unit) res.unit = unit; if (!since) return res; @@ -195,8 +200,8 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) .map(Math.abs) .map(nbr => nbr.toString().padStart(3, '0')) .join('') - const rtConfig = (this as any).config.intl?.relativeTime; - const rtOptions = opts['intl']?.relativeTime || opts['relativeTime']; + const rtConfig = (this as any).config.intl?.relativeTimeFormat || (this as any).config.intl?.relativeTime; + const rtOptions = opts['intl']?.relativeTimeFormat || opts['intl']?.relativeTime || opts['relativeTime']; const rtf = (isFunction(rtOptions) ? rtOptions : rtOptions?.format) || (isFunction(rtConfig) ? rtConfig : rtConfig?.format) @@ -206,7 +211,7 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) const su = singular(u); if (isFunction(rtf)) return rtf(val, su); if (rtf instanceof Intl.RelativeTimeFormat) return rtf.format(val, su); - const style = rtOptions?.style || rtConfig?.style || opts['intl']?.relativeTime?.style || opts['rtfStyle'] || (this as any).config.intl?.relativeTime?.style || (this as any).config['rtfStyle'] || 'narrow'; + const style = rtOptions?.style || rtConfig?.style || opts['intl']?.relativeTimeFormat?.style || opts['intl']?.relativeTime?.style || opts['rtfStyle'] || (this as any).config.intl?.relativeTimeFormat?.style || (this as any).config.intl?.relativeTime?.style || (this as any).config['rtfStyle'] || 'narrow'; return getRelativeTime(val, su as Intl.RelativeTimeFormatUnit, locale, style); } diff --git a/packages/tempo/src/support/support.intl.ts b/packages/tempo/src/support/support.intl.ts index bd3f524..c380516 100644 --- a/packages/tempo/src/support/support.intl.ts +++ b/packages/tempo/src/support/support.intl.ts @@ -2,8 +2,11 @@ import type { IntlOptions } from '../tempo.type.js'; /** @internal baseline Intl settings */ export const IntlDefault: IntlOptions = { - relativeTime: { + relativeTimeFormat: { style: 'narrow', + }, + durationFormat: { + style: 'long', } } @@ -33,7 +36,7 @@ export function resolveIntl(value: IntlOptions = {}, base: IntlOptions = IntlDef const result = { ...base } as Record; Object.entries(value).forEach(([k, v]) => { - if ((k === 'relativeTime' || k === 'numberFormat') && typeof v === 'object' && v !== null && typeof v !== 'function') { + if ((k === 'relativeTime' || k === 'relativeTimeFormat' || k === 'numberFormat' || k === 'durationFormat') && typeof v === 'object' && v !== null && typeof v !== 'function') { const current = result[k]; const isObj = (val: any) => typeof val === 'object' && val !== null && typeof val !== 'function'; @@ -46,5 +49,12 @@ export function resolveIntl(value: IntlOptions = {}, base: IntlOptions = IntlDef } }); + // Sync relativeTime and relativeTimeFormat (with precedence to relativeTimeFormat) + if (result.relativeTimeFormat !== undefined) { + result.relativeTime = result.relativeTimeFormat; + } else if (result.relativeTime !== undefined) { + result.relativeTimeFormat = result.relativeTime; + } + return result; } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index f4bc4fa..bbe6e16 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -385,16 +385,23 @@ export class Tempo { // 1d. Process Internationalization if (discovery.intl || discovery.relativeTime) { const intl: t.IntlOptions = { ...discovery.intl }; - if (discovery.relativeTime) { - if (typeof discovery.relativeTime === 'function') { - intl.relativeTime = discovery.relativeTime; - } else if (!(typeof intl.relativeTime === 'function')) { - intl.relativeTime = { ...intl.relativeTime, ...discovery.relativeTime }; + const shorthand = discovery.relativeTime; + if (shorthand) { + if (isFunction(shorthand)) { + intl.relativeTimeFormat = shorthand; + } else if (!(isFunction(intl.relativeTimeFormat) || isFunction(intl.relativeTime))) { + intl.relativeTimeFormat = { ...intl.relativeTime, ...intl.relativeTimeFormat, ...(discovery.relativeTime as any) }; } else { - // A function-based relativeTime in 'intl' takes precedence over a shorthand 'relativeTime' object - Tempo.#dbg.debug(shape.config, '[Discovery] Shorthand relativeTime object ignored; intl.relativeTime function has precedence.'); + // A function-based relativeTimeFormat in 'intl' takes precedence over a shorthand object + Tempo.#dbg.debug(shape.config, '[Discovery] Shorthand relativeTime object ignored; intl.relativeTimeFormat function has precedence.'); } } + // Sync legacy support + if (intl.relativeTimeFormat !== undefined) { + intl.relativeTime = intl.relativeTimeFormat; + } else if (intl.relativeTime !== undefined) { + intl.relativeTimeFormat = intl.relativeTime; + } shape.config.intl = { ...shape.config.intl, ...intl }; } diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index a950958..a7a8f8f 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -86,6 +86,7 @@ export type Groups = Record export interface Options extends Partial { planner?: PlannerOptions; intl?: IntlOptions; + /** @deprecated will be removed in v3.0.0; use `intl.relativeTimeFormat` instead */ relativeTime?: RelativeTime | ((value: number, unit: any) => string); [key: string]: any; } @@ -147,8 +148,8 @@ export type us = IntRange<0, 999> export type ns = IntRange<0, 999> export type ww = IntRange<1, 53> -export type Duration = NonOptional & Record<"iso", string> & Record<"sign", number> & Record<"blank", boolean> & Record<"unit", string | undefined> & { - balance(opts?: { nominal?: boolean; relativeTo?: any; largestUnit?: Unit | string }): Duration; +export type Duration = NonOptional & Record<"iso", string> & Record<"sign", number> & Record<"blank", boolean> & Record<"unit", string | undefined> & { + balance(opts?: { nominal?: boolean; relativeTo?: any; largestUnit?: Unit | string }): Duration; format(opts?: Intl.NumberFormatOptions & { locales?: string | string[] }): string; } @@ -190,7 +191,9 @@ export interface RelativeTime { } export interface IntlOptions { - /** relative time formatting configuration */ relativeTime?: RelativeTime | ((value: number, unit: any) => string); + /** @deprecated will be removed in v3.0.0; use `relativeTimeFormat` instead */ relativeTime?: RelativeTime | ((value: number, unit: any) => string); + /** relative time formatting configuration */ relativeTimeFormat?: RelativeTime | ((value: number, unit: any) => string); + /** multi-unit duration formatting configuration */ durationFormat?: any | ((duration: any) => string); /** absolute unit duration formatting configuration */ numberFormat?: Intl.NumberFormatOptions | ((value: number, unit: any) => string); } @@ -322,7 +325,7 @@ export namespace Internal { /** pre-defined config options for Tempo.#global */ options?: Options | (() => Options); /** aliases to merge in the TimeZone dictionary */ timeZones?: Record; /** regional date-parsing configuration */ monthDay?: MonthDay; - /** relative time configuration (shorthand) */ relativeTime?: RelativeTime | ((value: number, unit: any) => string); + /** @deprecated will be removed in v3.0.0; use `intl.relativeTimeFormat` instead */ relativeTime?: RelativeTime | ((value: number, unit: any) => string); /** parse planner configuration (layoutOrder, etc.) */ planner?: PlannerOptions; /** aliases to merge in the Number-Word dictionary */ numbers?: Record; /** @deprecated use 'terms' */ term?: TermPlugin; diff --git a/packages/tempo/test/plugins/duration.balance.test.ts b/packages/tempo/test/plugins/duration.balance.test.ts index 6b5687f..a6ded58 100644 --- a/packages/tempo/test/plugins/duration.balance.test.ts +++ b/packages/tempo/test/plugins/duration.balance.test.ts @@ -23,12 +23,12 @@ describe('Duration EDO Balance and Format', () => { expect(() => dur.balance()).not.toThrow(); }); - test('format() uses Intl.NumberFormat to render the largest unit', () => { + test('format() natively formats multi-unit durations', () => { const dur1 = Tempo.duration({ days: 365 }); expect(dur1.format({ locales: 'en-US' })).toBe('365 days'); const dur2 = Tempo.duration({ years: 1, days: 5 }); - expect(dur2.format({ locales: 'en-US' })).toBe('1 year'); + expect(dur2.format({ locales: 'en-US' })).toBe('1 yr, 5 days'); }); test('format() respects cascading numberFormat config', () => { From d8981b85f3c5c9b7fc78ec137799bb43a4b1fb1e Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 30 May 2026 11:40:34 +1000 Subject: [PATCH 04/20] initial work on v3 --- .gitignore | 3 + packages/library/package.json | 4 +- packages/library/src/common/number.library.ts | 4 +- packages/tempo/CHANGELOG.md | 13 + packages/tempo/doc/migration-guide.md | 25 +- packages/tempo/doc/release-notes-v3.0.0.md | 41 +++ packages/tempo/doc/tempo.format.md | 9 +- packages/tempo/doc/tempo.month-day.md | 2 +- packages/tempo/doc/tempo.ticker.md | 315 ------------------ packages/tempo/package.json | 16 +- packages/tempo/src/library.index.ts | 3 + packages/tempo/src/module/module.duration.ts | 6 +- packages/tempo/src/module/module.format.ts | 9 +- packages/tempo/src/support/support.default.ts | 6 +- packages/tempo/src/support/support.enum.ts | 6 +- packages/tempo/src/support/support.init.ts | 9 - packages/tempo/src/support/support.intl.ts | 34 +- packages/tempo/src/support/support.symbol.ts | 1 + packages/tempo/src/tempo.class.ts | 30 +- packages/tempo/src/tempo.type.ts | 5 - packages/tempo/src/tsconfig.json | 17 +- packages/tempo/test/core/dispose.core.test.ts | 93 ------ .../test/instance/instance.since.rtf.test.ts | 10 +- .../test/plugins/slick.verification.test.ts | 19 -- .../tempo/test/plugins/ticker.active.test.ts | 99 ------ .../tempo/test/plugins/ticker.hang.test.ts | 32 -- .../tempo/test/plugins/ticker.options.test.ts | 126 ------- .../test/plugins/ticker.patterns.test.ts | 120 ------- .../tempo/test/plugins/ticker.pulse.test.ts | 25 -- .../tempo/test/plugins/ticker.stop.test.ts | 40 --- .../test/plugins/ticker.term.core.test.ts | 89 ----- .../test/plugins/ticker_cold_start.test.ts | 24 -- .../tempo/test/support/error-handling.test.ts | 26 -- 33 files changed, 160 insertions(+), 1101 deletions(-) create mode 100644 packages/tempo/doc/release-notes-v3.0.0.md delete mode 100644 packages/tempo/doc/tempo.ticker.md delete mode 100644 packages/tempo/test/plugins/ticker.active.test.ts delete mode 100644 packages/tempo/test/plugins/ticker.hang.test.ts delete mode 100644 packages/tempo/test/plugins/ticker.options.test.ts delete mode 100644 packages/tempo/test/plugins/ticker.patterns.test.ts delete mode 100644 packages/tempo/test/plugins/ticker.pulse.test.ts delete mode 100644 packages/tempo/test/plugins/ticker.stop.test.ts delete mode 100644 packages/tempo/test/plugins/ticker.term.core.test.ts delete mode 100644 packages/tempo/test/plugins/ticker_cold_start.test.ts delete mode 100644 packages/tempo/test/support/error-handling.test.ts diff --git a/.gitignore b/.gitignore index 3f26473..d844ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ Thumbs.db .npmrc.local .npmrc !.npmrc + +# Local Proprietary Symlinks +/packages/tempo/premium diff --git a/packages/library/package.json b/packages/library/package.json index 1b30ce6..788bdde 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.11.3", + "version": "3.0.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", @@ -99,4 +99,4 @@ "optionalDependencies": { "@js-temporal/polyfill": "^0.5.1" } -} +} \ No newline at end of file diff --git a/packages/library/src/common/number.library.ts b/packages/library/src/common/number.library.ts index 4b8f951..c1193b2 100644 --- a/packages/library/src/common/number.library.ts +++ b/packages/library/src/common/number.library.ts @@ -12,8 +12,8 @@ export const toHex = (num: TValues = [], len?: number) => .substring(0, len ?? Number.MAX_SAFE_INTEGER) /** apply an Ordinal suffix */ -export const suffix = (idx: number) => { - const str = String(idx ?? ''); // so we can check 'endsWith' +export const suffix = (idx: number = 0) => { + const str = String(idx); switch (true) { case str.endsWith('1') && !str.endsWith('11'): diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 6926a79..28c6e79 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -5,6 +5,19 @@ 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.0.0] - 2026-05-29 + +### Changed (Breaking) +- **Ticker Extraction**: The `TickerModule` has been extracted from the core Tempo library into a standalone, licensed premium plugin (`@magmacomputing/tempo-plugin-ticker`). It is no longer bundled with the open-source distribution. +- **ISO Getter Precision**: The `.iso` property getter has been upgraded from native `Date.toISOString()` to Temporal's `Instant.toString()`. This provides full ISO 8601 nanosecond precision and omits fractional seconds when they evaluate to exactly zero. + +### Added +- **Compact Date Tokens**: Added `{dmy}`, `{mdy}`, and `{ymd}` to the `FormatModule` for generating 8-digit compact date strings (e.g. `24102026`). +- **Ordinal Format Tokens**: Added uppercase `{DAY}`, `{WW}`, and `{MM}` to the `FormatModule` which generate the ordinal string representation (e.g. `24th`, `1st`, `2nd`). +- **Compact Time Rename**: Renamed the `{hhmiss}` token to `{hms}` in the `FormatModule` for consistency with other token styles. +### Migration +- If you used `Tempo.ticker()`, you must now install `@magmacomputing/tempo-plugin-ticker` and register it. A migration stub is currently left in place that will throw an error with directions to the Tempo Registry to obtain your license key. + ## [2.11.2] - 2026-05-27 ### Changed diff --git a/packages/tempo/doc/migration-guide.md b/packages/tempo/doc/migration-guide.md index eb157c5..e367d0f 100644 --- a/packages/tempo/doc/migration-guide.md +++ b/packages/tempo/doc/migration-guide.md @@ -1,3 +1,22 @@ +# โš ๏ธ Migrating to Tempo v3.x + +Tempo v3.x finalizes the plugin ecosystem by extracting advanced features into standalone, licensed packages. + +## ๐Ÿ” Migrating from version 2.x to 3.0.0 (Ticker Extraction) + +The `TickerModule` has been extracted from the core open-source repository into a standalone premium plugin. + +**Action Required**: +1. If you use `Tempo.ticker()`, you must now install `@magmacomputing/tempo-plugin-ticker` alongside `@magmacomputing/tempo`. +2. Visit the [Tempo License Registry](https://registry.magmacomputing.com.au) to obtain your free license key to activate the plugin. +3. Import and register the plugin in your application initialization: + ```javascript + import { Tempo } from '@magmacomputing/tempo'; + import { TickerModule } from '@magmacomputing/tempo-plugin-ticker'; + + Tempo.extend(TickerModule); + ``` + # โš ๏ธ Migrating to Tempo v2.x Tempo v2.x introduces architectural improvements and a more modular engine. While we strive for backward compatibility, there are some key changes to consider when upgrading from v1.x. @@ -105,14 +124,14 @@ The individual `mdyLocales` and `mdyLayouts` options have been consolidated into - **Shortcut:** `new Tempo({ monthDay: true })` (enables forced MDY parsing using default locales). ### Relative Time -The individual `rtfFormat` and `rtfStyle` options have been consolidated into a single `relativeTime` object. +The individual `rtfFormat` and `rtfStyle` options have been consolidated into a single `relativeTimeFormat` object. - **v2.6.x:** `new Tempo({ rtfStyle: 'long' })` -- **v2.7.x:** `new Tempo({ relativeTime: { style: 'long' } })` +- **v2.7.x:** `new Tempo({ relativeTimeFormat: { style: 'long' } })` ### Action Required: Only the deprecated top-level keys `rtfFormat` and `rtfStyle` are still accepted as legacy fallbacks in the current release, handled specifically in the `Tempo` class constructor for backward compatibility. -In contrast, the old `mdyLocales` and `mdyLayouts` keys are **not** treated as aliases and will be ignored; these must be migrated to the new nested `monthDay` structure. Update your configuration to ensure compatibility with future versions and the Release-C optimization engine. Refer to the `Tempo` constructor for implementation details on legacy alias handling. +In contrast, the old `mdyLocales` and `mdyLayouts` keys are **not** treated as aliases and will be ignored; these must be migrated to the new nested `monthDay` structure. Update your configuration to ensure compatibility with future versions and the optimization engine. Refer to the `Tempo` constructor for implementation details on legacy alias handling. ## ๐Ÿ” Migrating to version 2.9.3 diff --git a/packages/tempo/doc/release-notes-v3.0.0.md b/packages/tempo/doc/release-notes-v3.0.0.md new file mode 100644 index 0000000..defe1b5 --- /dev/null +++ b/packages/tempo/doc/release-notes-v3.0.0.md @@ -0,0 +1,41 @@ +# Tempo v3.0.0 Release Notes + +Welcome to Tempo v3.0.0! This major release marks a significant milestone in our architectural journey by finalizing the decentralized plugin ecosystem. + +## ๐Ÿš€ What's New & Changed + +### Ticker Module Extraction (Breaking Change) +To lighten the core bundle and clean up the API surface for general use cases, the `TickerModule` has been extracted from the base open-source distribution into its own standalone premium plugin (`@magmacomputing/tempo-plugin-ticker`). + +The Ticker is still completely free to use, but it is now protected by a License Key via the Tempo Registry. This allows us to better protect the investment in the advanced scheduling algorithms and restrict its payload footprint strictly to applications that need it. + +### Formatting Module Additions +The `FormatModule` has been updated with new compact date tokens (`{dmy}`, `{mdy}`, `{ymd}`) for generating 8-digit compact date strings (e.g. `24102026`). Additionally, the `{hhmiss}` compact time token has been renamed to `{hms}` for consistency. +We have also introduced **Ordinal Tokens**: uppercase variants of standard date tokens (`{DAY}`, `{WW}`, `{MM}`) now output their ordinal string representation (e.g., `24th`, `1st`, `2nd`). + +### 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**: + ```bash + npm install @magmacomputing/tempo-plugin-ticker + ``` +2. **Activate your License**: Visit [registry.magmacomputing.com.au](https://registry.magmacomputing.com.au) to obtain your free JWT license key. +3. **Register the Plugin**: Wire the key into your application and extend Tempo: + ```javascript + import { Tempo } from '@magmacomputing/tempo'; + import { TickerModule } from '@magmacomputing/tempo-plugin-ticker'; + + Tempo.init({ license: 'YOUR_JWT_KEY' }); + Tempo.extend(TickerModule); + ``` + +A migration stub has been left in the core package for v3.0.0. If you accidentally call `Tempo.ticker()` without the plugin installed, the engine will safely throw an informative error directing you to the registry. + +## ๐Ÿ› ๏ธ Internal Improvements +- Bumped core engine to v3.0.0 to reflect the breaking API extraction. +- Fully synchronized build pipelines and TS declarations to ensure `vitest` and `tsc` operate seamlessly across local and premium workspaces. +- Removed legacy `Ticker` shorthands from core test suites for guaranteed separation of concerns. +- **ISO Getter Precision**: Upgraded the `.iso` property getter from native `Date.toISOString()` to Temporal's `Instant.toString()`. This provides full ISO 8601 nanosecond precision and conforms to RFC 3339 by gracefully omitting fractional seconds when they evaluate to exactly zero. + +Thank you for continuing to build with Tempo! diff --git a/packages/tempo/doc/tempo.format.md b/packages/tempo/doc/tempo.format.md index 608c608..f124f20 100644 --- a/packages/tempo/doc/tempo.format.md +++ b/packages/tempo/doc/tempo.format.md @@ -87,18 +87,25 @@ Tempo.extend(FormatModule); | `{mon}` | Full Month Name | `October` | | `{mmm}` | Short Month Name | `Oct` | | `{mm}` | 2-digit Month | `10` | +| `{MM}` | Ordinal Month | `10th` | | `{dd}` | 2-digit Day | `24` | | `{day}` | Unpadded Day | `24` (or `9`) | +| `{DAY}` | Ordinal Day | `24th` (or `9th`) | | `{wkd}` | Full Weekday Name | `Saturday` | | `{www}` | Short Weekday Name | `Sat` | | `{dow}` | Day of Week (1-7) | `6` | +| `{ww}` | Week of Year | `17` | +| `{WW}` | Ordinal Week of Year | `17th` | | `{hh}` | 2-digit Hour (24h) | `15` | | `{HH}` | 2-digit Hour (12h) | `03` | | `{mer}` | am/pm marker | `pm` | | `{MER}` | AM/PM marker | `PM` | | `{mi}` | Minutes | `30` | | `{ss}` | Seconds | `45` | -| `{hhmiss}` | Compact Time (24h) | `153045` | +| `{dmy}` | Compact Date (ddmmyyyy) | `24102026` | +| `{mdy}` | Compact Date (mmddyyyy) | `10242026` | +| `{ymd}` | Compact Date (yyyymmdd) | `20261024` | +| `{hms}` | Compact Time (24h) | `153045` | | `{ms}` | 3-digit Milliseconds | `123` | | `{us}` | 3-digit Microseconds | `456` | | `{ns}` | 3-digit Nanoseconds | `789` | diff --git a/packages/tempo/doc/tempo.month-day.md b/packages/tempo/doc/tempo.month-day.md index c3eb118..84b9731 100644 --- a/packages/tempo/doc/tempo.month-day.md +++ b/packages/tempo/doc/tempo.month-day.md @@ -62,7 +62,7 @@ Tempo.init({ When `monthDay.active` is true, Tempo performs two main actions: -1. **Pattern Selection**: The `{dt}` placeholder (used in the default `dateTime` layout) is switched from the `dayMonthYear` pattern to the `monthDayYear` pattern. +1. **Pattern Selection**: The `{dt}` composite-snippet (used in the default `dateTime` layout) is switched from the `dayMonthYear` pattern to the `monthDayYear` pattern. 2. **Layout Swapping**: The internal order of tried layouts is adjusted. For example, the `monthDayYear` layout is moved ahead of `dayMonthYear` in the priority list. This ensures that even complex or non-standard strings are interpreted according to the regional preference. diff --git a/packages/tempo/doc/tempo.ticker.md b/packages/tempo/doc/tempo.ticker.md deleted file mode 100644 index cd9be6b..0000000 --- a/packages/tempo/doc/tempo.ticker.md +++ /dev/null @@ -1,315 +0,0 @@ -# Tempo Ticker - -`Tempo.ticker` is an optional plugin (provided in the @magmacomputing/tempo/ticker module) that creates a reactive stream of `Tempo` instances at regular intervals. It is designed to be high-performance and lightweight, providing a simple way to build clocks, countdowns, or scheduled updates. - -## Installation - -To use the Ticker, you can import the module as a side effect or import the `TickerModule` directly. The side-effect import (`import '@magmacomputing/tempo/ticker'`) registers the `Tempo.ticker` method automatically, while importing the `TickerModule` explicitly requires you to call `Tempo.extend(TickerModule)` to register it with the core library: - -```typescript -// Pattern A: One-line activation (Side effect) -import '@magmacomputing/tempo/ticker'; - -// Pattern B: Explicit Module (Recommended) -import { Tempo } from '@magmacomputing/tempo/core'; -import { TickerModule } from '@magmacomputing/tempo/ticker'; - -Tempo.extend(TickerModule); -``` - -### 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/ticker'; - -console.log(Ticker.active); -``` - -## ๐Ÿš€ Enhancements - -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 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 }); - -// Pulse every time a new #quarter begins -await using quarterly = Tempo.ticker({ '#quarter': 1 }); -``` - -### 2. Term-Based Intervals -Ticker intervals can now be driven by any registered **Term**. This is powerful for syncing with business cycles or daily shifts. - -```typescript -// Pulse at the start of every 'morning', 'afternoon', etc. -using shiftTicker = Tempo.ticker({ '#period': 1 }, (t) => { - console.log(`New period started: ${t.term.per}`); -}); -``` - -### 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(); -}); -``` - -## 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/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); -} -``` diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 1de040b..82bd91d 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "2.11.3", + "version": "3.0.0", "description": "The Tempo core library", "author": "Magma Computing Solutions", "license": "MIT", @@ -181,12 +181,12 @@ "test:dist": "cross-env TEST_DIST=true vitest run", "test:ci": "cross-env TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 vitest run", "test:ci:prefilter": "cross-env TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TEMPO_PREFILTER_CI=true vitest run", - "repl": "tsx --conditions=development -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", + "repl": "tsx --tsconfig ./src/tsconfig.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 --conditions=development -i --harmony-temporal --import ./bin/repl.ts", - "repl:bare": "tsx --conditions=development -i --harmony-temporal", - "repl:core": "cross-env TEMPO_LITE=true tsx --conditions=development -i --harmony-temporal --import ./bin/core.ts", - "parse": "cross-env TEMPO_LITE=true tsx --conditions=development -i --harmony-temporal --import ./bin/parse.ts", + "repl:node": "tsx --tsconfig ./src/tsconfig.json -i --harmony-temporal --import ./bin/repl.ts", + "repl:bare": "tsx --tsconfig ./src/tsconfig.json -i --harmony-temporal", + "repl:core": "cross-env TEMPO_LITE=true tsx --tsconfig ./src/tsconfig.json -i --harmony-temporal --import ./bin/core.ts", + "parse": "cross-env TEMPO_LITE=true tsx --tsconfig ./src/tsconfig.json -i --harmony-temporal --import ./bin/parse.ts", "build": "npm run clean && tsc -b && npm run build:bundle && npm run build:resolve", "build:bundle": "rollup -c", "build:resolve": "tsx bin/resolve-types.ts", @@ -207,7 +207,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.11.3", + "@magmacomputing/library": "3.0.0", "@rollup/plugin-alias": "^6.0.0", "esbuild": "^0.25.12", "javascript-obfuscator": "^5.4.2", @@ -222,4 +222,4 @@ "doc": "doc", "test": "test" } -} +} \ No newline at end of file diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts index 9a7293a..eae4116 100644 --- a/packages/tempo/src/library.index.ts +++ b/packages/tempo/src/library.index.ts @@ -9,5 +9,8 @@ export { Cipher } from '#library/cipher.class.js'; export { enumify, type Enum } from '#library/enumerate.library.js'; export { proxify } from '#library/proxy.library.js'; export { stringify, objectify, cloneify } from '#library/serialize.library.js'; +export { isObject, isFunction, isDefined, isUndefined, isEmpty, isNumeric, isFiniteNumber } from '#library/assertion.library.js'; +export { asArray } from '#library/coercion.library.js'; +export { instant, normaliseFractionalDurations } from '#library/temporal.library.js'; export type { OwnOf, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; diff --git a/packages/tempo/src/module/module.duration.ts b/packages/tempo/src/module/module.duration.ts index eeb1d79..792d534 100644 --- a/packages/tempo/src/module/module.duration.ts +++ b/packages/tempo/src/module/module.duration.ts @@ -200,8 +200,8 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) .map(Math.abs) .map(nbr => nbr.toString().padStart(3, '0')) .join('') - const rtConfig = (this as any).config.intl?.relativeTimeFormat || (this as any).config.intl?.relativeTime; - const rtOptions = opts['intl']?.relativeTimeFormat || opts['intl']?.relativeTime || opts['relativeTime']; + const rtConfig = (this as any).config.intl?.relativeTimeFormat; + const rtOptions = opts['intl']?.relativeTimeFormat; const rtf = (isFunction(rtOptions) ? rtOptions : rtOptions?.format) || (isFunction(rtConfig) ? rtConfig : rtConfig?.format) @@ -211,7 +211,7 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) const su = singular(u); if (isFunction(rtf)) return rtf(val, su); if (rtf instanceof Intl.RelativeTimeFormat) return rtf.format(val, su); - const style = rtOptions?.style || rtConfig?.style || opts['intl']?.relativeTimeFormat?.style || opts['intl']?.relativeTime?.style || opts['rtfStyle'] || (this as any).config.intl?.relativeTimeFormat?.style || (this as any).config.intl?.relativeTime?.style || (this as any).config['rtfStyle'] || 'narrow'; + const style = rtOptions?.style || rtConfig?.style || opts['intl']?.relativeTimeFormat?.style || opts['rtfStyle'] || (this as any).config.intl?.relativeTimeFormat?.style || (this as any).config['rtfStyle'] || 'narrow'; return getRelativeTime(val, su as Intl.RelativeTimeFormatUnit, locale, style); } diff --git a/packages/tempo/src/module/module.format.ts b/packages/tempo/src/module/module.format.ts index a2023ad..066fc0c 100644 --- a/packages/tempo/src/module/module.format.ts +++ b/packages/tempo/src/module/module.format.ts @@ -1,5 +1,6 @@ import '#library/temporal.polyfill.js'; import { pad } from '#library/string.library.js'; +import { suffix } from '#library/number.library.js'; import { ifNumeric } from '#library/coercion.library.js'; import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined } from '#library/assertion.library.js'; import { delegator } from '#library/proxy.library.js'; @@ -94,6 +95,9 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol case 'wkd': return enums.WEEKDAYS.keyOf(zdt.dayOfWeek as any); case 'www': return enums.WEEKDAY.keyOf(zdt.dayOfWeek as any); case 'ww': return pad(zdt.weekOfYear); + case 'DAY': return suffix(zdt.day); + case 'WW': return suffix(zdt.weekOfYear); + case 'MM': return suffix(zdt.month); case 'hh': return pad(zdt.hour); case 'HH': return pad(zdt.hour > 12 ? zdt.hour % 12 : zdt.hour || 12); case 'mer': return zdt.hour >= 12 ? 'pm' : 'am'; @@ -104,7 +108,10 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol case 'us': return pad(zdt.microsecond, 3); case 'ns': return pad(zdt.nanosecond, 3); case 'ff': return `${pad(zdt.millisecond, 3)}${pad(zdt.microsecond, 3)}${pad(zdt.nanosecond, 3)}`; - case 'hhmiss': return `${pad(zdt.hour)}${pad(zdt.minute)}${pad(zdt.second)}`; + case 'dmy': return `${pad(zdt.day)}${pad(zdt.month)}${pad(zdt.year, 4)}`; + case 'mdy': return `${pad(zdt.month)}${pad(zdt.day)}${pad(zdt.year, 4)}`; + case 'ymd': return `${pad(zdt.year, 4)}${pad(zdt.month)}${pad(zdt.day)}`; + case 'hms': return `${pad(zdt.hour)}${pad(zdt.minute)}${pad(zdt.second)}`; case 'ts': return ((config?.timeStamp ?? 'ms') === 'ss') ? Math.trunc(zdt.epochMilliseconds / 1000).toString() : zdt.epochMilliseconds.toString(); diff --git a/packages/tempo/src/support/support.default.ts b/packages/tempo/src/support/support.default.ts index 501558e..66db66c 100644 --- a/packages/tempo/src/support/support.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -57,11 +57,12 @@ export const Match = proxify({ export const Snippet = looseIndex()({ [Token.yy]: /(?[0-9]{2}(?:[0-9]{2})?)/, // year must be exactly 2 or 4 digits [Token.mm]: /(?[0 ]?[1-9]|1[0-2]|Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)/, // month-name (abbrev or full) or month-number 01-12; leading '0' or space only (not \s โ€” tab/newline are not valid padding) - [Token.dd]: /(?
[0 ]?[1-9]|[12][0-9]|3[01])(?:\s?(?:st|nd|rd|th))?/, // day-number 01-31; leading '0' or space only (not \s โ€” tab/newline are not valid padding) + [Token.dd]: /(?
[0 ]?[1-9]|[12][0-9]|3[01]){ord}?/, // day-number 01-31; leading '0' or space only (not \s โ€” tab/newline are not valid padding) [Token.hh]: /(?2[0-4]|[01]?[0-9])/, // hour 00-24; CAUTION: in non-anchored use '25' partially matches as '2' via [01]?[0-9] โ€” always use within anchored layouts; single-digit hours (e.g. '9') are intentionally supported [Token.mi]: /(\:(?[0-5][0-9]))/, // minute-number 00-59 [Token.ss]: /(\:(?[0-5][0-9]))/, // seconds-number 00-59 [Token.ff]: /(\.(?[0-9]{1,9}))/, // fractional-seconds up-to 9-digits + [Token.ord]: /(?:\s?(?:st|nd|rd|th))/, // optional ordinal suffix [Token.mer]: /(\s*(?am|pm))/, // meridiem suffix (am,pm) [Token.sfx]: /((?:{sep}+|T)({tm}){tzd}?)/, // time-pattern suffix 'T {tm} Z'; NOTE: {tm} resolves via Layout fallback in compileRegExp (cross-registry dependency: Snippet โ†’ Layout) [Token.wkd]: /(?Mon(?:day)?|Tue(?:sday)?|Wed(?:nesday)?|Thu(?:rsday)?|Fri(?:day)?|Sat(?:urday)?|Sun(?:day)?)/, // day-name (abbrev or full) @@ -183,6 +184,7 @@ export const Guard = [ 'am', 'pm', 'ago', 'hence', 'this', 'next', 'prev', 'last', 'from', 'now', 'today', 'yesterday', 'tomorrow', 'start', 'mid', 'end', 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond', 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds', 'microseconds', 'nanoseconds', + 'st', 'nd', 'rd', 'th', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'mondays', 'tuesdays', 'wednesdays', 'thursdays', 'fridays', 'saturdays', 'sundays' ] as const; @@ -198,8 +200,6 @@ export const Default = secure({ /** default timezone if not specified */ timeZone: getDateTimeFormat().timeZone, /** default locale if not specified */ locale: getDateTimeFormat().locale, /** hemisphere for term.qtr or term.szn */ sphere: undefined, - // /** license key for premium plugins */ license: '', - /** regional date-parsing configuration */ monthDay: MONTH_DAY, /** internationalization configuration */ intl: IntlDefault, /** parse planner configuration (layoutOrder, etc.) */ planner: { diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index 1c61372..747565d 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -82,11 +82,11 @@ export const DEFAULTS = { /** useful for readable month and day */ dayMonth: '{dd}-{mmm}', /** useful for readable year, month and day */ dayDate: '{dd}-{mmm}-{yyyy}', /** display with Time */ dayTime: '{dd}-{mmm}-{yyyy} {hh}:{mi}:{ss}', - /** useful for stamping logs */ logStamp: '{yyyy}{mm}{dd}T{hhmiss}.{ff}', + /** useful for stamping logs */ logStamp: '{ymd}T{hms}.{ff}', /** useful for sorting display-strings */ sortTime: '{yyyy}-{mm}-{dd} {hh}:{mi}:{ss}', /** useful for sorting week order */ yearWeek: '{yw}{ww}', /** useful for sorting month order */ yearMonth: '{yyyy}{mm}', - /** useful for sorting date order */ yearMonthDay: '{yyyy}{mm}{dd}', + /** useful for sorting date order */ yearMonthDay: '{ymd}', /** just Date portion */ date: '{yyyy}-{mm}-{dd}', /** just Time portion */ time: '{hh}:{mi}:{ss}', }, @@ -247,7 +247,7 @@ export const PARSE = enumify(parseKeys, false); export type Parse = KeyOf /** allowed keys for global discovery objects */ -const discoveryKeys = ['options', 'plugins', 'plugin', 'terms', 'term', 'timeZones', 'monthDay', 'intl', 'relativeTime', 'planner', 'numbers', 'formats', 'ignore'] as const; +const discoveryKeys = ['options', 'plugins', 'terms', 'timeZones', 'monthDay', 'intl', 'planner', 'numbers', 'formats', 'ignore'] as const; export const DISCOVERY = enumify(discoveryKeys, false); export type Discovery = KeyOf diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 77adf10..6b3fc71 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -287,15 +287,6 @@ export function extendState(state: t.Internal.State, options: t.Options) { state.config.intl = { ...state.config.intl, ...arg.value }; break; - case 'relativeTime': - if (!hasOwn(state.config, 'intl')) state.config.intl = Object.create(state.config.intl || {}); - if (typeof arg.value === 'function') { - state.config.intl.relativeTime = arg.value; - } else { - state.config.intl.relativeTime = { ...state.config.intl.relativeTime, ...arg.value }; - } - break; - case 'planner': if (isDefined(arg.value.layoutOrder)) state.parse.planner.layoutOrder = normalizeLayoutOrder(arg.value.layoutOrder); if (isDefined(arg.value.preFilter)) state.parse.planner.preFilter = Boolean(arg.value.preFilter); diff --git a/packages/tempo/src/support/support.intl.ts b/packages/tempo/src/support/support.intl.ts index c380516..83bb34a 100644 --- a/packages/tempo/src/support/support.intl.ts +++ b/packages/tempo/src/support/support.intl.ts @@ -1,4 +1,5 @@ import type { IntlOptions } from '../tempo.type.js'; +import { isObject } from '#library/assertion.library.js'; /** @internal baseline Intl settings */ export const IntlDefault: IntlOptions = { @@ -34,27 +35,22 @@ export function probeMDY(locale: string): boolean { */ export function resolveIntl(value: IntlOptions = {}, base: IntlOptions = IntlDefault): IntlOptions { const result = { ...base } as Record; + const intls = ['relativeTimeFormat', 'numberFormat', 'durationFormat']; - Object.entries(value).forEach(([k, v]) => { - if ((k === 'relativeTime' || k === 'relativeTimeFormat' || k === 'numberFormat' || k === 'durationFormat') && typeof v === 'object' && v !== null && typeof v !== 'function') { - const current = result[k]; - const isObj = (val: any) => typeof val === 'object' && val !== null && typeof val !== 'function'; + Object + .entries(value) + .forEach(([k, v]) => { + if (intls.includes(k) && isObject(v)) { + const current = result[k]; - result[k] = { - ...(isObj(current) ? current as object : {}), - ...v as any - }; - } else { - result[k] = v; - } - }); - - // Sync relativeTime and relativeTimeFormat (with precedence to relativeTimeFormat) - if (result.relativeTimeFormat !== undefined) { - result.relativeTime = result.relativeTimeFormat; - } else if (result.relativeTime !== undefined) { - result.relativeTimeFormat = result.relativeTime; - } + result[k] = { + ...(isObject(current) ? current as object : {}), + ...v as any + }; + } else { + result[k] = v; + } + }); return result; } diff --git a/packages/tempo/src/support/support.symbol.ts b/packages/tempo/src/support/support.symbol.ts index 8e326c2..b39dc67 100644 --- a/packages/tempo/src/support/support.symbol.ts +++ b/packages/tempo/src/support/support.symbol.ts @@ -59,6 +59,7 @@ export const Token = looseIndex()({ /** year */ yy: Symbol('yy'), /** ISO yearOfWeek */ yw: Symbol('yw'), /** month */ mm: Symbol('mm'), + /** ordinal suffix */ ord: Symbol('ord'), /** day */ dd: Symbol('dd'), /** hour */ hh: Symbol('hh'), /** minute */ mi: Symbol('mi'), diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index bbe6e16..421a875 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -383,27 +383,8 @@ export class Tempo { } // 1d. Process Internationalization - if (discovery.intl || discovery.relativeTime) { - const intl: t.IntlOptions = { ...discovery.intl }; - const shorthand = discovery.relativeTime; - if (shorthand) { - if (isFunction(shorthand)) { - intl.relativeTimeFormat = shorthand; - } else if (!(isFunction(intl.relativeTimeFormat) || isFunction(intl.relativeTime))) { - intl.relativeTimeFormat = { ...intl.relativeTime, ...intl.relativeTimeFormat, ...(discovery.relativeTime as any) }; - } else { - // A function-based relativeTimeFormat in 'intl' takes precedence over a shorthand object - Tempo.#dbg.debug(shape.config, '[Discovery] Shorthand relativeTime object ignored; intl.relativeTimeFormat function has precedence.'); - } - } - // Sync legacy support - if (intl.relativeTimeFormat !== undefined) { - intl.relativeTime = intl.relativeTimeFormat; - } else if (intl.relativeTime !== undefined) { - intl.relativeTimeFormat = intl.relativeTime; - } - shape.config.intl = { ...shape.config.intl, ...intl }; - } + if (discovery.intl) + shape.config.intl = { ...shape.config.intl, ...discovery.intl }; // 1e. Process Planner if (isObject(discovery.planner)) { @@ -414,10 +395,6 @@ export class Tempo { } // 2. Process Terms - if (discovery.term) { - discovery.terms = [...asArray(discovery.terms || []), ...asArray(discovery.term)]; - Tempo.#dbg.warn(shape.config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); - } if (discovery.terms) this.extend(asArray(discovery.terms)); @@ -644,7 +621,6 @@ export class Tempo { break; case 'intl': - case 'relativeTime': case 'planner': case 'ignore': this[$setConfig](this[$Internal](), { [key]: val }); @@ -1408,7 +1384,7 @@ export class Tempo { /** Full weekday name (e.g., 'Monday') */ get wkd() { return Tempo.WEEKDAYS.keyOf(this.toDateTime().dayOfWeek as t.Weekday) } /** iso weekday number: Mon=1, Sun=7 */ get dow() { return this.toDateTime().dayOfWeek as t.Weekday } /** Nanoseconds since Unix epoch (BigInt) */ get nano() { return this.toDateTime().epochNanoseconds } - /** Standard ISO 8601 string in UTC */ get iso() { return this.toDate().toISOString() } + /** Standard ISO 8601 string in UTC */ get iso() { return this.toDateTime().toInstant().toString() } /** `true` if the underlying date-time is valid. */ get isValid() { return this.#resolve(zdt => !this.#errored && isZonedDateTime(zdt)); } /** list of registered terms and their available range keys */ diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index a7a8f8f..3b72ad0 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -86,8 +86,6 @@ export type Groups = Record export interface Options extends Partial { planner?: PlannerOptions; intl?: IntlOptions; - /** @deprecated will be removed in v3.0.0; use `intl.relativeTimeFormat` instead */ - relativeTime?: RelativeTime | ((value: number, unit: any) => string); [key: string]: any; } @@ -191,7 +189,6 @@ export interface RelativeTime { } export interface IntlOptions { - /** @deprecated will be removed in v3.0.0; use `relativeTimeFormat` instead */ relativeTime?: RelativeTime | ((value: number, unit: any) => string); /** relative time formatting configuration */ relativeTimeFormat?: RelativeTime | ((value: number, unit: any) => string); /** multi-unit duration formatting configuration */ durationFormat?: any | ((duration: any) => string); /** absolute unit duration formatting configuration */ numberFormat?: Intl.NumberFormatOptions | ((value: number, unit: any) => string); @@ -325,10 +322,8 @@ export namespace Internal { /** pre-defined config options for Tempo.#global */ options?: Options | (() => Options); /** aliases to merge in the TimeZone dictionary */ timeZones?: Record; /** regional date-parsing configuration */ monthDay?: MonthDay; - /** @deprecated will be removed in v3.0.0; use `intl.relativeTimeFormat` instead */ relativeTime?: RelativeTime | ((value: number, unit: any) => string); /** parse planner configuration (layoutOrder, etc.) */ planner?: PlannerOptions; /** aliases to merge in the Number-Word dictionary */ numbers?: Record; - /** @deprecated use 'terms' */ term?: TermPlugin; /** term plugins to be registered via Tempo.addTerm() */terms?: TermPlugin | TermPlugin[]; /** internationalization configuration (relativeTime, etc.) */intl?: IntlOptions; /** custom format strings to merge in the FORMAT dictionary */formats?: Property; diff --git a/packages/tempo/src/tsconfig.json b/packages/tempo/src/tsconfig.json index c9c15e5..e0f0e9e 100644 --- a/packages/tempo/src/tsconfig.json +++ b/packages/tempo/src/tsconfig.json @@ -16,9 +16,11 @@ "#server/*": [ "../../library/src/server/*" ], "#tempo": [ "./tempo.index.ts" ], "#tempo/core": [ "./core.index.ts" ], + "#tempo/enums": [ "./support/support.enum.ts" ], "#tempo/parse": [ "./module/module.parse.ts" ], "#tempo/format": [ "./module/module.format.ts" ], "#tempo/discrete": [ "./module/module.index.ts" ], + "#tempo/module": [ "./module/module.index.ts" ], "#tempo/duration": [ "./module/module.duration.ts" ], "#tempo/mutate": [ "./module/module.mutate.ts" ], "#tempo/ticker": [ "./plugin/extend/extend.ticker.ts" ], @@ -26,10 +28,23 @@ "#tempo/module/*.js": [ "./module/*.ts" ], "#tempo/plugin/extend/*.js": [ "./plugin/extend/*.ts" ], "#tempo/plugin/term/*.js": [ "./plugin/term/*.ts" ], + "#tempo/plugin/plugin.*.js": [ "./plugin/plugin.*.ts" ], + "#tempo/term": [ "./plugin/term/term.index.ts" ], "#tempo/term/*": [ "./plugin/term/term.*.ts" ], "#tempo/support": [ "./support/support.index.ts" ], "#tempo/support/*": [ "./support/*" ], - "#tempo/license": [ "./support/support.license.ts" ], + "#tempo/license": [ + "../premium/src/index.ts", + "./support/support.license.ts" + ], + "@magmacomputing/tempo": [ "./tempo.index.ts" ], + "@magmacomputing/tempo/plugin": [ "./plugin/plugin.index.ts" ], + "@magmacomputing/tempo/plugin/*": [ "./plugin/*.ts" ], + "@magmacomputing/tempo/term": [ "./plugin/term/term.index.ts" ], + "@magmacomputing/tempo/term/*": [ "./plugin/term/term.*.ts" ], + "@magmacomputing/tempo/enums": [ "./support/support.enum.ts" ], + "@magmacomputing/tempo/core": [ "./core.index.ts" ], + "@magmacomputing/tempo/*": [ "./*" ], "#tempo/*": [ "./*" ] } }, diff --git a/packages/tempo/test/core/dispose.core.test.ts b/packages/tempo/test/core/dispose.core.test.ts index 90ff2f5..73156ce 100644 --- a/packages/tempo/test/core/dispose.core.test.ts +++ b/packages/tempo/test/core/dispose.core.test.ts @@ -38,84 +38,6 @@ describe('Static Symbol.dispose', () => { expect(Pledge.status.tag).toBeUndefined(); } }); - -}); - -describe('Ticker Symbol.dispose', () => { - - test('Ticker callback returns a disposable function', async () => { - let count = 0; - const stop = Tempo.ticker(0.05, () => { count++ }); - - expect(typeof stop).toBe('function'); - - if (typeof Symbol.dispose === 'symbol') { - expect(stop[Symbol.dispose]).toBeDefined(); - expect(typeof stop[Symbol.dispose]).toBe('function'); - - // Use the dispose method instead of calling the function directly - stop[Symbol.dispose](); - - const finalCount = count; - await new Promise(resolve => setTimeout(resolve, 150)); - expect(count).toBe(finalCount); // Should have stopped - } else { - stop(); // Fallback for environments without the symbol - } - }); - - test('Ticker generator returns an async disposable', async () => { - const ticker = Tempo.ticker(0.02); - - if (typeof Symbol.asyncDispose === 'symbol') { - expect((ticker as any)[Symbol.asyncDispose]).toBeDefined(); - expect(typeof (ticker as any)[Symbol.asyncDispose]).toBe('function'); - - let i = 0; - for await (const t of ticker) { - expect(t).toBeDefined(); - if (++i === 2) break; - } - - // Explicitly call asyncDispose (though break does it via .return()) - await ticker[Symbol.asyncDispose](); - - const result = await ticker.next(); - expect(result.done).toBe(true); - } - }); - - test('Ticker callback seeding (Virtual Clock)', async () => { - const seed = '2024-01-01T00:00:00'; - const results: string[] = []; - const stop = Tempo.ticker({ seed, seconds: 0.05 }, (t) => { - results.push(t.format('sortTime') as string); - }); - - await new Promise(resolve => setTimeout(resolve, 125)); // Should have 3 ticks (0ms: seed, 50ms: +50, 100ms: +100) - stop(); - - expect(results[0]).toBe(new Tempo(seed).format('sortTime')); - expect(results[1]).toBe(new Tempo(seed).add({ milliseconds: 50 }).format('sortTime')); - expect(results[2]).toBe(new Tempo(seed).add({ milliseconds: 100 }).format('sortTime')); - }); - - test('Ticker generator seeding (Virtual Clock)', async () => { - const seed = '2024-01-01T00:00:00'; - const ticker = Tempo.ticker({ seed, seconds: 0.05 }); - const results: string[] = []; - - let i = 0; - for await (const t of ticker) { - results.push(t.format('sortTime') as string); - if (++i === 3) break; - } - - expect(results[0]).toBe(new Tempo(seed).format('sortTime')); - expect(results[1]).toBe(new Tempo(seed).add({ milliseconds: 50 }).format('sortTime')); - expect(results[2]).toBe(new Tempo(seed).add({ milliseconds: 100 }).format('sortTime')); - }); - test('Tempo static dispose resets global config (using syntax)', () => { const systemTZ = Intl.DateTimeFormat().resolvedOptions().timeZone; { @@ -127,19 +49,4 @@ describe('Ticker Symbol.dispose', () => { expect(Tempo.config.timeZone).toBe(systemTZ); }); - test('Ticker generator (await using syntax)', async () => { - const seed = '2024-01-01T12:00:00'; - const results: string[] = []; - { - await using ticker = Tempo.ticker({ seed, seconds: 0.02 }); - let i = 0; - for await (const t of ticker) { - results.push(t.format('sortTime') as string); - if (++i === 2) break; - } - } - expect(results.length).toBe(2); - expect(results[0]).toBe(new Tempo(seed).format('sortTime')); - }); - }); diff --git a/packages/tempo/test/instance/instance.since.rtf.test.ts b/packages/tempo/test/instance/instance.since.rtf.test.ts index 9d69a18..df5db4e 100644 --- a/packages/tempo/test/instance/instance.since.rtf.test.ts +++ b/packages/tempo/test/instance/instance.since.rtf.test.ts @@ -9,7 +9,7 @@ describe('instance.since relative formatting', () => { const t1 = new Tempo('2024-01-01T12:00:00'); const t2 = new Tempo('2024-01-01T14:30:00'); - const res = t2.since(t1, { unit: 'hours', relativeTime: { style: 'long' } }); + const res = t2.since(t1, { unit: 'hours', intl: { relativeTimeFormat: { style: 'long' } } }); expect(res).toMatch(/2 hours ago/i); }); @@ -17,7 +17,7 @@ describe('instance.since relative formatting', () => { const t1 = new Tempo('2024-01-01T12:00:00'); const t2 = new Tempo('2024-01-01T14:30:00'); - const res = t2.since(t1, { unit: 'hours', relativeTime: { style: 'short' } }); + const res = t2.since(t1, { unit: 'hours', intl: { relativeTimeFormat: { style: 'short' } } }); expect(res).toMatch(/2 hrs?\. ago/i); }); @@ -26,7 +26,7 @@ describe('instance.since relative formatting', () => { const t2 = new Tempo('2024-01-01T14:30:00'); const rtf = new Intl.RelativeTimeFormat('fr', { style: 'long' }); - const res = t2.since(t1, { unit: 'hours', relativeTime: { format: rtf } }); + const res = t2.since(t1, { unit: 'hours', intl: { relativeTimeFormat: { format: rtf } } }); // French long for 2 hours ago: "il y a 2 heures" expect(res).toMatch(/il y a 2 heures/i); @@ -34,7 +34,7 @@ describe('instance.since relative formatting', () => { test('inherits relativeTime.style from instance configuration', () => { const t1 = new Tempo('2024-01-01T12:00:00'); - const t = new Tempo('2024-01-01T14:30:00', { relativeTime: { style: 'long' } }); + const t = new Tempo('2024-01-01T14:30:00', { intl: { relativeTimeFormat: { style: 'long' } } }); const res = t.since(t1, 'hours'); expect(res).toMatch(/2 hours ago/i); @@ -43,7 +43,7 @@ describe('instance.since relative formatting', () => { test('inherits relativeTime.format from instance configuration', () => { const t1 = new Tempo('2024-01-01T12:00:00'); const rtf = new Intl.RelativeTimeFormat('fr', { style: 'long' }); - const t = new Tempo('2024-01-01T14:30:00', { relativeTime: { format: rtf } }); + const t = new Tempo('2024-01-01T14:30:00', { intl: { relativeTimeFormat: { format: rtf } } }); const res = t.since(t1, 'hours'); expect(res).toMatch(/il y a 2 heures/i); diff --git a/packages/tempo/test/plugins/slick.verification.test.ts b/packages/tempo/test/plugins/slick.verification.test.ts index 181e949..8d2dbc3 100644 --- a/packages/tempo/test/plugins/slick.verification.test.ts +++ b/packages/tempo/test/plugins/slick.verification.test.ts @@ -51,25 +51,6 @@ describe('Tempo Shorthand Suite (Comprehensive)', () => { }); }); - describe('Ticker Shorthand Integration', () => { - test('ticker with shorthand interval ({ "#qtr": ">" })', () => { - const t = new Tempo('2024-12-25'); - const tick = Tempo.ticker({ seed: t, '#qtr': '>' }); - const pulse1 = tick.pulse(); - expect(pulse1.format('{yyyy}-{mm}-{dd} ({#qtr})')).toBe('2025-01-01 (Q3)'); - const pulse2 = tick.pulse(); - expect(pulse2.format('{yyyy}-{mm}-{dd} ({#qtr})')).toBe('2025-04-01 (Q4)'); - }); - - test('ticker with multi-step shorthand ({ "#season": ">2" })', () => { - const t = new Tempo('2024-12-25'); // Summer - const tick = Tempo.ticker({ seed: t, '#season': '>2' }); - const pulse1 = tick.pulse(); - // Summer -> Autumn -> Winter - expect(pulse1.format('{#season}')).toBe('Winter'); - }); - }); - describe('Environment & Type Safety', () => { test('Tempo constructor casting timeZone to string', () => { // This test verifies the fix for tempo.class:209 / #setSphere diff --git a/packages/tempo/test/plugins/ticker.active.test.ts b/packages/tempo/test/plugins/ticker.active.test.ts deleted file mode 100644 index 7cf8d99..0000000 --- a/packages/tempo/test/plugins/ticker.active.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Tempo } from '#tempo'; -import { Ticker } from '#tempo/plugin/extend/extend.ticker.js'; - -describe('Ticker Management (Static Registry)', () => { - beforeEach(() => { - Tempo.init(); - }); - - it('should track active tickers in the registry', async () => { - const initialCount = Ticker.active.length; - - const t1 = Tempo.ticker(0.5); - const t2 = Tempo.ticker(0.5); - - expect(Ticker.active.length).toBe(initialCount + 2); - - t1.stop(); - expect(Ticker.active.length).toBe(initialCount + 1); - - t2.stop(); - expect(Ticker.active.length).toBe(initialCount); - }); - - it('should provide accurate snapshots via Ticker.active', async () => { - const seed = '2020-01-01T00:00:00'; - const ticker = Tempo.ticker({ seconds: 1, seed, limit: 10 }); - - try { - const snapshot = Ticker.active.find((s: any) => s.ticker === ticker); - - expect(snapshot).toBeDefined(); - expect(snapshot?.ticks).toBe(0); - expect(snapshot?.limit).toBe(10); - expect(snapshot?.next.format('sortTime')).toContain('00:00:00'); - - ticker.pulse(); - const snapshot2 = Ticker.active.find((s: any) => s.ticker === ticker); - expect(snapshot2?.ticks).toBe(1); - expect(snapshot2?.next.format('sortTime')).toContain('00:00:01'); - } finally { - ticker.stop(); - } - }); - - it('should expose instance info via .info getter', () => { - const ticker = Tempo.ticker(5); - try { - const info = ticker.info; - - expect(info.interval).toEqual({ seconds: 5 }); - expect(info.stopped).toBe(false); - expect(info.ticks).toBe(0); - } finally { - ticker.stop(); - } - expect(ticker.info.stopped).toBe(true); - }); - - describe('One-Shot Behavior', () => { - - it('should treat seed-only tickers as one-shot (limit:1)', () => { - // Pattern A: string seed - const t1 = Tempo.ticker('Fri 10am'); - try { - expect(t1.info.limit).toBe(1); - } finally { - t1.stop(); - } - - // Pattern B: options object with only seed - const t2 = Tempo.ticker({ seed: '2025-01-01' }); - try { - expect(t2.info.limit).toBe(1); - } finally { - t2.stop(); - } - }); - - it('should NOT imply limit:1 if a duration property is present', () => { - const t1 = Tempo.ticker({ seed: 'Fri 10am', seconds: 30 }); - try { - expect(t1.info.limit).toBeUndefined(); - } finally { - t1.stop(); - } - }); - }); - - it('should support explicit resource management (using)', async () => { - const limit = 1234; - { - // @ts-ignore - 'using' might require newer TS target but is supported by Vitest/Node - using t = Tempo.ticker({ limit, seconds: 1 }); - expect(Ticker.active.find((s: any) => s.limit === limit)).toBeDefined(); - } - // t is now stopped automatically - expect(Ticker.active.find((s: any) => s.limit === limit)).toBeUndefined(); - }); -}); diff --git a/packages/tempo/test/plugins/ticker.hang.test.ts b/packages/tempo/test/plugins/ticker.hang.test.ts deleted file mode 100644 index c0e1378..0000000 --- a/packages/tempo/test/plugins/ticker.hang.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Tempo } from '#tempo'; -import '#tempo/plugin/extend/extend.ticker.js'; - -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/tempo/test/plugins/ticker.options.test.ts b/packages/tempo/test/plugins/ticker.options.test.ts deleted file mode 100644 index fbaa599..0000000 --- a/packages/tempo/test/plugins/ticker.options.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Tempo } from '#tempo'; -import '#tempo/plugin/extend/extend.ticker.js'; - -describe('Tempo.ticker Options & Enhancements', () => { - beforeEach(() => { - Tempo.init(); - }); - - test('ticker with limit (callback)', async () => { - let count = 0; - const results: string[] = []; - - // Stop after 3 ticks - const stop = Tempo.ticker({ seconds: 0.05, limit: 3 }, (t) => { - count++; - results.push(t.format('{ss}:{ms}') as string); - }); - - try { - await new Promise(resolve => setTimeout(resolve, 250)); - - expect(count).toBe(3); - expect(results.length).toBe(3); - } finally { - stop(); - } - }); - - test('ticker with limit (generator)', async () => { - const ticker = Tempo.ticker({ seconds: 0.03, limit: 2 }); - const results: any[] = []; - - for await (const t of ticker) { - results.push(t); - } - - expect(results.length).toBe(2); - }); - - test('ticker with until (virtual deadline)', async () => { - const seed = '2024-01-01T12:00:00'; - const until = '2024-01-01T12:00:01'; // 1 second later - const results: string[] = []; - - // 200ms interval, should tick at 0ms, 200ms, 400ms, 600ms, 800ms, 1000ms - const stop = Tempo.ticker({ - seconds: 0.2, - seed, - until - }, (t) => { - results.push(t.format('sortTime') as string); - }); - - try { - await new Promise(resolve => setTimeout(resolve, 300)); // enough for virtual time to pass, but real time is fast - - expect(results.length).toBe(6); // 0, 0.2, 0.4, 0.6, 0.8, 1.0 - expect(results[results.length - 1]).toContain('12:00:01'); - } finally { - stop(); - } - }); - - test('ticker with flattened DurationLike options', async () => { - const seed = '2024-01-01T00:00:00'; - // Direct use of 'milliseconds' in options - const ticker = Tempo.ticker({ - milliseconds: 20, - seed, - limit: 2 - }); - - const results: string[] = []; - for await (const t of ticker) { - results.push(t.format('{ss}:{ms}') as string); - } - - expect(results).toEqual(['00:000', '00:020']); - }); - - test('ticker with default 1s interval', async () => { - const seed = '2024-01-01T00:00:00'; - // No interval or duration keys provided - const ticker = Tempo.ticker({ - seed, - limit: 3 - }); - - const results: string[] = []; - for await (const t of ticker) { - results.push(t.format('{ss}') as string); - } - - expect(results).toEqual(['00', '01', '02']); - }); - - test('backward compatibility (numeric interval)', async () => { - let count = 0; - const stop = Tempo.ticker(0.05, () => { - count++; - }); - - try { - await new Promise(resolve => setTimeout(resolve, 150)); - expect(count).toBeGreaterThanOrEqual(2); - } finally { - stop(); - } - }); - - test('ergonomic callback-only ticker (default 1s)', async () => { - let count = 0; - const stop = Tempo.ticker(() => { - count++; - }); - - try { - // Check immediate tick - await new Promise(resolve => setTimeout(resolve, 0)); - expect(count).toBe(1); - } finally { - stop(); - } - }); - -}); diff --git a/packages/tempo/test/plugins/ticker.patterns.test.ts b/packages/tempo/test/plugins/ticker.patterns.test.ts deleted file mode 100644 index 4c2c3db..0000000 --- a/packages/tempo/test/plugins/ticker.patterns.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Tempo } from '#tempo'; -import { isTempo } from '#tempo/support'; -import '#tempo/ticker' - -// TickerModule self-registers on import via definePlugin -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.1, (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, 500)); // wait for ~5 total ticks - } // stop() is called automatically here - - expect(count).toBeGreaterThanOrEqual(4); - expect(lastTick).toBeDefined(); - expect(isTempo(lastTick)).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(isTempo(results[0])).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', () => { - // In Tempo v2 Logify pattern, terminal errors are thrown by default unless caught. - // @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/tempo/test/plugins/ticker.pulse.test.ts b/packages/tempo/test/plugins/ticker.pulse.test.ts deleted file mode 100644 index b579450..0000000 --- a/packages/tempo/test/plugins/ticker.pulse.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Tempo } from '#tempo'; -import '#tempo/plugin/extend/extend.ticker.js'; - -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)); - // This is expected to FAIL currently because limit: 0 is ignored - expect(count).toBe(0); - t.stop(); - }); -}); diff --git a/packages/tempo/test/plugins/ticker.stop.test.ts b/packages/tempo/test/plugins/ticker.stop.test.ts deleted file mode 100644 index e3eee29..0000000 --- a/packages/tempo/test/plugins/ticker.stop.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Tempo } from '#tempo'; -import '#tempo/plugin/extend/extend.ticker.js'; - -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/tempo/test/plugins/ticker.term.core.test.ts b/packages/tempo/test/plugins/ticker.term.core.test.ts deleted file mode 100644 index cc1f788..0000000 --- a/packages/tempo/test/plugins/ticker.term.core.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Tempo } from '#tempo/core'; -import '#tempo/parse'; -import '#tempo/term'; -import { MutateModule } from '#tempo/mutate'; -import { TickerModule } from '#tempo/ticker'; - -Tempo.extend(MutateModule, TickerModule); - -describe('Ticker with Terms', () => { - beforeEach(() => { - Tempo.init({ sphere: 'north' }); - }); - - test.each([ - { - 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 #period term', - interval: { '#period': '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: { '#period.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/tempo/test/plugins/ticker_cold_start.test.ts b/packages/tempo/test/plugins/ticker_cold_start.test.ts deleted file mode 100644 index 1d75499..0000000 --- a/packages/tempo/test/plugins/ticker_cold_start.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Tempo } from '#tempo'; -import '#tempo/plugin/extend/extend.ticker.js'; - -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/tempo/test/support/error-handling.test.ts b/packages/tempo/test/support/error-handling.test.ts deleted file mode 100644 index cbba6e3..0000000 --- a/packages/tempo/test/support/error-handling.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Tempo } from '#tempo'; -import '#tempo/plugin/extend/extend.ticker.js'; - -describe('Error Handling stabilization', () => { - it('should throw an error for invalid ticker interval by default', () => { - Tempo.init({ catch: false }); - - expect(() => Tempo.ticker('invalid')).toThrow(); - expect(console.error).toHaveBeenCalled(); - }); - - it('should log an error and fallback to 1s when catch: true', () => { - Tempo.init({ catch: true }); - - let t: any; - expect(() => { - t = Tempo.ticker('invalid'); - }).not.toThrow(); - - expect(console.error).toHaveBeenCalled(); - expect(t).toBeDefined(); - - t?.[Symbol.dispose](); - Tempo.init({ catch: false }); // Reset - }); -}); From 7d80530c91c17e5cb055ee7961a14acf8ba731fd Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 30 May 2026 13:49:00 +1000 Subject: [PATCH 05/20] PR v3 1st review --- package.json | 2 +- .../src/common/international.library.ts | 6 +-- packages/tempo/doc/migration-guide.md | 9 ++-- packages/tempo/doc/tempo.config.md | 12 ++--- packages/tempo/doc/tempo.cookbook.md | 15 +++++++ packages/tempo/doc/tempo.duration.md | 2 +- packages/tempo/doc/tempo.format.md | 4 +- packages/tempo/package.json | 10 ++--- packages/tempo/src/module/module.duration.ts | 4 +- packages/tempo/src/support/support.enum.ts | 2 +- packages/tempo/src/support/support.license.ts | 4 +- packages/tempo/src/tempo.class.ts | 12 ++++- packages/tempo/src/tempo.type.ts | 2 +- packages/tempo/src/tsconfig.json | 13 +----- packages/tempo/src/tsconfig.repl.json | 45 +++++++++++++++++++ .../test/plugins/duration.balance.test.ts | 3 +- 16 files changed, 101 insertions(+), 44 deletions(-) create mode 100644 packages/tempo/src/tsconfig.repl.json diff --git a/package.json b/package.json index b2b65e5..ad2a3c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.11.3", + "version": "3.0.0", "private": true, "description": "Magma Computing Monorepo", "repository": { diff --git a/packages/library/src/common/international.library.ts b/packages/library/src/common/international.library.ts index 3672300..c410b54 100644 --- a/packages/library/src/common/international.library.ts +++ b/packages/library/src/common/international.library.ts @@ -65,11 +65,7 @@ export function formatList(list: string[], locale?: string, type: Intl.ListForma /** return a localized duration string natively (using Intl.DurationFormat) */ export function formatDuration(duration: any, locale?: string, options?: any) { - try { - return getDF(locale, options).format(duration); - } catch (e) { - return ''; // This shouldn't be relied on if calling code does a feature check first, but it's safe - } + return getDF(locale, options).format(duration); } /** return a localized number string */ diff --git a/packages/tempo/doc/migration-guide.md b/packages/tempo/doc/migration-guide.md index e367d0f..0f4877f 100644 --- a/packages/tempo/doc/migration-guide.md +++ b/packages/tempo/doc/migration-guide.md @@ -14,6 +14,7 @@ The `TickerModule` has been extracted from the core open-source repository into import { Tempo } from '@magmacomputing/tempo'; import { TickerModule } from '@magmacomputing/tempo-plugin-ticker'; + Tempo.init({ license: 'YOUR_JWT_KEY' }); Tempo.extend(TickerModule); ``` @@ -156,15 +157,15 @@ If you previously relied on `BigInt` being treated as nanoseconds, you must now new Tempo(1000n, { timeStamp: 'ns' }); ``` -## ๐Ÿ” Upcoming Deprecations (v3.0.0) +## ๐Ÿ” Removed Features (v3.0.0) ### Internationalization Naming -To better align with ECMAScript standards (specifically `Intl.RelativeTimeFormat`), the `relativeTime` configuration option inside `intl` is deprecated. +To better align with ECMAScript standards (specifically `Intl.RelativeTimeFormat`), the `relativeTime` configuration option inside `intl` is no longer supported in v3.0.0. -- **Deprecated:** `new Tempo({ intl: { relativeTime: { style: 'long' } } })` +- **Removed:** `new Tempo({ intl: { relativeTime: { style: 'long' } } })` - **Recommended:** `new Tempo({ intl: { relativeTimeFormat: { style: 'long' } } })` -Currently, `relativeTime` is still supported and will automatically sync with `relativeTimeFormat`, but it will be entirely removed in Tempo v3.0.0. Please update your configurations. +Please migrate your configurations from `relativeTime` to `relativeTimeFormat`. ## ๐Ÿงช Testing and Stability v2.x has been hardened with a 100% pass rate on our regression suite. If you were relying on undocumented "quirks" or bugs in v1.x parsing, you may find that v2.x is more strict and deterministic. diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 6b88203..bce6cb5 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -244,7 +244,7 @@ console.log(t.toString()); // Resolved correctly (noise words stripped) For high-performance applications, you can enable the **Parse Planner** to optimize the pattern-matching loop. -#### `preFilter` (Boolean) +#### `planner.preFilter` (Boolean) When enabled, Tempo performs a fast upfront classification of the input string (detecting digits, letters, colons, etc.) and skips layouts that cannot possibly match. - **Purely numeric inputs**: Skips `event`, `period`, `wkd`, and `rel` layouts. @@ -252,20 +252,22 @@ When enabled, Tempo performs a fast upfront classification of the input string ( - **Colon detected**: Prioritizes time-based layouts (`tm`, `dtm`) to find a match faster. ```javascript -Tempo.init({ preFilter: true }); +Tempo.init({ + planner: { preFilter: true } +}); ``` -#### `layoutOrder` (Array) +#### `planner.layoutOrder` (Array) You can manually define the order in which layouts are attempted. This is useful if you know your data primarily uses a specific format (e.g., ISO dates) and want to avoid checking other layouts first. ```javascript Tempo.init({ - layoutOrder: ['ymd', 'dt', 'tm', 'rel'] + planner: { layoutOrder: ['ymd', 'dt', 'tm', 'rel'] } }); ``` ::: tip -**Observability**: Set `debug: true` along with `preFilter: true` to see a detailed "Planner summary" in the console, showing how many layouts were skipped for a given input. +**Observability**: Set `debug: true` along with `planner.preFilter` to see a detailed "Planner summary" in the console, showing how many layouts were skipped for a given input. ::: --- diff --git a/packages/tempo/doc/tempo.cookbook.md b/packages/tempo/doc/tempo.cookbook.md index 6087436..29b6aa5 100644 --- a/packages/tempo/doc/tempo.cookbook.md +++ b/packages/tempo/doc/tempo.cookbook.md @@ -224,6 +224,21 @@ console.log(t.format('We are currently in the {#quarter}')); // "We are currentl The examples below use the `using` and `await using` syntax, which require **TypeScript 5.2+** and a runtime that supports **TC39 Explicit Resource Management**. ::: +### โš ๏ธ Ticker Plugin Initialization + +The Ticker engine is a premium feature. Before using `Tempo.ticker()` in the examples below, you must import the plugin, initialize Tempo with your valid license, and extend it with the `TickerModule`. + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { TickerModule } from '@magmacomputing/tempo-plugin-ticker'; + +// 1. Initialize with your JWT license +Tempo.init({ license: 'YOUR_JWT_LICENSE_HERE' }); + +// 2. Extend Tempo with the Ticker Module +Tempo.extend(TickerModule); +``` + ### Subscription Billing (Recurring Payments) Use a `seed` to anchor your subscription to a specific day, then use a month-based Ticker. diff --git a/packages/tempo/doc/tempo.duration.md b/packages/tempo/doc/tempo.duration.md index 966437e..44a9169 100644 --- a/packages/tempo/doc/tempo.duration.md +++ b/packages/tempo/doc/tempo.duration.md @@ -56,7 +56,7 @@ anchor.since(birthday, 'days'); // โ†’ "13,149d ago" (deterministic) // 2. Pass a custom formatter for natural language output (e.g. "yesterday") const yesterday = anchor.add({ days: -1 }); const autoFormat = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' }); -anchor.since(yesterday, { unit: 'days', intl: { relativeTimeFormat: { format: autoFormat } } }); // โ†’ "yesterday" +anchor.since(yesterday, { unit: 'days', intl: { relativeTimeFormat: autoFormat.format.bind(autoFormat) } }); // โ†’ "yesterday" // 3. Returns an ISO 8601 Duration String if no unit is provided anchor.since(birthday); // โ†’ "-P36Y..." diff --git a/packages/tempo/doc/tempo.format.md b/packages/tempo/doc/tempo.format.md index f124f20..5d6f7f0 100644 --- a/packages/tempo/doc/tempo.format.md +++ b/packages/tempo/doc/tempo.format.md @@ -94,8 +94,8 @@ Tempo.extend(FormatModule); | `{wkd}` | Full Weekday Name | `Saturday` | | `{www}` | Short Weekday Name | `Sat` | | `{dow}` | Day of Week (1-7) | `6` | -| `{ww}` | Week of Year | `17` | -| `{WW}` | Ordinal Week of Year | `17th` | +| `{ww}` | Week of Year | `43` | +| `{WW}` | Ordinal Week of Year | `43rd` | | `{hh}` | 2-digit Hour (24h) | `15` | | `{HH}` | 2-digit Hour (12h) | `03` | | `{mer}` | am/pm marker | `pm` | diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 82bd91d..dc76025 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -181,12 +181,12 @@ "test:dist": "cross-env TEST_DIST=true vitest run", "test:ci": "cross-env TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 vitest run", "test:ci:prefilter": "cross-env TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TEMPO_PREFILTER_CI=true vitest run", - "repl": "tsx --tsconfig ./src/tsconfig.json -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", + "repl": "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.json -i --harmony-temporal --import ./bin/repl.ts", - "repl:bare": "tsx --tsconfig ./src/tsconfig.json -i --harmony-temporal", - "repl:core": "cross-env TEMPO_LITE=true tsx --tsconfig ./src/tsconfig.json -i --harmony-temporal --import ./bin/core.ts", - "parse": "cross-env TEMPO_LITE=true tsx --tsconfig ./src/tsconfig.json -i --harmony-temporal --import ./bin/parse.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", + "repl:core": "cross-env TEMPO_LITE=true tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal --import ./bin/core.ts", + "parse": "cross-env TEMPO_LITE=true tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal --import ./bin/parse.ts", "build": "npm run clean && tsc -b && npm run build:bundle && npm run build:resolve", "build:bundle": "rollup -c", "build:resolve": "tsx bin/resolve-types.ts", diff --git a/packages/tempo/src/module/module.duration.ts b/packages/tempo/src/module/module.duration.ts index 792d534..ff02b91 100644 --- a/packages/tempo/src/module/module.duration.ts +++ b/packages/tempo/src/module/module.duration.ts @@ -113,7 +113,6 @@ function toDuration(dur: Temporal.Duration, ctx: { relativeTo?: any, locale?: st unit: unitName, unitDisplay: 'long', ...(ctx.numberFormat || {}), - ...intlOpts }) ); } @@ -252,7 +251,8 @@ export const DurationModule: TempoModule = defineInterpreterModule('DurationModu duration(this: typeof Tempo, input: any) { const ctx = { locale: this.config?.locale, - numberFormat: this.config?.intl?.numberFormat + numberFormat: this.config?.intl?.numberFormat, + durationFormat: this.config?.intl?.durationFormat, }; return interpret(this, 'DurationModule', 'toDuration', false, input, ctx); } diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index 747565d..714e839 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -179,7 +179,7 @@ export type FORMAT = typeof FORMAT; export type Format = LooseUnion & string> /** patterns that return a number */ -export const NumericPattern = ['{yyyy}{ww}', '{yyyy}{mm}', '{yyyy}{mm}{dd}', '{yyww}', '{yw}{ww}', '{yw}'] as const; +export const NumericPattern = ['{yyyy}{ww}', '{yyyy}{mm}', '{yyyy}{mm}{dd}', '{yyww}', '{yw}{ww}', '{yw}', '{ymd}', '{ymd6}'] as const; export type NumericPattern = typeof NumericPattern[number] /** pre-configured format strings */ diff --git a/packages/tempo/src/support/support.license.ts b/packages/tempo/src/support/support.license.ts index 83aef6e..de5a132 100644 --- a/packages/tempo/src/support/support.license.ts +++ b/packages/tempo/src/support/support.license.ts @@ -18,7 +18,7 @@ export class Validator { error: 'Cryptographic engine missing. Premium plugins cannot be validated by the Community Build.', } } - async syncRevocation(_jwsUrl: string, _currentJti: string): Promise { - return false; // No revocation checking in community edition + async syncRevocation(_jwsUrl: string, _currentJti: string): Promise<{ revoked: boolean, success: boolean }> { + return { revoked: false, success: false }; // No revocation checking in community edition } } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 421a875..91976b4 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -383,8 +383,16 @@ export class Tempo { } // 1d. Process Internationalization - if (discovery.intl) - shape.config.intl = { ...shape.config.intl, ...discovery.intl }; + if (discovery.intl) { + shape.config.intl = shape.config.intl || {}; + for (const [key, val] of Object.entries(discovery.intl)) { + if (isObject(val) && isObject((shape.config.intl as any)[key])) { + (shape.config.intl as any)[key] = { ...(shape.config.intl as any)[key], ...val }; + } else { + (shape.config.intl as any)[key] = val; + } + } + } // 1e. Process Planner if (isObject(discovery.planner)) { diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 3b72ad0..7d08cf8 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -351,7 +351,7 @@ export namespace Internal { export interface LicensingModule { Validator: new (jwt: string) => { verify(): Promise; - syncRevocation(jwsUrl: string, currentJti: string): Promise; + syncRevocation(jwsUrl: string, currentJti: string): Promise<{ revoked: boolean, success: boolean }>; }; } diff --git a/packages/tempo/src/tsconfig.json b/packages/tempo/src/tsconfig.json index e0f0e9e..d581e40 100644 --- a/packages/tempo/src/tsconfig.json +++ b/packages/tempo/src/tsconfig.json @@ -33,18 +33,7 @@ "#tempo/term/*": [ "./plugin/term/term.*.ts" ], "#tempo/support": [ "./support/support.index.ts" ], "#tempo/support/*": [ "./support/*" ], - "#tempo/license": [ - "../premium/src/index.ts", - "./support/support.license.ts" - ], - "@magmacomputing/tempo": [ "./tempo.index.ts" ], - "@magmacomputing/tempo/plugin": [ "./plugin/plugin.index.ts" ], - "@magmacomputing/tempo/plugin/*": [ "./plugin/*.ts" ], - "@magmacomputing/tempo/term": [ "./plugin/term/term.index.ts" ], - "@magmacomputing/tempo/term/*": [ "./plugin/term/term.*.ts" ], - "@magmacomputing/tempo/enums": [ "./support/support.enum.ts" ], - "@magmacomputing/tempo/core": [ "./core.index.ts" ], - "@magmacomputing/tempo/*": [ "./*" ], + "#tempo/license": [ "./support/support.license.ts" ], "#tempo/*": [ "./*" ] } }, diff --git a/packages/tempo/src/tsconfig.repl.json b/packages/tempo/src/tsconfig.repl.json new file mode 100644 index 0000000..a87d3e0 --- /dev/null +++ b/packages/tempo/src/tsconfig.repl.json @@ -0,0 +1,45 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "#library": [ "../../library/src/common.index.ts" ], + "#library/*": [ "../../library/src/common/*" ], + "#browser": [ "../../library/src/browser.index.ts" ], + "#browser/*": [ "../../library/src/browser/*" ], + "#server": [ "../../library/src/server.index.ts" ], + "#server/*": [ "../../library/src/server/*" ], + "#tempo": [ "./tempo.index.ts" ], + "#tempo/core": [ "./core.index.ts" ], + "#tempo/enums": [ "./support/support.enum.ts" ], + "#tempo/parse": [ "./module/module.parse.ts" ], + "#tempo/format": [ "./module/module.format.ts" ], + "#tempo/discrete": [ "./module/module.index.ts" ], + "#tempo/module": [ "./module/module.index.ts" ], + "#tempo/duration": [ "./module/module.duration.ts" ], + "#tempo/mutate": [ "./module/module.mutate.ts" ], + "#tempo/ticker": [ "./plugin/extend/extend.ticker.ts" ], + "#tempo/engine/*.js": [ "./engine/*.ts" ], + "#tempo/module/*.js": [ "./module/*.ts" ], + "#tempo/plugin/extend/*.js": [ "./plugin/extend/*.ts" ], + "#tempo/plugin/term/*.js": [ "./plugin/term/*.ts" ], + "#tempo/plugin/plugin.*.js": [ "./plugin/plugin.*.ts" ], + "#tempo/term": [ "./plugin/term/term.index.ts" ], + "#tempo/term/*": [ "./plugin/term/term.*.ts" ], + "#tempo/support": [ "./support/support.index.ts" ], + "#tempo/support/*": [ "./support/*" ], + "#tempo/license": [ + "../premium/src/index.ts", + "./support/support.license.ts" + ], + "@magmacomputing/tempo": [ "./tempo.index.ts" ], + "@magmacomputing/tempo/plugin": [ "./plugin/plugin.index.ts" ], + "@magmacomputing/tempo/plugin/*": [ "./plugin/*.ts" ], + "@magmacomputing/tempo/term": [ "./plugin/term/term.index.ts" ], + "@magmacomputing/tempo/term/*": [ "./plugin/term/term.*.ts" ], + "@magmacomputing/tempo/enums": [ "./support/support.enum.ts" ], + "@magmacomputing/tempo/core": [ "./core.index.ts" ], + "@magmacomputing/tempo/*": [ "./*" ], + "#tempo/*": [ "./*" ] + } + } +} \ No newline at end of file diff --git a/packages/tempo/test/plugins/duration.balance.test.ts b/packages/tempo/test/plugins/duration.balance.test.ts index a6ded58..72f9bc6 100644 --- a/packages/tempo/test/plugins/duration.balance.test.ts +++ b/packages/tempo/test/plugins/duration.balance.test.ts @@ -28,7 +28,8 @@ describe('Duration EDO Balance and Format', () => { expect(dur1.format({ locales: 'en-US' })).toBe('365 days'); const dur2 = Tempo.duration({ years: 1, days: 5 }); - expect(dur2.format({ locales: 'en-US' })).toBe('1 yr, 5 days'); + const fmt2 = dur2.format({ locales: 'en-US' }); + expect(['1 yr, 5 days', '1 year and 5 days']).toContain(fmt2); }); test('format() respects cascading numberFormat config', () => { From c2518772e156be4ba0693d0cbfe388ac752f8881 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 31 May 2026 10:37:15 +1000 Subject: [PATCH 06/20] safe Sun morning --- packages/tempo/src/support/support.license.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tempo/src/support/support.license.ts b/packages/tempo/src/support/support.license.ts index de5a132..4f2d3c1 100644 --- a/packages/tempo/src/support/support.license.ts +++ b/packages/tempo/src/support/support.license.ts @@ -7,7 +7,9 @@ import { decodeJWT } from '#library/utility.library.js'; */ export class Validator { - constructor(public key: string) { } + constructor(public key: string) { + console.warn('Tempo Community Edition: License keys are ignored. Premium plugins cannot be validated without the cryptographic engine.'); + } async verify() { // Decodes but DOES NOT verify the signature. // Cannot safely unlock Premium Plugins without cryptographic proof. From f5a5970c39012c0f9c036e1799b25e793d2e3a1f Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 1 Jun 2026 14:00:39 +1000 Subject: [PATCH 07/20] complete Vitest v4 upgrade --- package-lock.json | 2279 +++++++++++++---- package.json | 19 +- .../library/src/browser/webstore.class.ts | 8 +- packages/library/src/common/cipher.class.ts | 26 +- packages/library/src/common/class.library.ts | 40 +- packages/library/src/common/pledge.class.ts | 64 +- packages/library/src/common/type.library.ts | 15 +- packages/library/src/server/file.library.ts | 10 +- packages/library/vitest.config.ts | 15 + packages/tempo/.vitepress/config.ts | 13 +- packages/tempo/doc/tempo.extension.md | 145 ++ packages/tempo/doc/tempo.plugin.md | 32 +- packages/tempo/doc/tempo.ticker.md | 11 + packages/tempo/index.md | 11 +- packages/tempo/package.json | 5 +- packages/tempo/rollup.config.js | 2 +- packages/tempo/src/engine/engine.alias.ts | 10 +- packages/tempo/src/support/support.init.ts | 7 +- packages/tempo/src/tempo.class.ts | 159 +- packages/tempo/src/tempo.type.ts | 4 +- packages/tempo/test/core/alias-engine.test.ts | 4 - .../tempo/test/plugins/licensing.full.test.ts | 31 +- packages/tempo/vitest.config.ts | 37 +- packages/tempo/vitest.workspace.ts | 23 - vitest.config.ts | 43 +- vitest.workspace.ts | 40 - 26 files changed, 2196 insertions(+), 857 deletions(-) create mode 100644 packages/tempo/doc/tempo.extension.md create mode 100644 packages/tempo/doc/tempo.ticker.md delete mode 100644 packages/tempo/vitest.workspace.ts delete mode 100644 vitest.workspace.ts diff --git a/package-lock.json b/package-lock.json index 11cef43..ef001ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,30 +1,31 @@ { "name": "tempo-monorepo", - "version": "2.11.3", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "2.11.3", + "version": "3.0.0", "workspaces": [ "packages/*" ], "devDependencies": { "@js-temporal/polyfill": "^0.5.1", "@rollup/plugin-node-resolve": "^16.0.3", - "@types/google.maps": "^3.64.1", + "@types/google.maps": "^3.65.0", "@types/hammerjs": "^2.0.46", "@types/jquery": "^4.0.0", - "@types/node": "^25.8.0", - "@vitest/ui": "^2.1.9", + "@types/node": "^25.9.1", + "@vitest/ui": "^4.1.7", "cross-env": "^10.1.0", "markdown-it-mathjax3": "^4.3.2", "rollup": "^4.60.4", "tslib": "^2.8.1", - "tsx": "^4.22.0", + "tsx": "^4.22.3", "typescript": "^6.0.3", - "vitest": "^2.1.9" + "unplugin-swc": "^1.5.9", + "vitest": "^4.1.7" } }, "node_modules/@algolia/abtesting": { @@ -386,6 +387,40 @@ } } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -394,9 +429,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -411,9 +446,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -428,9 +463,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -445,9 +480,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -462,9 +497,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -479,9 +514,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -496,9 +531,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -513,9 +548,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -530,9 +565,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -547,9 +582,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -564,9 +599,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -581,9 +616,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -598,9 +633,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -615,9 +650,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -632,9 +667,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -649,9 +684,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -666,9 +701,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -683,9 +718,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -700,9 +735,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -717,9 +752,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -734,9 +769,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -750,10 +785,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -768,9 +820,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -785,9 +837,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -802,9 +854,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -850,31 +902,65 @@ "license": "MIT" }, "node_modules/@inversifyjs/common": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@inversifyjs/common/-/common-1.3.3.tgz", - "integrity": "sha512-ZH0wrgaJwIo3s9gMCDM2wZoxqrJ6gB97jWXncROfYdqZJv8f3EkqT57faZqN5OTeHWgtziQ6F6g3L8rCvGceCw==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@inversifyjs/common/-/common-1.5.2.tgz", + "integrity": "sha512-WlzR9xGadABS9gtgZQ+luoZ8V6qm4Ii6RQfcfC9Ho2SOlE6ZuemFo7PKJvKI0ikm8cmKbU8hw5UK6E4qovH21w==", "dev": true, "license": "MIT" }, + "node_modules/@inversifyjs/container": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@inversifyjs/container/-/container-1.15.0.tgz", + "integrity": "sha512-U2xYsPrJTz5za2TExi5lg8qOWf8TEVBpN+pQM7B8BVA2rajtbRE9A66SLRHk8c1eGXmg+0K4Hdki6tWAsSQBUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inversifyjs/common": "1.5.2", + "@inversifyjs/core": "9.2.0", + "@inversifyjs/plugin": "0.2.0", + "@inversifyjs/reflect-metadata-utils": "1.4.1" + }, + "peerDependencies": { + "reflect-metadata": "~0.2.2" + } + }, "node_modules/@inversifyjs/core": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@inversifyjs/core/-/core-1.3.4.tgz", - "integrity": "sha512-gCCmA4BdbHEFwvVZ2elWgHuXZWk6AOu/1frxsS+2fWhjEk2c/IhtypLo5ytSUie1BCiT6i9qnEo4bruBomQsAA==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@inversifyjs/core/-/core-9.2.0.tgz", + "integrity": "sha512-Nm7BR6KmpgshIHpVQWuEDehqRVb6GBm8LFEuhc2s4kSZWrArZ15RmXQzROLk4m+hkj4kMXgvMm5Qbopot/D6Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inversifyjs/common": "1.5.2", + "@inversifyjs/prototype-utils": "0.1.3", + "@inversifyjs/reflect-metadata-utils": "1.4.1" + } + }, + "node_modules/@inversifyjs/plugin": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@inversifyjs/plugin/-/plugin-0.2.0.tgz", + "integrity": "sha512-R/JAdkTSD819pV1zi0HP54mWHyX+H2m8SxldXRgPQarS3ySV4KPyRdosWcfB8Se0JJZWZLHYiUNiS6JvMWSPjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@inversifyjs/prototype-utils": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@inversifyjs/prototype-utils/-/prototype-utils-0.1.3.tgz", + "integrity": "sha512-EzRamZzNgE9Sn3QtZ8NncNa2lpPMZfspqbK6BWFguWnOpK8ymp2TUuH46ruFHZhrHKnknPd7fG22ZV7iF517TQ==", "dev": true, "license": "MIT", "dependencies": { - "@inversifyjs/common": "1.3.3", - "@inversifyjs/reflect-metadata-utils": "0.2.3" + "@inversifyjs/common": "1.5.2" } }, "node_modules/@inversifyjs/reflect-metadata-utils": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@inversifyjs/reflect-metadata-utils/-/reflect-metadata-utils-0.2.3.tgz", - "integrity": "sha512-d3D0o9TeSlvaGM2I24wcNw/Aj3rc4OYvHXOKDC09YEph5fMMiKd6fq1VTQd9tOkDNWvVbw+cnt45Wy9P/t5Lvw==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@inversifyjs/reflect-metadata-utils/-/reflect-metadata-utils-1.4.1.tgz", + "integrity": "sha512-Cp77C4d2wLaHXiUB7iH6Cxb7i1lD/YDuTIHLTDzKINqGSz0DCSoL/Dg2wVkW/6Qx03r/yQMLJ+32Agl32N2X8g==", "dev": true, "license": "MIT", "peerDependencies": { - "reflect-metadata": "0.2.2" + "reflect-metadata": "~0.2.2" } }, "node_modules/@javascript-obfuscator/escodegen": { @@ -906,6 +992,38 @@ "node": ">=4.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -913,6 +1031,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@js-temporal/polyfill": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", @@ -934,104 +1063,46 @@ "resolved": "packages/tempo", "link": true }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/plugin-alias": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-6.0.0.tgz", - "integrity": "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "rollup": ">=4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", - "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" + "@tybys/wasm-util": "^0.10.1" }, - "engines": { - "node": ">=14.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -1040,12 +1111,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -1054,12 +1128,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -1068,42 +1145,51 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" - ] + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ - "arm" + "arm64" ], "dev": true, "libc": [ @@ -1113,14 +1199,17 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ - "arm" + "arm64" ], "dev": true, "libc": [ @@ -1130,14 +1219,17 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, "libc": [ @@ -1147,24 +1239,372 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ - "arm64" + "s390x" ], "dev": true, "libc": [ - "musl" + "glibc" ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-alias": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-6.0.0.tgz", + "integrity": "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "rollup": ">=4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.4", @@ -1512,25 +1952,356 @@ "dev": true, "license": "MIT", "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/core": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz", + "integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.40", + "@swc/core-darwin-x64": "1.15.40", + "@swc/core-linux-arm-gnueabihf": "1.15.40", + "@swc/core-linux-arm64-gnu": "1.15.40", + "@swc/core-linux-arm64-musl": "1.15.40", + "@swc/core-linux-ppc64-gnu": "1.15.40", + "@swc/core-linux-s390x-gnu": "1.15.40", + "@swc/core-linux-x64-gnu": "1.15.40", + "@swc/core-linux-x64-musl": "1.15.40", + "@swc/core-win32-arm64-msvc": "1.15.40", + "@swc/core-win32-ia32-msvc": "1.15.40", + "@swc/core-win32-x64-msvc": "1.15.40" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz", + "integrity": "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz", + "integrity": "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz", + "integrity": "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz", + "integrity": "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz", + "integrity": "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz", + "integrity": "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz", + "integrity": "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz", + "integrity": "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz", + "integrity": "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz", + "integrity": "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz", + "integrity": "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz", + "integrity": "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@shikijs/types": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", - "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, @@ -1542,9 +2313,9 @@ "license": "MIT" }, "node_modules/@types/google.maps": { - "version": "3.64.1", - "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.1.tgz", - "integrity": "sha512-nEBoa6iDNipICtxJ5VlrOgPNZQ6ixIg5nuv8iryFj0Z/1NLgxyg3pQCVegPuCzGCyTQwQI/N3uZvLUysqAzaaw==", + "version": "3.65.0", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.65.0.tgz", + "integrity": "sha512-u4SHiRC3m27lPa4vDBxh2AI7mDcHcheX6GSHn1Mwi0Gap8/uhM2kFppiFTnWASXLHZO+1ahHciLeEIV+Sjqk/A==", "dev": true, "license": "MIT" }, @@ -1615,9 +2386,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.8.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", - "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1691,135 +2462,108 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/ui": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-2.1.9.tgz", - "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.7.tgz", + "integrity": "sha512-TP6utB2yX6rsJNVRo2qAlsi48i1YwFTrLV2tnTtWqJaYX7m4lRCCLirZBjU6xC5m0RsPHr+L2+N+eIPhgEzFfw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", + "@vitest/utils": "4.1.7", "fflate": "^0.8.2", - "flatted": "^3.3.1", - "pathe": "^1.1.2", - "sirv": "^3.0.0", - "tinyglobby": "^0.2.10", - "tinyrainbow": "^1.2.0" + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.1.9" + "vitest": "4.1.7" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2310,16 +3054,6 @@ "concat-map": "0.0.1" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2382,18 +3116,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2464,16 +3191,6 @@ "node": "*" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/cheerio": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz", @@ -2573,6 +3290,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -2687,16 +3411,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2760,6 +3474,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -2935,9 +3659,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2948,49 +3672,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escape-goat": { @@ -3443,14 +4150,15 @@ "license": "ISC" }, "node_modules/inversify": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/inversify/-/inversify-6.1.4.tgz", - "integrity": "sha512-PbxrZH/gTa1fpPEEGAjJQzK8tKMIp5gRg6EFNJlCtzUcycuNdmhv3uk5P8Itm/RIjgHJO16oQRLo9IHzQN51bA==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/inversify/-/inversify-7.11.0.tgz", + "integrity": "sha512-yZDprSSr8TyVeMGI/AOV4ws6gwjX22hj9Z8/oHAVpJORY6WRFTcUzhnZtibBUHEw2U8ArvHcR+i863DplQ3Cwg==", "dev": true, "license": "MIT", "dependencies": { - "@inversifyjs/common": "1.3.3", - "@inversifyjs/core": "1.3.4" + "@inversifyjs/common": "1.5.2", + "@inversifyjs/container": "1.15.0", + "@inversifyjs/core": "9.2.0" } }, "node_modules/is-arguments": { @@ -3643,9 +4351,9 @@ "license": "ISC" }, "node_modules/javascript-obfuscator": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/javascript-obfuscator/-/javascript-obfuscator-5.4.2.tgz", - "integrity": "sha512-VUcjC6IPDuB5vAFVZ7qhGRkyewGWV5p05GuCWr3wwQjAym8icDprqz7B9595pqN6aI8EgyazgogOuac89xIgxw==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/javascript-obfuscator/-/javascript-obfuscator-5.4.3.tgz", + "integrity": "sha512-G5WUgh84tJa5jtk49w+nQCxQpXIvp65xhLyaFeTAdQeYqWji+SXo89k96UvYIDqojyWZEWSDKRmaWKK6bBn/2A==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3663,7 +4371,7 @@ "eslint-scope": "8.4.0", "eslint-visitor-keys": "4.2.1", "fast-deep-equal": "3.1.3", - "inversify": "6.1.4", + "inversify": "7.11.0", "js-string-escape": "1.0.1", "md5": "2.3.0", "multimatch": "5.0.0", @@ -3707,46 +4415,319 @@ "devOptional": true, "license": "Apache-2.0" }, - "node_modules/juice": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/juice/-/juice-8.1.0.tgz", - "integrity": "sha512-FLzurJrx5Iv1e7CfBSZH68dC04EEvXvvVvPYB7Vx1WAuhCp1ZPIMtqxc+WTWxVkpTIC2Ach/GAv0rQbtGf6YMA==", + "node_modules/juice": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/juice/-/juice-8.1.0.tgz", + "integrity": "sha512-FLzurJrx5Iv1e7CfBSZH68dC04EEvXvvVvPYB7Vx1WAuhCp1ZPIMtqxc+WTWxVkpTIC2Ach/GAv0rQbtGf6YMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio": "1.0.0-rc.10", + "commander": "^6.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^6.0.1" + }, + "bin": { + "juice": "bin/juice" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.2.tgz", + "integrity": "sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "cheerio": "1.0.0-rc.10", - "commander": "^6.1.0", - "mensch": "^0.3.4", - "slick": "^1.12.2", - "web-resource-inliner": "^6.0.1" - }, - "bin": { - "juice": "bin/juice" - }, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10.0.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/libphonenumber-js": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.2.tgz", - "integrity": "sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, "node_modules/linkify-it": { "version": "5.0.0", @@ -3758,12 +4739,15 @@ "uc.micro": "^2.0.0" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } }, "node_modules/lunr": { "version": "2.3.9", @@ -4082,9 +5066,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -4182,6 +5166,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/oniguruma-to-es": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", @@ -4247,22 +5242,12 @@ "license": "MIT" }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -4301,9 +5286,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4321,7 +5306,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4462,6 +5447,40 @@ "dev": true, "license": "MIT" }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -4760,9 +5779,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -4865,11 +5884,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", + "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.16", @@ -4888,30 +5910,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -4953,9 +5955,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", - "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", "dev": true, "license": "MIT", "dependencies": { @@ -5042,9 +6044,9 @@ } }, "node_modules/typedoc/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -5181,6 +6183,37 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-swc": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.5.9.tgz", + "integrity": "sha512-RKwK3yf0M+MN17xZfF14bdKqfx0zMXYdtOdxLiE6jHAoidupKq3jGdJYANyIM1X/VmABhh1WpdO+/f4+Ol89+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.3.0", + "load-tsconfig": "^0.2.5", + "unplugin": "^2.3.11" + }, + "peerDependencies": { + "@swc/core": "^1.2.108" + } + }, "node_modules/unplugin-utils": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.2.5.tgz", @@ -5198,13 +6231,6 @@ "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/unplugin-utils/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -5329,29 +6355,6 @@ } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitepress": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", @@ -5406,58 +6409,79 @@ } }, "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -5468,6 +6492,121 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, @@ -5560,6 +6699,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -5672,7 +6818,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "2.12.0", + "version": "3.0.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -5683,17 +6829,16 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "2.12.0", + "version": "3.0.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "*", + "@magmacomputing/library": "3.0.0", "@rollup/plugin-alias": "^6.0.0", - "esbuild": "^0.25.12", - "javascript-obfuscator": "^5.4.2", + "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", "rollup-plugin-esbuild": "^6.2.1", "typedoc": "^0.28.19", diff --git a/package.json b/package.json index ad2a3c2..0231f6c 100644 --- a/package.json +++ b/package.json @@ -27,20 +27,25 @@ "devDependencies": { "@js-temporal/polyfill": "^0.5.1", "@rollup/plugin-node-resolve": "^16.0.3", - "@types/google.maps": "^3.64.1", + "@types/google.maps": "^3.65.0", "@types/hammerjs": "^2.0.46", "@types/jquery": "^4.0.0", - "@types/node": "^25.8.0", - "@vitest/ui": "^2.1.9", + "@types/node": "^25.9.1", + "@vitest/ui": "^4.1.7", "cross-env": "^10.1.0", "markdown-it-mathjax3": "^4.3.2", "rollup": "^4.60.4", "tslib": "^2.8.1", - "tsx": "^4.22.0", + "tsx": "^4.22.3", "typescript": "^6.0.3", - "vitest": "^2.1.9" + "unplugin-swc": "^1.5.9", + "vitest": "^4.1.7" }, "overrides": { - "esbuild": "^0.25.0" + "esbuild": "^0.28.0" + }, + "allowScripts": { + "esbuild": true, + "@swc/core": true } -} +} \ No newline at end of file diff --git a/packages/library/src/browser/webstore.class.ts b/packages/library/src/browser/webstore.class.ts index 25fbeca..8ef3ce4 100644 --- a/packages/library/src/browser/webstore.class.ts +++ b/packages/library/src/browser/webstore.class.ts @@ -15,17 +15,17 @@ type STORAGE = ValueOf * Refactored for lazy-initialization to ensure side-effect free imports. */ export class WebStore { - static #localInstance?: WebStore; - static #sessionInstance?: WebStore; + private static _localInstance?: WebStore; + private static _sessionInstance?: WebStore; /** Lazy getter for localStorage wrapper */ static get local() { - return WebStore.#localInstance ??= new WebStore(STORAGE.Local); + return WebStore._localInstance ??= new WebStore(STORAGE.Local); } /** Lazy getter for sessionStorage wrapper */ static get session() { - return WebStore.#sessionInstance ??= new WebStore(STORAGE.Session); + return WebStore._sessionInstance ??= new WebStore(STORAGE.Session); } #type: STORAGE; diff --git a/packages/library/src/common/cipher.class.ts b/packages/library/src/common/cipher.class.ts index 10e224e..6f39af6 100644 --- a/packages/library/src/common/cipher.class.ts +++ b/packages/library/src/common/cipher.class.ts @@ -13,19 +13,19 @@ const keys = { TypeKey: 'AES-GCM', } as const +const _cryptoKey = subtle.generateKey({ name: keys.TypeKey, length: 128 }, false, ['encrypt', 'decrypt']); +const _vector = crypto.getRandomValues(new Uint8Array(16)); +const _asymmetricKey = subtle.generateKey({ + name: keys.SignKey, + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: keys.Algorithm }, +}, false, ['sign', 'verify']); + /** Static-only cryptographic methods */ @Immutable @Static // prevent instantiation export class Cipher { - static #cryptoKey = subtle.generateKey({ name: keys.TypeKey, length: 128 }, false, ['encrypt', 'decrypt']); - static #vector = crypto.getRandomValues(new Uint8Array(16)); - static #asymmetricKey = subtle.generateKey({ - name: keys.SignKey, - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: { name: keys.Algorithm }, - }, false, ['sign', 'verify']); - /** random UUID */ static randomKey = () => crypto.randomUUID().split('-')[0]; @@ -74,20 +74,20 @@ export class Cipher { static decodeBuffer = (buf: Uint16Array) => new TextDecoder(keys.Encoding).decode(buf); static encrypt = async (data: any) => - subtle.encrypt({ name: keys.TypeKey, iv: Cipher.#vector }, await Cipher.#cryptoKey, Cipher.encodeBuffer(data)) + subtle.encrypt({ name: keys.TypeKey, iv: _vector }, await _cryptoKey, Cipher.encodeBuffer(data)) .then(result => new Uint16Array(result)) .then(Cipher.decodeBuffer); static decrypt = async (secret: Promise) => - subtle.decrypt({ name: keys.TypeKey, iv: Cipher.#vector }, await Cipher.#cryptoKey, await secret) + subtle.decrypt({ name: keys.TypeKey, iv: _vector }, await _cryptoKey, await secret) .then(result => new Uint16Array(result)) .then(Cipher.decodeBuffer); static sign = async (doc: any) => - subtle.sign(keys.SignKey, (await Cipher.#asymmetricKey).privateKey!, Cipher.encodeBuffer(doc)) + subtle.sign(keys.SignKey, (await _asymmetricKey).privateKey!, Cipher.encodeBuffer(doc)) .then(result => new Uint16Array(result)) .then(Cipher.decodeBuffer); static verify = async (signature: Promise, doc: any) => - subtle.verify(keys.SignKey, (await Cipher.#asymmetricKey).publicKey!, await signature, Cipher.encodeBuffer(doc)); + subtle.verify(keys.SignKey, (await _asymmetricKey).publicKey!, await signature, Cipher.encodeBuffer(doc)); } diff --git a/packages/library/src/common/class.library.ts b/packages/library/src/common/class.library.ts index 1acd96f..8947f2e 100644 --- a/packages/library/src/common/class.library.ts +++ b/packages/library/src/common/class.library.ts @@ -1,12 +1,21 @@ import { $ImmutableSkip } from '#library/symbol.library.js'; import { secure } from '#library/proxy.library.js'; import { registerSerializable } from '#library/serialize.library.js'; -import { type Constructor, type Type, registerType } from '#library/type.library.js'; +import { registerType, getSafeTag } from '#library/type.library.js'; +import type { Constructor, Type } from '#library/type.library.js'; /** * Some interesting Class Decorators */ +/** + * Safely extracts the class name from Symbol.toStringTag (if present) to prevent + * minifiers and compilers from mangling the registered class name. + */ +function getClassName(value: T, contextName: string | symbol | undefined): string { + return getSafeTag(value) ?? String(contextName); +} + /** * Shared helper to create an immutable or secure class wrapper */ @@ -78,7 +87,7 @@ function hardenClassStaticsAndPrototypes(value: any, wrapper: any, skip: any) { Object.defineProperty(proto, name, { ...desc, ...update }); }); } - + lockPrototype(value.prototype); lockPrototype(wrapper.prototype); } @@ -87,11 +96,11 @@ function hardenClassStaticsAndPrototypes(value: any, wrapper: any, skip: any) { * Decorator to secure a class with a mutation-throwing Proxy (noisy immutability) */ export function Securable(value: T, { kind, name, addInitializer }: ClassDecoratorContext): T | void { - name = String(name); + const finalName = getClassName(value, name); switch (kind) { case 'class': - return createImmutableWrapper(value, name, addInitializer, secure); + return createImmutableWrapper(value, finalName, addInitializer, secure); default: throw new Error(`@Securable decorating unknown 'kind': ${kind} (${name})`); } @@ -99,11 +108,11 @@ export function Securable(value: T, { kind, name, addInit /** decorator to freeze a Class to prevent modification (silent immutability) */ export function Immutable(value: T, { kind, name, addInitializer }: ClassDecoratorContext): T | void { - name = String(name); + const finalName = getClassName(value, name); switch (kind) { case 'class': - return createImmutableWrapper(value, name, addInitializer, (instance) => { Object.freeze(instance); return instance; }); + return createImmutableWrapper(value, finalName, addInitializer, (instance) => { Object.freeze(instance); return instance; }); default: throw new Error(`@Immutable decorating unknown 'kind': ${kind} (${name})`); @@ -112,12 +121,13 @@ export function Immutable(value: T, { kind, name, addInit /** register a Class for serialization */ export function Serializable(value: T, { kind, name, addInitializer }: ClassDecoratorContext): T | void { - name = String(name); // cast as String - registerType(value, name as Type); + const finalName = getClassName(value, name); + + registerType(value, finalName as Type); switch (kind) { case 'class': - addInitializer(() => registerSerializable(name, value));// register the class for serialization, via its toString() method + addInitializer(() => registerSerializable(finalName, value));// register the class for serialization, via its toString() method return value; @@ -128,21 +138,21 @@ export function Serializable(value: T, { kind, name, addI /** make a Class not instantiable */ export function Static(value: T, { kind, name }: ClassDecoratorContext): T | void { - name = String(name); + const finalName = getClassName(value, name); switch (kind) { case 'class': const wrapper = { - [name]: class extends value { + [finalName]: class extends value { constructor(...args: any[]) { super(...args); - throw new TypeError(`${name} is not a constructor`); + throw new TypeError(`${finalName} is not a constructor`); } } - }[name] as T; + }[finalName] as T; - registerType(value, `${name}_original` as Type); // register the original class definition - registerType(wrapper, name as Type); // register the wrapper as the authoritative definition + registerType(value, `${finalName}_original` as Type); // register the original class definition + registerType(wrapper, finalName as Type); // register the wrapper as the authoritative definition return wrapper; diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/pledge.class.ts index 573bb10..4d04ab8 100644 --- a/packages/library/src/common/pledge.class.ts +++ b/packages/library/src/common/pledge.class.ts @@ -13,6 +13,14 @@ declare module '#library/type.library.js' { } } +const _dbg = new Logify('Pledge'); +let _static = {} as Pledge.Constructor; +const _STATE = secure({ + Pending: Symbol('pending'), + Resolved: Symbol('resolved'), + Rejected: Symbol('rejected') +}); + /** * Wrap a Promise's resolve/reject/finally methods for later fulfilment. * with useful methods for tracking the state of the Promise, chaining fulfilment, etc. @@ -25,32 +33,26 @@ declare module '#library/type.library.js' { export class Pledge { #pledge: PromiseWithResolvers; #status = {} as Pledge.Status; - static #dbg = new Logify('Pledge'); - static #static = {} as Pledge.Constructor; - static STATE = secure({ - Pending: Symbol('pending'), - Resolved: Symbol('resolved'), - Rejected: Symbol('rejected') - }) + static get STATE() { return _STATE; } /** initialize future Pledge instances */ static init(arg?: Pledge.Constructor | string) { if (isObject(arg)) { if (isEmpty(arg)) - Pledge.#static = {}; // reset static values + _static = {}; // reset static values - markConfig(Pledge.#static); - Object.assign(Pledge.#static, + markConfig(_static); + Object.assign(_static, ifDefined({ tag: arg.tag, debug: arg.debug, catch: arg.catch, silent: arg.silent }), ifDefined({ onResolve: arg.onResolve, onReject: arg.onReject, onSettle: arg.onSettle, }), ) } else { - markConfig(Pledge.#static); - Object.assign(Pledge.#static, ifDefined({ tag: arg, })); + markConfig(_static); + Object.assign(_static, ifDefined({ tag: arg, })); } - Pledge.#dbg.debug(Pledge.#static, Pledge.#static); + _dbg.debug(_static, _static); return Pledge.status; } @@ -59,26 +61,26 @@ export class Pledge { static [Symbol.dispose]() { Pledge.init({}) } static get status() { - return Pledge.#static as Pledge.Status; + return _static as Pledge.Status; } constructor(arg?: Pledge.Constructor | string) { const opts = isObject(arg) ? arg : { tag: arg as string }; - const config = { ...Pledge.#static, ...ifDefined({ tag: opts.tag, debug: opts.debug, catch: opts.catch, silent: opts.silent }) }; + const config = { ..._static, ...ifDefined({ tag: opts.tag, debug: opts.debug, catch: opts.catch, silent: opts.silent }) }; this.#pledge = Promise.withResolvers(); - this.#status = markConfig({ state: Pledge.STATE.Pending, ...config }); + this.#status = markConfig({ state: _STATE.Pending, ...config }); - const onResolve = asArray(Pledge.#static.onResolve).concat(asArray(opts.onResolve)); - const onReject = asArray(Pledge.#static.onReject).concat(asArray(opts.onReject)); - const onSettle = asArray(Pledge.#static.onSettle).concat(asArray(opts.onSettle)); + const onResolve = asArray(_static.onResolve).concat(asArray(opts.onResolve)); + const onReject = asArray(_static.onReject).concat(asArray(opts.onReject)); + const onSettle = asArray(_static.onSettle).concat(asArray(opts.onSettle)); const runSafely = (callbacks: ((...args: A) => any)[], ...args: A) => { callbacks.forEach(cb => { try { cb(...args); } catch (err) { - Pledge.#dbg.warn(this.#status, 'Pledge callback failed', err); + _dbg.warn(this.#status, 'Pledge callback failed', err); } }); } @@ -91,7 +93,7 @@ export class Pledge { this.#pledge.promise.finally(() => runSafely(onSettle)); if (this.#status.catch) - this.#pledge.promise.catch(err => Pledge.#dbg.warn(this.#status, err)); + this.#pledge.promise.catch(err => _dbg.warn(this.#status, err)); } get [Symbol.toStringTag]() { @@ -116,16 +118,16 @@ export class Pledge { } get isPending() { - return this.#status.state === Pledge.STATE.Pending; + return this.#status.state === _STATE.Pending; } get isResolved() { - return this.#status.state === Pledge.STATE.Resolved; + return this.#status.state === _STATE.Resolved; } get isRejected() { - return this.#status.state === Pledge.STATE.Rejected; + return this.#status.state === _STATE.Rejected; } get isSettled() { - return this.#status.state !== Pledge.STATE.Pending; + return this.#status.state !== _STATE.Pending; } toString() { @@ -135,11 +137,11 @@ export class Pledge { resolve(value: T) { if (this.isPending) { this.#status.settled = value; - this.#status.state = Pledge.STATE.Resolved; - Pledge.#dbg.debug(this.#status, 'Resolved'); // debug + this.#status.state = _STATE.Resolved; + _dbg.debug(this.#status, 'Resolved'); // debug this.#pledge.resolve(value); // resolve } - else Pledge.#dbg.warn(this.#status, `Pledge was already ${this.state}`); + else _dbg.warn(this.#status, `Pledge was already ${this.state}`); return this.#pledge.promise; } @@ -147,11 +149,11 @@ export class Pledge { reject(error: any) { if (this.isPending) { this.#status.error = error; - this.#status.state = Pledge.STATE.Rejected; - Pledge.#dbg.debug(this.#status, 'Rejected', error); // debug + this.#status.state = _STATE.Rejected; + _dbg.debug(this.#status, 'Rejected', error); // debug this.#pledge.reject(error); // reject } - else Pledge.#dbg.warn(this.#status, `Pledge was already ${this.state}`); + else _dbg.warn(this.#status, `Pledge was already ${this.state}`); return this.#pledge.promise; } diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index c6d0e5b..20519af 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -9,6 +9,15 @@ export const protoType = (obj?: unknown) => { return Object.prototype.toString.call(raw).slice(8, -1) as Type; } +/** Safely extract Symbol.toStringTag, guarding against dynamic getters that throw */ +export const getSafeTag = (obj: any): string | undefined => { + try { + const tag = obj?.[Symbol.toStringTag] ?? obj?.prototype?.[Symbol.toStringTag]; + if (typeof tag === 'string') return tag; + } catch { } + return undefined; +} + /** * # getType * return an object's type as a ProperCase string. @@ -75,7 +84,7 @@ const isClassConstructor = (obj: any): boolean => { // This is a high-performance check to immediately classify arrow functions as non-classes. if (!('prototype' in raw)) return false; const name = (raw as any)?.name; - const tag = raw?.[Symbol.toStringTag] ?? raw?.prototype?.[Symbol.toStringTag]; + const tag = getSafeTag(raw); // Absolute bypass for branded identities (using universal brand) if (raw?.[sym.$Identity] || raw?.prototype?.[sym.$Identity]) { @@ -194,7 +203,9 @@ export type Instance = { type: Type, class: Constructor } // allow for Class in * @NOTE Custom types must augment `TypeValueMap` to be recognized by the type system! */ export const registerType = (cls: Constructor, type?: Type) => { - const tag = (cls.prototype as any)?.[Symbol.toStringTag]; // toStringTag is the source-of-truth, if present + if (typeof cls !== 'function') return; + + const tag = getSafeTag(cls); // toStringTag is the source-of-truth, if present const name = (tag ?? type ?? cls.name) as Type; if (name && !['Object', 'Function', ''].includes(name as string)) { diff --git a/packages/library/src/server/file.library.ts b/packages/library/src/server/file.library.ts index cc94356..6205789 100644 --- a/packages/library/src/server/file.library.ts +++ b/packages/library/src/server/file.library.ts @@ -15,7 +15,7 @@ export class File { * @throws {Error} If path traversal is detected * @returns The resolved absolute path */ - static #resolvePath(filename: string): string { + private static _resolvePath(filename: string): string { if (path.isAbsolute(filename)) { throw new Error(`Absolute paths are not allowed: ${filename}`); } @@ -33,7 +33,7 @@ export class File { static read = (file: string): Promise => new Promise((resolve, reject) => { try { - const target = File.#resolvePath(file); + const target = File._resolvePath(file); fs.readFile(target, File.encoding, (err, data) => { if (err) return (err.code === 'ENOENT') @@ -49,7 +49,7 @@ export class File { static write = (file: string, doc: string | NodeJS.ArrayBufferView) => new Promise((resolve, reject) => { try { - const target = File.#resolvePath(file); + const target = File._resolvePath(file); fs.writeFile(target, doc, File.encoding, (err => err ? reject(err) : resolve(doc))); } catch (err) { reject(err); @@ -58,7 +58,7 @@ export class File { static exist = (file: string) => new Promise((resolve, reject) => { try { - const target = File.#resolvePath(file); + const target = File._resolvePath(file); fs.access(target, (err => err && err.code !== 'ENOENT' ? reject(err) // anything other than 'file not-exists' @@ -71,7 +71,7 @@ export class File { static remove = (file: string) => new Promise((resolve, reject) => { try { - const target = File.#resolvePath(file); + const target = File._resolvePath(file); fs.unlink(target, (err => err && err.code !== 'ENOENT' ? reject(err) // anything other than 'file not-exists' diff --git a/packages/library/vitest.config.ts b/packages/library/vitest.config.ts index a1b874b..0fa3711 100644 --- a/packages/library/vitest.config.ts +++ b/packages/library/vitest.config.ts @@ -1,14 +1,29 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; +import swc from 'unplugin-swc'; const __dirname = dirname(fileURLToPath(import.meta.url)); const isDist = process.env.TEST_DIST === 'true'; export default defineConfig({ + esbuild: false, + oxc: false, + plugins: [ + swc.vite({ + jsc: { + target: 'es2022', + parser: { syntax: 'typescript', decorators: true }, + transform: { decoratorVersion: '2023-11' }, + }, + }), + ], test: { name: 'Library: Full', globals: true, + pool: 'forks', + maxWorkers: 2, + slowTestThreshold: 2_000, environment: 'node', include: ['test/**/*.{test,spec}.ts'], setupFiles: [resolve(__dirname, '../tempo/bin/temporal-polyfill.ts')], diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index dbd429c..e97d950 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -51,12 +51,12 @@ export default defineConfig({ ] }, { - text: 'Extensions & Plugins', + text: 'Extensions & Terms', items: [ { text: 'Modularity', link: '/doc/tempo.modularity' }, - { text: 'Terms System', link: '/doc/tempo.term' }, - { text: 'Ticker Plugin', link: '/doc/tempo.ticker' }, - { text: 'Premium Plugins โ†—', link: 'https://magmacomputing.github.io/tempo-plugin-docs/' } + { text: 'Terms Plugins', link: '/doc/tempo.term' }, + { text: 'Extension Plugins', link: '/doc/tempo.extension' }, + { text: 'Premium Plugins โ†—', link: 'https://magmacomputing.github.io/tempo-plugin-docs/' }, ] }, { @@ -119,6 +119,11 @@ export default defineConfig({ esbuild: { target: 'esnext' }, + optimizeDeps: { + esbuildOptions: { + target: 'esnext' + } + }, resolve: { conditions: ['development', 'module', 'browser', 'import', 'default'], alias: [ diff --git a/packages/tempo/doc/tempo.extension.md b/packages/tempo/doc/tempo.extension.md new file mode 100644 index 0000000..ddfc3a7 --- /dev/null +++ b/packages/tempo/doc/tempo.extension.md @@ -0,0 +1,145 @@ +# Creating an Extension Plugin + +While [Term Plugins](./tempo.term.md) are excellent for providing static, memoized data (like astrological signs or fiscal quarters), **Extension Plugins** allow you to fundamentally enhance the `Tempo` class with entirely new methods and behaviors. + +This guide will teach you the "Tempo-way" of authoring an Extension Plugin by building a classic, real-world example: **The Business Days Extension**. + +## The Goal + +We want to add an `.addBusinessDays()` method to the Tempo instance that adds or subtracts a specific number of working days (defaulting to 1), skipping weekends automatically. + +```typescript +const t = new Tempo('2026-05-22'); // Friday +console.log(t.addBusinessDays(2).format('{www}')); // Output: 'Tue' +``` + +--- + +## 1. The `defineExtension` Factory + +The safest and most efficient way to author a plugin is using the `defineExtension` factory. This handles the internal registration automatically. + +```typescript +// src/index.ts +import { defineExtension } from '@magmacomputing/tempo/plugin'; +import type { Tempo } from '@magmacomputing/tempo/core'; + +export const BusinessDaysPlugin = defineExtension({ + name: 'BusinessDaysPlugin', + install(TempoClass: any) { + // Plugin implementation goes here! + } +}); +``` + +## 2. Extending the Prototype + +To add an instance method, you extend the `TempoClass.prototype`. + +> [!WARNING] Immutability is King +> Tempo is strictly immutable. When authoring an instance method that modifies the date, **never mutate `this`**. Instead, use the core methods (like `this.add` or `this.set`) which automatically generate and return a fresh, isolated clone for you. + +Let's implement our `.addBusinessDays()` logic: + +```typescript +export const BusinessDaysPlugin = defineExtension({ + name: 'BusinessDaysPlugin', + install(TempoClass: any) { + TempoClass.prototype.addBusinessDays = function(days: number = 1) { + let next = this; + const direction = days >= 0 ? 1 : -1; + let remaining = Math.abs(days); + + // Loop using the underlying Temporal API data + // dayOfWeek: 1 = Monday ... 7 = Sunday + while (remaining > 0) { + next = next.add({ days: direction }); + + // Only count Monday-Friday as a valid jump + if (next.toDateTime().dayOfWeek <= 5) { + remaining--; + } + } + + // Return the new clone! (Tempo's native .add() already guarantees a fresh instance) + return next; + }; + } +}); +``` + +Notice how we drop into `.toDateTime()` to access the raw `Temporal.PlainDateTime` object? This is a common pattern in plugins when you need to access raw calendar properties (like `dayOfWeek`, `dayOfYear`, or `daysInMonth`) without triggering unnecessary string formatting. + +## 3. TypeScript Module Augmentation + +If you are using TypeScript (highly recommended), your IDE will not know about `.addBusinessDays()` until you augment the `Tempo` interface. + +You must declare this augmentation in the same file that exports your plugin: + +```typescript +// src/index.ts +import { defineExtension } from '@magmacomputing/tempo/plugin'; + +// ... (plugin implementation) ... + +// Inform TypeScript that the core Tempo class now has this method +declare module '@magmacomputing/tempo/core' { + interface Tempo { + addBusinessDays(days?: number): Tempo; + } +} +``` + +## 4. Packing it as a Configurable Module + +Sometimes, you want your plugin to accept options (e.g., passing in a custom array of public holidays to skip). To do this, wrap your `defineExtension` call in a standard factory function: + +```typescript +export type BusinessDayOptions = { + skipHolidays?: boolean; +}; + +export const BusinessDaysModule = (pluginOptions: BusinessDayOptions = {}) => { + return defineExtension({ + name: 'BusinessDaysModule', + install(TempoClass: any) { + TempoClass.prototype.addBusinessDays = function(days: number = 1) { + let next = this; + const direction = days >= 0 ? 1 : -1; + let remaining = Math.abs(days); + + while (remaining > 0) { + next = next.add({ days: direction }); + if (next.toDateTime().dayOfWeek <= 5) { + // We can now use 'pluginOptions' in our logic! + if (!pluginOptions.skipHolidays /* || !isHoliday(next) */) { + remaining--; + } + } + } + + return next; + }; + } + }); +}; +``` + +## Consuming the Plugin + +Your users can now import and register your extension elegantly: + +```typescript +import { Tempo } from '@magmacomputing/tempo/core'; +import { BusinessDaysModule } from 'my-business-days-plugin'; + +Tempo.extend(BusinessDaysModule({ skipHolidays: true })); + +const t = new Tempo(); +const nextBiz = t.addBusinessDays(2); +``` + +--- + +> [!TIP] Need something more complex? +> If you need to build advanced scheduling engines, AsyncGenerators, or precision arithmetic tools that you plan to distribute commercially, check out our **[Premium Plugin Registry โ†—](https://magmacomputing.github.io/tempo-plugin-docs/)** for inspiration, or contact Magma Computing for professional plugin development. diff --git a/packages/tempo/doc/tempo.plugin.md b/packages/tempo/doc/tempo.plugin.md index cc113eb..f2cc607 100644 --- a/packages/tempo/doc/tempo.plugin.md +++ b/packages/tempo/doc/tempo.plugin.md @@ -18,21 +18,17 @@ Tempo.extend(HolidayModule({ region: 'US-NY' })); --- -The most efficient way to author a plugin is using the `definePlugin` factory. This helper automatically handles the internal registration logic, making your plugin available as soon as it is imported (via side effect). +The most efficient way to author a plugin is using the `defineExtension` factory. This helper automatically handles the internal registration logic, making your plugin available as soon as it is imported (via side effect). ## Example Plugin ```typescript -import { definePlugin } from '@magmacomputing/tempo/plugin'; +import { defineExtension } from '@magmacomputing/tempo/plugin'; -export const MyPlugin = definePlugin((TempoClass, options, factory) => { - /** - * TempoClass: The internal Tempo class (for static methods) - * options: The global configuration object - * factory: A helper to create new Tempo instances without 'new' - */ - - // 1. Add a static method +export const MyPlugin = defineExtension({ + name: 'MyPlugin', + install(TempoClass: any) { + // 1. Add a static method TempoClass.myStaticMethod = () => { /* ... */ }; // 2. Add an instance method (on the prototype) @@ -87,7 +83,7 @@ declare module '@magmacomputing/tempo/core' { --- -Modern Tempo plugin 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. +Modern Tempo plugin are designed to be "plug-and-play." By using the `defineExtension` factory, a plugin registers itself with the global Tempo registry as soon as it's imported. ```typescript import '@magmacomputing/tempo/ticker'; // 1. Module self-registers via side-effect @@ -193,12 +189,14 @@ class MyPluginInstance implements MyPluginTypes.Descriptor { ``` ### 3. Wrap with a Proxy in the Factory -Use a `Proxy` in your `definePlugin` factory to handle the callability trap. This allows your plugin to act as a function (the shortcut) and an object (the stateful class) simultaneously. +Use a `Proxy` in your `defineExtension` factory to handle the callability trap. This allows your plugin to act as a function (the shortcut) and an object (the stateful class) simultaneously. ```typescript -export const MyPlugin = definePlugin((TempoClass, options, factory) => { - (TempoClass as any).myTool = function(arg1: any): MyPluginTypes.Instance { - const instance = new MyPluginInstance(arg1); +export const MyPlugin = defineExtension({ + name: 'MyPlugin', + install(TempoClass: any) { + TempoClass.myTool = function(arg1: any): MyPluginTypes.Instance { + const instance = new MyPluginInstance(arg1); const proxy = new Proxy((() => instance.doSomething()) as any, { get: (_, prop) => { @@ -250,7 +248,7 @@ export const MyFeatureModule = defineModule((TempoClass, options) => { ### Commercial & Premium Plugins -If you have built a powerful plugin and wish to distribute it commercially, you do not need to implement your own licensing engine. Build your plugin using the standard `defineModule` or `definePlugin` wrappers. +If you have built a powerful plugin and wish to distribute it commercially, you do not need to implement your own licensing engine. Build your plugin using the standard `defineModule` or `defineExtension` wrappers. Once your plugin is ready for the marketplace, **[Contact Magma Computing](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. @@ -293,5 +291,5 @@ If you have a complex business requirement or need a high-performance plugin bui **[Contact Magma Computing](https://github.com/magmacomputing)** to discuss your requirements. -- [Tempo Ticker Guide](./tempo.ticker.md): A deep dive into an "Async Generator" based plugin. +- [Extension Plugin Guide](./tempo.extension.md): Learn the "Tempo-way" to write a prototype extension (like Business Days). - [Tempo Terms Guide](./tempo.term.md): Documentation on the "Memoized Lookup" pattern for business logic. diff --git a/packages/tempo/doc/tempo.ticker.md b/packages/tempo/doc/tempo.ticker.md new file mode 100644 index 0000000..df88bb2 --- /dev/null +++ b/packages/tempo/doc/tempo.ticker.md @@ -0,0 +1,11 @@ +# Ticker Plugin + +The **Ticker Plugin** is a high-performance, asynchronous generator extension for Tempo that allows you to schedule, pause, and iterate over temporal intervals (e.g., polling every 5 minutes, or triggering an event every quarter). + +Because it uses advanced JavaScript `AsyncGenerator` patterns and is designed for enterprise-grade scheduling, it has been moved to our **Premium Plugin Registry**. + +## Accessing the Ticker Plugin + +To view the documentation, install the plugin, and generate a license token, please visit the official Tempo Registry: + +**[Explore the Tempo Ticker Plugin โ†—](https://magmacomputing.github.io/tempo-plugin-docs/)** diff --git a/packages/tempo/index.md b/packages/tempo/index.md index 8bfed36..20d221b 100644 --- a/packages/tempo/index.md +++ b/packages/tempo/index.md @@ -103,14 +103,10 @@ let initPromise = (async () => { registry.has = () => false } - const [{ Tempo }, { TickerModule }] = await Promise.all([ - import('@magmacomputing/tempo'), - import('@magmacomputing/tempo/ticker'), - ]) + const { Tempo } = await import('@magmacomputing/tempo') if (import.meta.env.DEV) registry.has = originalHas - if (!Tempo.ticker) Tempo.extend(TickerModule) Tempo.init() return Tempo @@ -143,7 +139,10 @@ async function startTicker() { if (isManualTickerPaused.value) return - ticker = Tempo.ticker({ seconds: 1, seed: { timeZone: selectedTz.value } }, sync) + ticker = { + _id: setInterval(() => sync(new Tempo({ timeZone: selectedTz.value })), 1000), + stop() { clearInterval(this._id) } + } } catch (e) { timeStr.value = `Error: ${e.message || 'Unknown'}` const fallback = () => { diff --git a/packages/tempo/package.json b/packages/tempo/package.json index dc76025..68a8cc1 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -209,8 +209,7 @@ "@js-temporal/polyfill": "^0.5.1", "@magmacomputing/library": "3.0.0", "@rollup/plugin-alias": "^6.0.0", - "esbuild": "^0.25.12", - "javascript-obfuscator": "^5.4.2", + "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", "rollup-plugin-esbuild": "^6.2.1", "typedoc": "^0.28.19", @@ -222,4 +221,4 @@ "doc": "doc", "test": "test" } -} \ No newline at end of file +} diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index 798752b..046acd1 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -164,7 +164,7 @@ export default [ // Map library imports to lib/ for browser-ready granular ESM const rel = path.relative(__dirname, id); - const normalizedRel = rel.replace(/\\/g, '/'); // Ensure forward slashes + const normalizedRel = rel.replace(/\\/g, '/'); // Ensure forward slashes if (id.includes('magma/packages/library') || rel.startsWith('../library')) { const match = normalizedRel.match(/library\/(?:src|dist\/common)\/(.*)$/); diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 948d560..1b963fc 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -52,9 +52,9 @@ interface Registry { // information about each registered ali export class AliasEngine { static aliasPattern = /^(evt|per)(\d+)_(\d+)$/; - static #idCounter = 0; + private static _idCounter = 0; - static #getBaseWord(s: string): string { + private static _getBaseWord(s: string): string { return s .toLowerCase() .replace(/\[[^\]]*\]\?/g, '') @@ -84,7 +84,7 @@ export class AliasEngine { const parent = options.parent; this.#logger = options.logger; this.#config = options.config; - this.#id = AliasEngine.#idCounter++; + this.#id = AliasEngine._idCounter++; if (parent instanceof AliasEngine) { this.#parent = parent; @@ -113,7 +113,7 @@ export class AliasEngine { */ registerAliases(type: AliasType, events: [string, AliasTarget][]) { for (const [name, target] of events) { - const baseWord = AliasEngine.#getBaseWord(name); + const baseWord = AliasEngine._getBaseWord(name); const existingKey = this.#words[baseWord]; const existing = existingKey ? this.getAlias(existingKey) : undefined; @@ -187,7 +187,7 @@ export class AliasEngine { } hasAlias(name: string, type?: AliasType) { - const baseWord = AliasEngine.#getBaseWord(name); + const baseWord = AliasEngine._getBaseWord(name); const key = this.#words[baseWord]; return !key ? false diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 6b3fc71..7d3196b 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -156,9 +156,9 @@ function setLicense(state: t.Internal.State, key: string) { const initialJti = runtime.license.jti; const initialKey = runtime.license.key; - runtime.license.jws = new Pledge({ + const argObj = { tag: 'license', - onResolve: (m) => { + onResolve: (m: any) => { const validator = new m.Validator(runtime.license.key!); validator.verify().then((res: any) => { // ๐Ÿ›ก๏ธ Race Condition Guard: Only apply results if identity (JTI + Key) hasn't changed since we started @@ -182,7 +182,8 @@ function setLicense(state: t.Internal.State, key: string) { logWarn(state.config, `โš ๏ธ Tempo Licensing: ${runtime.license.error}`); }); } - }); + }; + runtime.license.jws = new Pledge(argObj as any); } } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 91976b4..bd52098 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -65,6 +65,17 @@ namespace Internal { * A powerful wrapper around `Temporal.ZonedDateTime` for flexible parsing and intuitive manipulation of date-time objects. * Bridges the gap between raw string/number inputs and the strict requirements of the ECMAScript Temporal API. */ +/** Logify for internal errors and debug logs */ +const _dbg = new Logify('Tempo', { debug: Default?.debug ?? false, catch: Default?.catch ?? false }); +/** Tempo state for the global configuration */ +let _global = {} as Internal.State; +/** cache for next-available 'usr' Token key */ +let _usrCount = 0; +/** flag to prevent recursion during init */ +const _lifecycle = { bootstrap: true, initialising: false, extendDepth: 0, ready: false }; +/** Master Guard predicate (implements RegExp-like interface) */ +let _guard: { test(str: string): boolean } = { test: () => true }; + @Serializable @Immutable export class Tempo { @@ -86,17 +97,13 @@ export class Tempo { /** initialization strategies */ static get MODE() { return enums.MODE } /** some useful Dates */ static get LIMIT() { return enums.LIMIT } - /** @internal check if Tempo is currently initializing */ static get isInitializing() { return !Tempo.#lifecycle.ready } - /** @internal check if Tempo is currently extending */ static get isExtending() { return Tempo.#lifecycle.extendDepth > 0 } - - /** Logify for internal errors and debug logs */ static #dbg = new Logify('Tempo', { debug: Default?.debug ?? false, catch: Default?.catch ?? false }) + /** @internal check if Tempo is currently initializing */ static get isInitializing() { return !_lifecycle.ready } + /** @internal check if Tempo is currently extending */ static get isExtending() { return _lifecycle.extendDepth > 0 } - /** Tempo state for the global configuration */ static #global = {} as Internal.State; - /** cache for next-available 'usr' Token key */ static #usrCount = 0; - /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return getRuntime().pluginsDb.terms } - /** @internal raw license state */ static get #license() { return getRuntime().license } + /** mutable list of registered term plugins */ private static get _terms(): TermPlugin[] { return getRuntime().pluginsDb.terms } + /** @internal raw license state */ private static get _license() { return getRuntime().license } /** human-readable formatted license state */ static get license() { - const { jws, key, ...raw } = Tempo.#license; // omit internal Pledge and JWT string from user-facing snapshot + const { jws, key, ...raw } = Tempo._license; // omit internal Pledge and JWT string from user-facing snapshot const ss = { timeStamp: 'ss' } as const; // JWT timestamps are always in seconds (RFC 7519) const scopesSource = (raw.scopes && typeof raw.scopes === 'object') ? raw.scopes : {}; const scopes = Object.fromEntries( @@ -116,15 +123,15 @@ export class Tempo { ...(typeof raw.issuedAt === 'number' && { issuedAt: new Tempo(raw.issuedAt, ss).fmt.weekTime }), }); } - /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); - /** flag to prevent recursion during init */ static #lifecycle = { bootstrap: true, initialising: false, extendDepth: 0, ready: false }; - /** Master Guard predicate (implements RegExp-like interface) */static #guard: { test(str: string): boolean } = { test: () => true }; + /** mapping of terms to their resolved values */ private static _termMap: Map = new Map(); + + /** Master Guard predicate (implements RegExp-like interface) */private static _guard: { test(str: string): boolean } = { test: () => true }; static [$IsBase] = true; /** @internal Static access to global private state. */ static [$Internal]() { - return ClassStates.get(this) ?? Tempo.#global; + return ClassStates.get(this) ?? _global; } static get $ImmutableSkip() { @@ -149,7 +156,7 @@ export class Tempo { const config = markConfig(Object.create(global)); if (provided) Object.entries(provided).forEach(([k, v]) => setProperty(config, k, v)); markConfig(config); // ensure config is marked for Logify - Tempo.#dbg.error(config, ...msg); + _dbg.error(config, ...msg); } /** @internal handle internal debug logs */ @@ -158,7 +165,7 @@ export class Tempo { const global = this[$Internal]().config; const config = markConfig(Object.create(global)); if (provided) Object.entries(provided).forEach(([k, v]) => setProperty(config, k, v)); - Tempo.#dbg.debug(config, ...args); + _dbg.debug(config, ...args); } /** @@ -194,7 +201,7 @@ export class Tempo { if (!hasOwn(shape, 'aliasEngine')) { engine = shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, - logger: Tempo.#dbg, + logger: _dbg, config: shape.config }); } @@ -229,7 +236,7 @@ export class Tempo { } /** try to infer hemisphere using the timezone's daylight-savings setting */ - static #setSphere = (shape: Internal.State, options: t.Options) => { + private static _setSphere = (shape: Internal.State, options: t.Options) => { if (isDefined(options.sphere)) return options.sphere; const tz = options.timeZone; @@ -244,16 +251,16 @@ export class Tempo { } /** determine if we have a {timeZone} which prefers {mdy} date-order */ - static #isMonthDay(shape: Internal.State) { + private static _isMonthDay(shape: Internal.State) { const { timeZone, locale } = shape.config; const mdy = shape.parse.monthDay; const globalMdy = Tempo.MONTH_DAY as t.MonthDay; let intl: Intl.Locale; try { - intl = new Intl.Locale(Tempo.#locale(locale)); + intl = new Intl.Locale(Tempo._locale(locale)); } catch (e) { - Tempo.#dbg.warn(shape.config, `Invalid locale encountered in #isMonthDay: ${locale}. Falling back to en-US.`, e); + _dbg.warn(shape.config, `Invalid locale encountered in #isMonthDay: ${locale}. Falling back to en-US.`, e); intl = new Intl.Locale('en-US'); } @@ -270,11 +277,11 @@ export class Tempo { * swap parsing-order of layouts to suit different timeZones * this allows the parser to try to interpret '04012023' as Apr-01-2023 before trying 04-Jan-2023 */ - static #swapLayout(shape: Internal.State) { + private static _swapLayout(shape: Internal.State) { const { layouts } = shape.parse.monthDay; if (isEmpty(layouts)) return; - const isMonthDay = shape.parse.monthDay.isExplicit ? shape.parse.monthDay.active! : Tempo.#isMonthDay(shape); + const isMonthDay = shape.parse.monthDay.isExplicit ? shape.parse.monthDay.active! : Tempo._isMonthDay(shape); shape.parse.monthDay.active = isMonthDay; // ensure Token.dt matches the local monthDay preference @@ -296,11 +303,11 @@ export class Tempo { if (layout !== shape.parse.layout) shape.parse.layout = layout as Layout; - Tempo.#dbg.debug(shape.config, `Resolved layout order: ${getLayoutOrder(layout).join(' -> ')}`); + _dbg.debug(shape.config, `Resolved layout order: ${getLayoutOrder(layout).join(' -> ')}`); } /** get first Canonical name of a supplied locale */ - static #locale = (locale?: string) => { + private static _locale = (locale?: string) => { let language: string | undefined; try { // lookup locale @@ -333,14 +340,14 @@ export class Tempo { extendState(shape, mergedOptions); // Side-effects - const newSphere = Tempo.#setSphere(shape, mergedOptions); + const newSphere = Tempo._setSphere(shape, mergedOptions); if (shape.config.scope === 'local') { const parentSphere = Object.getPrototypeOf(shape.config).sphere; if (newSphere !== parentSphere) shape.config.sphere = newSphere; } else { shape.config.sphere = newSphere; } - Tempo.#swapLayout(shape); + Tempo._swapLayout(shape); if (isDefined(shape.parse.event)) this[$setEvents](shape, undefined, false); if (isDefined(shape.parse.period)) this[$setPeriods](shape, undefined, false); @@ -424,7 +431,7 @@ export class Tempo { } const res = isFunction(opts) ? opts() : opts; - if (shape === Tempo.#global) { + if (shape === _global) { this[$buildGuard](); setPatterns(shape); } @@ -447,20 +454,20 @@ export class Tempo { ...ownKeys(this[$Internal]().parse.snippet), ...ownKeys(this[$Internal]().parse.layout), ...[Token.slk], - ...Tempo.#terms.map(t => t.key), - ...Tempo.#terms.map(t => t.scope), + ...Tempo._terms.map(t => t.key), + ...Tempo._terms.map(t => t.scope), ...Guard ]; - Tempo.#guard = createMasterGuard(wordsList); + _guard = createMasterGuard(wordsList); - if (this[$Internal]() === Tempo.#global) { + if (this[$Internal]() === _global) { setPatterns(this[$Internal]()); } } /** @internal resolve a global discovery config object by symbol key */ - static #getConfig(sym: symbol) { + private static _getConfig(sym: symbol) { const discovery = (globalThis as Record)[sym]; return proxify(omit({ ...discovery, scope: 'discovery' }, 'value')); } @@ -491,7 +498,7 @@ export class Tempo { if (isEmpty(items)) return this; - Tempo.#lifecycle.extendDepth++; // increment the re-entrant nesting counter + _lifecycle.extendDepth++; // increment the re-entrant nesting counter try { items.forEach(item => { const arg = item as any; @@ -506,7 +513,7 @@ export class Tempo { } catch (e: any) { const msg = (e?.message ?? '').toLowerCase(); if (msg.includes('constructor') || msg.includes('class') || (e instanceof TypeError) || isClass(arg)) { - Tempo.#dbg.warn(this[$Internal]().config, `Misidentified class in plugin registration: ${(arg as any).name}`, e.stack ?? e); + _dbg.warn(this[$Internal]().config, `Misidentified class in plugin registration: ${(arg as any).name}`, e.stack ?? e); } else { throw e; } @@ -517,7 +524,7 @@ export class Tempo { const name = (item as any).name; const rt = getRuntime(); if (rt.installed.has(name)) { - Tempo.#dbg.debug(this[$Internal]().config, `Plugin already installed by name: ${name}`); + _dbg.debug(this[$Internal]().config, `Plugin already installed by name: ${name}`); return; } rt.installed.add(name); @@ -531,19 +538,19 @@ export class Tempo { const config = item as TermPlugin; const state = this[$Internal](); - if (Tempo.#termMap.get(config.key) === config) return; - if (Tempo.#termMap.has(config.key)) { + if (Tempo._termMap.get(config.key) === config) return; + if (Tempo._termMap.has(config.key)) { Tempo[$logError](state.config, `[Tempo#extend] Term collision on key: "${config.key}". Registration aborted.`); return; } - if (config.scope && Tempo.#termMap.get(config.scope) === config) { /* continue */ } - else if (config.scope && Tempo.#termMap.has(config.scope)) { + if (config.scope && Tempo._termMap.get(config.scope) === config) { /* continue */ } + else if (config.scope && Tempo._termMap.has(config.scope)) { Tempo[$logError](state.config, `[Tempo#extend] Term collision on scope: "${config.scope}". Registration aborted.`); return; } - Tempo.#termMap.set(config.key, config); - if (config.scope) Tempo.#termMap.set(config.scope, config); + Tempo._termMap.set(config.key, config); + if (config.scope) Tempo._termMap.set(config.scope, config); registerTerm(config); @@ -583,7 +590,7 @@ export class Tempo { const discovery = item as any if (discovery.term) { discovery.terms = [...asArray(discovery.terms || []), ...asArray(discovery.term)]; - Tempo.#dbg.warn(this[$Internal]().config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); + _dbg.warn(this[$Internal]().config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); } if (discovery.plugin) { discovery.plugins = [...asArray(discovery.plugins || []), ...asArray(discovery.plugin)]; @@ -650,10 +657,10 @@ export class Tempo { } }) } finally { - Tempo.#lifecycle.extendDepth--; // decrement the re-entrant nesting counter + _lifecycle.extendDepth--; // decrement the re-entrant nesting counter } - if (Tempo.#lifecycle.extendDepth === 0) { + if (_lifecycle.extendDepth === 0) { this[$buildGuard](); setPatterns(this[$Internal]()); // rebuild the global patterns } @@ -711,8 +718,8 @@ export class Tempo { /** Reset Tempo to its default, built-in registration state */ static init(options: t.Options = {}): typeof Tempo { - if (Tempo.#lifecycle.initialising) return this; - Tempo.#lifecycle.initialising = true; + if (_lifecycle.initialising) return this; + _lifecycle.initialising = true; try { const rt = getRuntime(); @@ -720,7 +727,7 @@ export class Tempo { const state = init(options); (state as any)._count = 0; if (this[$IsBase]) { - Tempo.#global = state; + _global = state; } else { ClassStates.set(this, state); } @@ -746,7 +753,7 @@ export class Tempo { // Normalize discovery to a symbol if it's an object to prevent leakage into config state if (isObject(discovery) && !isSymbol(discovery)) { const data = discovery; - discovery = Symbol.for(`tempo.discovery.${Tempo.#usrCount++}`); + discovery = Symbol.for(`tempo.discovery.${_usrCount++}`); (globalThis as any)[discovery] = data; } const normalizedDiscovery = isString(discovery) ? Symbol.for(discovery) : (discovery as symbol); @@ -754,7 +761,7 @@ export class Tempo { // Resolve locale if missing or invalid const currentLocale = config.locale; - const locale = (!currentLocale || currentLocale === 'en-US') ? Tempo.#locale(currentLocale) : currentLocale; + const locale = (!currentLocale || currentLocale === 'en-US') ? Tempo._locale(currentLocale) : currentLocale; if (!hasOwn(config, 'get')) { Object.defineProperty(config, 'get', { @@ -763,12 +770,12 @@ export class Tempo { }); } - Tempo.#usrCount = 0; // reset user-key counter + _usrCount = 0; // reset user-key counter for (const key of Object.keys(Token)) // purge user-allocated Tokens if (key.startsWith('usr.')) // only remove 'usr.' prefixed keys delete Token[key]; - Tempo.#termMap.clear(); // clear term lookup map + Tempo._termMap.clear(); // clear term lookup map registryReset(); // purge formats and numbers // 3. Apply configuration via unified setters (non-destructive merge) @@ -791,9 +798,9 @@ export class Tempo { if (options.plugins) this.extend(options.plugins); // ensure init-plugins are processed before 'ready' if (Context.type === CONTEXT.Browser || options.debug === true) - Tempo.#dbg.info(this.config, 'Tempo:', state.config); + _dbg.info(this.config, 'Tempo:', state.config); - Tempo.#lifecycle.ready = true; + _lifecycle.ready = true; setPatterns(state); // rebuild the global patterns (Master Guard etc) // ๐Ÿ›๏ธ Licensing Reckoning (Background Verification) @@ -810,8 +817,8 @@ export class Tempo { } } finally { - Tempo.#lifecycle.initialising = false; - Tempo.#lifecycle.bootstrap = false; + _lifecycle.initialising = false; + _lifecycle.bootstrap = false; } return this @@ -853,7 +860,7 @@ export class Tempo { /** @internal lookup or registers a new `Symbol` for a given key. */ static getSymbol(key?: string | symbol) { if (isUndefined(key)) { - const usr = `usr.${++Tempo.#usrCount}`; // allocate a prefixed 'user' key + const usr = `usr.${++_usrCount}`; // allocate a prefixed 'user' key return Token[usr] = Symbol(usr); // add to Symbol register } @@ -916,7 +923,7 @@ export class Tempo { static get discovery() { const discovery = this.config.discovery; const sym = isString(discovery) ? Symbol.for(discovery) : discovery; - return Tempo.#getConfig(sym as symbol); + return Tempo._getConfig(sym as symbol); } static get options() { @@ -937,7 +944,7 @@ export class Tempo { /** static Tempo.terms (registry) */ static get terms(): Secure & Record { const rt = getRuntime(); - const list = Tempo.#terms.map(({ define, resolve, ...rest }) => { + const list = Tempo._terms.map(({ define, resolve, ...rest }) => { const item = { ...rest } as any; if (hasOwn(rt.license.scopes, rest.key)) { const meta = rt.license.scopes[rest.key]; @@ -1047,7 +1054,7 @@ export class Tempo { }); Tempo.init(); // synchronously initialize the library - getRuntime().logger = Tempo.#dbg; + getRuntime().logger = _dbg; } /** constructor tempo */ #tempo?: t.DateTime; @@ -1072,13 +1079,13 @@ export class Tempo { static [$errored] = $errored; /** @internal */ static [TermError](config: Internal.Config, term: string): void { - const hint = Tempo.#terms.length === 0 ? ". (No term plugins are registeredโ€”did you forget to call Tempo.extend(TermsModule)?)" : ""; + const hint = Tempo._terms.length === 0 ? ". (No term plugins are registeredโ€”did you forget to call Tempo.extend(TermsModule)?)" : ""; const msg = `Unknown Term identifier: ${term}${hint}`; - Tempo.#dbg.error(config, msg); + _dbg.error(config, msg); } - /** @internal */ static get [$dbg](): Logify { return Tempo.#dbg } - /** @internal */ static get [$guard]() { return Tempo.#guard } + /** @internal */ static get [$dbg](): Logify { return _dbg } + /** @internal */ static get [$guard]() { return _guard } /** * @internal Internal access to instance private state. @@ -1164,7 +1171,7 @@ export class Tempo { // ๐Ÿ›๏ธ Initialization Strategy ('auto' | 'strict' | 'defer') if (mode === Tempo.MODE.Defer) this.#local.parse.lazy = true; else if (mode === Tempo.MODE.Strict) this.#local.parse.lazy = false; - else if (isString(this.#tempo) && !isEmpty(input) && Tempo.#guard.test(trimAll(input))) { + else if (isString(this.#tempo) && !isEmpty(input) && _guard.test(trimAll(input))) { this.#local.parse.lazy = true; // auto-switch to lazy-mode for valid strings } @@ -1223,7 +1230,7 @@ export class Tempo { if (isUndefined(this.#zdt)) { this.#errored = true; const msg = `Tempo parse returned undefined for: ${String(this.#tempo)}`; - Tempo.#dbg.error(this.#local.config, msg); + _dbg.error(this.#local.config, msg); this.#zdt = now; } secure(this.#local.config); @@ -1232,10 +1239,10 @@ export class Tempo { this.#errored = true; // mark as errored const msg = `Cannot create Tempo: ${(err as Error).message}\n${(err as Error).stack}`; if (this.#local.config.catch === true) { - Tempo.#dbg.error(this.#local.config, msg); // log as error if in catch-mode + _dbg.error(this.#local.config, msg); // log as error if in catch-mode this.#zdt = now; } else { - Tempo.#dbg.error(this.#local.config, err, msg); // log as error then re-throw + _dbg.error(this.#local.config, err, msg); // log as error then re-throw throw err; } } @@ -1263,7 +1270,7 @@ export class Tempo { } catch (e: any) { const msg = (e?.message ?? '').toLowerCase(); if (msg.includes('constructor') || msg.includes('class') || (e instanceof TypeError) || isClass(define)) { - Tempo.#dbg.warn(this.#local.config, `Misidentified class in delegator evaluate: ${String(define)}`, e.stack ?? e); + _dbg.warn(this.#local.config, `Misidentified class in delegator evaluate: ${String(define)}`, e.stack ?? e); memo = define; } else { throw e; @@ -1306,7 +1313,7 @@ export class Tempo { // ๐Ÿ›ก๏ธ Lazy Proxy Guard (Licensing) if (this.#isBlocked(key)) return undefined; - const term = Tempo.#termMap.get(key); + const term = Tempo._termMap.get(key); if (term) { const isKeyOnly = term.key === key; const define = (keyOnly: boolean) => { @@ -1320,7 +1327,7 @@ export class Tempo { return isObject(res) ? secure(res) : res; } catch (err: any) { if (err.message.includes('Class constructor')) { - Tempo.#dbg.warn(this.#local.config, `Misidentified class in term definition: ${key}`, err.stack ?? err); + _dbg.warn(this.#local.config, `Misidentified class in term definition: ${key}`, err.stack ?? err); } else { throw err; } @@ -1341,13 +1348,13 @@ export class Tempo { } #discover(host: 'term' | 'fmt', target: any) { - if (!Tempo.#lifecycle.ready) return; + if (!_lifecycle.ready) return; if (host === 'fmt') { ownKeys(this.#local.config.formats).forEach(key => { if (isString(key)) this.#setLazy(target, key, () => this.format(key as t.Format)); }); } else { - Tempo.#terms.forEach(term => { + Tempo._terms.forEach(term => { const define = (keyOnly: boolean, anchor?: any) => { // ๐Ÿ›ก๏ธ Resolution Guard (Licensing) if (getRuntime().license.status !== LICENSE.Active && this.#isBlocked(term.key)) return undefined; @@ -1358,7 +1365,7 @@ export class Tempo { return isObject(out) ? secure(out) : out; } catch (err: any) { if (err.message.includes('Class constructor')) { - Tempo.#dbg.warn(this.#local.config, `Misidentified class in term discovery: ${term.key}`, err.stack ?? err); + _dbg.warn(this.#local.config, `Misidentified class in term discovery: ${term.key}`, err.stack ?? err); } else { throw err; } @@ -1556,7 +1563,7 @@ export class Tempo { const res = interpret(this, 'ParseModule', 'parse', false, tempo, dateTime, term); if (isUndefined(res)) { const msg = `ParseModule error. Could not parse ${String(tempo)}`; - Tempo.#dbg.error(this.#local.config, msg); + _dbg.error(this.#local.config, msg); return undefined as any; } return res; @@ -1575,7 +1582,7 @@ export class Tempo { if (isUndefined(tempo) || isEmpty(tempo)) return dateTime ?? instant().toZonedDateTimeISO(this.#local.config.timeZone); const msg = 'Tempo ParseModule not loaded. Did you forget to Tempo.extend(ParseModule)?'; - Tempo.#dbg.error(this.#local.config, msg); + _dbg.error(this.#local.config, msg); return undefined as any; } @@ -1616,7 +1623,7 @@ export class Tempo { if (blocked.includes(rt.license.status) && hasOwn(rt.license.scopes, key)) { const msg = `License for premium term '${key}' is ${rt.license.status}. Access denied.`; - Tempo.#dbg.warn(this.#local.config, msg); + _dbg.warn(this.#local.config, msg); return true; } diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 7d08cf8..787c701 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -240,7 +240,7 @@ export namespace Internal { /** custom time aliases (periods). */ period: Period | RegistryOption; /** noise words to ignore during parsing. */ ignore: Ignore; /** custom format strings to merge in the FORMAT enum */formats: Property; - /** plugins to be automatically extended */ plugins: TempoPlugin | TempoPlugin[]; + /** plugins to be automatically extended */ plugins: (TempoPlugin | TermPlugin) | (TempoPlugin | TermPlugin)[]; /** supplied value to parse */ value: DateTime; /** @internal temporary anchor used during parsing */ anchor: any; /** @internal accumulated parse results */ result?: Match[] | undefined; @@ -328,7 +328,7 @@ export namespace Internal { /** internationalization configuration (relativeTime, etc.) */intl?: IntlOptions; /** custom format strings to merge in the FORMAT dictionary */formats?: Property; /** noise words to ignore during parsing via Tempo.ignore() */ignore?: Ignore; - /** plugins to be automatically extended via Tempo.extend() */plugins?: TempoPlugin | TempoPlugin[]; + /** plugins to be automatically extended via Tempo.extend() */plugins?: (TempoPlugin | TermPlugin) | (TempoPlugin | TermPlugin)[]; } export interface LicenseScope { diff --git a/packages/tempo/test/core/alias-engine.test.ts b/packages/tempo/test/core/alias-engine.test.ts index 621b921..8cf1559 100644 --- a/packages/tempo/test/core/alias-engine.test.ts +++ b/packages/tempo/test/core/alias-engine.test.ts @@ -42,7 +42,6 @@ describe('AliasEngine', () => { const localEngine = new AliasEngine({ parent: globalEngine, logger }); localEngine.registerAliases('evt', [['xmas', '24-Dec']]); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); - warnSpy.mockRestore(); }); it('warns on local collision', () => { @@ -50,7 +49,6 @@ describe('AliasEngine', () => { const engine = new AliasEngine({ logger }); engine.registerAliases('evt', [['xmas', '25-Dec'], ['xmas', '24-Dec']]); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); - warnSpy.mockRestore(); }); it('registers and resolves batch aliases', () => { @@ -81,7 +79,6 @@ describe('AliasEngine', () => { engine.registerAliases('evt', [['xmas( )?eve', '24-Dec'], ['xmas eve', '24-Dec']]); // Should treat "xmas eve" and "xmas( )?eve" as same base word expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); - warnSpy.mockRestore(); }); it('does not warn on non-colliding aliases', () => { @@ -89,7 +86,6 @@ describe('AliasEngine', () => { const engine = new AliasEngine({ logger }); engine.registerAliases('evt', [['xmas', '25-Dec'], ['bday', '20-May']]); expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); }); it('resolves to parent after clear', () => { diff --git a/packages/tempo/test/plugins/licensing.full.test.ts b/packages/tempo/test/plugins/licensing.full.test.ts index 941537d..e344192 100644 --- a/packages/tempo/test/plugins/licensing.full.test.ts +++ b/packages/tempo/test/plugins/licensing.full.test.ts @@ -9,7 +9,7 @@ vi.mock('#tempo/license', () => { status: 'active', scopes: { astro: {} } }); - const Validator = vi.fn().mockImplementation(() => ({ verify })); + const Validator = vi.fn().mockImplementation(function() { return { verify }; }); return { Validator }; }); @@ -173,12 +173,14 @@ describe('Tempo Licensing Strategy', () => { // Update mock for this specific test const { Validator } = await import(licenseModule as any); - vi.mocked(Validator).mockReturnValue({ - verify: vi.fn().mockResolvedValue({ - status: 'revoked', - scopes: {}, - error: 'License has been revoked' - }) + vi.mocked(Validator).mockImplementation(function() { + return { + verify: vi.fn().mockResolvedValue({ + status: 'revoked', + scopes: {}, + error: 'License has been revoked' + }) + }; } as any); Tempo.init({ license: mockToken }); @@ -197,12 +199,14 @@ describe('Tempo Licensing Strategy', () => { const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; const { Validator } = await import(licenseModule as any); - vi.mocked(Validator).mockReturnValue({ - verify: vi.fn().mockResolvedValue({ - status: 'revoked', - scopes: { premium: {} }, - error: 'Access denied' - }) + vi.mocked(Validator).mockImplementation(function() { + return { + verify: vi.fn().mockResolvedValue({ + status: 'revoked', + scopes: { premium: {} }, + error: 'Access denied' + }) + }; } as any); Tempo.init({ license: mockToken }); @@ -216,6 +220,7 @@ describe('Tempo Licensing Strategy', () => { const rt = getRuntime(); await rt.license.jws; + await rt.license.jws; await vi.waitFor(() => expect(rt.license.status).toBe(LICENSE.Revoked)); expect(rt.license.status).toBe(LICENSE.Revoked); diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 26748c1..6df23de 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -1,7 +1,9 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import fs from 'node:fs'; + import { defineConfig } from 'vitest/config'; +import swc from 'unplugin-swc'; const __dirname = dirname(fileURLToPath(import.meta.url)); const isDist = process.env.TEST_DIST === 'true'; @@ -12,33 +14,38 @@ const consoleSpySetup = resolve(__dirname, './test/support/setup.console-spy.ts' const licensePremium = process.env.TEMPO_LICENSE_PATH ? resolve(process.env.TEMPO_LICENSE_PATH) : undefined; const licenseDefault = resolve(__dirname, './src/support/support.license.ts'); -const isPremiumAvailable = !!( +const isPremiumAvailable = Boolean( licensePremium && fs.existsSync(licensePremium) && fs.existsSync(resolve(dirname(licensePremium), '../tsconfig.json')) ); export default defineConfig({ - plugins: [], + esbuild: false, + oxc: false, + plugins: [ + swc.vite({ + jsc: { + target: 'es2022', + parser: { syntax: 'typescript', decorators: true }, + transform: { decoratorVersion: '2023-11' }, + }, + }), + ], test: { globals: true, pool: 'forks', - poolOptions: { - forks: { - minForks: 1, - maxForks: 2, - }, - }, + maxWorkers: 2, + slowTestThreshold: 2_000, + include: ['test/**/*.{test,spec}.ts'], + exclude: [ + '**/node_modules/**', + '**/test/**/*.core.test.ts', + '**/test/**/*.lazy.test.ts' + ], setupFiles: process.env.TEMPO_PREFILTER_CI === 'true' ? [polyfill, consoleSpySetup, ciPrefilterSetup] : [polyfill, consoleSpySetup], - // *.core.test.ts and *.lazy.test.ts assert plugin-isolation behaviour - // (e.g. "DurationModule not loaded"). The ciPrefilterSetup imports '#tempo' - // (full build) which side-effects modules into the runtime, making those - // assertions impossible to satisfy. They run in the standard test job. - exclude: process.env.TEMPO_PREFILTER_CI === 'true' - ? ['**/*.core.test.ts', '**/*.lazy.test.ts', '**/node_modules/**'] - : ['**/node_modules/**'], }, resolve: { alias: isDist ? [ diff --git a/packages/tempo/vitest.workspace.ts b/packages/tempo/vitest.workspace.ts deleted file mode 100644 index e43460e..0000000 --- a/packages/tempo/vitest.workspace.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineWorkspace } from 'vitest/config' - -export default defineWorkspace([ - { - extends: 'vitest.config.ts', - test: { - name: 'Tempo: Full', - include: ['test/**/*.{test,spec}.ts'], - exclude: [ - '**/node_modules/**', - '**/test/**/*.core.test.ts', - '**/test/**/*.lazy.test.ts' - ], - } - }, - { - extends: 'vitest.config.ts', - test: { - name: 'Tempo: Core', - include: ['test/**/*.core.test.ts', 'test/**/*.lazy.test.ts'], - } - } -]) diff --git a/vitest.config.ts b/vitest.config.ts index 799ee32..3d8f233 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,14 +1,55 @@ import { defineConfig } from 'vitest/config' import path, { dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import swc from 'unplugin-swc' const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + esbuild: false, + oxc: false, + plugins: [ + swc.vite({ + jsc: { + target: 'es2022', + parser: { syntax: 'typescript', decorators: true }, + transform: { decoratorVersion: '2023-11' }, + }, + }), + ], test: { globals: true, environment: 'node', - setupFiles: [path.resolve(__dirname, './packages/tempo/bin/temporal-polyfill.ts')], + projects: [ + { + extends: './packages/tempo/vitest.config.ts', + test: { + name: 'Tempo: Full', + include: ['packages/tempo/test/**/*.{test,spec}.ts'], + exclude: ['**/node_modules/**', '**/test/**/*.core.test.ts', '**/test/**/*.lazy.test.ts'], + setupFiles: process.env.TEMPO_PREFILTER_CI === 'true' + ? ['./packages/tempo/bin/temporal-polyfill.ts', './packages/tempo/test/support/setup.console-spy.ts', './packages/tempo/test/support/ci.prefilter.setup.ts'] + : ['./packages/tempo/bin/temporal-polyfill.ts', './packages/tempo/test/support/setup.console-spy.ts'], + } + }, + { + extends: './packages/tempo/vitest.config.ts', + test: { + name: 'Tempo: Core', + include: ['packages/tempo/test/**/*.core.test.ts', 'packages/tempo/test/**/*.lazy.test.ts'], + exclude: ['**/node_modules/**'], + setupFiles: ['./packages/tempo/bin/temporal-polyfill.ts', './packages/tempo/test/support/setup.console-spy.ts'], + } + }, + { + extends: './packages/library/vitest.config.ts', + test: { + name: 'Library: Full', + include: ['packages/library/test/**/*.{test,spec}.ts'], + exclude: ['**/node_modules/**'], + } + } + ], alias: [ { find: /^#library\/(browser|server|common)\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/$1/$2.ts') }, { find: /^#library\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/$1.ts') }, diff --git a/vitest.workspace.ts b/vitest.workspace.ts deleted file mode 100644 index 3244195..0000000 --- a/vitest.workspace.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { defineWorkspace } from 'vitest/config' -import { fileURLToPath } from 'node:url' -import { dirname, resolve } from 'node:path' - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const polyfill = resolve(__dirname, 'packages/tempo/bin/temporal-polyfill.ts'); - -export default defineWorkspace([ - { - extends: 'packages/tempo/vitest.config.ts', - test: { - name: 'Tempo: Full', - include: ['packages/tempo/test/**/*.{test,spec}.ts'], - exclude: [ - '**/node_modules/**', - '**/test/**/*.core.test.ts', - '**/test/**/*.lazy.test.ts' - ], - setupFiles: [polyfill], - } - }, - { - extends: 'packages/tempo/vitest.config.ts', - test: { - name: 'Tempo: Core', - include: ['packages/tempo/test/**/*.core.test.ts', 'packages/tempo/test/**/*.lazy.test.ts'], - exclude: ['**/node_modules/**'], - setupFiles: [polyfill], - } - }, - { - extends: 'packages/library/vitest.config.ts', - test: { - name: 'Library: Full', - include: ['packages/library/test/**/*.{test,spec}.ts'], - exclude: ['**/node_modules/**'], - setupFiles: [polyfill], - } - } -]) From c7ea27e7ab16eeb1bb79d4f2257306b07096fd54 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 2 Jun 2026 11:07:16 +1000 Subject: [PATCH 08/20] pre logTrace --- CHANGELOG.md | 19 +++ packages/library/doc/browser/types.md | 2 +- .../library/src/browser/mapper.library.ts | 13 +- packages/library/src/common.index.ts | 3 +- .../library/src/common/boundary.library.ts | 43 ++++++ packages/library/src/common/logger.class.ts | 98 +++++++++++++ packages/library/src/common/logify.class.ts | 131 ------------------ packages/library/src/common/pledge.class.ts | 10 +- packages/library/src/common/symbol.library.ts | 10 +- .../library/test/common/logify.class.test.ts | 71 ---------- packages/tempo/.vitepress/config.ts | 8 +- packages/tempo/CHANGELOG.md | 2 +- .../tempo/bench/bench.parse.prefilter.e2e.ts | 2 +- packages/tempo/doc/architecture.md | 4 +- packages/tempo/doc/migration-guide.md | 6 + packages/tempo/doc/releases/v2.x.md | 2 +- packages/tempo/doc/sandbox-factory.md | 2 +- packages/tempo/doc/tempo.config.md | 10 +- packages/tempo/doc/tempo.debugging.md | 6 +- packages/tempo/doc/tempo.pledge.md | 4 +- packages/tempo/doc/tempo.plugin.md | 2 +- packages/tempo/src/engine/engine.alias.ts | 23 ++- packages/tempo/src/engine/engine.composer.ts | 6 +- packages/tempo/src/engine/engine.lexer.ts | 8 +- .../tempo/src/engine/engine.normalizer.ts | 6 +- packages/tempo/src/engine/engine.pattern.ts | 4 +- packages/tempo/src/module/module.duration.ts | 4 +- packages/tempo/src/module/module.mutate.ts | 14 +- packages/tempo/src/module/module.parse.ts | 19 ++- packages/tempo/src/plugin/plugin.util.ts | 22 ++- packages/tempo/src/support/support.default.ts | 3 +- packages/tempo/src/support/support.error.ts | 10 ++ packages/tempo/src/support/support.index.ts | 7 +- packages/tempo/src/support/support.init.ts | 12 +- packages/tempo/src/support/support.license.ts | 3 +- .../tempo/src/support/support.register.ts | 4 +- packages/tempo/src/support/support.symbol.ts | 10 +- packages/tempo/src/support/support.util.ts | 68 ++++++--- packages/tempo/src/tempo.class.ts | 71 ++++------ packages/tempo/src/tempo.type.ts | 8 +- .../test/core/alias-engine-protochain.test.ts | 28 ++-- packages/tempo/test/core/alias-engine.test.ts | 56 ++++---- packages/tempo/test/core/dispose.core.test.ts | 4 +- .../test/engine/parse.prefilter.flag.test.ts | 2 +- .../tempo/test/issues/issue-fixes.test.ts | 8 +- .../tempo/test/support/library-import.test.ts | 5 - .../tempo/test/support/symbol-import.test.ts | 2 +- 47 files changed, 404 insertions(+), 451 deletions(-) create mode 100644 packages/library/src/common/boundary.library.ts create mode 100644 packages/library/src/common/logger.class.ts delete mode 100644 packages/library/src/common/logify.class.ts delete mode 100644 packages/library/test/common/logify.class.test.ts create mode 100644 packages/tempo/src/support/support.error.ts delete mode 100644 packages/tempo/test/support/library-import.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7b9bd..f3fb501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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.0.0] - 2026-06-01 + +### Added +- **Tempo Registry Integration**: Moving towards deeper, centralized integration with the Tempo Registry infrastructure to streamline community and proprietary plugin distribution. +- **Modernized Duration Engine**: Introduced the chainable `.balance()` method to intelligently normalize temporal intervals (e.g., converting 365 days seamlessly into 1 year). +- **Duration Formatting**: Added a localized `.format()` method utilizing `Intl.DurationFormat` (with `Intl.NumberFormat` fallback support) for plural-aware, human-readable duration strings. Added `durationFormat` and `numberFormat` into `IntlOptions` to support global, instance-level, and method-level formatting overrides. +- **Enterprise Plugins Scaffolding**: Initial architectural scaffolding for upcoming enterprise `Cron` and `SLA` plugins, including a performant asynchronous `preloadHolidays` pattern for synchronous holiday-aware calculations. + +### Changed +- **Ticker Plugin Licensing**: The `Ticker` plugin has transitioned to a licensed model. It remains completely free to use, but now requires validation via the Tempo Registry ecosystem. +- **Robust License Validation**: Hardened runtime integrity for Tempo Pro plugins to enhance state security and prevent tampering. +- **Edition Boundaries**: Added proactive runtime warnings for unlicensed attempts, establishing strict boundaries between the Community and Proprietary editions. +- **Extension Resolution**: Stabilized module resolution for plugins by migrating to `defineExtension` and correcting internal export paths. +- **Documentation**: Clarified API behavior for the Duration engine (especially `.since()` return types) and relative time math. Improved visibility and navigation for the Tempo License Registry and installation guides. + +### Fixed +- **Module & Key Collisions**: Fixed registry authentication double-initialization bugs and terminal key registration collisions (e.g., the `[Tempo#extend] Term collision on key: "qtr"` error). +- **Ticker Plugin Stabilization**: Resolved lifecycle issues and nailed down the Ticker plugin architecture and API behavior. + ## [2.11.0] - 2026-05-25 ### Fixed diff --git a/packages/library/doc/browser/types.md b/packages/library/doc/browser/types.md index 306999b..5dffdd5 100644 --- a/packages/library/doc/browser/types.md +++ b/packages/library/doc/browser/types.md @@ -26,7 +26,7 @@ A convenience type for registering events: `[Tapper.EVENT, Tapper.Callback]`. ### `MapOpts` Options for mapping functions: - `catch?: boolean` (Interprets Promise reject as resolve) -- `debug?: boolean` (Enables logging) +- `debug?: number` (Sets verbosity level) ### `MapStore` Internal interface for cached geolocation and geocoder results. diff --git a/packages/library/src/browser/mapper.library.ts b/packages/library/src/browser/mapper.library.ts index 0ad3618..df0bc7a 100644 --- a/packages/library/src/browser/mapper.library.ts +++ b/packages/library/src/browser/mapper.library.ts @@ -3,15 +3,16 @@ import { CONTEXT, getContext } from '#library/utility.library.js'; import { isNullish } from '#library/assertion.library.js'; import { instant } from '#library/temporal.library.js'; import { getHemisphere } from '#library/international.library.js'; +import type { DebugLevel } from '#library/logger.class.js'; -import { Logify } from '#library/logify.class.js'; +import { Logger } from '#library/logger.class.js'; import type { WebStore } from '#browser/webstore.class.js'; // Various functions to allow geolocating a user-device via the browser. interface MapOpts { catch?: boolean; // intercept Promise reject() as resolve() (default: true) - debug?: boolean; // console.log some checkpoints + debug?: DebugLevel; // console.log some checkpoints } /** @@ -23,11 +24,11 @@ interface MapStore { // a localStorage object georesponse: google.maps.GeocoderResponse & { error?: Error["message"] }; } -const defaults = { catch: true, debug: false } as MapOpts; // default Options -const context = getContext(); // browser / nodejs / google-apps +const defaults = { catch: true, debug: 3 } as MapOpts; // default Options +const context = getContext(); // browser / nodejs / google-apps const mapStore = {} as MapStore; // static object to hold last position const MAP_KEY = '_map_'; // localStorage key -const log = new Logify('Mapper'); +const log = new Logger('[Mapper]'); const store = await new Promise((resolve, reject) => { if (context.type === CONTEXT.Browser) { @@ -35,7 +36,7 @@ const store = await new Promise((resolve, reject) => { .then(({ WebStore }) => { const local = new WebStore('local'); Object.assign(mapStore, local.get(MAP_KEY, {})); // fetch the previous MAP_KEY coordinates - resolve(local); // localStorage wrapper + resolve(local); // localStorage wrapper }) .catch(reject) } diff --git a/packages/library/src/common.index.ts b/packages/library/src/common.index.ts index 708a9af..90469bb 100644 --- a/packages/library/src/common.index.ts +++ b/packages/library/src/common.index.ts @@ -4,6 +4,7 @@ export * from './common/array.library.js'; export * from './common/assertion.library.js'; +export * from './common/boundary.library.js'; export * from './common/buffer.library.js'; export * from './common/cipher.class.js'; export * from './common/class.library.js'; @@ -11,7 +12,7 @@ export * from './common/coercion.library.js'; export * from './common/enumerate.library.js'; export * from './common/function.library.js'; export * from './common/international.library.js'; -export * from './common/logify.class.js'; +export * from './common/logger.class.js'; export * from './common/number.library.js'; export * from './common/object.library.js'; export * from './common/pledge.class.js'; diff --git a/packages/library/src/common/boundary.library.ts b/packages/library/src/common/boundary.library.ts new file mode 100644 index 0000000..8bbe478 --- /dev/null +++ b/packages/library/src/common/boundary.library.ts @@ -0,0 +1,43 @@ +import { isString } from './assertion.library.js'; +import type { Logger } from './logger.class.js'; + +export interface BoundaryContext { + /** + * If true, errors will be caught, logged, and execution will return gracefully. + * If false, errors will be immediately thrown. + */ + catch?: boolean | undefined; + + /** + * If true, suppresses the logger output when catch is true. + */ + silent?: boolean | undefined; + + /** + * The namespaced logger to use for outputting the caught error. + */ + logger?: Logger | null | undefined; +} + +/** + * Global Error Boundary Utility. + * Decouples the decision to throw an error from the act of logging it. + */ +export function raise(err: Error | string, context: BoundaryContext = {}): void { + const error = isString(err) ? new Error(err) : err; + + // 1. Output the error telemetry + if (!context.silent) { + if (context.logger) { + context.logger.error(error.message); + } else { + console.error(`[Boundary] ${error.message}`); + } + } + + // 2. Control flow + if (context.catch) + return; // gracefully swallow the error + + throw error; +} diff --git a/packages/library/src/common/logger.class.ts b/packages/library/src/common/logger.class.ts new file mode 100644 index 0000000..bcd3399 --- /dev/null +++ b/packages/library/src/common/logger.class.ts @@ -0,0 +1,98 @@ +import { isObject, isEmpty, isNumber, isError, isString } from '#library/assertion.library.js'; +import { enumify } from '#library/enumerate.library.js'; +import { sym } from '#library/symbol.library.js'; +import type { KeyOf, ValueOf } from '#library/type.library.js'; + +export const LOG = enumify(['Off', 'Error', 'Warn', 'Info', 'Debug', 'Trace']); +export type LOG = ValueOf +export type LogLevel = KeyOf +export type DebugLevel = LOG | Method; + +const Method = { + Off: 'off', + Log: 'log', + Info: 'info', + Warn: 'warn', + Debug: 'debug', + Trace: 'trace', + Error: 'error', +} as const; +type Method = ValueOf + +const Level = { + [Method.Off]: LOG.Off, + [Method.Error]: LOG.Error, + [Method.Warn]: LOG.Warn, + [Method.Info]: LOG.Info, + [Method.Log]: LOG.Info, + [Method.Debug]: LOG.Debug, + [Method.Trace]: LOG.Trace, +} as const; + +export function parseLogLevel(level?: DebugLevel, fallback: LOG = LOG.Info): LOG { + if (isNumber(level)) return level as LOG; + if (isString(level)) return Level[level.toLowerCase() as Method] ?? fallback; + return fallback; +} + +/** + * A lightweight, dependency-free namespaced logger. + * Decoupled from error handling and boundaries. + */ +export class Logger { + #namespace: string; + + /** The current verbosity level */ + level: LOG; + + constructor(namespace: string, level: LOG = LOG.Info) { + this.#namespace = namespace.startsWith('[') ? namespace : `[${namespace}]`; + this.level = level; + } + + #emit(method: typeof Method[keyof typeof Method], ...msg: any[]) { + let config: any; + if (msg.length > 0 && isObject(msg[0]) && msg[0][sym.$LogConfig]) + config = msg.shift(); + + let activeLevel = this.level; + if (config) { + if (config.silent === true) return; + if (config.debug !== undefined) { + activeLevel = parseLogLevel(config.debug, activeLevel); + } + } + + if (activeLevel === LOG.Off) return; + const methodLevel = Level[method] ?? 0; + if (methodLevel > activeLevel) return; + + const output = msg + .map(m => { + if (isError(m)) return m.message; + if (isObject(m)) { + try { + const name = m.constructor?.name ?? 'Object'; + if (name === 'Object') { + const keys = Object.keys(m); + const summary = keys.slice(0, 3).join(', '); + return `{ ${summary}${keys.length > 3 ? `, ... (+${keys.length - 3} more)` : ''} }`; + } + return `[${name}]`; + } catch { return '[Object]'; } + } + return String(m); + }) + .filter(s => !isEmpty(s)).join(' '); + + if (!isEmpty(output)) + (console as any)[method](`${this.#namespace} ${output}`); + } + + /** console.log */ log = (...msg: any[]) => this.#emit(Method.Log, ...msg); + /** console.info */ info = (...msg: any[]) => this.#emit(Method.Info, ...msg); + /** console.warn */ warn = (...msg: any[]) => this.#emit(Method.Warn, ...msg); + /** console.debug */ debug = (...msg: any[]) => this.#emit(Method.Debug, ...msg); + /** console.trace */ trace = (...msg: any[]) => this.#emit(Method.Trace, ...msg); + /** console.error */ error = (...msg: any[]) => this.#emit(Method.Error, ...msg); +} diff --git a/packages/library/src/common/logify.class.ts b/packages/library/src/common/logify.class.ts deleted file mode 100644 index 77bd128..0000000 --- a/packages/library/src/common/logify.class.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Immutable } from '#library/class.library.js'; -import { sym, markConfig } from '#library/symbol.library.js'; -import { asType } from '#library/type.library.js'; -import { isObject, isEmpty } from '#library/assertion.library.js'; -import { enumify } from '#library/enumerate.library.js'; -import type { ValueOf, KeyOf } from '#library/type.library.js'; - -export const LOG = enumify(['Off', 'Error', 'Warn', 'Info', 'Debug', 'Trace']); -export type LOG = ValueOf -export type LogLevel = KeyOf - -/** @internal console method names keyed by internal identifiers (not exported; see LOG enum for public API) */ -const Method = { - Log: 'log', - Info: 'info', - Warn: 'warn', - Debug: 'debug', - Trace: 'trace', - Error: 'error', -} as const; - -/** @internal severity levels mapped to Method names for gating logic, derived from LOG */ -const Level = { - [Method.Error]: LOG.Error, - [Method.Warn]: LOG.Warn, - [Method.Info]: LOG.Info, - [Method.Log]: LOG.Info, - [Method.Debug]: LOG.Debug, - [Method.Trace]: LOG.Trace, -} as const; - -/** logging severity levels for Logify output control */ -/** - * provide standard logging methods to the console for a class - */ -@Immutable -export class Logify { - #name: string; - #opts: Logify.Constructor = { [sym.$Logify]: true }; - - /** - * if {catch:true} then show a warning on the console and return - * otherwise show an error on the console and re-throw the error - */ - #trap(method: Logify.Method, ...msg: any[]) { - const config = (isObject(msg[0]) && (msg[0] as any)[sym.$Logify] === true) ? msg.shift() : this.#opts; - const currentLevel = (typeof config.debug === 'number') - ? config.debug - : (config.debug === true ? LOG.Trace : LOG.Info); - const methodLevel = Level[method] ?? 0; - - const output = msg.map(m => { - if (m instanceof Error) return m.message; - if (isObject(m)) { - try { - const name = m.constructor?.name ?? 'Object'; // avoiding JSON.stringify (expensive) - if (name === 'Object') { - const keys = Object.keys(m); - const summary = keys.slice(0, 3).join(', '); - return `{ ${summary}${keys.length > 3 ? `, ... (+${keys.length - 3} more)` : ''} }`; - } - return `[${name}]`; - } catch { return '[Object]'; } - } - return String(m); - }).filter(s => !isEmpty(s)).join(' '); - - if (!config.silent && !isEmpty(output) && methodLevel <= currentLevel) - (console as any)[method](`${this.#name}: ${output}`); - - if (method === Method.Error && !config.catch) { - const e = msg.find(m => m instanceof Error); - const message = `${this.#name}: ${output}`; - if (e) { - e.message = message; - throw e; - } - throw new Error(message); - } - } - - /** console.log */ log = (...msg: any[]) => this.#trap(Method.Log, ...msg); - /** console.info */ info = (...msg: any[]) => this.#trap(Method.Info, ...msg); - /** console.warn */ warn = (...msg: any[]) => this.#trap(Method.Warn, ...msg); - /** console.debug */ debug = (...msg: any[]) => this.#trap(Method.Debug, ...msg); - /** console.trace */ trace = (...msg: any[]) => this.#trap(Method.Trace, ...msg); - /** console.error */ error = (...msg: any[]) => this.#trap(Method.Error, ...msg); - - constructor(self?: Logify.Constructor | string, opts = {} as Logify.Constructor) { - opts = { ...opts }; // defensive copy of the options - const arg = asType(self); - this.#name = (arg.type === 'String') - ? arg.value - : (self as any)?.constructor?.name - ?? 'Logify'; - - if (arg.type === 'Object') { - const cfg = { ...arg.value as object }; - markConfig(cfg); // auto-mark if it's a config object - Object.assign(opts, cfg); - } - - markConfig(opts); // auto-mark the options object - - this.#opts.debug = opts.debug ?? false; // default debug to 'false' - this.#opts.catch = opts.catch ?? false; // default catch to 'false' - this.#opts.silent = opts.silent ?? false; // default silent to 'false' - } -} - -export namespace Logify { - export type Method = ValueOf - - export interface Constructor { - /** - * Logging verbosity: `boolean | number`. - * - `true` maps to `LOG.Trace`, enabling trace-level logging - * - `false` (or unset) maps to `LOG.Info` - * - numeric values map directly to `LOG` levels - * - * Note: numeric `0` (`LOG.Off`) suppresses all console emission, including - * `console.error`. Errors can still be rethrown when `catch: false`, but no - * error log is emitted. Use `true` or a higher numeric level to ensure errors - * are logged to the console. - */ - debug?: boolean | number | undefined, - catch?: boolean | undefined, - silent?: boolean | undefined, - [sym.$Logify]?: boolean | undefined - } -} \ No newline at end of file diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/pledge.class.ts index 4d04ab8..4d79f2f 100644 --- a/packages/library/src/common/pledge.class.ts +++ b/packages/library/src/common/pledge.class.ts @@ -1,4 +1,4 @@ -import { Logify } from '#library/logify.class.js'; +import { Logger, type DebugLevel } from '#library/logger.class.js'; import { markConfig } from '#library/symbol.library.js'; import { asArray } from '#library/coercion.library.js'; import { ifDefined } from '#library/object.library.js'; @@ -13,7 +13,7 @@ declare module '#library/type.library.js' { } } -const _dbg = new Logify('Pledge'); +const _dbg = new Logger('[Pledge]'); let _static = {} as Pledge.Constructor; const _STATE = secure({ Pending: Symbol('pending'), @@ -177,9 +177,9 @@ export namespace Pledge { onResolve?: Pledge.Resolve | Pledge.Resolve[] | undefined; onReject?: Pledge.Reject | Pledge.Reject[] | undefined; onSettle?: Pledge.Settle | Pledge.Settle[] | undefined; - debug?: Logify.Constructor["debug"]; - catch?: Logify.Constructor["catch"]; - silent?: Logify.Constructor["silent"]; + debug?: DebugLevel; + catch?: boolean; + silent?: boolean; } export interface Status { diff --git a/packages/library/src/common/symbol.library.ts b/packages/library/src/common/symbol.library.ts index aa73770..f73e9f5 100644 --- a/packages/library/src/common/symbol.library.ts +++ b/packages/library/src/common/symbol.library.ts @@ -7,7 +7,7 @@ export const $Target: unique symbol = Symbol.for('$LibraryTarget') as any; export const $Discover: unique symbol = Symbol.for('$LibraryDiscover') as any; export const $Extensible: unique symbol = Symbol.for('$LibraryExtensible') as any; export const $Inspect: unique symbol = Symbol.for('nodejs.util.inspect.custom') as any; -export const $Logify: unique symbol = Symbol.for('$LibraryLogify') as any; +export const $LogConfig: unique symbol = Symbol.for('$LibraryLogConfig') as any; export const $Registry: unique symbol = Symbol.for('$LibraryRegistry') as any; export const $Register: unique symbol = Symbol.for('$LibraryRegister') as any; export const $SerializerRegistry: unique symbol = Symbol.for('$LibrarySerializerRegistry') as any; @@ -15,13 +15,13 @@ export const $ImmutableSkip: unique symbol = Symbol.for('$LibraryImmutableSkip') export const $Identity: unique symbol = Symbol.for('$LibraryIdentity') as any; export const sym = { - $Target, $Discover, $Extensible, $Inspect, $Logify, $Registry, $Register, $SerializerRegistry, $Identity, $ImmutableSkip + $Target, $Discover, $Extensible, $Inspect, $LogConfig, $Registry, $Register, $SerializerRegistry, $Identity, $ImmutableSkip } as const; -/** identify and mark a Logify configuration object */ +/** identify and mark a logging configuration object */ export function markConfig(obj: T): T { - if (!(obj as any)[sym.$Logify] && Object.isExtensible(obj)) - Object.defineProperty(obj, sym.$Logify, { value: true, enumerable: false, writable: true, configurable: true }); + if (!(obj as any)[sym.$LogConfig] && Object.isExtensible(obj)) + Object.defineProperty(obj, sym.$LogConfig, { value: true, enumerable: false, writable: true, configurable: true }); return obj; } diff --git a/packages/library/test/common/logify.class.test.ts b/packages/library/test/common/logify.class.test.ts deleted file mode 100644 index 2609fb9..0000000 --- a/packages/library/test/common/logify.class.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Logify } from '#library/logify.class.js'; - -describe('Logify severity gating', () => { - test('defaults to Info level when debug is false/undefined', () => { - const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); - const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); - - try { - const log = new Logify('LogifyTestDefault'); - log.info('info-visible'); - log.debug('debug-hidden'); - - expect(infoSpy).toHaveBeenCalledTimes(1); - expect(infoSpy).toHaveBeenCalledWith('LogifyTestDefault: info-visible'); - expect(debugSpy).not.toHaveBeenCalled(); - } finally { - infoSpy.mockRestore(); - debugSpy.mockRestore(); - } - }); - - test('enables trace level when debug is true', () => { - const traceSpy = vi.spyOn(console, 'trace').mockImplementation(() => {}); - const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); - - try { - const log = new Logify('LogifyTestTrace', { debug: true }); - log.trace('trace-visible'); - log.debug('debug-visible'); - - expect(traceSpy).toHaveBeenCalledTimes(1); - expect(traceSpy).toHaveBeenCalledWith('LogifyTestTrace: trace-visible'); - expect(debugSpy).toHaveBeenCalledTimes(1); - expect(debugSpy).toHaveBeenCalledWith('LogifyTestTrace: debug-visible'); - } finally { - traceSpy.mockRestore(); - debugSpy.mockRestore(); - } - }); - - test('uses numeric debug level directly', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); - - try { - const log = new Logify('LogifyTestNumeric', { debug: 2 }); - log.warn('warn-visible'); - log.info('info-hidden'); - - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith('LogifyTestNumeric: warn-visible'); - expect(infoSpy).not.toHaveBeenCalled(); - } finally { - warnSpy.mockRestore(); - infoSpy.mockRestore(); - } - }); - - test('rethrows errors even when log emission is gated off', () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const err = new Error('boom'); - - try { - const log = new Logify('LogifyTestRethrow', { debug: 0, catch: false }); - expect(() => log.error(err)).toThrow('LogifyTestRethrow: boom'); - expect(errorSpy).not.toHaveBeenCalled(); - } finally { - errorSpy.mockRestore(); - } - }); -}); diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index e97d950..1181649 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -33,9 +33,7 @@ export default defineConfig({ items: [ { text: 'Introduction', link: '/README' }, { text: 'Installation', link: '/doc/installation' }, - { text: 'Cookbook', link: '/doc/tempo.cookbook' }, - { text: 'Migration Guide', link: '/doc/migration-guide' }, - { text: 'Release Notes', link: '/doc/releases/' } + { text: 'Cookbook', link: '/doc/tempo.cookbook' } ] }, { @@ -98,8 +96,10 @@ export default defineConfig({ ] }, { - text: 'Services & Support', + text: 'Project & Support', items: [ + { text: 'Migration Guide', link: '/doc/migration-guide' }, + { text: 'Release Notes', link: '/doc/releases/' }, { text: 'Professional Services', link: '/doc/commercial' } ] } diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 28c6e79..2c082b6 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -188,7 +188,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Layout Order Resolver Module**: Extracted layout-ordering decision logic from the Tempo class into a dedicated `engine.layout` module (`src/engine/engine.layout.ts`). This module provides deterministic functions for resolving parse layout order based on locale preference and maintains existing pair-swap semantics. - **Layout Controller Framework**: Implemented a minimal controller-map infrastructure (`LayoutController` type, `createLayoutController`, `resolveLayoutClassificationOrder`) to enable future input-class pre-filtering and custom layout ordering without structural refactors. The framework currently has a single default classification that mirrors existing behavior. -- **Debug Layout Order Visibility**: Added optional debug output in `Tempo.#swapLayout` to emit the resolved layout order for diagnostics (when `debug: true`). +- **Debug Layout Order Visibility**: Added optional debug output in `Tempo.#swapLayout` to emit the resolved layout order for diagnostics (when `debug: 5`). ### Changed - **Internal Layout Resolution**: Refactored `Tempo.#swapLayout` to delegate ordering to the external resolver, improving separation of concerns and testability. diff --git a/packages/tempo/bench/bench.parse.prefilter.e2e.ts b/packages/tempo/bench/bench.parse.prefilter.e2e.ts index 943fb92..27755a2 100644 --- a/packages/tempo/bench/bench.parse.prefilter.e2e.ts +++ b/packages/tempo/bench/bench.parse.prefilter.e2e.ts @@ -37,7 +37,7 @@ try { function runE2E(enablePrefilter: boolean, iterations: number) { Tempo.init({ parsePrefilter: enablePrefilter, - debug: false, + debug: 0, catch: true, timeZone: 'UTC', }); diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index 7c9f491..cbe269b 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -29,8 +29,8 @@ To solve the "Split-Brain" issue inherent in monorepo development (where multipl ## ๐Ÿ•ต๏ธ Decoupled Logging (Logify) Tempo uses **Logify**, a diagnostic engine that leverages private Symbols to avoid polluting the public console or object state. - **Context-Aware**: Logs track their discovery path (e.g., "Applied via Global Discovery"). -- **Zero-Footprint**: When `debug: false`, the logging overhead is mathematically eliminated. -- **Symbol-Gated**: Diagnostic metadata is attached via `Symbol.for($Logify)`, making it invisible to standard iteration (`Object.keys`) and serialization (`JSON.stringify`). +- **Zero-Footprint**: When `debug: 0`, the logging overhead is mathematically eliminated. +- **Symbol-Gated**: Diagnostic metadata is attached via `Symbol.for($LogConfig)`, making it invisible to standard iteration (`Object.keys`) and serialization (`JSON.stringify`). ## ๐Ÿ›ก๏ธ Hardened Functional Resolution The engine implements a "Fail-Safe" execution pattern for functional inputs, automatically recovering from misidentified typesโ€”such as ES6 classes wrapped in defensive Proxies or circular dependency deadlocks. diff --git a/packages/tempo/doc/migration-guide.md b/packages/tempo/doc/migration-guide.md index 0f4877f..263c1b5 100644 --- a/packages/tempo/doc/migration-guide.md +++ b/packages/tempo/doc/migration-guide.md @@ -159,6 +159,12 @@ new Tempo(1000n, { timeStamp: 'ns' }); ## ๐Ÿ” Removed Features (v3.0.0) +### Deprecated Boolean Debug Flag +The `debug` configuration property no longer accepts `boolean` values. It has been strictly typed to accept numeric verbosity levels matching the internal `LOG` enum, or lowercase string labels (e.g. `'trace'`, `'info'`). + +- **Removed:** `new Tempo({ debug: true })` +- **Recommended:** `new Tempo({ debug: 5 })`, `new Tempo({ debug: 'trace' })`, or `new Tempo({ debug: LOG.Trace })` (for maximum trace verbosity). + ### Internationalization Naming To better align with ECMAScript standards (specifically `Intl.RelativeTimeFormat`), the `relativeTime` configuration option inside `intl` is no longer supported in v3.0.0. diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index 24a8e93..63b7286 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -180,7 +180,7 @@ Added v2.6.0 migration guide for season changes. - **Module Path Flattening**: Relocated core modules (e.g., `mutate`, `duration`) from `src/plugin/module/` to a top-level `src/module/` directory. This simplifies the internal architecture and provides cleaner internal import paths, reflecting their role as core library features rather than just "plugins". ### ๐Ÿ“Š Diagnostics & Debug Support -- **Layout Order Tracing**: Added optional debug emission to show the final resolved layout order when `debug: true` is set, helping developers understand parse-order decisions. +- **Layout Order Tracing**: Added optional debug emission to show the final resolved layout order when `debug: 5` is set, helping developers understand parse-order decisions. ## [v2.3.0] - 2026-04-22 ### ๐Ÿงฉ Parsing Innovations diff --git a/packages/tempo/doc/sandbox-factory.md b/packages/tempo/doc/sandbox-factory.md index 967d49e..a96b7d0 100644 --- a/packages/tempo/doc/sandbox-factory.md +++ b/packages/tempo/doc/sandbox-factory.md @@ -66,4 +66,4 @@ Sandboxed classes created via `Tempo.create()` are protected by the same `@Immut ## Best Practices 1. **Create Once**: Create your application-specific Sandbox once and export it as your primary entry point. 2. **Prefer Sandboxes for Custom Aliases**: Avoid modifying the base `Tempo` class if your app is intended to be used as a library. -3. **Use Debug Mode**: When developing new aliases, set `debug: true` to receive console warnings about naming collisions. +3. **Use Debug Mode**: When developing new aliases, set `debug: 5` to receive console warnings about naming collisions. diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index bce6cb5..9668c5a 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -121,7 +121,7 @@ Tempo.init({ timeZone: 'Australia/Sydney', locale: 'en-AU', pivot: 80, - debug: false + debug: 0 }); ``` @@ -145,7 +145,7 @@ Tempo.init({ | `plugins` | `Plugin \| Plugin[]` | `[]` | Plugins/modules to extend during initialization. Unlike core init options such as `snippet`, `layout`, `event`, or `period`, these values are not merged into internal state via `extendState`; `Tempo.init()` applies each plugin with `Tempo.extend(p)`, so plugin authors should treat them as instance/class augmentations rather than internal-state merges. | | `store` | `string` | `'$Tempo'` | Persistent storage key used by `readStore`/`writeStore`. | | `discovery` | `string \| symbol` | `'$Tempo'` symbol key | Discovery slot used to resolve global discovery config. | -| `debug` | `boolean \| number` | `false` | Controls log verbosity. `true` maps to `LOG.Debug`, `false` maps to `LOG.Info`, and numeric values map directly to `LOG` levels (`0=Off ... 5=Trace`). | +| `debug` | `number \| string` | `'info'` | Controls log verbosity via direct `LOG` levels (`0=Off ... 5=Trace`) or string labels (`'trace'`, `'info'`, etc). | | `catch` | `boolean` | `false` | If true, invalid inputs return a Void instance instead of throwing. | | `mode` | `'auto' \| 'strict' \| 'defer'` | `'auto'` | Controls the hydration strategy (e.g., `defer` for Zero-Cost creation). | | `silent` | `boolean` | `false` | Suppresses console output. Combined with `catch: true` for silent failover. | @@ -155,7 +155,7 @@ Tempo.init({ --- ::: info -`debug` currently accepts only `boolean` or numeric level values. String labels like `'trace'` are not supported. +`debug` accepts numeric level values (`0` through `5`) or lowercase string labels (`'off'`, `'error'`, `'warn'`, `'info'`, `'debug'`, `'trace'`). ::: ## 4. Instance-Level Overrides @@ -267,7 +267,7 @@ Tempo.init({ ``` ::: tip -**Observability**: Set `debug: true` along with `planner.preFilter` to see a detailed "Planner summary" in the console, showing how many layouts were skipped for a given input. +**Observability**: Set `debug: 5` along with `planner.preFilter` to see a detailed "Planner summary" in the console, showing how many layouts were skipped for a given input. ::: --- @@ -283,7 +283,7 @@ Tempo.init({ | **Instance** | ๐Ÿฅ‡ Highest | Ad-hoc overrides for specific calculations. | ::: tip -**Observability**: When `debug: true` is set, Tempo logs its discovery path to the console (e.g., "Global Discovery found via Symbol"), making it easy to trace exactly where a setting originated. +**Observability**: When `debug: 5` is set, Tempo logs its discovery path to the console (e.g., "Global Discovery found via Symbol"), making it easy to trace exactly where a setting originated. ::: ::: info diff --git a/packages/tempo/doc/tempo.debugging.md b/packages/tempo/doc/tempo.debugging.md index 7d1f4c0..583f101 100644 --- a/packages/tempo/doc/tempo.debugging.md +++ b/packages/tempo/doc/tempo.debugging.md @@ -66,9 +66,9 @@ If you simply need to see the value represented in different primitive formats, Tempo has an internal logging utility (`Tempo.#dbg`) that responds to specific configuration flags. ### The `debug` Flag -When instantiating a `Tempo`, you can pass `{ debug: true }` in the options object. +When instantiating a `Tempo`, you can pass `{ debug: 5 }` in the options object. ```typescript -const t = new Tempo('next Friday', { debug: true }); +const t = new Tempo('next Friday', { debug: 5 }); ``` When this flag is enabled, Tempo will output detailed `console.info` logs during instantiation, including: * The raw input being parsed. @@ -90,7 +90,7 @@ const t = new Tempo('invalid string', { catch: false }); Tempo uses a **Master Guard** to avoid the expensive regex parsing phase for strings that are obviously not date-time inputs. If you find that a string is not being parsed as expected, it's possible that the guard is Rejecting it. 1. **Check characters**: The guard only allows digits, common symbols (`-`, `:`, `.`, `T`, `Z`, `/`, `+`, `#`), space, and standard Latin characters. -2. **Use `debug: true`**: If a string passes the guard but fails the parser, you will see "conformed groups" logs. If you see *nothing* and the input is returned as-is (falling back to a timestamp interpretation), then the guard likely rejected the string. +2. **Use `debug: 5`**: If a string passes the guard but fails the parser, you will see "conformed groups" logs. If you see *nothing* and the input is returned as-is (falling back to a timestamp interpretation), then the guard likely rejected the string. 3. **Invalid String fallback**: If the guard rejects a string and it cannot be parsed as a numeric timestamp, it will result in an Invalid instance (see below). --- diff --git a/packages/tempo/doc/tempo.pledge.md b/packages/tempo/doc/tempo.pledge.md index cf805af..c2ffb51 100644 --- a/packages/tempo/doc/tempo.pledge.md +++ b/packages/tempo/doc/tempo.pledge.md @@ -59,7 +59,7 @@ Each `Pledge` can be assigned a `tag` string. This tag is included in logs and e ```typescript // Enable debug logging for this instance -const p = new Pledge({ tag: 'DatabaseQuery', debug: true }); +const p = new Pledge({ tag: 'DatabaseQuery', debug: 5 }); // If p is rejected, the tag will be included in the trace p.reject('Timeout'); @@ -71,7 +71,7 @@ You can set global defaults for all future `Pledge` instances using `Pledge.init ```typescript Pledge.init({ - debug: true, + debug: 5, onSettle: () => GlobalSpinner.stop() }); ``` diff --git a/packages/tempo/doc/tempo.plugin.md b/packages/tempo/doc/tempo.plugin.md index f2cc607..99baeff 100644 --- a/packages/tempo/doc/tempo.plugin.md +++ b/packages/tempo/doc/tempo.plugin.md @@ -279,7 +279,7 @@ Tempo.extend(HolidayPlugin({ Tempo.extend( [PluginA, PluginB], PluginC, - { debug: true } // applied to A, B, and C + { debug: 5 } // applied to A, B, and C ); ``` diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 1b963fc..eb4e89e 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -15,9 +15,8 @@ */ import type { Nullable } from '#library/type.library.js'; -import type { Logify } from '#library/logify.class.js'; -import { isDefined, isFunction, isZonedDateTime } from '#library/assertion.library.js'; -import { Match } from '#tempo/support'; +import { isDefined, isFunction } from '#library/assertion.library.js'; +import { Match, logError, logWarn } from '#tempo/support'; import { ownEntries } from '#library/primitive.library.js'; import * as t from '../tempo.type.js'; @@ -28,7 +27,7 @@ type State = Record export interface AliasResult { value: string; - key: string; // The normalized baseWord (e.g. 'noon') + key: string; // the normalized baseWord (e.g. 'noon') type: AliasType; source: 'global' | 'local'; isClock: boolean; @@ -37,7 +36,6 @@ export interface AliasResult { export interface AliasEngineOptions { parent?: Nullable; - logger?: Nullable; config?: Nullable; } interface Registry { // information about each registered alias @@ -63,7 +61,6 @@ export class AliasEngine { } #parent?: AliasEngineOptions["parent"]; - #logger?: AliasEngineOptions["logger"]; #config?: AliasEngineOptions["config"]; #depth: number; // the depth of this engine in the proto chain @@ -82,18 +79,17 @@ export class AliasEngine { constructor(options = {} as AliasEngineOptions) { const parent = options.parent; - this.#logger = options.logger; this.#config = options.config; this.#id = AliasEngine._idCounter++; if (parent instanceof AliasEngine) { this.#parent = parent; this.#depth = parent.#depth + 1; - this.#state = Object.create(parent.#state); // create a new state object that inherits from the parent engine's state - this.#words = Object.create(parent.#words); // create a new words object that inherits from the parent engine's words for collision detection + this.#state = Object.create(parent.#state); // create a new state object that inherits from the parent engine's state + this.#words = Object.create(parent.#words); // create a new words object that inherits from the parent engine's words for collision detection } else { if (parent) - this.#logger?.error(this.#config, "Parent engine must be an instance of AliasEngine"); + logError("Parent engine must be an instance of AliasEngine", this.#config); this.#parent = null; this.#depth = 0; @@ -125,9 +121,10 @@ export class AliasEngine { const aliasKey = `${type}${this.#depth}_${index}` as AliasKey; const shouldOverwrite = !(existing?.type === 'evt' && type === 'per'); - if (this.#logger && baseWord in this.#words) { - this.#logger.warn(this.#config, - `[AliasEngine] Collision detected for ${type} alias "${name}". ${shouldOverwrite ? 'Overwriting' : 'Preserving'} existing alias.` + if (baseWord in this.#words) { + logWarn( + `[AliasEngine] Collision detected for ${type} alias "${name}". ${shouldOverwrite ? 'Overwriting' : 'Preserving'} existing alias.`, + this.#config ); } diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index 127df27..7137ba9 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -59,7 +59,7 @@ export function compose( try { temporal = Temporal.PlainDateTime.from(value, { overflow: 'constrain' }); } catch (err2) { - logError(config, `[Tempo#composer] Unrecognized or invalid ISO 8601 string: "${value}"`); + logError(`[Tempo#composer] Unrecognized or invalid ISO 8601 string: "${value}"`, config); return { dateTime: today }; } } @@ -94,7 +94,7 @@ export function compose( case 'BigInt': { if (type === 'Number' && !Number.isFinite(value)) { - logError(config, `Invalid Tempo number: ${value}`); + logError(`Invalid Tempo number: ${value}`, config); temporal = today; break; } @@ -163,7 +163,7 @@ export function compose( break; default: { - logError(config, `Cannot convert ${type} (value: ${String(temporal)}) to ZonedDateTime`); + logError(`Cannot convert ${type} (value: ${String(temporal)}) to ZonedDateTime`, config); return { dateTime: today }; } } diff --git a/packages/tempo/src/engine/engine.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts index 56d5b6b..32c7143 100644 --- a/packages/tempo/src/engine/engine.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -130,7 +130,7 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, return dateTime; if (!isEmpty(mod) && !isEmpty(sfx)) { - logWarn(config, `Cannot provide both a modifier '${mod}' and suffix '${sfx}'`); + logWarn(`Cannot provide both a modifier '${mod}' and suffix '${sfx}'`, config); return dateTime; } @@ -139,7 +139,7 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, const offset = (enums.WEEKDAY as any)[weekday] ?? (enums.WEEKDAYS as any)[weekday]; if (!Number.isFinite(offset)) { - logError(config, `Invalid weekday token: "${wkd}"`); + logError(`Invalid weekday token: "${wkd}"`, config); return dateTime; } @@ -167,7 +167,7 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, co return dateTime; if (!isEmpty(mod) && !isEmpty(afx)) { - logWarn(config, `Cannot provide both a modifier '${mod}' and suffix '${afx}'`); + logWarn(`Cannot provide both a modifier '${mod}' and suffix '${afx}'`, config); return dateTime; } @@ -220,7 +220,7 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, co delete groups["afx"]; if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { - logError(config, `Invalid Date components: year=${year}, month=${month}, day=${day}`); + logError(`Invalid Date components: year=${year}, config, month=${month}, day=${day}`); return dateTime; } diff --git a/packages/tempo/src/engine/engine.normalizer.ts b/packages/tempo/src/engine/engine.normalizer.ts index 7a419e4..e980735 100644 --- a/packages/tempo/src/engine/engine.normalizer.ts +++ b/packages/tempo/src/engine/engine.normalizer.ts @@ -3,7 +3,7 @@ import { getTemporalIds, instant } from '#library/temporal.library.js'; import { ownKeys } from '#library/primitive.library.js'; import type { TypeValue } from '#library/type.library.js'; -import { getRuntime, sym, Match } from '#tempo/support'; +import { getRuntime, sym, Match, logError, TempoError } from '#tempo/support'; import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './engine.lexer.js'; import { resolveTermMutation } from './engine.term.js'; import enums from '#tempo/support/support.enum.js'; @@ -167,7 +167,7 @@ export function resolveAliases( if (resolvingKeys.size > MAX_TEMPO_RESOLVE_DEPTH || resolvingKeys.has(aliasKey)) { const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; state.errored = true; - if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new RangeError(msg)); + logError(new RangeError(msg), state.config); delete groups[key]; continue; } @@ -186,7 +186,7 @@ export function resolveAliases( } as const)[res.type as 'evt' | 'per']; if (!mapped) - throw new Error(`[ParseEngine] Unexpected AliasType: ${res.type}`); + throw new TempoError(`[ParseEngine] Unexpected AliasType: ${res.type}`); const { type, pat } = mapped; diff --git a/packages/tempo/src/engine/engine.pattern.ts b/packages/tempo/src/engine/engine.pattern.ts index 9c0105d..2721935 100644 --- a/packages/tempo/src/engine/engine.pattern.ts +++ b/packages/tempo/src/engine/engine.pattern.ts @@ -49,7 +49,7 @@ export class PatternCompiler { const matcher = (source: string, d = 0): string => { if (d > 10) { // Emit a diagnostic if recursion limit is hit (likely circular placeholder) - logWarn(this.#state.config, `[PatternCompiler] Recursion limit exceeded in matcher (d > 10) for src:`, source, `depth:`, d); + logWarn(`[PatternCompiler] Recursion limit exceeded in matcher (d > 10) for src:`, this.#state.config, source, `depth:`, d); return source; } @@ -96,7 +96,7 @@ export class PatternCompiler { return compiled; } catch (e: any) { // Use the computed source for fallback, do not cache fallback, and log error - logError(this.#state.config, { context: 'pattern compile failed', pattern: source }, e); + logError(e, this.#state.config, { context: 'pattern compile failed', pattern: source }); return new RegExp(`^${Match.escape(source)}$`, 'i'); } } diff --git a/packages/tempo/src/module/module.duration.ts b/packages/tempo/src/module/module.duration.ts index ff02b91..4577e7e 100644 --- a/packages/tempo/src/module/module.duration.ts +++ b/packages/tempo/src/module/module.duration.ts @@ -6,7 +6,7 @@ import { ifDefined } from '#library/object.library.js'; import { getRelativeTime, formatNumber, formatDuration, formatList } from '#library/international.library.js'; import { defineInterpreterModule, interpret, type TempoModule } from '../plugin/plugin.util.js'; -import { enums, isTempo } from '#tempo/support'; +import { enums, isTempo, TempoError } from '#tempo/support'; import { Tempo } from '../tempo.class.js'; declare module '../tempo.class.js' { @@ -78,7 +78,7 @@ function toDuration(dur: Temporal.Duration, ctx: { relativeTo?: any, locale?: st // Strict Temporal balancing const anchor = customAnchor || ctx.relativeTo; if (!anchor) - throw new Error("A relativeTo anchor is required for strict balancing. Pass an anchor or use { nominal: true } for mathematical balancing."); + throw new TempoError("A relativeTo anchor is required for strict balancing. Pass an anchor or use { nominal: true } for mathematical balancing."); const balanced = dur.round({ largestUnit, relativeTo: anchor }); diff --git a/packages/tempo/src/module/module.mutate.ts b/packages/tempo/src/module/module.mutate.ts index 0bad4d8..051311b 100644 --- a/packages/tempo/src/module/module.mutate.ts +++ b/packages/tempo/src/module/module.mutate.ts @@ -2,7 +2,7 @@ import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#li import { singular } from '#library/string.library.js'; import { normaliseFractionalDurations } from '#library/temporal.library.js'; -import { sym, enums } from '#tempo/support'; +import { sym, enums, logError } from '#tempo/support'; import { defineInterpreterModule, type TempoModule } from '../plugin/plugin.util.js'; import { findTermPlugin } from '../plugin/term/term.util.js'; import { resolveTermMutation } from '../engine/engine.term.js'; @@ -59,10 +59,8 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options if (key === 'timeZone' || key === 'calendar') return currZdt; try { - // @ts-ignore - access to mutation guard if (++state.mutateDepth > 100) { - // @ts-ignore - access to static logger - (this.constructor as any)[sym.$logError](this.config, `Infinite recursion detected in mutation engine for key: ${key}, adjust: ${adjust}, depth: ${state.mutateDepth}`); + logError(`Infinite recursion detected in mutation engine for key: ${key}, this.config, adjust: ${adjust}, depth: ${state.mutateDepth}`); state.errored = true; return currZdt; } @@ -165,33 +163,27 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options return currZdt.round({ smallestUnit: offset as any, roundingMode: 'ceil' }).subtract({ nanoseconds: 1 }); default: - // @ts-ignore - (this.constructor as any)[sym.$logError](this.config, `Unexpected method(${op}), unit(${key}) and offset(${adjust})`); + logError(`Unexpected method(${op}), this.config, unit(${key}) and offset(${adjust})`); state.errored = true; return currZdt; } } finally { - // @ts-ignore state.mutateDepth--; } }, zdt); } else { // 3. Return a new instance with the final state - // @ts-ignore - access to private constructor/state return new (this.constructor as any)(args, { ...state.options, ...this.config, ...options, anchor: zdt, [sym.$Internal]: { ...state, matches } }); } } if (state.errored) { - // @ts-ignore - access to private constructor fallback return new (this.constructor as any)(null, { ...state.options, ...overrides, ...options, [sym.$Internal]: { ...state, matches } }); } - // @ts-ignore matches.push({ type: 'Mutation', value: zdt, match: 'mutation' }); - // @ts-ignore return new (this.constructor as any)(zdt, { ...state.options, ...overrides, ...options, anchor: zdt, [sym.$Internal]: { ...state, matches } }); } finally { diff --git a/packages/tempo/src/module/module.parse.ts b/packages/tempo/src/module/module.parse.ts index a06455e..ce4cfff 100644 --- a/packages/tempo/src/module/module.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -15,9 +15,9 @@ import { normalizeMatch, accumulateResult } from '../engine/engine.normalizer.js import { getRange, getTermRange } from '../plugin/term/term.util.js'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Range, ResolvedRange } from '../plugin/term/term.type.js'; -import { sym, isTempo, TermError, getRuntime, Match } from '../support/support.index.js'; +import { sym, isTempo, TermError, getRuntime, Match, TempoError } from '../support/support.index.js'; import { markConfig, setPatterns, init, extendState } from '../support/support.index.js'; -import { setProperty } from '#tempo/support/support.util.js'; +import { setProperty, logError, logDebug } from '#tempo/support/support.util.js'; import * as t from '../tempo.type.js'; /** @@ -113,8 +113,8 @@ const _ParseEngine = { if (termKey) { if (isUndefined(term)) { const msg = `Unsupported Syntax: Term-based mutations (#) cannot be passed to the constructor. Use new Tempo().set(${JSON.stringify(tempo)}) instead.`; - if (TempoClass) (TempoClass as any)[sym.$logError](state.config, msg); - throw new Error(msg); + logError(msg, state.config); + throw new TempoError(msg); } if (terms.length === 0) { if (TempoClass) (TempoClass as any)[TermError](state.config, termKey); @@ -155,7 +155,7 @@ const _ParseEngine = { if (isTempo(dateTime)) dateTime = dateTime.toDateTime(); if (!isZonedDateTime(dateTime)) { - if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new TypeError(`Sacred Anchor corrupted: ${String(value)}`)); + logError(new TypeError(`Sacred Anchor corrupted: ${String(value)}`), state.config); return { type: 'Void', value: undefined as any }; } @@ -238,7 +238,7 @@ const _ParseEngine = { const TempoClass = getRuntime().modules['Tempo']; if (resolving.size >= 100) { - if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new RangeError(`Infinite recursion detected in layout resolution for: ${String(value)}`)); + logError(new RangeError(`Infinite recursion detected in layout resolution for: ${String(value)}`), state.config); return arg; } @@ -281,7 +281,7 @@ const _ParseEngine = { } else if (trim.length <= 7) { const msg = 'Cannot safely interpret number with less than 8-digits: use string instead'; - if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new TypeError(msg)); + logError(new TypeError(msg), state.config); return arg; } } @@ -301,13 +301,12 @@ const _ParseEngine = { enablePrefilter: state.parse.preFilter === true, onPlan: (summary) => { if (state.parse.preFilter !== true || !state.config?.debug) return; - if (!TempoClass) return; - const reduced = summary.totalCandidates - summary.selectedCandidates; if (reduced <= 0 && !summary.fallbackToFull) return; - (TempoClass as any)[sym.$logDebug](state.config, + logDebug( `Planner summary: selected ${summary.selectedCandidates}/${summary.totalCandidates}`, + state.config, `rules=${summary.rulesApplied.join(',') || 'none'}`, `fallback=${summary.fallbackToFull}`, `input="${summary.inputClass.trim}"` diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 3538b30..e475433 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -1,10 +1,10 @@ import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from '#library/assertion.library.js'; -import { reveal } from '#library/string.library.js'; import { secureRef } from '#library/proxy.library.js'; import { sym, isTempo } from '../support/support.symbol.js'; +import { TempoError } from '../support/support.error.js'; import { getRuntime } from '../support/support.runtime.js'; -import { hasOwn } from '#tempo/support/support.util.js'; +import { hasOwn, logError } from '#tempo/support/support.util.js'; import type { Tempo } from '../tempo.class.js'; import type { Plugin, Module, Extension } from './plugin.type.js'; @@ -38,11 +38,11 @@ export function ensureModule(t: any, module: string, silent: boolean = false): b if (!isDefined(hostLogic) && !isTermsLoaded) { const baseName = mod.endsWith('Module') ? mod.slice(0, -6) : mod; const msg = `${mod} not loaded. (Did you forget to Tempo.extend(${mod}) or import '#tempo/${baseName.toLowerCase()}'?)`; - if (!silent && isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg); + if (!silent) logError(msg, t?.config); if (silent) return false; if (t?.config?.catch === true) return false; - throw new Error(msg); + throw new TempoError(msg); } return true; } @@ -78,8 +78,8 @@ export function interpret(t: any, module: string, methodOrFallback?: any, silent } const msg = `${module} method '${String(methodOrFallback)}' not found`; - if (isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg); - throw new Error(msg); + logError(msg, t?.config); + throw new TempoError(msg); } // 4. Execute the logic @@ -104,10 +104,8 @@ export function attachStatics(TempoClass: any, props: Record) { for (const [key, val] of Object.entries(props)) { if (hasOwn(TempoClass, key)) { const msg = `Static name collision on "${key}". Property is already defined on the host class.`; - if (isFunction(TempoClass[sym.$logError])) { - // use catch:true to report the collision without a fatal throw (supports re-extension in shared environments) - TempoClass[sym.$logError]({ ...TempoClass.config, catch: true }, msg); - } + // use catch:true to report the collision without a fatal throw (supports re-extension in shared environments) + logError(msg, { ...TempoClass?.config, catch: true }); // console.error(msg); continue; } @@ -145,7 +143,7 @@ export function defineInterpreterModule(name: string, logic: any, statics?: Reco if (isUndefined(modules[name])) { modules[name] = logic; } else if (modules[name] !== logic) { - throw new Error(`Tempo Security: Core Module clash for '${name}'. Logic is already defined.`); + throw new TempoError(`Tempo Security: Core Module clash for '${name}'. Logic is already defined.`); } // 2. Fallback for legacy class-local access @@ -159,7 +157,7 @@ export function defineInterpreterModule(name: string, logic: any, statics?: Reco } if (isDefined((TempoClass as any)[sym.$Interpreter][name])) { - if ((TempoClass as any)[sym.$Interpreter][name] !== logic) throw new Error(`Tempo Interpreter Module clash: '${name}' logic is already defined.`); + if ((TempoClass as any)[sym.$Interpreter][name] !== logic) throw new TempoError(`Tempo Interpreter Module clash: '${name}' logic is already defined.`); } else { (TempoClass as any)[sym.$Interpreter][name] = logic; } diff --git a/packages/tempo/src/support/support.default.ts b/packages/tempo/src/support/support.default.ts index 66db66c..f782e1d 100644 --- a/packages/tempo/src/support/support.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -1,6 +1,7 @@ import { looseIndex } from '#library/object.library.js'; import { secure, proxify } from '#library/proxy.library.js'; import { getDateTimeFormat } from '#library/international.library.js'; +import { LOG } from '#library/logger.class.js'; import { NUMBER, MODE, MONTH_DAY } from './support.enum.js'; import { Token } from './support.symbol.js'; @@ -191,7 +192,7 @@ export const Guard = [ /** @internal Tempo Default options */ export const Default = secure({ - /** log to console */ debug: false, + /** log to console */ debug: LOG.Info, /** catch or throw Errors */ catch: false, /** initialization strategy (auto | strict | defer) */ mode: MODE.Auto, /** used to parse two-digit years*/ pivot: 75, /** @link https: //en.wikipedia.org/wiki/Date_windowing */ diff --git a/packages/tempo/src/support/support.error.ts b/packages/tempo/src/support/support.error.ts new file mode 100644 index 0000000..bab97ae --- /dev/null +++ b/packages/tempo/src/support/support.error.ts @@ -0,0 +1,10 @@ +/** + * Custom Error class for fatal Tempo exceptions. + */ +export class TempoError extends Error { + constructor(message: string) { + super(`[Tempo] ${message}`); + this.name = 'TempoError'; + Object.setPrototypeOf(this, TempoError.prototype); + } +} diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 4aa98b2..0907b3d 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -29,10 +29,11 @@ export { export { markConfig } from '#library/symbol.library.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './support.symbol.js'; -export { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $Logify, $Discover, $ImmutableSkip } from './support.symbol.js'; +export { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $LogConfig, $Discover, $ImmutableSkip } from './support.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './support.register.js'; export { getRuntime, TempoRuntime } from './support.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './support.default.js'; -export { SCHEMA, getLargestUnit, logError, logWarn, logDebug } from './support.util.js'; +export { SCHEMA, getLargestUnit, logError, logWarn, logDebug, setLogLevel, logTempo } from './support.util.js'; export { setPatterns } from '../engine/engine.pattern.js'; -export { init, extendState } from './support.init.js'; \ No newline at end of file +export { init, extendState } from './support.init.js'; +export { TempoError } from './support.error.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 7d3196b..944d8bd 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -174,12 +174,12 @@ function setLicense(state: t.Internal.State, key: string) { if (res.jti) runtime.license.jti = res.jti; if ([LICENSE.Revoked, LICENSE.Invalid].includes(res.status)) - logWarn(state.config, `โš ๏ธ Tempo Licensing: ${res.error || 'Verification failed'}`); + logWarn(`โš ๏ธ Tempo Licensing: ${res.error || 'Verification failed'}`, state.config); }).catch((err: any) => { if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; runtime.license.status = LICENSE.Invalid; runtime.license.error = err?.message || 'Verification rejected'; - logWarn(state.config, `โš ๏ธ Tempo Licensing: ${runtime.license.error}`); + logWarn(`โš ๏ธ Tempo Licensing: ${runtime.license.error}`, state.config); }); } }; @@ -214,11 +214,11 @@ export function extendState(state: t.Internal.State, options: t.Options) { const pattern = isRegExp(v) ? v.source : String(v); // ๐Ÿ›ก๏ธ Security Check: Prevent catastrophic backtracking and malicious patterns if (pattern.length > 500) { - logError(state.config, `[Tempo#extend] Snippet pattern too long (max 500 chars).`); + logError(`[Tempo#extend] Snippet pattern too long (max 500 chars).`, state.config); return new RegExp(Match.escape(pattern)); } if (Match.backtrack.test(pattern)) { - logError(state.config, `[Tempo#extend] Snippet contains suspicious nested quantifiers.`); + logError(`[Tempo#extend] Snippet contains suspicious nested quantifiers.`, state.config); return new RegExp(Match.escape(pattern)); } return new RegExp(pattern); @@ -305,7 +305,7 @@ export function extendState(state: t.Internal.State, options: t.Options) { const unit = (isString(arg.value) ? arg.value : arg.value?.unit)?.trim()?.toLowerCase(); if (isUndefined(unit) || !['ss', 'ms', 'us', 'ns'].includes(unit)) { - logError(state.config, `[Tempo#extend] Invalid timeStamp unit: ${String(unit ?? arg.value)}. Expected 'ss', 'ms', 'us', or 'ns'.`); + logError(`[Tempo#extend] Invalid timeStamp unit: ${String(unit ?? arg.value)}. Expected 'ss', state.config, 'ms', 'us', or 'ns'.`); break; } @@ -316,7 +316,7 @@ export function extendState(state: t.Internal.State, options: t.Options) { case 'license': { const runtime = getRuntime(); if (state !== runtime.state) { - logWarn(state.config, `[Tempo#extend] Licensing is a global-only feature and cannot be set on local instances.`); + logWarn(`[Tempo#extend] Licensing is a global-only feature and cannot be set on local instances.`, state.config); break; } const key = String(arg.value); diff --git a/packages/tempo/src/support/support.license.ts b/packages/tempo/src/support/support.license.ts index 4f2d3c1..10b22ab 100644 --- a/packages/tempo/src/support/support.license.ts +++ b/packages/tempo/src/support/support.license.ts @@ -1,4 +1,5 @@ import { decodeJWT } from '#library/utility.library.js'; +import { logWarn } from './support.util.js'; /** * # Tempo Licensing Engine (Open Core) @@ -8,7 +9,7 @@ import { decodeJWT } from '#library/utility.library.js'; export class Validator { constructor(public key: string) { - console.warn('Tempo Community Edition: License keys are ignored. Premium plugins cannot be validated without the cryptographic engine.'); + logWarn('Tempo Community Edition: License keys are ignored. Premium plugins cannot be validated without the cryptographic engine.'); } async verify() { // Decodes but DOES NOT verify the signature. diff --git a/packages/tempo/src/support/support.register.ts b/packages/tempo/src/support/support.register.ts index 2859f9e..70b2a1a 100644 --- a/packages/tempo/src/support/support.register.ts +++ b/packages/tempo/src/support/support.register.ts @@ -7,7 +7,7 @@ import { sym } from './support.symbol.js'; import type { Property } from '#library/type.library.js'; import { getRuntime } from './support.runtime.js'; -import { hasOwn, setProperty } from './support.util.js'; +import { hasOwn, setProperty, logWarn } from './support.util.js'; // Import the live enums and their mutable state from the enum module import { STATE, REGISTRIES, DEFAULTS } from './support.enum.js'; @@ -53,7 +53,7 @@ export function registryReset() { if (hasOwn(obj, key)) { Object.defineProperty(obj, key, desc); } else { - console.warn(`[tempo] registryReset: Cannot define property '${String(key)}' on non-extensible object (property does not exist)`, obj); + logWarn(`[tempo] registryReset: Cannot define property '${String(key)}' on non-extensible object (property does not exist)`, {}, obj); } } }); diff --git a/packages/tempo/src/support/support.symbol.ts b/packages/tempo/src/support/support.symbol.ts index b39dc67..0c24387 100644 --- a/packages/tempo/src/support/support.symbol.ts +++ b/packages/tempo/src/support/support.symbol.ts @@ -1,6 +1,6 @@ import { looseIndex } from '#library/object.library.js'; -import { sym as lib, $Target, $Discover, $Extensible, $Inspect, $Logify, $Registry, $Register as $LibRegister, $SerializerRegistry, $Identity, $ImmutableSkip } from '#library/symbol.library.js'; -export { $Target, $Discover, $Extensible, $Inspect, $Logify, $Registry, $LibRegister, $SerializerRegistry, $Identity, $ImmutableSkip }; +import { sym as lib, $Target, $Discover, $Extensible, $Inspect, $LogConfig, $Registry, $Register as $LibRegister, $SerializerRegistry, $Identity, $ImmutableSkip } from '#library/symbol.library.js'; +export { $Target, $Discover, $Extensible, $Inspect, $LogConfig, $Registry, $LibRegister, $SerializerRegistry, $Identity, $ImmutableSkip }; /** check valid Tempo instance */ export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym.$Identity]); @@ -16,9 +16,7 @@ export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termErr /** key for Global Discovery of Tempo configuration */ export const $Tempo: unique symbol = Symbol.for('$Tempo') as any; /** key for Reactive Plugin Registration */ export const $Register: unique symbol = Symbol.for('magmacomputing/tempo/register') as any; /** key for Internal Interpreter Service */ export const $Interpreter: unique symbol = Symbol.for('magmacomputing/tempo/interpreter') as any; -/** key for contextual Error Logging */ export const $logError: unique symbol = Symbol.for('magmacomputing/tempo/logError') as any; -/** key for contextual Debug Logging */ export const $logDebug: unique symbol = Symbol.for('magmacomputing/tempo/logDebug') as any; -/** key for contextual Debugger */ export const $dbg: unique symbol = Symbol.for('magmacomputing/tempo/dbg') as any; + /** key for Master Guard */ export const $guard: unique symbol = Symbol.for('magmacomputing/tempo/guard') as any; /** internal key for signaling pre-errored state */ export const $errored: unique symbol = Symbol.for('magmacomputing/tempo/errored') as any; /** internal key for accessing private instance state */ export const $Internal: unique symbol = Symbol.for('magmacomputing/tempo/internal') as any; @@ -37,7 +35,7 @@ export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termErr /** @internal Tempo Symbol Registry (Local Keys) */ const local = { - $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, + $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $License, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase } as const; diff --git a/packages/tempo/src/support/support.util.ts b/packages/tempo/src/support/support.util.ts index 8425f4f..cf164d8 100644 --- a/packages/tempo/src/support/support.util.ts +++ b/packages/tempo/src/support/support.util.ts @@ -1,13 +1,13 @@ -import { isBoolean } from '#library/assertion.library.js'; +import { isBoolean, isError } from '#library/assertion.library.js'; +import { Logger, LOG, parseLogLevel, type DebugLevel } from '#library/logger.class.js'; +import { raise as boundaryRaise } from '#library/boundary.library.js'; import { sym, Token } from './support.symbol.js'; import { asType } from '#library/type.library.js'; import { asArray } from '#library/coercion.library.js'; -import { isSymbol, isUndefined, isDefined, isString, isRegExp, isNullish, isObject, isEmpty } from '#library/assertion.library.js'; -import { ownEntries, ownKeys, unwrap } from '#library/primitive.library.js'; +import { isSymbol, isUndefined, isDefined, isString, isNullish, isObject } from '#library/assertion.library.js'; +import { ownEntries, unwrap } from '#library/primitive.library.js'; import { getRuntime } from './support.runtime.js'; -import { Match, Snippet, Layout } from './support.default.js'; -import enums from './support.enum.js'; import type * as t from '../tempo.type.js'; /** @internal normalize layout-order options into a clean string array */ @@ -22,7 +22,7 @@ export const setProperty = (target: object, key: PropertyKey, value: T) => { if (Object.isExtensible(target)) { Object.defineProperty(target, key, { value, writable: true, configurable: true, enumerable: true }); } else { - console.warn(`[tempo] setProperty: Cannot define property '${String(key)}' on non-extensible object`, target); + logWarn(`[tempo] setProperty: Cannot define property '${String(key)}' on non-extensible object`, {}, target); } } @@ -31,22 +31,50 @@ export const setProperties = (target: object, properties: Record setProperty(target, key, value)); } -/** @internal Centralized Error Logger โ€” retrieves the shared Logify instance from the TempoRuntime */ -export function logError(config: any, ...msg: any[]) { - const rt = getRuntime(); - rt.logger?.error(config ?? rt.state?.config, ...msg); +export const logTempo = new Logger('Tempo'); +export const logParse = new Logger('Tempo:Parse'); +export const logEngine = new Logger('Tempo:Engine'); + +const loggers = [logTempo, logParse, logEngine]; + +/** @internal Centralized setter for global verbosity */ +export function setLogLevel(debug?: DebugLevel) { + const level = parseLogLevel(debug, LOG.Info); + loggers.forEach(l => l.level = level); } -/** @internal Centralized Warning Logger โ€” retrieves the shared Logify instance from the TempoRuntime */ -export function logWarn(config: any, ...msg: any[]) { - const rt = getRuntime(); - rt.logger?.warn(config ?? rt.state?.config, ...msg); +/** @internal Concatenate multiple arguments into a single string for logging */ +const concatMsg = (msg: any[]) => msg.map(m => isError(m) ? m.message : String(m)).join(' '); + +/** @internal Centralized Error Boundary โ€” evaluates config.catch and logs automatically */ +export function raise(err: Error | string, config: any = {}, ...msg: any[]) { + // Combine extra messages if multiple are provided + if (msg.length > 0) { + const text = concatMsg(msg); + err = isString(err) ? new Error(`${err} ${text}`) : err; + if (isError(err) && typeof err.message === 'string' && text) { + err.message = `${err.message} ${text}`; + } + } + + boundaryRaise(err, { + catch: config?.catch ?? false, + silent: config?.silent ?? false, + logger: logTempo + }); +} + +/** @internal Wrapper for legacy logError calls */ +export const logError = raise; + +/** @internal Centralized Warning Logger */ +export function logWarn(msg: any, config: any = {}, ...extraMsg: any[]) { + if (!config?.silent) logTempo.warn(concatMsg([msg, ...extraMsg])); } -/** @internal Centralized Debug Logger โ€” retrieves the shared Logify instance from the TempoRuntime */ -export function logDebug(config: any, ...msg: any[]) { - const rt = getRuntime(); - rt.logger?.debug(config ?? rt.state?.config, ...msg); +/** @internal Centralized Debug Logger */ +export function logDebug(msg: any, config: any = {}, ...extraMsg: any[]) { + if (!config?.silent) logTempo.debug(concatMsg([msg, ...extraMsg])); } /** @internal check if an object is a proxy */ @@ -65,13 +93,13 @@ export const proto = (obj: any): any => Object.getPrototypeOf(unwrap(obj)); export function create(obj: any, name: string): T { const prototype = proto(obj); if (!isObject(prototype)) { - logError(null, `[Tempo#create] Failed to create shadowed object for '${name}'. Proto(obj) is null or not an object.`); + logError(`[Tempo#create] Failed to create shadowed object for '${name}'. Proto(obj) is null or not an object.`, null); return {} as T; } const entry = prototype[name]; if (!isObject(entry)) { - logError(null, `[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${typeof entry}).`); + logError(`[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${typeof entry}).`, null); return {} as T; } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index bd52098..87430e0 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1,6 +1,5 @@ import '#library/temporal.polyfill.js'; -import { Logify } from '#library/logify.class.js'; import { Immutable, Serializable } from '#library/class.library.js'; import { asArray } from '#library/coercion.library.js'; import { getStorage, setStorage } from '#library/storage.library.js'; @@ -15,6 +14,7 @@ import { clone } from '#library/serialize.library.js'; import { isEmpty, isDefined, isUndefined, isString, isObject, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; +import { LOG } from '#library/logger.class.js'; import type { Property, Secure } from '#library/type.library.js'; import { registerPlugin, interpret, ensureModule, type TempoPlugin } from './plugin/plugin.util.js' @@ -27,7 +27,7 @@ import { createMasterGuard } from './engine/engine.guard.js'; import { resolveMonthDay, setProperty, proto, hasOwn, normalizeLayoutOrder } from './support/support.util.js'; import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './engine/engine.layout.js'; import { datePattern } from './support/support.default.js'; -import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $logError, $logDebug, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $Logify, $errored, $dbg, $guard, $Discover, $setDiscovery } from '#tempo/support'; +import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $errored, $guard, $Discover, $setDiscovery, $LogConfig, logError, logDebug, logWarn, logTempo, setLogLevel } from '#tempo/support'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) declare module '#library/type.library.js' { @@ -65,8 +65,7 @@ namespace Internal { * A powerful wrapper around `Temporal.ZonedDateTime` for flexible parsing and intuitive manipulation of date-time objects. * Bridges the gap between raw string/number inputs and the strict requirements of the ECMAScript Temporal API. */ -/** Logify for internal errors and debug logs */ -const _dbg = new Logify('Tempo', { debug: Default?.debug ?? false, catch: Default?.catch ?? false }); + /** Tempo state for the global configuration */ let _global = {} as Internal.State; /** cache for next-available 'usr' Token key */ @@ -149,24 +148,6 @@ export class Tempo { /** @internal brand check to distinguish Tempo objects from other objects */ get [$Identity](): true { return true } - /** @internal handle internal errors using the global config */ - static [$logError](...msg: any[]): void { - const provided = (isObject(msg[0]) && (msg[0] as any)[$Logify] === true) ? msg.shift() : undefined; - const global = this[$Internal]().config; - const config = markConfig(Object.create(global)); - if (provided) Object.entries(provided).forEach(([k, v]) => setProperty(config, k, v)); - markConfig(config); // ensure config is marked for Logify - _dbg.error(config, ...msg); - } - - /** @internal handle internal debug logs */ - static [$logDebug](...args: any[]): void { - const provided = (isObject(args[0]) && (args[0] as any)[$Logify] === true) ? args.shift() : undefined; - const global = this[$Internal]().config; - const config = markConfig(Object.create(global)); - if (provided) Object.entries(provided).forEach(([k, v]) => setProperty(config, k, v)); - _dbg.debug(config, ...args); - } /** * {dt} is a layout that combines date-related {snippets} (e.g. dd, mm -or- evt) into a pattern against which a string can be tested. @@ -201,7 +182,6 @@ export class Tempo { if (!hasOwn(shape, 'aliasEngine')) { engine = shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, - logger: _dbg, config: shape.config }); } @@ -260,7 +240,7 @@ export class Tempo { try { intl = new Intl.Locale(Tempo._locale(locale)); } catch (e) { - _dbg.warn(shape.config, `Invalid locale encountered in #isMonthDay: ${locale}. Falling back to en-US.`, e); + logWarn(e, `Invalid locale encountered in #isMonthDay: ${locale}. Falling back to en-US.`, shape.config); intl = new Intl.Locale('en-US'); } @@ -303,7 +283,7 @@ export class Tempo { if (layout !== shape.parse.layout) shape.parse.layout = layout as Layout; - _dbg.debug(shape.config, `Resolved layout order: ${getLayoutOrder(layout).join(' -> ')}`); + logDebug(`Resolved layout order: ${getLayoutOrder(layout).join(' -> ')}`, shape.config); } /** get first Canonical name of a supplied locale */ @@ -513,7 +493,7 @@ export class Tempo { } catch (e: any) { const msg = (e?.message ?? '').toLowerCase(); if (msg.includes('constructor') || msg.includes('class') || (e instanceof TypeError) || isClass(arg)) { - _dbg.warn(this[$Internal]().config, `Misidentified class in plugin registration: ${(arg as any).name}`, e.stack ?? e); + logWarn(`Misidentified class in plugin registration: ${(arg as any).name}`, this[$Internal]().config, e.stack ?? e); } else { throw e; } @@ -524,7 +504,7 @@ export class Tempo { const name = (item as any).name; const rt = getRuntime(); if (rt.installed.has(name)) { - _dbg.debug(this[$Internal]().config, `Plugin already installed by name: ${name}`); + logDebug(`Plugin already installed by name: ${name}`, this[$Internal]().config); return; } rt.installed.add(name); @@ -540,12 +520,12 @@ export class Tempo { if (Tempo._termMap.get(config.key) === config) return; if (Tempo._termMap.has(config.key)) { - Tempo[$logError](state.config, `[Tempo#extend] Term collision on key: "${config.key}". Registration aborted.`); + logError(`[Tempo#extend] Term collision on key: "${config.key}". Registration aborted.`, state.config); return; } if (config.scope && Tempo._termMap.get(config.scope) === config) { /* continue */ } else if (config.scope && Tempo._termMap.has(config.scope)) { - Tempo[$logError](state.config, `[Tempo#extend] Term collision on scope: "${config.scope}". Registration aborted.`); + logError(`[Tempo#extend] Term collision on scope: "${config.scope}". Registration aborted.`, state.config); return; } @@ -590,7 +570,7 @@ export class Tempo { const discovery = item as any if (discovery.term) { discovery.terms = [...asArray(discovery.terms || []), ...asArray(discovery.term)]; - _dbg.warn(this[$Internal]().config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); + logWarn('Legacy "term" key in Discovery is deprecated. Please use "terms" instead.', this[$Internal]().config); } if (discovery.plugin) { discovery.plugins = [...asArray(discovery.plugins || []), ...asArray(discovery.plugin)]; @@ -722,6 +702,8 @@ export class Tempo { _lifecycle.initialising = true; try { + setLogLevel(options.debug ?? Default?.debug ?? LOG.Info); + const rt = getRuntime(); rt.state = undefined; // force fresh state const state = init(options); @@ -797,8 +779,8 @@ export class Tempo { if (options.plugins) this.extend(options.plugins); // ensure init-plugins are processed before 'ready' - if (Context.type === CONTEXT.Browser || options.debug === true) - _dbg.info(this.config, 'Tempo:', state.config); + if (Context.type === CONTEXT.Browser || options.debug === LOG.Trace) + logDebug('Tempo:', this.config, state.config); _lifecycle.ready = true; setPatterns(state); // rebuild the global patterns (Master Guard etc) @@ -1054,7 +1036,7 @@ export class Tempo { }); Tempo.init(); // synchronously initialize the library - getRuntime().logger = _dbg; + getRuntime().logger = logTempo; } /** constructor tempo */ #tempo?: t.DateTime; @@ -1071,7 +1053,7 @@ export class Tempo { /** current parsing depth to manage state isolation */ #parseDepth = 0; /** current mutation depth to manage infinite recursion */#mutateDepth = 0; /** instance values to complement static values */ #local = { - /** instance configuration */ config: { [$Logify]: true } as unknown as Internal.Config, + /** instance configuration */ config: { [$LogConfig]: true } as unknown as Internal.Config, /** instance parse rules (only populated if provided) */ parse: { result: [] as Internal.MatchResult[] } as Internal.Parse } as Internal.State; @@ -1081,10 +1063,9 @@ export class Tempo { /** @internal */ static [TermError](config: Internal.Config, term: string): void { const hint = Tempo._terms.length === 0 ? ". (No term plugins are registeredโ€”did you forget to call Tempo.extend(TermsModule)?)" : ""; const msg = `Unknown Term identifier: ${term}${hint}`; - _dbg.error(config, msg); + logError(msg, config); } - /** @internal */ static get [$dbg](): Logify { return _dbg } /** @internal */ static get [$guard]() { return _guard } /** @@ -1230,7 +1211,7 @@ export class Tempo { if (isUndefined(this.#zdt)) { this.#errored = true; const msg = `Tempo parse returned undefined for: ${String(this.#tempo)}`; - _dbg.error(this.#local.config, msg); + logError(msg, this.#local.config); this.#zdt = now; } secure(this.#local.config); @@ -1239,10 +1220,10 @@ export class Tempo { this.#errored = true; // mark as errored const msg = `Cannot create Tempo: ${(err as Error).message}\n${(err as Error).stack}`; if (this.#local.config.catch === true) { - _dbg.error(this.#local.config, msg); // log as error if in catch-mode + logError(msg, this.#local.config); // log as error if in catch-mode this.#zdt = now; } else { - _dbg.error(this.#local.config, err, msg); // log as error then re-throw + logError((err as Error), this.#local.config, msg); // log as error then re-throw throw err; } } @@ -1270,7 +1251,7 @@ export class Tempo { } catch (e: any) { const msg = (e?.message ?? '').toLowerCase(); if (msg.includes('constructor') || msg.includes('class') || (e instanceof TypeError) || isClass(define)) { - _dbg.warn(this.#local.config, `Misidentified class in delegator evaluate: ${String(define)}`, e.stack ?? e); + logWarn(`Misidentified class in delegator evaluate: ${String(define)}`, this.#local.config, e.stack ?? e); memo = define; } else { throw e; @@ -1327,7 +1308,7 @@ export class Tempo { return isObject(res) ? secure(res) : res; } catch (err: any) { if (err.message.includes('Class constructor')) { - _dbg.warn(this.#local.config, `Misidentified class in term definition: ${key}`, err.stack ?? err); + logWarn(`Misidentified class in term definition: ${key}`, this.#local.config, err.stack ?? err); } else { throw err; } @@ -1365,7 +1346,7 @@ export class Tempo { return isObject(out) ? secure(out) : out; } catch (err: any) { if (err.message.includes('Class constructor')) { - _dbg.warn(this.#local.config, `Misidentified class in term discovery: ${term.key}`, err.stack ?? err); + logWarn(`Misidentified class in term discovery: ${term.key}`, this.#local.config, err.stack ?? err); } else { throw err; } @@ -1563,7 +1544,7 @@ export class Tempo { const res = interpret(this, 'ParseModule', 'parse', false, tempo, dateTime, term); if (isUndefined(res)) { const msg = `ParseModule error. Could not parse ${String(tempo)}`; - _dbg.error(this.#local.config, msg); + logError(msg, this.#local.config); return undefined as any; } return res; @@ -1582,7 +1563,7 @@ export class Tempo { if (isUndefined(tempo) || isEmpty(tempo)) return dateTime ?? instant().toZonedDateTimeISO(this.#local.config.timeZone); const msg = 'Tempo ParseModule not loaded. Did you forget to Tempo.extend(ParseModule)?'; - _dbg.error(this.#local.config, msg); + logError(msg, this.#local.config); return undefined as any; } @@ -1623,7 +1604,7 @@ export class Tempo { if (blocked.includes(rt.license.status) && hasOwn(rt.license.scopes, key)) { const msg = `License for premium term '${key}' is ${rt.license.status}. Access denied.`; - _dbg.warn(this.#local.config, msg); + logWarn(msg, this.#local.config); return true; } diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 787c701..2504cc8 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -7,7 +7,7 @@ * Inside `tempo.class.ts` these are accessed via `import * as t`. */ import type { Pledge } from '#library/pledge.class.js'; -import type { Logify } from '#library/logify.class.js'; +import type { DebugLevel } from '#library/logger.class.js'; import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue, RegistryOption, Branded } from '#library/type.library.js'; import { sym, type TempoBrand } from '#tempo/support/support.symbol.js'; @@ -221,9 +221,9 @@ export namespace Internal { export interface BaseOptions { /** localStorage key */ store: string; /** globalThis Discovery Symbol */ discovery: string | symbol | Discovery; - /** additional console.log for tracking */ debug: Logify.Constructor["debug"]; - /** catch or throw Errors */ catch: Logify.Constructor["catch"]; - /** suppress console output during catch */ silent: Logify.Constructor["silent"]; + /** 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; diff --git a/packages/tempo/test/core/alias-engine-protochain.test.ts b/packages/tempo/test/core/alias-engine-protochain.test.ts index fd42d4f..ad0cc37 100644 --- a/packages/tempo/test/core/alias-engine-protochain.test.ts +++ b/packages/tempo/test/core/alias-engine-protochain.test.ts @@ -1,30 +1,25 @@ import { AliasEngine } from '#tempo/engine/engine.alias.js'; -import { Logify } from '#library/logify.class.js'; -import { vi } from 'vitest'; +import { logTempo } from '#tempo/support/support.util.js'; +import { vi, afterEach } from 'vitest'; describe('AliasEngine prototype chain (Global โ†’ Sandbox โ†’ Instance)', () => { - const logger = { - warn: vi.fn(), - debug: vi.fn(), - error: vi.fn(), - log: vi.fn(), - info: vi.fn(), - trace: vi.fn() - } as unknown as Logify; + afterEach(() => { + vi.clearAllMocks(); + }); // Simulate a global state const globalShape = {} as { aliasEngine: AliasEngine }; - globalShape.aliasEngine = new AliasEngine({ logger }); + globalShape.aliasEngine = new AliasEngine(); globalShape.aliasEngine.registerAliases('evt', [ ['globalEvt', 'globalValue'] ]); // Simulate a sandbox state inheriting from global const sandboxShape = Object.create(globalShape); - sandboxShape.aliasEngine = new AliasEngine({ parent: globalShape.aliasEngine, logger }); + sandboxShape.aliasEngine = new AliasEngine({ parent: globalShape.aliasEngine }); sandboxShape.aliasEngine.registerAliases('evt', [ ['sandboxEvt', 'sandboxValue'] ]); // Simulate a local/instance state inheriting from sandbox const localShape = Object.create(sandboxShape); - localShape.aliasEngine = new AliasEngine({ parent: sandboxShape.aliasEngine, logger }); + localShape.aliasEngine = new AliasEngine({ parent: sandboxShape.aliasEngine }); localShape.aliasEngine.registerAliases('evt', [ ['localEvt', 'localValue'] ]); it('resolves local, sandbox, and global aliases in correct order', () => { @@ -46,18 +41,15 @@ describe('AliasEngine prototype chain (Global โ†’ Sandbox โ†’ Instance)', () => }); it('collision detection traverses the prototype chain', () => { - (logger.warn as any).mockClear(); + const warnSpy = vi.spyOn(logTempo, 'warn'); // Register a colliding alias in local localShape.aliasEngine.registerAliases('evt', [ ['globalEvt', 'localShadow'] ]); // Should warn about collision with global - expect(logger.warn).toHaveBeenCalled(); - expect((logger.warn as any).mock.calls[0][1]).toMatch(/Collision detected/i); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected')); expect(localShape.aliasEngine.resolveAlias('evt2_1')?.value).toBe('localShadow'); expect(globalShape.aliasEngine.resolveAlias('evt0_0')?.value).toBe('globalValue'); - - (logger.warn as any).mockReset(); }); }); diff --git a/packages/tempo/test/core/alias-engine.test.ts b/packages/tempo/test/core/alias-engine.test.ts index 8cf1559..9243213 100644 --- a/packages/tempo/test/core/alias-engine.test.ts +++ b/packages/tempo/test/core/alias-engine.test.ts @@ -1,19 +1,13 @@ import { AliasEngine } from '#tempo/engine/engine.alias.js'; -import type { Logify } from '#library/logify.class.js'; - -// Use a real Logify logger, but spy on console.warn -const logger = { - warn: (config: any, msg: string) => console.warn(msg, config), - debug: () => { }, - error: () => { }, - log: () => { }, - info: () => { }, - trace: () => { }, -} as unknown as Logify; +import { logTempo } from '#tempo/support/support.util.js'; +import { vi, afterEach } from 'vitest'; describe('AliasEngine', () => { + afterEach(() => { + vi.clearAllMocks(); + }); it('registers and resolves string and function aliases', () => { - const engine = new AliasEngine({ logger }); + const engine = new AliasEngine(); engine.registerAliases('evt', [['foo', 'bar']]); expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); engine.registerAliases('per', [['noon', function () { return '12:00'; }]]); @@ -22,9 +16,9 @@ describe('AliasEngine', () => { }); it('supports parent/child shadowing and fallback', () => { - const globalEngine = new AliasEngine({ logger }); + const globalEngine = new AliasEngine(); globalEngine.registerAliases('evt', [['foo', 'bar']]); - const localEngine = new AliasEngine({ parent: globalEngine, logger }); + const localEngine = new AliasEngine({ parent: globalEngine }); // Local should resolve parent's alias before shadowing expect(localEngine.resolveAlias('evt0_0')?.value).toBe('bar'); expect(localEngine.resolveAlias('evt0_0')?.source).toBe('global'); @@ -36,30 +30,30 @@ describe('AliasEngine', () => { }); it('warns on local/global collision', () => { - const warnSpy = vi.spyOn(console, 'warn'); - const globalEngine = new AliasEngine({ logger }); + const warnSpy = vi.spyOn(logTempo, 'warn'); + const globalEngine = new AliasEngine(); globalEngine.registerAliases('evt', [['xmas', '25-Dec']]); - const localEngine = new AliasEngine({ parent: globalEngine, logger }); + const localEngine = new AliasEngine({ parent: globalEngine }); localEngine.registerAliases('evt', [['xmas', '24-Dec']]); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected')); }); it('warns on local collision', () => { - const warnSpy = vi.spyOn(console, 'warn'); - const engine = new AliasEngine({ logger }); + const warnSpy = vi.spyOn(logTempo, 'warn'); + const engine = new AliasEngine(); engine.registerAliases('evt', [['xmas', '25-Dec'], ['xmas', '24-Dec']]); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected')); }); it('registers and resolves batch aliases', () => { - const engine = new AliasEngine({ logger }); + const engine = new AliasEngine(); engine.registerAliases('evt', [['foo', 'bar'], ['baz', 'qux']]); expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); expect(engine.resolveAlias('evt0_1')?.value).toBe('qux'); }); it('clears only events or periods', () => { - const engine = new AliasEngine({ logger }); + const engine = new AliasEngine(); engine.registerAliases('evt', [['foo', 'bar']]); engine.registerAliases('per', [['noon', '12:00']]); expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); @@ -74,24 +68,24 @@ describe('AliasEngine', () => { }); it('handles regex-like collision heuristics', () => { - const warnSpy = vi.spyOn(console, 'warn'); - const engine = new AliasEngine({ logger }); + const warnSpy = vi.spyOn(logTempo, 'warn'); + const engine = new AliasEngine(); engine.registerAliases('evt', [['xmas( )?eve', '24-Dec'], ['xmas eve', '24-Dec']]); // Should treat "xmas eve" and "xmas( )?eve" as same base word - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected')); }); it('does not warn on non-colliding aliases', () => { - const warnSpy = vi.spyOn(console, 'warn'); - const engine = new AliasEngine({ logger }); + const warnSpy = vi.spyOn(logTempo, 'warn'); + const engine = new AliasEngine(); engine.registerAliases('evt', [['xmas', '25-Dec'], ['bday', '20-May']]); expect(warnSpy).not.toHaveBeenCalled(); }); it('resolves to parent after clear', () => { - const globalEngine = new AliasEngine({ logger }); + const globalEngine = new AliasEngine(); globalEngine.registerAliases('evt', [['foo', 'bar']]); - const localEngine = new AliasEngine({ parent: globalEngine, logger }); + const localEngine = new AliasEngine({ parent: globalEngine }); localEngine.registerAliases('evt', [['foo', 'baz']]); expect(localEngine.resolveAlias('evt1_0')?.value).toBe('baz'); @@ -101,7 +95,7 @@ describe('AliasEngine', () => { }); it('handles empty/optional/edge-case aliases', () => { - const engine = new AliasEngine({ logger }); + const engine = new AliasEngine(); engine.registerAliases('evt', [['', 'empty'], ['foo', '']]); expect(engine.resolveAlias('evt0_0')?.value).toBe('empty'); expect(engine.resolveAlias('evt0_1')?.value).toBe(''); diff --git a/packages/tempo/test/core/dispose.core.test.ts b/packages/tempo/test/core/dispose.core.test.ts index 73156ce..80ba2d3 100644 --- a/packages/tempo/test/core/dispose.core.test.ts +++ b/packages/tempo/test/core/dispose.core.test.ts @@ -25,8 +25,8 @@ describe('Static Symbol.dispose', () => { test('Pledge static dispose resets static state', () => { // 1. Set a non-default static config - Pledge.init({ debug: true, silent: true, tag: 'TestPledge' }); - expect(Pledge.status.debug).toBe(true); + Pledge.init({ debug: 5, silent: true, tag: 'TestPledge' }); + expect(Pledge.status.debug).toBe(5); expect(Pledge.status.tag).toBe('TestPledge'); // 2. Dispose diff --git a/packages/tempo/test/engine/parse.prefilter.flag.test.ts b/packages/tempo/test/engine/parse.prefilter.flag.test.ts index f8bc4e2..fb61bbc 100644 --- a/packages/tempo/test/engine/parse.prefilter.flag.test.ts +++ b/packages/tempo/test/engine/parse.prefilter.flag.test.ts @@ -35,7 +35,7 @@ describe('parse prefilter feature flag', () => { }); test('emits planner debug telemetry when debug + preFilter are enabled', () => { - Tempo.init({ debug: true, preFilter: true }); + Tempo.init({ debug: 5, preFilter: true }); const t = new Tempo('2 days ago', { timeZone: 'UTC' }); expect(t.parse.result?.[0]?.match).toBe('relativeOffset'); expect(console.debug).toHaveBeenCalled(); diff --git a/packages/tempo/test/issues/issue-fixes.test.ts b/packages/tempo/test/issues/issue-fixes.test.ts index f013cec..4798d71 100644 --- a/packages/tempo/test/issues/issue-fixes.test.ts +++ b/packages/tempo/test/issues/issue-fixes.test.ts @@ -112,17 +112,17 @@ describe('Tempo Issue Fixes', () => { test('set() accepts two arguments (value, options)', () => { const t = new Tempo('2024-05-20 10:00', { timeZone: 'UTC' }) // This would have failed before - const shifted = t.set('tomorrow', { timeZone: 'America/New_York', debug: false }) + const shifted = t.set('tomorrow', { timeZone: 'America/New_York', debug: 0 }) expect(shifted.tz).toBe('America/New_York') expect(shifted.format('{yyyy}-{mm}-{dd}')).toBe('2024-05-21') }) - test('set() with debug: true preserves behavior and flags', () => { + test('set() with debug: 5 preserves behavior and flags', () => { const t = new Tempo('2024-05-20 10:00', { timeZone: 'UTC' }) - const shifted = t.set('tomorrow', { timeZone: 'America/New_York', debug: true }) + const shifted = t.set('tomorrow', { timeZone: 'America/New_York', debug: 5 }) expect(shifted.tz).toBe('America/New_York') expect(shifted.format('{yyyy}-{mm}-{dd}')).toBe('2024-05-21') - expect(shifted.config.debug).toBe(true) + expect(shifted.config.debug).toBe(5) }) test('add() accepts a duration payload', () => { diff --git a/packages/tempo/test/support/library-import.test.ts b/packages/tempo/test/support/library-import.test.ts deleted file mode 100644 index af6ef9f..0000000 --- a/packages/tempo/test/support/library-import.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Logify } from '#library/logify.class.js' - -test('library import', () => { - expect(Logify).toBeDefined() -}) diff --git a/packages/tempo/test/support/symbol-import.test.ts b/packages/tempo/test/support/symbol-import.test.ts index 7d28063..dad3bc9 100644 --- a/packages/tempo/test/support/symbol-import.test.ts +++ b/packages/tempo/test/support/symbol-import.test.ts @@ -1,5 +1,5 @@ import { sym } from '#library/symbol.library.js' test('symbol import', () => { - expect(sym.$Logify).toBeDefined() + expect(sym.$LogConfig).toBeDefined() }) From 63950d596870a7902345814b0bf6576cb9c40a5f Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 2 Jun 2026 16:39:25 +1000 Subject: [PATCH 09/20] ready for review --- CHANGELOG.md | 2 + .../library/src/common/assertion.library.ts | 24 ++++---- packages/library/src/common/logger.class.ts | 6 +- .../test/common/boundary.library.test.ts | 37 ++++++++++++ .../library/test/common/logger.class.test.ts | 58 +++++++++++++++++++ packages/tempo/doc/architecture.md | 4 +- packages/tempo/doc/soft_freeze_strategy.md | 2 +- packages/tempo/doc/tempo.config.md | 4 +- packages/tempo/doc/tempo.debugging.md | 18 +++--- packages/tempo/doc/tempo.plugin.md | 2 +- packages/tempo/src/engine/engine.alias.ts | 2 +- packages/tempo/src/engine/engine.composer.ts | 3 +- packages/tempo/src/engine/engine.guard.ts | 4 +- packages/tempo/src/engine/engine.lexer.ts | 18 ++++-- .../tempo/src/engine/engine.normalizer.ts | 8 ++- packages/tempo/src/engine/engine.planner.ts | 3 +- packages/tempo/src/engine/engine.term.ts | 4 +- packages/tempo/src/module/module.mutate.ts | 4 +- packages/tempo/src/module/module.parse.ts | 6 +- packages/tempo/src/support/support.index.ts | 2 +- packages/tempo/src/support/support.symbol.ts | 4 +- packages/tempo/src/support/support.util.ts | 23 +++++++- packages/tempo/src/tempo.class.ts | 2 +- 23 files changed, 190 insertions(+), 50 deletions(-) create mode 100644 packages/library/test/common/boundary.library.test.ts create mode 100644 packages/library/test/common/logger.class.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3fb501..35022aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Robust License Validation**: Hardened runtime integrity for Tempo Pro plugins to enhance state security and prevent tampering. - **Edition Boundaries**: Added proactive runtime warnings for unlicensed attempts, establishing strict boundaries between the Community and Proprietary editions. - **Extension Resolution**: Stabilized module resolution for plugins by migrating to `defineExtension` and correcting internal export paths. +- **Logging Subsystem Decoupling**: Fully decoupled the internal parsing and error boundaries from the legacy `Logify` architecture, enabling zero-cost trace instrumentation. +- **Diagnostic Consistency**: Standardized fatal exceptions by introducing the `TempoError` class for internal invariant violations, and unified all console output through the centralized diagnostic bus (`logWarn`). - **Documentation**: Clarified API behavior for the Duration engine (especially `.since()` return types) and relative time math. Improved visibility and navigation for the Tempo License Registry and installation guides. ### Fixed diff --git a/packages/library/src/common/assertion.library.ts b/packages/library/src/common/assertion.library.ts index 238c599..8451c3b 100644 --- a/packages/library/src/common/assertion.library.ts +++ b/packages/library/src/common/assertion.library.ts @@ -52,9 +52,9 @@ export const isFunction = (obj: unknown): obj is Function => isType(ob export const isPromise = (obj: unknown): obj is Promise => isType>(obj, 'Promise'); export const isMap = (obj: unknown): obj is Map => isType>(obj, 'Map'); export const isSet = (obj: unknown): obj is Set => isType>(obj, 'Set'); -export const isError = (err: unknown): err is Error => isType(err, 'Error'); +export const isError = (err: unknown, constructor?: new (...args: any[]) => E): err is E => isType(err, 'Error') && (constructor ? err instanceof constructor : true); -export const isTemporal = (obj: unknown): obj is Temporals => protoType(obj).startsWith('Temporal.') || (!!(globalThis as any).Temporal && ( +export const isTemporal = (obj: unknown): obj is Temporals => protoType(obj).startsWith('Temporal.') || (isDefined((globalThis as any).Temporal) && ( (obj as any) instanceof (globalThis as any).Temporal.Instant || (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime || (obj as any) instanceof (globalThis as any).Temporal.PlainDate || @@ -65,12 +65,12 @@ export const isTemporal = (obj: unknown): obj is Temporals => protoType(obj).sta (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay )); -export const isInstant = (obj: unknown): obj is Temporal.Instant => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (!!obj && typeof (obj as any).toZonedDateTimeISO === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone)); -export const isZonedDateTime = (obj: unknown): obj is Temporal.ZonedDateTime => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (!!obj && typeof (obj as any).toInstant === 'function' && (isDefined((obj as any).timeZoneId) || isDefined((obj as any).timeZone))); -export const isPlainDate = (obj: unknown): obj is Temporal.PlainDate => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && isDefined((obj as any).daysInMonth) && isUndefined((obj as any).hour) && isUndefined((obj as any).minute) && isUndefined((obj as any).second) && isUndefined((obj as any).nanosecond)); -export const isPlainTime = (obj: unknown): obj is Temporal.PlainTime => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime') || (!!obj && typeof (obj as any).toPlainDateTime === 'function' && isUndefined((obj as any).daysInMonth)); -export const isPlainDateTime = (obj: unknown): obj is Temporal.PlainDateTime => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && (isDefined((obj as any).hour) || isDefined((obj as any).minute) || isDefined((obj as any).second) || isDefined((obj as any).nanosecond))); -export const isDuration = (obj: unknown): obj is Temporal.Duration => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Duration'); +export const isInstant = (obj: unknown): obj is Temporal.Instant => isType(obj, 'Temporal.Instant') || (isDefined((globalThis as any).Temporal?.Instant) && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (isDefined(obj) && typeof (obj as any).toZonedDateTimeISO === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone)); +export const isZonedDateTime = (obj: unknown): obj is Temporal.ZonedDateTime => isType(obj, 'Temporal.ZonedDateTime') || (isDefined((globalThis as any).Temporal?.ZonedDateTime) && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (isDefined(obj) && typeof (obj as any).toInstant === 'function' && (isDefined((obj as any).timeZoneId) || isDefined((obj as any).timeZone))); +export const isPlainDate = (obj: unknown): obj is Temporal.PlainDate => isType(obj, 'Temporal.PlainDate') || (isDefined((globalThis as any).Temporal?.PlainDate) && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (isDefined(obj) && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && isDefined((obj as any).daysInMonth) && isUndefined((obj as any).hour) && isUndefined((obj as any).minute) && isUndefined((obj as any).second) && isUndefined((obj as any).nanosecond)); +export const isPlainTime = (obj: unknown): obj is Temporal.PlainTime => isType(obj, 'Temporal.PlainTime') || (isDefined((globalThis as any).Temporal?.PlainTime) && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime') || (isDefined(obj) && typeof (obj as any).toPlainDateTime === 'function' && isUndefined((obj as any).daysInMonth)); +export const isPlainDateTime = (obj: unknown): obj is Temporal.PlainDateTime => isType(obj, 'Temporal.PlainDateTime') || (isDefined((globalThis as any).Temporal?.PlainDateTime) && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (isDefined(obj) && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && (isDefined((obj as any).hour) || isDefined((obj as any).minute) || isDefined((obj as any).second) || isDefined((obj as any).nanosecond))); +export const isDuration = (obj: unknown): obj is Temporal.Duration => isType(obj, 'Temporal.Duration') || (isDefined((globalThis as any).Temporal?.Duration) && (obj as any) instanceof (globalThis as any).Temporal.Duration) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.Duration'); export const isDurationLike = (obj: unknown): obj is Temporal.DurationLike | string | Temporal.Duration => isString(obj) || isDuration(obj) || (isObject(obj) && ( 'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj || 'hours' in obj || 'minutes' in obj || 'seconds' in obj || @@ -80,16 +80,16 @@ export const isZonedDateTimeLike = (obj: unknown): obj is Temporal.ZonedDateTime 'year' in obj || 'month' in obj || 'day' in obj || 'hour' in obj || 'minute' in obj || 'second' in obj || 'millisecond' in obj || 'microsecond' in obj || 'nanosecond' in obj || 'monthCode' in obj || 'offset' in obj || 'timeZone' in obj || 'calendar' in obj )); -export const isPlainYearMonth = (obj: unknown): obj is Temporal.PlainYearMonth => isType(obj, 'Temporal.PlainYearMonth') || (!!(globalThis as any).Temporal?.PlainYearMonth && (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth); -export const isPlainMonthDay = (obj: unknown): obj is Temporal.PlainMonthDay => isType(obj, 'Temporal.PlainMonthDay') || (!!(globalThis as any).Temporal?.PlainMonthDay && (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay); +export const isPlainYearMonth = (obj: unknown): obj is Temporal.PlainYearMonth => isType(obj, 'Temporal.PlainYearMonth') || (isDefined((globalThis as any).Temporal?.PlainYearMonth) && (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth); +export const isPlainMonthDay = (obj: unknown): obj is Temporal.PlainMonthDay => isType(obj, 'Temporal.PlainMonthDay') || (isDefined((globalThis as any).Temporal?.PlainMonthDay) && (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay); // non-standard Objects export const isEnum = >(obj: unknown): obj is GetType<'Enumify', E> => isType>(obj, 'Enumify'); export const isPledge =

(obj: unknown): obj is GetType<'Pledge', P> => isType>(obj, 'Pledge'); /** assert value for secure() */ -export const isExtensible = (obj: any): obj is any => !!(obj?.[sym.$Extensible]); -export const isTarget = (obj: any): obj is any => !!(obj?.[sym.$Target]); +export const isExtensible = (obj: any): obj is any => isDefined(obj?.[sym.$Extensible]); +export const isTarget = (obj: any): obj is any => isDefined(obj?.[sym.$Target]); /** object has no values */ export const isEmpty = (obj?: T) => false diff --git a/packages/library/src/common/logger.class.ts b/packages/library/src/common/logger.class.ts index bcd3399..4afcbd9 100644 --- a/packages/library/src/common/logger.class.ts +++ b/packages/library/src/common/logger.class.ts @@ -85,8 +85,10 @@ export class Logger { }) .filter(s => !isEmpty(s)).join(' '); - if (!isEmpty(output)) - (console as any)[method](`${this.#namespace} ${output}`); + if (!isEmpty(output)) { + const consoleMethod = method === Method.Trace ? 'debug' : method; + (console as any)[consoleMethod](`${this.#namespace} ${output}`); + } } /** console.log */ log = (...msg: any[]) => this.#emit(Method.Log, ...msg); diff --git a/packages/library/test/common/boundary.library.test.ts b/packages/library/test/common/boundary.library.test.ts new file mode 100644 index 0000000..480ef99 --- /dev/null +++ b/packages/library/test/common/boundary.library.test.ts @@ -0,0 +1,37 @@ +import { raise } from '../../src/common/boundary.library.js'; + +describe('Boundary Library', () => { + let consoleSpy: any; + let mockLogger: any; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + mockLogger = { error: vi.fn() }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should throw immediately if no catch is specified', () => { + expect(() => raise('Test Exception')).toThrow('Test Exception'); + expect(consoleSpy).toHaveBeenCalledWith('[Boundary] Test Exception'); + }); + + it('should log to custom logger if provided', () => { + expect(() => raise('Test Exception', { logger: mockLogger })).toThrow('Test Exception'); + expect(mockLogger.error).toHaveBeenCalledWith('Test Exception'); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should swallow the error if catch is true', () => { + expect(() => raise('Handled Exception', { catch: true })).not.toThrow(); + expect(consoleSpy).toHaveBeenCalledWith('[Boundary] Handled Exception'); + }); + + it('should completely suppress output if silent is true', () => { + expect(() => raise('Silent Exception', { catch: true, silent: true })).not.toThrow(); + expect(consoleSpy).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/library/test/common/logger.class.test.ts b/packages/library/test/common/logger.class.test.ts new file mode 100644 index 0000000..bc796e2 --- /dev/null +++ b/packages/library/test/common/logger.class.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Logger, LOG, parseLogLevel } from '../../src/common/logger.class.js'; +import { sym } from '../../src/common/symbol.library.js'; + +describe('Logger Class', () => { + let consoleSpy: any; + + beforeEach(() => { + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + debug: vi.spyOn(console, 'debug').mockImplementation(() => {}), + trace: vi.spyOn(console, 'trace').mockImplementation(() => {}) + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should parse log levels correctly', () => { + expect(parseLogLevel('trace')).toBe(LOG.Trace); + expect(parseLogLevel('error')).toBe(LOG.Error); + expect(parseLogLevel(undefined, LOG.Warn)).toBe(LOG.Warn); + expect(parseLogLevel(LOG.Debug)).toBe(LOG.Debug); + }); + + it('should format namespace correctly', () => { + const logger = new Logger('Test'); + logger.info('Hello'); + expect(consoleSpy.info).toHaveBeenCalledWith('[Test] Hello'); + }); + + it('should respect default level boundaries', () => { + const logger = new Logger('Test', LOG.Warn); + logger.info('Should not print'); + logger.error('Should print'); + expect(consoleSpy.info).not.toHaveBeenCalled(); + expect(consoleSpy.error).toHaveBeenCalledWith('[Test] Should print'); + }); + + it('should respect inline config overrides', () => { + const logger = new Logger('Test', LOG.Error); + // Normally debug wouldn't print on Error level, but we override it via config + const config = { [sym.$LogConfig]: true, debug: 'trace' }; + logger.debug(config, 'Inline debug'); + expect(consoleSpy.debug).toHaveBeenCalledWith('[Test] Inline debug'); + }); + + it('should suppress output when silent is true', () => { + const logger = new Logger('Test', LOG.Trace); + const config = { [sym.$LogConfig]: true, silent: true }; + logger.error(config, 'Fatal error'); + expect(consoleSpy.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index cbe269b..9d036bd 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -26,8 +26,8 @@ The property descriptor is `enumerable: false, configurable: false, writable: fa To solve the "Split-Brain" issue inherent in monorepo development (where multiple instances of the same library might be loaded), Tempo utilizes a **Shared Global Registry**. By leveraging `Symbol.for('magmacomputing/library/registry')` on `globalThis`, all versions of the Tempo and Library packages share a unified type-identification engine. This ensures that classes are correctly identified as constructors even when loaded across different module boundaries. -## ๐Ÿ•ต๏ธ Decoupled Logging (Logify) -Tempo uses **Logify**, a diagnostic engine that leverages private Symbols to avoid polluting the public console or object state. +## ๐Ÿ•ต๏ธ Decoupled Logging +Tempo uses a centralized, functional diagnostic engine (via `logError` / `logWarn` utilities) that relies on private context to avoid polluting the public console or object state. This ensures that parsing telemetry does not clash with application logic. - **Context-Aware**: Logs track their discovery path (e.g., "Applied via Global Discovery"). - **Zero-Footprint**: When `debug: 0`, the logging overhead is mathematically eliminated. - **Symbol-Gated**: Diagnostic metadata is attached via `Symbol.for($LogConfig)`, making it invisible to standard iteration (`Object.keys`) and serialization (`JSON.stringify`). diff --git a/packages/tempo/doc/soft_freeze_strategy.md b/packages/tempo/doc/soft_freeze_strategy.md index c45949f..6179a2f 100644 --- a/packages/tempo/doc/soft_freeze_strategy.md +++ b/packages/tempo/doc/soft_freeze_strategy.md @@ -87,5 +87,5 @@ This ensures that while the library is extensible, its fundamental logic remains --- ::: info -**v2.1.2 Update**: The Soft Freeze is now tightly integrated with **Logify**. Internal state updates bypass the Proxy using a private Symbol, allowing the engine to remain "Silent" while performing complex transactional updates during the discovery phase. +**v2.1.2 Update**: The Soft Freeze is now tightly integrated with the **Diagnostic Engine**. Internal state updates bypass the Proxy using a private Symbol, allowing the engine to remain "Silent" while performing complex transactional updates during the discovery phase. ::: diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 9668c5a..0c98e8f 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -267,7 +267,7 @@ Tempo.init({ ``` ::: tip -**Observability**: Set `debug: 5` along with `planner.preFilter` to see a detailed "Planner summary" in the console, showing how many layouts were skipped for a given input. +**Observability**: Set `debug: 'trace'` along with `planner.preFilter` to see a detailed "Planner summary" in the console, showing how many layouts were skipped for a given input. ::: --- @@ -283,7 +283,7 @@ Tempo.init({ | **Instance** | ๐Ÿฅ‡ Highest | Ad-hoc overrides for specific calculations. | ::: tip -**Observability**: When `debug: 5` is set, Tempo logs its discovery path to the console (e.g., "Global Discovery found via Symbol"), making it easy to trace exactly where a setting originated. +**Observability**: When `debug: 'trace'` is set, Tempo logs its discovery path to the console (e.g., "Global Discovery found via Symbol"), making it easy to trace exactly where a setting originated. ::: ::: info diff --git a/packages/tempo/doc/tempo.debugging.md b/packages/tempo/doc/tempo.debugging.md index 583f101..c169eea 100644 --- a/packages/tempo/doc/tempo.debugging.md +++ b/packages/tempo/doc/tempo.debugging.md @@ -63,18 +63,20 @@ If you simply need to see the value represented in different primitive formats, ## Debugging and Console Logging -Tempo has an internal logging utility (`Tempo.#dbg`) that responds to specific configuration flags. +Tempo utilizes a central **Diagnostic Engine** (replacing the legacy Logify strategy) that respects configuration flags to provide structured output without polluting the console. ### The `debug` Flag -When instantiating a `Tempo`, you can pass `{ debug: 5 }` in the options object. +When instantiating a `Tempo`, or globally via `Tempo.init()`, you can pass `{ debug: 'trace' }` in the options object. ```typescript -const t = new Tempo('next Friday', { debug: 5 }); +const t = new Tempo('next Friday', { debug: 'trace' }); ``` -When this flag is enabled, Tempo will output detailed `console.info` logs during instantiation, including: -* The raw input being parsed. -* The regex pattern that matched it. -* The raw groups extracted before mutation logic. -* The final, conformed groups applied to the `Temporal.ZonedDateTime`. +When this flag is enabled, Tempo's Diagnostic Engine will output detailed `console.trace` logs during parsing. These traces provide a deep-dive look into the Parse Engine's resolution logic, including: +* The prioritized layout regex patterns evaluated against the string. +* The raw regex groups extracted (e.g., specific day offsets, localized term mappings). +* The intermediate steps in temporal normalization (e.g., resolving aliases, computing time and date). +* The absolute temporal coordinates composed before final instantiation. + +*Note: The Diagnostic Engine evaluates the `debug` setting before serializing any string interpolations, guaranteeing zero-cost execution overhead for standard users who do not request trace-level diagnostics.* ### The `catch` Flag By default, `Tempo` is designed to be resilient. If it encounters parsing errors or invalid inputs, it will gracefully fallback. diff --git a/packages/tempo/doc/tempo.plugin.md b/packages/tempo/doc/tempo.plugin.md index 99baeff..a9667a6 100644 --- a/packages/tempo/doc/tempo.plugin.md +++ b/packages/tempo/doc/tempo.plugin.md @@ -125,7 +125,7 @@ Tempo.extend({ Using `Tempo.extend()` ensures that the library safely bypasses the "Soft Freeze" protection and that all internal caches (like the Master Guard) are correctly synchronized. -### 5. Error Handling & The Logify Pattern +### 5. Error Handling & The Diagnostic Engine When building plugin that perform complex parsing or logic, follow Tempo's **"Fail-fast by Default"** principle. - **Strict Mode (Default)**: If your plugin encounters a terminal error (e.g., invalid input that cannot be recovered), you should `throw` a descriptive error. diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index eb4e89e..c34f67c 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -136,7 +136,7 @@ export class AliasEngine { target, // string, number, or function type, // 'evt' or 'per' baseWord, // used for collision detection - collision: Boolean(existingKey), + collision: isDefined(existingKey), depth: this.#depth, } } diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index 7137ba9..06af375 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -2,7 +2,7 @@ import { getTemporalIds } from '#library/temporal.library.js'; import { isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/assertion.library.js'; import type { TemporalObject, TypeValue } from '#library/type.library.js'; -import { isTempo, logError } from '#tempo/support'; +import { isTempo, logError, logTrace } from '#tempo/support'; import type { Tempo } from '#tempo/tempo.class.js'; import * as t from '../tempo.type.js'; @@ -168,5 +168,6 @@ export function compose( } } + logTrace(`[Composer] Composed final DateTime: ${dateTime?.toString()}`, config); return { dateTime: dateTime ?? today, timeZone }; } diff --git a/packages/tempo/src/engine/engine.guard.ts b/packages/tempo/src/engine/engine.guard.ts index 33a02b9..94781fe 100644 --- a/packages/tempo/src/engine/engine.guard.ts +++ b/packages/tempo/src/engine/engine.guard.ts @@ -1,4 +1,4 @@ -import { isString, isSymbol } from '#library/assertion.library.js'; +import { isString, isSymbol, isDefined } from '#library/assertion.library.js'; import { Match } from '#tempo/support/support.default.js'; /** @@ -16,7 +16,7 @@ export interface MasterGuard { */ export function createMasterGuard(words: (string | symbol)[]): MasterGuard { const wordsList = words - .filter(w => isString(w) || (isSymbol(w) && !!w.description)) + .filter(w => isString(w) || (isSymbol(w) && isDefined(w.description))) .map(w => (isSymbol(w) ? w.description! : (w as string)).toLowerCase()) .filter(Boolean); diff --git a/packages/tempo/src/engine/engine.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts index 32c7143..309a95d 100644 --- a/packages/tempo/src/engine/engine.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -2,7 +2,7 @@ import '#library/temporal.polyfill.js'; import { isString, isEmpty, isUndefined, isDefined, isTemporal, isInstant } from '#library/assertion.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import { pad, singular } from '#library/string.library.js'; -import { Match, enums, isTempo, logError, logWarn } from '#tempo/support'; +import { Match, enums, isTempo, logError, logWarn, logTrace } from '#tempo/support'; import * as t from '../tempo.type.js'; /** @@ -151,7 +151,9 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, delete groups["nbr"]; delete groups["sfx"]; - return dateTime.add({ days }); + const finalDateTime = dateTime.add({ days }); + logTrace(`[Lexer] Applied weekday offset of ${days} days`, config); + return finalDateTime; } /** resolve a date pattern match */ @@ -224,9 +226,12 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, co return dateTime; } - return Temporal.PlainDate.from({ year, month, day }, { overflow: 'constrain' }) + const finalDateTime = Temporal.PlainDate.from({ year, month, day }, { overflow: 'constrain' }) .toZonedDateTime(tz) .withPlainTime(dateTime.toPlainTime()); + + logTrace(`[Lexer] Resolved Date components to ${year}-${month}-${day}`, config); + return finalDateTime; } /** resolve a time pattern match */ @@ -251,7 +256,9 @@ export function parseTime(groups: t.Groups = {}, dateTime: Temporal.ZonedDateTim if (groups["mer"]?.toLowerCase() === 'am' && hh >= 12) hh -= 12; - return dateTime.withPlainTime({ hour: hh, minute: mi, second: ss, millisecond: ms, microsecond: us, nanosecond: ns }); + const finalDateTime = dateTime.withPlainTime({ hour: hh, minute: mi, second: ss, millisecond: ms, microsecond: us, nanosecond: ns }); + logTrace(`[Lexer] Resolved Time components to ${pad(hh)}:${pad(mi)}:${pad(ss)}`, undefined); + return finalDateTime; } /** @@ -286,5 +293,8 @@ export function parseZone(groups: t.Groups, dateTime: Temporal.ZonedDateTime, co delete groups["cal"]; delete groups["tzd"]; + if (zone || cal) + logTrace(`[Lexer] Applied Zone/Calendar adjustments: Zone=${zone ?? 'unchanged'}, Calendar=${cal ?? 'unchanged'}`, config); + return dateTime; } diff --git a/packages/tempo/src/engine/engine.normalizer.ts b/packages/tempo/src/engine/engine.normalizer.ts index e980735..bf45420 100644 --- a/packages/tempo/src/engine/engine.normalizer.ts +++ b/packages/tempo/src/engine/engine.normalizer.ts @@ -3,7 +3,7 @@ import { getTemporalIds, instant } from '#library/temporal.library.js'; import { ownKeys } from '#library/primitive.library.js'; import type { TypeValue } from '#library/type.library.js'; -import { getRuntime, sym, Match, logError, TempoError } from '#tempo/support'; +import { getRuntime, sym, Match, logError, logTrace, TempoError } from '#tempo/support'; import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './engine.lexer.js'; import { resolveTermMutation } from './engine.term.js'; import enums from '#tempo/support/support.enum.js'; @@ -178,6 +178,8 @@ export function resolveAliases( const host = getAliasContext(ctx, dateTime); const res = aliasEngine?.resolveAlias(key as any, host); if (!res) continue; + + logTrace(`[Normalizer] Resolved alias '${aliasKey}'`, state.config); try { const mapped = ({ @@ -225,8 +227,10 @@ export function resolveAliases( const mm = prefix(groups["mm"] as t.MONTH); const monthVal = enums.MONTH[mm]; - if (isDefined(monthVal)) + if (isDefined(monthVal)) { groups["mm"] = monthVal.toString().padStart(2, '0'); + logTrace(`[Normalizer] Normalized month string '${mm}' to ${groups["mm"]}`, state.config); + } } return dateTime; diff --git a/packages/tempo/src/engine/engine.planner.ts b/packages/tempo/src/engine/engine.planner.ts index 9549e03..f934ae7 100644 --- a/packages/tempo/src/engine/engine.planner.ts +++ b/packages/tempo/src/engine/engine.planner.ts @@ -1,4 +1,5 @@ import { ownEntries } from '#library/primitive.library.js'; +import { isDefined } from '#library/assertion.library.js'; import type * as t from '../tempo.type.js'; const AGO_HENCE_RE = /\b(ago|hence|from\s+now|prior)\b/i; @@ -139,7 +140,7 @@ export function selectLayoutPatterns( : (state.parse.token?.[String(layoutKey)] as symbol | undefined); return [symKey, symKey ? state.parse.pattern.get(symKey) : undefined] as const; }) - .filter((entry): entry is readonly [symbol, RegExp] => Boolean(entry[0] && entry[1])); + .filter((entry): entry is readonly [symbol, RegExp] => isDefined(entry[0]) && isDefined(entry[1])); if (options.enablePrefilter !== true) { if (wantsPlan) { diff --git a/packages/tempo/src/engine/engine.term.ts b/packages/tempo/src/engine/engine.term.ts index 2187ae6..602f066 100644 --- a/packages/tempo/src/engine/engine.term.ts +++ b/packages/tempo/src/engine/engine.term.ts @@ -53,7 +53,7 @@ export function resolveTermMutation(Tempo: TempoTermType, instance: Tempo, mutat const slick = slickStr.match(Match.slick) || (isString(offset) ? offset.match(Match.slickValue) : null); const { groups } = (slick || {}) as any; if (groups) { - const hasMod = !!groups.sh_mod; + const hasMod = isDefined(groups.sh_mod); const hasNbr = isNumeric(groups.sh_nbr); mod = hasMod ? groups.sh_mod : undefined; nbr = hasNbr ? Number(groups.sh_nbr) : 1; @@ -64,7 +64,7 @@ export function resolveTermMutation(Tempo: TempoTermType, instance: Tempo, mutat // 0. Handle relative .add() โ€” preserving position within the target range if (mutate === 'add') { - const slickParsed = !!slickStr; + const slickParsed = isDefined(slickStr); const directional = mod && !['this', '>=', '<='].includes(mod); const numericOffset = !directional && isNumeric(offset); diff --git a/packages/tempo/src/module/module.mutate.ts b/packages/tempo/src/module/module.mutate.ts index 051311b..351341b 100644 --- a/packages/tempo/src/module/module.mutate.ts +++ b/packages/tempo/src/module/module.mutate.ts @@ -68,7 +68,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options const { mutate: op, offset, single, term } = ((key, adjust, type) => { const isTerm = key.startsWith('#'); if (type === 'add') { - const isTermPlugin = !isTerm && !!findTermPlugin(key as string); + const isTermPlugin = !isTerm && isDefined(findTermPlugin(key as string)); const isStandard = ['period', 'event', 'time', 'date', 'dow', 'wkd'].includes(key as string); return { mutate: 'add', @@ -87,7 +87,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options return { mutate: key as any, offset: val, single: isTermVal ? 'term' : singular(val), term: isTermVal ? val : undefined }; } default: { - const isTermPlugin = !isTerm && !!findTermPlugin(key as string); + const isTermPlugin = !isTerm && isDefined(findTermPlugin(key as string)); const isStandard = ['period', 'event', 'time', 'date', 'dow', 'wkd'].includes(key as string); return { mutate: 'set', diff --git a/packages/tempo/src/module/module.parse.ts b/packages/tempo/src/module/module.parse.ts index ce4cfff..ae25e95 100644 --- a/packages/tempo/src/module/module.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -17,7 +17,7 @@ import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Range, ResolvedRange } from '../plugin/term/term.type.js'; import { sym, isTempo, TermError, getRuntime, Match, TempoError } from '../support/support.index.js'; import { markConfig, setPatterns, init, extendState } from '../support/support.index.js'; -import { setProperty, logError, logDebug } from '#tempo/support/support.util.js'; +import { setProperty, logError, logDebug, logTrace } from '#tempo/support/support.util.js'; import * as t from '../tempo.type.js'; /** @@ -314,11 +314,15 @@ const _ParseEngine = { } }); + logTrace(`[ParseEngine] Selected layouts: ${orderedPatterns.map(p => p[0].description).join(', ')}`, state.config); + for (const [symKey, pat] of orderedPatterns) { const groups = _ParseEngine.parseMatch(state, pat, trim); if (isEmpty(groups)) continue; + logTrace(`[ParseEngine] Matched layout '${symKey.description}' with groups: ${JSON.stringify(groups)}`, state.config); + const hasTime = Object.keys(groups) .some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key) || (Match.named.test(key) && key.endsWith('tm'))) || Object.values(groups).includes('now'); accumulateResult(state, { match: symKey.description, value: trim, groups: { ...groups } }); diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 0907b3d..011ca2e 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -33,7 +33,7 @@ export { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, export { registryUpdate, registryReset, onRegistryReset } from './support.register.js'; export { getRuntime, TempoRuntime } from './support.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './support.default.js'; -export { SCHEMA, getLargestUnit, logError, logWarn, logDebug, setLogLevel, logTempo } from './support.util.js'; +export { SCHEMA, getLargestUnit, logError, logWarn, logDebug, logTrace, setLogLevel, logTempo } from './support.util.js'; export { setPatterns } from '../engine/engine.pattern.js'; export { init, extendState } from './support.init.js'; export { TempoError } from './support.error.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/support.symbol.ts b/packages/tempo/src/support/support.symbol.ts index 0c24387..ae42dbf 100644 --- a/packages/tempo/src/support/support.symbol.ts +++ b/packages/tempo/src/support/support.symbol.ts @@ -1,9 +1,11 @@ import { looseIndex } from '#library/object.library.js'; +import { isDefined } from '#library/assertion.library.js'; import { sym as lib, $Target, $Discover, $Extensible, $Inspect, $LogConfig, $Registry, $Register as $LibRegister, $SerializerRegistry, $Identity, $ImmutableSkip } from '#library/symbol.library.js'; export { $Target, $Discover, $Extensible, $Inspect, $LogConfig, $Registry, $LibRegister, $SerializerRegistry, $Identity, $ImmutableSkip }; + /** check valid Tempo instance */ -export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym.$Identity]); +export const isTempo = (tempo?: any): tempo is TempoBrand => isDefined(tempo?.[sym.$Identity]); /** * Centralized registry for all Tempo-specific Global Symbols. diff --git a/packages/tempo/src/support/support.util.ts b/packages/tempo/src/support/support.util.ts index cf164d8..ec36bec 100644 --- a/packages/tempo/src/support/support.util.ts +++ b/packages/tempo/src/support/support.util.ts @@ -69,16 +69,33 @@ export const logError = raise; /** @internal Centralized Warning Logger */ export function logWarn(msg: any, config: any = {}, ...extraMsg: any[]) { - if (!config?.silent) logTempo.warn(concatMsg([msg, ...extraMsg])); + if (!config?.silent) { + const outMsg = concatMsg([msg, ...extraMsg]); + if (config[sym.$LogConfig]) logTempo.warn(config, outMsg); + else logTempo.warn(outMsg); + } } /** @internal Centralized Debug Logger */ export function logDebug(msg: any, config: any = {}, ...extraMsg: any[]) { - if (!config?.silent) logTempo.debug(concatMsg([msg, ...extraMsg])); + if (!config?.silent) { + const outMsg = concatMsg([msg, ...extraMsg]); + if (config[sym.$LogConfig]) logTempo.debug(config, outMsg); + else logTempo.debug(outMsg); + } +} + +/** @internal Centralized Trace Logger */ +export function logTrace(msg: any, config: any = {}, ...extraMsg: any[]) { + if (!config?.silent) { + const outMsg = concatMsg([msg, ...extraMsg]); + if (config[sym.$LogConfig]) logTempo.trace(config, outMsg); + else logTempo.trace(outMsg); + } } /** @internal check if an object is a proxy */ -export const isProxy = (obj: any): boolean => !!obj && !!(obj as any)[sym.$Target]; +export const isProxy = (obj: any): boolean => isDefined(obj?.[sym.$Target]); /** @internal check if an object has an own property (respects Proxy/Shadowing) */ export const hasOwn = (obj: any, key: PropertyKey): boolean => { diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 87430e0..8a7e299 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1008,7 +1008,7 @@ export class Tempo { /** allow instanceof to work across module boundaries via the local brand symbol */ static [$Identity] = true; static [Symbol.hasInstance](instance: any) { - return Boolean(instance?.[$Identity]) + return isDefined(instance?.[$Identity]) } /** check if a supplied variable is a valid Tempo instance */ From a38de0b1f0fe1c5d9799ae84ca435b8bdbd7e1d5 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 2 Jun 2026 17:23:08 +1000 Subject: [PATCH 10/20] review after Logger --- packages/library/doc/browser/types.md | 2 +- .../library/src/browser/mapper.library.ts | 2 +- .../library/src/common/boundary.library.ts | 2 +- packages/library/src/common/cipher.class.ts | 22 ++- packages/library/src/common/class.library.ts | 38 ++-- packages/library/src/common/logger.class.ts | 11 +- packages/library/src/common/pledge.class.ts | 2 +- packages/library/src/common/type.library.ts | 4 +- packages/tempo/.vitepress/config.ts | 2 +- packages/tempo/doc/architecture.md | 2 +- packages/tempo/doc/sandbox-factory.md | 2 +- packages/tempo/doc/tempo.extension.md | 2 +- packages/tempo/rollup.config.js | 16 +- packages/tempo/src/engine/engine.lexer.ts | 2 +- packages/tempo/src/module/module.mutate.ts | 4 +- packages/tempo/src/module/module.parse.ts | 8 +- packages/tempo/src/support/support.init.ts | 2 +- packages/tempo/src/support/support.symbol.ts | 2 +- packages/tempo/src/tempo.class.ts | 10 +- .../test/core/alias-engine-protochain.test.ts | 37 ++-- packages/tempo/vitest.config.ts | 164 ++++++++++-------- 21 files changed, 198 insertions(+), 138 deletions(-) diff --git a/packages/library/doc/browser/types.md b/packages/library/doc/browser/types.md index 5dffdd5..883ab5f 100644 --- a/packages/library/doc/browser/types.md +++ b/packages/library/doc/browser/types.md @@ -26,7 +26,7 @@ A convenience type for registering events: `[Tapper.EVENT, Tapper.Callback]`. ### `MapOpts` Options for mapping functions: - `catch?: boolean` (Interprets Promise reject as resolve) -- `debug?: number` (Sets verbosity level) +- `debug?: number` (A non-negative integer determining verbosity scale: `0` = no debug output, larger values increase verbosity: `1` = errors, `2` = warnings, `3` = info, `4` = verbose, `5` = trace. Expected range is `0โ€“5`. Default is `0`. Example usage: `{ debug: 0 }` for silent, `{ debug: 5 }` for maximum diagnostics. Replaces the older boolean `debug` flag.) ### `MapStore` Internal interface for cached geolocation and geocoder results. diff --git a/packages/library/src/browser/mapper.library.ts b/packages/library/src/browser/mapper.library.ts index df0bc7a..262e6db 100644 --- a/packages/library/src/browser/mapper.library.ts +++ b/packages/library/src/browser/mapper.library.ts @@ -24,7 +24,7 @@ interface MapStore { // a localStorage object georesponse: google.maps.GeocoderResponse & { error?: Error["message"] }; } -const defaults = { catch: true, debug: 3 } as MapOpts; // default Options +const defaults = { catch: true, debug: 0 } as MapOpts; // default Options const context = getContext(); // browser / nodejs / google-apps const mapStore = {} as MapStore; // static object to hold last position const MAP_KEY = '_map_'; // localStorage key diff --git a/packages/library/src/common/boundary.library.ts b/packages/library/src/common/boundary.library.ts index 8bbe478..27cd337 100644 --- a/packages/library/src/common/boundary.library.ts +++ b/packages/library/src/common/boundary.library.ts @@ -9,7 +9,7 @@ export interface BoundaryContext { catch?: boolean | undefined; /** - * If true, suppresses the logger output when catch is true. + * If true, suppresses telemetry/logging for `raise()` regardless of whether the error is rethrown or swallowed. */ silent?: boolean | undefined; diff --git a/packages/library/src/common/cipher.class.ts b/packages/library/src/common/cipher.class.ts index 6f39af6..bcfa41d 100644 --- a/packages/library/src/common/cipher.class.ts +++ b/packages/library/src/common/cipher.class.ts @@ -14,7 +14,6 @@ const keys = { } as const const _cryptoKey = subtle.generateKey({ name: keys.TypeKey, length: 128 }, false, ['encrypt', 'decrypt']); -const _vector = crypto.getRandomValues(new Uint8Array(16)); const _asymmetricKey = subtle.generateKey({ name: keys.SignKey, modulusLength: 2048, @@ -73,15 +72,24 @@ export class Cipher { static encodeBuffer = (str: string) => new Uint16Array(new TextEncoder().encode(str)); static decodeBuffer = (buf: Uint16Array) => new TextDecoder(keys.Encoding).decode(buf); - static encrypt = async (data: any) => - subtle.encrypt({ name: keys.TypeKey, iv: _vector }, await _cryptoKey, Cipher.encodeBuffer(data)) - .then(result => new Uint16Array(result)) - .then(Cipher.decodeBuffer); + static encrypt = async (data: any) => { + const iv = crypto.getRandomValues(new Uint8Array(16)); + const cipherBuf = await subtle.encrypt({ name: keys.TypeKey, iv }, await _cryptoKey, Cipher.encodeBuffer(data)); + const combined = new Uint8Array(16 + cipherBuf.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(cipherBuf), 16); + return Cipher.decodeBuffer(new Uint16Array(combined.buffer)); + } - static decrypt = async (secret: Promise) => - subtle.decrypt({ name: keys.TypeKey, iv: _vector }, await _cryptoKey, await secret) + static decrypt = async (secret: Promise | ArrayBuffer | Uint16Array) => { + const buf = await secret; + const uint8 = new Uint8Array(buf instanceof Uint16Array ? buf.buffer : buf); + const iv = uint8.slice(0, 16); + const data = uint8.slice(16); + return subtle.decrypt({ name: keys.TypeKey, iv }, await _cryptoKey, data) .then(result => new Uint16Array(result)) .then(Cipher.decodeBuffer); + } static sign = async (doc: any) => subtle.sign(keys.SignKey, (await _asymmetricKey).privateKey!, Cipher.encodeBuffer(doc)) diff --git a/packages/library/src/common/class.library.ts b/packages/library/src/common/class.library.ts index 8947f2e..5e530d1 100644 --- a/packages/library/src/common/class.library.ts +++ b/packages/library/src/common/class.library.ts @@ -12,8 +12,8 @@ import type { Constructor, Type } from '#library/type.library.js'; * Safely extracts the class name from Symbol.toStringTag (if present) to prevent * minifiers and compilers from mangling the registered class name. */ -function getClassName(value: T, contextName: string | symbol | undefined): string { - return getSafeTag(value) ?? String(contextName); +function getClassName(value: T, contextName: string | symbol | undefined): string | undefined { + return getSafeTag(value) ?? (contextName === undefined ? (value.name || undefined) : String(contextName)); } /** @@ -21,21 +21,24 @@ function getClassName(value: T, contextName: string | sym */ function createImmutableWrapper( value: T, - name: string, + name: string | undefined, addInitializer: (fn: () => void) => void, immutabilityStrategy: (instance: any) => any // either Object.freeze or secure (Proxy) strategy ): T { + const safeName = name || value.name || 'Anonymous'; const wrapper = { - [name]: class extends value { + [safeName]: class extends value { constructor(...args: any[]) { super(...args); return immutabilityStrategy(this); } } - }[name] as T; + }[safeName] as T; - registerType(value, `${name}_original` as Type); - registerType(wrapper, name as Type); + if (name) { + registerType(value, `${name}_original` as Type); + registerType(wrapper, name as Type); + } addInitializer(() => { const skip = (value as any)[$ImmutableSkip] @@ -123,11 +126,15 @@ export function Immutable(value: T, { kind, name, addInit export function Serializable(value: T, { kind, name, addInitializer }: ClassDecoratorContext): T | void { const finalName = getClassName(value, name); - registerType(value, finalName as Type); + if (finalName) { + registerType(value, finalName as Type); + } switch (kind) { case 'class': - addInitializer(() => registerSerializable(finalName, value));// register the class for serialization, via its toString() method + if (finalName) { + addInitializer(() => registerSerializable(finalName, value));// register the class for serialization, via its toString() method + } return value; @@ -142,17 +149,20 @@ export function Static(value: T, { kind, name }: ClassDec switch (kind) { case 'class': + const safeName = finalName || value.name || 'Anonymous'; const wrapper = { - [finalName]: class extends value { + [safeName]: class extends value { constructor(...args: any[]) { super(...args); - throw new TypeError(`${finalName} is not a constructor`); + throw new TypeError(`${safeName} is not a constructor`); } } - }[finalName] as T; + }[safeName] as T; - registerType(value, `${finalName}_original` as Type); // register the original class definition - registerType(wrapper, finalName as Type); // register the wrapper as the authoritative definition + if (finalName) { + registerType(value, `${finalName}_original` as Type); // register the original class definition + registerType(wrapper, finalName as Type); // register the wrapper as the authoritative definition + } return wrapper; diff --git a/packages/library/src/common/logger.class.ts b/packages/library/src/common/logger.class.ts index 4afcbd9..39e3e8d 100644 --- a/packages/library/src/common/logger.class.ts +++ b/packages/library/src/common/logger.class.ts @@ -52,8 +52,15 @@ export class Logger { #emit(method: typeof Method[keyof typeof Method], ...msg: any[]) { let config: any; - if (msg.length > 0 && isObject(msg[0]) && msg[0][sym.$LogConfig]) - config = msg.shift(); + if (msg.length > 0 && isObject(msg[0])) { + try { + if (msg[0][sym.$LogConfig]) { + config = msg.shift(); + } + } catch { + // Ignore access errors on proxies/getters + } + } let activeLevel = this.level; if (config) { diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/pledge.class.ts index 4d79f2f..16600ee 100644 --- a/packages/library/src/common/pledge.class.ts +++ b/packages/library/src/common/pledge.class.ts @@ -61,7 +61,7 @@ export class Pledge { static [Symbol.dispose]() { Pledge.init({}) } static get status() { - return _static as Pledge.Status; + return { ..._static, state: _STATE.Pending } as Pledge.Status; } constructor(arg?: Pledge.Constructor | string) { diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index 20519af..fdf2045 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -205,8 +205,8 @@ export type Instance = { type: Type, class: Constructor } // allow for Class in export const registerType = (cls: Constructor, type?: Type) => { if (typeof cls !== 'function') return; - const tag = getSafeTag(cls); // toStringTag is the source-of-truth, if present - const name = (tag ?? type ?? cls.name) as Type; + const tag = getSafeTag(cls); + const name = (type ?? tag ?? cls.name) as Type; if (name && !['Object', 'Function', ''].includes(name as string)) { if (!registry.some(inst => inst.class === cls)) diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 1181649..3bbf28e 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -52,7 +52,7 @@ export default defineConfig({ text: 'Extensions & Terms', items: [ { text: 'Modularity', link: '/doc/tempo.modularity' }, - { text: 'Terms Plugins', link: '/doc/tempo.term' }, + { text: 'Terms Plugins', link: '/doc/tempo.plugin' }, { text: 'Extension Plugins', link: '/doc/tempo.extension' }, { text: 'Premium Plugins โ†—', link: 'https://magmacomputing.github.io/tempo-plugin-docs/' }, ] diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index 9d036bd..ef804ee 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -30,7 +30,7 @@ To solve the "Split-Brain" issue inherent in monorepo development (where multipl Tempo uses a centralized, functional diagnostic engine (via `logError` / `logWarn` utilities) that relies on private context to avoid polluting the public console or object state. This ensures that parsing telemetry does not clash with application logic. - **Context-Aware**: Logs track their discovery path (e.g., "Applied via Global Discovery"). - **Zero-Footprint**: When `debug: 0`, the logging overhead is mathematically eliminated. -- **Symbol-Gated**: Diagnostic metadata is attached via `Symbol.for($LogConfig)`, making it invisible to standard iteration (`Object.keys`) and serialization (`JSON.stringify`). +- **Symbol-Gated**: Diagnostic metadata is attached via the symbol variable directly (e.g., `config[sym.$LogConfig]`), making it invisible to standard iteration (`Object.keys`) and serialization (`JSON.stringify`). Note that `$LogConfig` is already a Symbol created via `Symbol.for('$LibraryLogConfig')`. ## ๐Ÿ›ก๏ธ Hardened Functional Resolution The engine implements a "Fail-Safe" execution pattern for functional inputs, automatically recovering from misidentified typesโ€”such as ES6 classes wrapped in defensive Proxies or circular dependency deadlocks. diff --git a/packages/tempo/doc/sandbox-factory.md b/packages/tempo/doc/sandbox-factory.md index a96b7d0..07e0e8b 100644 --- a/packages/tempo/doc/sandbox-factory.md +++ b/packages/tempo/doc/sandbox-factory.md @@ -66,4 +66,4 @@ Sandboxed classes created via `Tempo.create()` are protected by the same `@Immut ## Best Practices 1. **Create Once**: Create your application-specific Sandbox once and export it as your primary entry point. 2. **Prefer Sandboxes for Custom Aliases**: Avoid modifying the base `Tempo` class if your app is intended to be used as a library. -3. **Use Debug Mode**: When developing new aliases, set `debug: 5` to receive console warnings about naming collisions. +3. **Use Debug Mode**: When developing new aliases, set `debug: 'trace'` to receive console warnings about naming collisions. diff --git a/packages/tempo/doc/tempo.extension.md b/packages/tempo/doc/tempo.extension.md index ddfc3a7..67936ef 100644 --- a/packages/tempo/doc/tempo.extension.md +++ b/packages/tempo/doc/tempo.extension.md @@ -68,7 +68,7 @@ export const BusinessDaysPlugin = defineExtension({ }); ``` -Notice how we drop into `.toDateTime()` to access the raw `Temporal.PlainDateTime` object? This is a common pattern in plugins when you need to access raw calendar properties (like `dayOfWeek`, `dayOfYear`, or `daysInMonth`) without triggering unnecessary string formatting. +Notice how we drop into `.toDateTime()` to access the raw zone-aware `Temporal.ZonedDateTime` object? This is a common pattern in plugins when you need to access raw calendar properties (like `dayOfWeek`, `dayOfYear`, or `daysInMonth`) while preserving zone information without triggering unnecessary string formatting. ## 3. TypeScript Module Augmentation diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index 046acd1..fa02598 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -13,10 +13,22 @@ 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/support/support.license.ts'); -const isPremiumAvailable = !!( + +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 ''; +})(); + +const isPremiumAvailable = Boolean( licensePremium && fs.existsSync(licensePremium) && - fs.existsSync(path.resolve(path.dirname(licensePremium), '../tsconfig.json')) + fs.existsSync(foundTsconfigPath) ); const licensePath = isPremiumAvailable ? licensePremium : licenseDefault; diff --git a/packages/tempo/src/engine/engine.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts index 309a95d..f6b837e 100644 --- a/packages/tempo/src/engine/engine.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -222,7 +222,7 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, co delete groups["afx"]; if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { - logError(`Invalid Date components: year=${year}, config, month=${month}, day=${day}`); + logError(`Invalid Date components: year=${year}, month=${month}, day=${day}`, config); return dateTime; } diff --git a/packages/tempo/src/module/module.mutate.ts b/packages/tempo/src/module/module.mutate.ts index 351341b..c4a04df 100644 --- a/packages/tempo/src/module/module.mutate.ts +++ b/packages/tempo/src/module/module.mutate.ts @@ -60,7 +60,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options try { if (++state.mutateDepth > 100) { - logError(`Infinite recursion detected in mutation engine for key: ${key}, this.config, adjust: ${adjust}, depth: ${state.mutateDepth}`); + logError(`Infinite recursion detected in mutation engine for key: ${key}, adjust: ${adjust}, depth: ${state.mutateDepth}`, this.config); state.errored = true; return currZdt; } @@ -163,7 +163,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options return currZdt.round({ smallestUnit: offset as any, roundingMode: 'ceil' }).subtract({ nanoseconds: 1 }); default: - logError(`Unexpected method(${op}), this.config, unit(${key}) and offset(${adjust})`); + logError(`Unexpected method(${op}), unit(${key}) and offset(${adjust})`, this.config); state.errored = true; return currZdt; } diff --git a/packages/tempo/src/module/module.parse.ts b/packages/tempo/src/module/module.parse.ts index ae25e95..edacef8 100644 --- a/packages/tempo/src/module/module.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -314,14 +314,18 @@ const _ParseEngine = { } }); - logTrace(`[ParseEngine] Selected layouts: ${orderedPatterns.map(p => p[0].description).join(', ')}`, state.config); + if (state.config?.debug === 'trace' || state.config?.debug === 5) { + logTrace(`[ParseEngine] Selected layouts: ${orderedPatterns.map(p => p[0].description).join(', ')}`, state.config); + } for (const [symKey, pat] of orderedPatterns) { const groups = _ParseEngine.parseMatch(state, pat, trim); if (isEmpty(groups)) continue; - logTrace(`[ParseEngine] Matched layout '${symKey.description}' with groups: ${JSON.stringify(groups)}`, state.config); + if (state.config?.debug === 'trace' || state.config?.debug === 5) { + logTrace(`[ParseEngine] Matched layout '${symKey.description}' with groups: ${JSON.stringify(groups)}`, state.config); + } const hasTime = Object.keys(groups) .some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key) || (Match.named.test(key) && key.endsWith('tm'))) || Object.values(groups).includes('now'); diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 944d8bd..8287fcd 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -305,7 +305,7 @@ export function extendState(state: t.Internal.State, options: t.Options) { const unit = (isString(arg.value) ? arg.value : arg.value?.unit)?.trim()?.toLowerCase(); if (isUndefined(unit) || !['ss', 'ms', 'us', 'ns'].includes(unit)) { - logError(`[Tempo#extend] Invalid timeStamp unit: ${String(unit ?? arg.value)}. Expected 'ss', state.config, 'ms', 'us', or 'ns'.`); + logError(`[Tempo#extend] Invalid timeStamp unit: ${String(unit ?? arg.value)}. Expected 'ss', 'ms', 'us', or 'ns'.`, state.config); break; } diff --git a/packages/tempo/src/support/support.symbol.ts b/packages/tempo/src/support/support.symbol.ts index ae42dbf..eac616b 100644 --- a/packages/tempo/src/support/support.symbol.ts +++ b/packages/tempo/src/support/support.symbol.ts @@ -5,7 +5,7 @@ export { $Target, $Discover, $Extensible, $Inspect, $LogConfig, $Registry, $LibR /** check valid Tempo instance */ -export const isTempo = (tempo?: any): tempo is TempoBrand => isDefined(tempo?.[sym.$Identity]); +export const isTempo = (tempo?: any): tempo is TempoBrand => tempo?.[sym.$Identity] === true; /** * Centralized registry for all Tempo-specific Global Symbols. diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 8a7e299..c4e552e 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -240,7 +240,7 @@ export class Tempo { try { intl = new Intl.Locale(Tempo._locale(locale)); } catch (e) { - logWarn(e, `Invalid locale encountered in #isMonthDay: ${locale}. Falling back to en-US.`, shape.config); + logWarn(`Invalid locale encountered in #isMonthDay: ${locale}. Falling back to en-US.`, shape.config, e); intl = new Intl.Locale('en-US'); } @@ -782,7 +782,6 @@ export class Tempo { if (Context.type === CONTEXT.Browser || options.debug === LOG.Trace) logDebug('Tempo:', this.config, state.config); - _lifecycle.ready = true; setPatterns(state); // rebuild the global patterns (Master Guard etc) // ๐Ÿ›๏ธ Licensing Reckoning (Background Verification) @@ -798,12 +797,15 @@ export class Tempo { }); } + _lifecycle.ready = true; + return this; + } catch (err) { + _lifecycle.ready = false; + throw err; } finally { _lifecycle.initialising = false; _lifecycle.bootstrap = false; } - - return this } /** explicitly enable/disable "catch" mode for internal errors */ diff --git a/packages/tempo/test/core/alias-engine-protochain.test.ts b/packages/tempo/test/core/alias-engine-protochain.test.ts index ad0cc37..1051fe9 100644 --- a/packages/tempo/test/core/alias-engine-protochain.test.ts +++ b/packages/tempo/test/core/alias-engine-protochain.test.ts @@ -1,26 +1,31 @@ import { AliasEngine } from '#tempo/engine/engine.alias.js'; import { logTempo } from '#tempo/support/support.util.js'; -import { vi, afterEach } from 'vitest'; describe('AliasEngine prototype chain (Global โ†’ Sandbox โ†’ Instance)', () => { afterEach(() => { vi.clearAllMocks(); }); - // Simulate a global state - const globalShape = {} as { aliasEngine: AliasEngine }; - globalShape.aliasEngine = new AliasEngine(); - globalShape.aliasEngine.registerAliases('evt', [ ['globalEvt', 'globalValue'] ]); - - // Simulate a sandbox state inheriting from global - const sandboxShape = Object.create(globalShape); - sandboxShape.aliasEngine = new AliasEngine({ parent: globalShape.aliasEngine }); - sandboxShape.aliasEngine.registerAliases('evt', [ ['sandboxEvt', 'sandboxValue'] ]); - - // Simulate a local/instance state inheriting from sandbox - const localShape = Object.create(sandboxShape); - localShape.aliasEngine = new AliasEngine({ parent: sandboxShape.aliasEngine }); - localShape.aliasEngine.registerAliases('evt', [ ['localEvt', 'localValue'] ]); + let globalShape!: { aliasEngine: AliasEngine }; + let sandboxShape!: { aliasEngine: AliasEngine }; + let localShape!: { aliasEngine: AliasEngine }; + + beforeEach(() => { + // Simulate a global state + globalShape = {} as { aliasEngine: AliasEngine }; + globalShape.aliasEngine = new AliasEngine(); + globalShape.aliasEngine.registerAliases('evt', [['globalEvt', 'globalValue']]); + + // Simulate a sandbox state inheriting from global + sandboxShape = Object.create(globalShape); + sandboxShape.aliasEngine = new AliasEngine({ parent: globalShape.aliasEngine }); + sandboxShape.aliasEngine.registerAliases('evt', [['sandboxEvt', 'sandboxValue']]); + + // Simulate a local/instance state inheriting from sandbox + localShape = Object.create(sandboxShape); + localShape.aliasEngine = new AliasEngine({ parent: sandboxShape.aliasEngine }); + localShape.aliasEngine.registerAliases('evt', [['localEvt', 'localValue']]); + }); it('resolves local, sandbox, and global aliases in correct order', () => { // Local should resolve its own @@ -44,7 +49,7 @@ describe('AliasEngine prototype chain (Global โ†’ Sandbox โ†’ Instance)', () => const warnSpy = vi.spyOn(logTempo, 'warn'); // Register a colliding alias in local - localShape.aliasEngine.registerAliases('evt', [ ['globalEvt', 'localShadow'] ]); + localShape.aliasEngine.registerAliases('evt', [['globalEvt', 'localShadow']]); // Should warn about collision with global expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected')); diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 6df23de..6de2bd2 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -14,84 +14,96 @@ const consoleSpySetup = resolve(__dirname, './test/support/setup.console-spy.ts' const licensePremium = process.env.TEMPO_LICENSE_PATH ? resolve(process.env.TEMPO_LICENSE_PATH) : undefined; const licenseDefault = resolve(__dirname, './src/support/support.license.ts'); + +const foundTsconfigPath = (() => { + if (!licensePremium) return ''; + let dir = dirname(licensePremium); + while (dir !== resolve(dir, '..')) { + const p = resolve(dir, 'tsconfig.json'); + if (fs.existsSync(p)) return p; + dir = resolve(dir, '..'); + } + return ''; +})(); + const isPremiumAvailable = Boolean( - licensePremium && - fs.existsSync(licensePremium) && - fs.existsSync(resolve(dirname(licensePremium), '../tsconfig.json')) + licensePremium && + fs.existsSync(licensePremium) && + fs.existsSync(foundTsconfigPath) ); export default defineConfig({ - esbuild: false, - oxc: false, - plugins: [ - swc.vite({ - jsc: { - target: 'es2022', - parser: { syntax: 'typescript', decorators: true }, - transform: { decoratorVersion: '2023-11' }, - }, - }), - ], - test: { - globals: true, - pool: 'forks', - maxWorkers: 2, - slowTestThreshold: 2_000, - include: ['test/**/*.{test,spec}.ts'], - exclude: [ - '**/node_modules/**', - '**/test/**/*.core.test.ts', - '**/test/**/*.lazy.test.ts' - ], - setupFiles: process.env.TEMPO_PREFILTER_CI === 'true' - ? [polyfill, consoleSpySetup, ciPrefilterSetup] - : [polyfill, consoleSpySetup], - }, - resolve: { - alias: isDist ? [ - { find: /^#tempo\/license$/, replacement: resolve(__dirname, './dist/support/support.license.js') }, - { find: /^#tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') }, - { find: /^#tempo\/term$/, replacement: resolve(__dirname, './dist/plugin/term/term.index.js') }, - { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './dist/module/module.duration.js') }, - { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './dist/module/module.$1.js') }, - { find: /^#tempo\/module$/, replacement: resolve(__dirname, './dist/module/module.index.js') }, - { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './dist/module/module.mutate.js') }, - { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.ticker.js') }, - { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.js') }, - { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/plugin.$1.js') }, - { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/extend/$1.js') }, - { find: /^#tempo\/engine\/(.*)\.js$/, replacement: resolve(__dirname, './dist/engine/$1.js') }, - { find: /^#tempo\/module\/(.*)\.js$/, replacement: resolve(__dirname, './dist/module/$1.js') }, - { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/term/$1.js') }, - { find: /^#tempo\/support$/, replacement: resolve(__dirname, './dist/support/support.index.js') }, - { find: /^#tempo\/(.*)\.js$/, replacement: resolve(__dirname, './dist/$1.js') }, - { find: /^#tempo$/, replacement: resolve(__dirname, './dist/tempo.index.js') }, - { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, '../library/dist/common/$1.js') }, - { find: /^#library$/, replacement: resolve(__dirname, '../library/dist/common.index.js') }, - ] : [ - { find: /^#tempo\/license$/, replacement: isPremiumAvailable ? (licensePremium as string) : licenseDefault }, - { find: /^@magmacomputing\/tempo\/plugin$/, replacement: resolve(__dirname, './src/plugin/plugin.index.ts') }, - { find: /^@magmacomputing\/tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, - { find: /^@magmacomputing\/tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, - { find: /^#tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, - { find: /^#tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, - { find: /^#tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/$1') }, - { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './src/plugin/extend/extend.ticker.ts') }, - { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './src/module/module.duration.ts') }, - { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './src/module/module.$1.ts') }, - { find: /^#tempo\/module$/, replacement: resolve(__dirname, './src/module/module.index.ts') }, - { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './src/module/module.mutate.ts') }, - { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.ts') }, - { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/plugin.$1.ts') }, - { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/extend/$1.ts') }, - { find: /^#tempo\/engine\/(.*)\.js$/, replacement: resolve(__dirname, './src/engine/$1.ts') }, - { find: /^#tempo\/module\/(.*)\.js$/, replacement: resolve(__dirname, './src/module/$1.ts') }, - { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/term/$1.ts') }, - { find: /^#tempo\/support$/, replacement: resolve(__dirname, './src/support/support.index.ts') }, - { find: /^#tempo\/(.*)\.js$/, replacement: resolve(__dirname, './src/$1.ts') }, - { find: /^#tempo$/, replacement: resolve(__dirname, './src/tempo.index.ts') }, - { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, '../library/src/common/$1.ts') }, - { find: /^#library$/, replacement: resolve(__dirname, '../library/src/common.index.ts') }, - ] - } + esbuild: false, + oxc: false, + plugins: [ + swc.vite({ + jsc: { + target: 'es2022', + parser: { syntax: 'typescript', decorators: true }, + transform: { decoratorVersion: '2023-11' }, + }, + }), + ], + test: { + globals: true, + pool: 'forks', + maxWorkers: 2, + slowTestThreshold: 2_000, + include: ['test/**/*.{test,spec}.ts'], + exclude: [ + '**/node_modules/**', + '**/test/**/*.core.test.ts', + '**/test/**/*.lazy.test.ts' + ], + setupFiles: process.env.TEMPO_PREFILTER_CI === 'true' + ? [polyfill, consoleSpySetup, ciPrefilterSetup] + : [polyfill, consoleSpySetup], + }, + resolve: { + alias: isDist ? [ + { find: /^#tempo\/license$/, replacement: resolve(__dirname, './dist/support/support.license.js') }, + { find: /^#tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') }, + { find: /^#tempo\/term$/, replacement: resolve(__dirname, './dist/plugin/term/term.index.js') }, + { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './dist/module/module.duration.js') }, + { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './dist/module/module.$1.js') }, + { find: /^#tempo\/module$/, replacement: resolve(__dirname, './dist/module/module.index.js') }, + { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './dist/module/module.mutate.js') }, + { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.ticker.js') }, + { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.js') }, + { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/plugin.$1.js') }, + { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/extend/$1.js') }, + { find: /^#tempo\/engine\/(.*)\.js$/, replacement: resolve(__dirname, './dist/engine/$1.js') }, + { find: /^#tempo\/module\/(.*)\.js$/, replacement: resolve(__dirname, './dist/module/$1.js') }, + { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/term/$1.js') }, + { find: /^#tempo\/support$/, replacement: resolve(__dirname, './dist/support/support.index.js') }, + { find: /^#tempo\/(.*)\.js$/, replacement: resolve(__dirname, './dist/$1.js') }, + { find: /^#tempo$/, replacement: resolve(__dirname, './dist/tempo.index.js') }, + { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, '../library/dist/common/$1.js') }, + { find: /^#library$/, replacement: resolve(__dirname, '../library/dist/common.index.js') }, + ] : [ + { find: /^#tempo\/license$/, replacement: isPremiumAvailable ? (licensePremium as string) : licenseDefault }, + { find: /^@magmacomputing\/tempo\/plugin$/, replacement: resolve(__dirname, './src/plugin/plugin.index.ts') }, + { find: /^@magmacomputing\/tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, + { find: /^@magmacomputing\/tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, + { find: /^#tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, + { find: /^#tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, + { find: /^#tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/$1') }, + { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './src/plugin/extend/extend.ticker.ts') }, + { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './src/module/module.duration.ts') }, + { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './src/module/module.$1.ts') }, + { find: /^#tempo\/module$/, replacement: resolve(__dirname, './src/module/module.index.ts') }, + { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './src/module/module.mutate.ts') }, + { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.ts') }, + { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/plugin.$1.ts') }, + { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/extend/$1.ts') }, + { find: /^#tempo\/engine\/(.*)\.js$/, replacement: resolve(__dirname, './src/engine/$1.ts') }, + { find: /^#tempo\/module\/(.*)\.js$/, replacement: resolve(__dirname, './src/module/$1.ts') }, + { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/term/$1.ts') }, + { find: /^#tempo\/support$/, replacement: resolve(__dirname, './src/support/support.index.ts') }, + { find: /^#tempo\/(.*)\.js$/, replacement: resolve(__dirname, './src/$1.ts') }, + { find: /^#tempo$/, replacement: resolve(__dirname, './src/tempo.index.ts') }, + { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, '../library/src/common/$1.ts') }, + { find: /^#library$/, replacement: resolve(__dirname, '../library/src/common.index.ts') }, + ] + } }); From 4c59150b166574e3c0be4499e1a52c7e1fd172eb Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 3 Jun 2026 07:52:59 +1000 Subject: [PATCH 11/20] PR 1st review --- package.json | 1 + packages/tempo/bin/push-docs.sh | 49 +++++++++++++++++++++++++++++++++ packages/tempo/package.json | 3 +- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100755 packages/tempo/bin/push-docs.sh diff --git a/package.json b/package.json index 0231f6c..ca91a35 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "docs:dev": "npm run docs:dev --workspace=@magmacomputing/tempo", "docs:build": "npm run docs:build --workspace=@magmacomputing/tempo", "docs:preview": "npm run docs:preview --workspace=@magmacomputing/tempo", + "docs:push": "npm run docs:push --workspace=@magmacomputing/tempo", "test:dist": "npm run build:library && npm run build:tempo && cross-env TEST_DIST=true vitest run" }, "devDependencies": { diff --git a/packages/tempo/bin/push-docs.sh b/packages/tempo/bin/push-docs.sh new file mode 100755 index 0000000..e3c6bbf --- /dev/null +++ b/packages/tempo/bin/push-docs.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Push documentation changes from the current branch directly to main. +set -e + +# Define the paths that constitute "documentation" in this repository/package. +# Since this script runs from within packages/tempo, paths are relative to this directory. +DOC_PATHS="doc/ img/ index.md typedoc.json .vitepress/" + +CURRENT_BRANCH=$(git branch --show-current) + +if [ "$CURRENT_BRANCH" = "main" ]; then + echo "Already on main branch. Please use standard git commit and push." + exit 1 +fi + +# Ensure workspace is clean to avoid losing uncommitted work +if ! git diff-index --quiet HEAD --; then + echo "Working directory is not clean. Please commit or stash your changes first." + exit 1 +fi + +echo "Switching to main branch..." +git checkout main + +echo "Pulling latest main..." +git pull origin main + +echo "Applying doc changes from $CURRENT_BRANCH to main..." +# Checkout only the doc files from the branch +git checkout $CURRENT_BRANCH -- $DOC_PATHS + +# Check if there's actually anything to commit +if git diff-index --quiet HEAD --; then + echo "No doc changes found between main and $CURRENT_BRANCH." + echo "Switching back to $CURRENT_BRANCH..." + git checkout $CURRENT_BRANCH + exit 0 +fi + +echo "Committing doc changes..." +git commit -m "docs: quick publish from $CURRENT_BRANCH" + +echo "Pushing directly to main..." +ALLOW_MAIN_PUSH=true git push origin main + +echo "Switching back to $CURRENT_BRANCH..." +git checkout $CURRENT_BRANCH + +echo "Done! Doc changes have been pushed to main." diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 68a8cc1..5cbf8a5 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -196,7 +196,8 @@ "docs:api": "typedoc", "docs:dev": "npm run build && npm run docs:api && vitepress dev", "docs:build": "npm run build && npm run docs:api && vitepress build", - "docs:preview": "vitepress preview" + "docs:preview": "vitepress preview", + "docs:push": "bash ./bin/push-docs.sh" }, "files": [ "dist/", From 10fdec24faada36f4f0eef64aff21a5c75d20776 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 3 Jun 2026 11:33:48 +1000 Subject: [PATCH 12/20] PR 2nd review --- packages/library/src/common/cipher.class.ts | 14 +++++++------- packages/library/src/common/class.library.ts | 19 +++++++++---------- packages/tempo/bin/push-docs.sh | 6 +++--- packages/tempo/doc/commercial.md | 4 ++-- packages/tempo/doc/tempo.extension.md | 2 +- packages/tempo/doc/tempo.layout.md | 4 ++-- packages/tempo/doc/tempo.plugin.md | 4 ++-- packages/tempo/doc/tempo.term.md | 2 +- packages/tempo/rollup.config.js | 3 +++ 9 files changed, 30 insertions(+), 28 deletions(-) diff --git a/packages/library/src/common/cipher.class.ts b/packages/library/src/common/cipher.class.ts index bcfa41d..7262f6e 100644 --- a/packages/library/src/common/cipher.class.ts +++ b/packages/library/src/common/cipher.class.ts @@ -23,7 +23,7 @@ const _asymmetricKey = subtle.generateKey({ /** Static-only cryptographic methods */ @Immutable -@Static // prevent instantiation +@Static // prevent instantiation export class Cipher { /** random UUID */ static randomKey = () => crypto.randomUUID().split('-')[0]; @@ -38,10 +38,10 @@ export class Cipher { /** encode object into base64 */ static encodeBase64 = (buf: unknown) => { - const str = stringify(buf); // first, stringify the incoming buffer + const str = stringify(buf); // first, stringify the incoming buffer const uint8 = strToUTF8Arr(str); // convert to Uint8Array - return base64EncArr(uint8); // convert to string + return base64EncArr(uint8); // convert to string } static hmac = async (source: string | Object, secret: string, alg = 'SHA-512', len?: number) => { @@ -78,12 +78,12 @@ export class Cipher { const combined = new Uint8Array(16 + cipherBuf.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(cipherBuf), 16); - return Cipher.decodeBuffer(new Uint16Array(combined.buffer)); + return base64EncArr(combined); } - static decrypt = async (secret: Promise | ArrayBuffer | Uint16Array) => { - const buf = await secret; - const uint8 = new Uint8Array(buf instanceof Uint16Array ? buf.buffer : buf); + static decrypt = async (secret: Promise | string) => { + const str = await secret; + const uint8 = base64DecToArr(str); const iv = uint8.slice(0, 16); const data = uint8.slice(16); return subtle.decrypt({ name: keys.TypeKey, iv }, await _cryptoKey, data) diff --git a/packages/library/src/common/class.library.ts b/packages/library/src/common/class.library.ts index 5e530d1..a711a54 100644 --- a/packages/library/src/common/class.library.ts +++ b/packages/library/src/common/class.library.ts @@ -25,7 +25,7 @@ function createImmutableWrapper( addInitializer: (fn: () => void) => void, immutabilityStrategy: (instance: any) => any // either Object.freeze or secure (Proxy) strategy ): T { - const safeName = name || value.name || 'Anonymous'; + const safeName = name || 'Anonymous'; const wrapper = { [safeName]: class extends value { constructor(...args: any[]) { @@ -126,15 +126,13 @@ export function Immutable(value: T, { kind, name, addInit export function Serializable(value: T, { kind, name, addInitializer }: ClassDecoratorContext): T | void { const finalName = getClassName(value, name); - if (finalName) { + if (finalName) registerType(value, finalName as Type); - } switch (kind) { case 'class': - if (finalName) { + if (finalName) addInitializer(() => registerSerializable(finalName, value));// register the class for serialization, via its toString() method - } return value; @@ -145,11 +143,11 @@ export function Serializable(value: T, { kind, name, addI /** make a Class not instantiable */ export function Static(value: T, { kind, name }: ClassDecoratorContext): T | void { - const finalName = getClassName(value, name); + const finalName = getClassName(value, name) as Type; switch (kind) { - case 'class': - const safeName = finalName || value.name || 'Anonymous'; + case 'class': { + const safeName = finalName || 'Anonymous'; const wrapper = { [safeName]: class extends value { constructor(...args: any[]) { @@ -160,11 +158,12 @@ export function Static(value: T, { kind, name }: ClassDec }[safeName] as T; if (finalName) { - registerType(value, `${finalName}_original` as Type); // register the original class definition - registerType(wrapper, finalName as Type); // register the wrapper as the authoritative definition + registerType(value, `${finalName}_original` as Type)// register the original class definition + registerType(wrapper, finalName); // register the wrapper as the authoritative definition } return wrapper; + } default: throw new Error(`@Static decorating unknown 'kind': ${kind} (${name})`); diff --git a/packages/tempo/bin/push-docs.sh b/packages/tempo/bin/push-docs.sh index e3c6bbf..e635fd6 100755 --- a/packages/tempo/bin/push-docs.sh +++ b/packages/tempo/bin/push-docs.sh @@ -3,8 +3,8 @@ set -e # Define the paths that constitute "documentation" in this repository/package. -# Since this script runs from within packages/tempo, paths are relative to this directory. -DOC_PATHS="doc/ img/ index.md typedoc.json .vitepress/" +TEMPO_ROOT="$(git rev-parse --show-toplevel)/packages/tempo" +DOC_PATHS="$TEMPO_ROOT/doc/ $TEMPO_ROOT/img/ $TEMPO_ROOT/index.md $TEMPO_ROOT/typedoc.json $TEMPO_ROOT/.vitepress/" CURRENT_BRANCH=$(git branch --show-current) @@ -23,7 +23,7 @@ echo "Switching to main branch..." git checkout main echo "Pulling latest main..." -git pull origin main +git pull --ff-only origin main echo "Applying doc changes from $CURRENT_BRANCH to main..." # Checkout only the doc files from the branch diff --git a/packages/tempo/doc/commercial.md b/packages/tempo/doc/commercial.md index ae18eb8..5d772bd 100644 --- a/packages/tempo/doc/commercial.md +++ b/packages/tempo/doc/commercial.md @@ -1,6 +1,6 @@ -# ๐Ÿค Magma Computing: Professional Services +# ๐Ÿค Magma Computing Solutions: Professional Services -Tempo is a high-performance, precision date-time library maintained by **Magma Computing**. We offer a range of professional services and private extensions to help teams build robust, time-sensitive applications. +Tempo is a high-performance, precision date-time library maintained by **Magma Computing Solutions**. We offer a range of professional services and private extensions to help teams build robust, time-sensitive applications. ## ๐Ÿš€ Our Services diff --git a/packages/tempo/doc/tempo.extension.md b/packages/tempo/doc/tempo.extension.md index 67936ef..bc79e1e 100644 --- a/packages/tempo/doc/tempo.extension.md +++ b/packages/tempo/doc/tempo.extension.md @@ -142,4 +142,4 @@ const nextBiz = t.addBusinessDays(2); --- > [!TIP] Need something more complex? -> If you need to build advanced scheduling engines, AsyncGenerators, or precision arithmetic tools that you plan to distribute commercially, check out our **[Premium Plugin Registry โ†—](https://magmacomputing.github.io/tempo-plugin-docs/)** for inspiration, or contact Magma Computing for professional plugin development. +> If you need to build advanced scheduling engines, AsyncGenerators, or precision arithmetic tools that you plan to distribute commercially, check out our **[Premium Plugin Registry โ†—](https://magmacomputing.github.io/tempo-plugin-docs/)** for inspiration, or contact Magma Computing Solutions for professional plugin development. diff --git a/packages/tempo/doc/tempo.layout.md b/packages/tempo/doc/tempo.layout.md index 1f2fbbb..40068fd 100644 --- a/packages/tempo/doc/tempo.layout.md +++ b/packages/tempo/doc/tempo.layout.md @@ -97,6 +97,6 @@ console.log(regex.source); ## Professional Services -If your project involves specialized terminology, complex financial calendars, or legacy application log formats, the **Magma Computing** team offers professional services to design and test custom `Tempo` Layouts optimized for your business needs. +If your project involves specialized terminology, complex financial calendars, or legacy application log formats, the **Magma Computing Solutions** team offers professional services to design and test custom `Tempo` Layouts optimized for your business needs. -Contact us at [Magma Computing](https://github.com/magmacomputing). +Contact us at [Magma Computing Solutions](https://github.com/magmacomputing). diff --git a/packages/tempo/doc/tempo.plugin.md b/packages/tempo/doc/tempo.plugin.md index a9667a6..1a2d10c 100644 --- a/packages/tempo/doc/tempo.plugin.md +++ b/packages/tempo/doc/tempo.plugin.md @@ -250,7 +250,7 @@ export const MyFeatureModule = defineModule((TempoClass, options) => { If you have built a powerful plugin and wish to distribute it commercially, you do not need to implement your own licensing engine. Build your plugin using the standard `defineModule` or `defineExtension` wrappers. -Once your plugin is ready for the marketplace, **[Contact Magma Computing](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. +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. --- @@ -289,7 +289,7 @@ Tempo.extend( If you have a complex business requirement or need a high-performance plugin built to professional standards, we can help. Our team can design, implement, and verify custom Tempo extensions tailored to your specific domain. -**[Contact Magma Computing](https://github.com/magmacomputing)** to discuss your requirements. +**[Contact Magma Computing Solutions](https://github.com/magmacomputing)** to discuss your requirements. - [Extension Plugin Guide](./tempo.extension.md): Learn the "Tempo-way" to write a prototype extension (like Business Days). - [Tempo Terms Guide](./tempo.term.md): Documentation on the "Memoized Lookup" pattern for business logic. diff --git a/packages/tempo/doc/tempo.term.md b/packages/tempo/doc/tempo.term.md index a5f19f8..8d49460 100644 --- a/packages/tempo/doc/tempo.term.md +++ b/packages/tempo/doc/tempo.term.md @@ -158,7 +158,7 @@ Tempo.extend(QuarterTerm); 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 to have our proprietary licensing and cryptographic verification engine wrapped around your plugin prior to distribution. +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 to have our proprietary licensing and cryptographic verification engine wrapped around your plugin prior to distribution. ### Plugin Definition diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index fa02598..a625923 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -25,6 +25,9 @@ const foundTsconfigPath = (() => { 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) && From 9a2b417234a7328e37596ea80a67e7d1ad55d6df Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 3 Jun 2026 16:13:31 +1000 Subject: [PATCH 13/20] PR 3rd review --- packages/library/src/common/array.library.ts | 2 +- .../library/src/common/assertion.library.ts | 10 +- packages/library/src/common/cipher.class.ts | 8 +- packages/library/src/common/class.library.ts | 3 +- .../library/src/common/function.library.ts | 11 +- packages/library/src/common/object.library.ts | 5 +- .../library/src/common/reflection.library.ts | 4 +- .../library/test/common/pledge.class.test.ts | 4 +- packages/tempo/CHANGELOG.md | 5 + packages/tempo/CONTRIBUTING.md | 2 +- .../tempo/bench/bench.parse.prefilter.e2e.ts | 8 +- packages/tempo/bench/bench.parse.prefilter.ts | 2 +- packages/tempo/bench/benchmark-results.json | 42 +++++ packages/tempo/bench/runner.test.ts | 30 ++++ packages/tempo/bench/test-benchmark-output.ts | 7 + packages/tempo/bench/test-pizza.test.ts | 5 + packages/tempo/bench/test-pizza.ts | 5 + packages/tempo/bench/tsconfig.json | 39 +++++ packages/tempo/bin/repl.ts | 1 - packages/tempo/bin/tsconfig.json | 3 - packages/tempo/doc/sandbox-factory.md | 10 ++ packages/tempo/doc/tempo.benchmarks.md | 55 +++++++ packages/tempo/doc/tempo.config.md | 36 +++++ packages/tempo/importmap.json | 1 - packages/tempo/package.json | 9 +- packages/tempo/src/module/module.benchmark.ts | 150 ++++++++++++++++++ packages/tempo/src/module/module.format.ts | 2 +- packages/tempo/src/support/support.default.ts | 2 +- packages/tempo/src/support/support.util.ts | 6 +- packages/tempo/src/tempo.class.ts | 100 ++---------- packages/tempo/src/tsconfig.json | 1 - packages/tempo/src/tsconfig.repl.json | 1 - packages/tempo/test/core/dispose.core.test.ts | 3 +- .../tempo/test/core/sandbox-factory.test.ts | 15 ++ .../test/engine/parse.prefilter.flag.test.ts | 22 +-- .../test/module/module.benchmark.test.ts | 59 +++++++ .../test/plugins/plugin_registration.test.ts | 27 ++-- .../test/plugins/slick.verification.test.ts | 1 - packages/tempo/test/support/proof.test.ts | 4 +- packages/tempo/test/tsconfig.json | 1 - packages/tempo/tsconfig.build.json | 2 +- packages/tempo/tsconfig.json | 3 +- packages/tempo/vitest.config.ts | 2 - 43 files changed, 539 insertions(+), 169 deletions(-) create mode 100644 packages/tempo/bench/benchmark-results.json create mode 100644 packages/tempo/bench/runner.test.ts create mode 100644 packages/tempo/bench/test-benchmark-output.ts create mode 100644 packages/tempo/bench/test-pizza.test.ts create mode 100644 packages/tempo/bench/test-pizza.ts create mode 100644 packages/tempo/bench/tsconfig.json create mode 100644 packages/tempo/src/module/module.benchmark.ts create mode 100644 packages/tempo/test/module/module.benchmark.test.ts diff --git a/packages/library/src/common/array.library.ts b/packages/library/src/common/array.library.ts index c6db534..dbdf3a5 100644 --- a/packages/library/src/common/array.library.ts +++ b/packages/library/src/common/array.library.ts @@ -54,7 +54,7 @@ export function sortBy>(...keys: (PropertyKey | SortBy)[]) switch (true) { case isNumber(valueA) && isNumber(valueB): case isDate(valueA) && isDate(valueB): - case isObject(valueA) && isObject(valueB) && typeof valueA.valueOf() === 'number' && typeof valueB.valueOf() === 'number': + case isObject(valueA) && isObject(valueB) && isNumber(valueA.valueOf()) && isNumber(valueB.valueOf()): result = (dir as any) * ((valueA as any) - (valueB as any)); break; diff --git a/packages/library/src/common/assertion.library.ts b/packages/library/src/common/assertion.library.ts index 8451c3b..3cd5775 100644 --- a/packages/library/src/common/assertion.library.ts +++ b/packages/library/src/common/assertion.library.ts @@ -65,11 +65,11 @@ export const isTemporal = (obj: unknown): obj is Temporals => protoType(obj).sta (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay )); -export const isInstant = (obj: unknown): obj is Temporal.Instant => isType(obj, 'Temporal.Instant') || (isDefined((globalThis as any).Temporal?.Instant) && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (isDefined(obj) && typeof (obj as any).toZonedDateTimeISO === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone)); -export const isZonedDateTime = (obj: unknown): obj is Temporal.ZonedDateTime => isType(obj, 'Temporal.ZonedDateTime') || (isDefined((globalThis as any).Temporal?.ZonedDateTime) && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (isDefined(obj) && typeof (obj as any).toInstant === 'function' && (isDefined((obj as any).timeZoneId) || isDefined((obj as any).timeZone))); -export const isPlainDate = (obj: unknown): obj is Temporal.PlainDate => isType(obj, 'Temporal.PlainDate') || (isDefined((globalThis as any).Temporal?.PlainDate) && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (isDefined(obj) && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && isDefined((obj as any).daysInMonth) && isUndefined((obj as any).hour) && isUndefined((obj as any).minute) && isUndefined((obj as any).second) && isUndefined((obj as any).nanosecond)); -export const isPlainTime = (obj: unknown): obj is Temporal.PlainTime => isType(obj, 'Temporal.PlainTime') || (isDefined((globalThis as any).Temporal?.PlainTime) && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime') || (isDefined(obj) && typeof (obj as any).toPlainDateTime === 'function' && isUndefined((obj as any).daysInMonth)); -export const isPlainDateTime = (obj: unknown): obj is Temporal.PlainDateTime => isType(obj, 'Temporal.PlainDateTime') || (isDefined((globalThis as any).Temporal?.PlainDateTime) && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (isDefined(obj) && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && (isDefined((obj as any).hour) || isDefined((obj as any).minute) || isDefined((obj as any).second) || isDefined((obj as any).nanosecond))); +export const isInstant = (obj: unknown): obj is Temporal.Instant => isType(obj, 'Temporal.Instant') || (isDefined((globalThis as any).Temporal?.Instant) && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (isDefined(obj) && isFunction((obj as any).toZonedDateTimeISO) && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone)); +export const isZonedDateTime = (obj: unknown): obj is Temporal.ZonedDateTime => isType(obj, 'Temporal.ZonedDateTime') || (isDefined((globalThis as any).Temporal?.ZonedDateTime) && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (isDefined(obj) && isFunction((obj as any).toInstant) && (isDefined((obj as any).timeZoneId) || isDefined((obj as any).timeZone))); +export const isPlainDate = (obj: unknown): obj is Temporal.PlainDate => isType(obj, 'Temporal.PlainDate') || (isDefined((globalThis as any).Temporal?.PlainDate) && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (isDefined(obj) && isFunction((obj as any).toZonedDateTime) && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && isDefined((obj as any).daysInMonth) && isUndefined((obj as any).hour) && isUndefined((obj as any).minute) && isUndefined((obj as any).second) && isUndefined((obj as any).nanosecond)); +export const isPlainTime = (obj: unknown): obj is Temporal.PlainTime => isType(obj, 'Temporal.PlainTime') || (isDefined((globalThis as any).Temporal?.PlainTime) && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime') || (isDefined(obj) && isFunction((obj as any).toPlainDateTime) && isUndefined((obj as any).daysInMonth)); +export const isPlainDateTime = (obj: unknown): obj is Temporal.PlainDateTime => isType(obj, 'Temporal.PlainDateTime') || (isDefined((globalThis as any).Temporal?.PlainDateTime) && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (isDefined(obj) && isFunction((obj as any).toZonedDateTime) && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && (isDefined((obj as any).hour) || isDefined((obj as any).minute) || isDefined((obj as any).second) || isDefined((obj as any).nanosecond))); export const isDuration = (obj: unknown): obj is Temporal.Duration => isType(obj, 'Temporal.Duration') || (isDefined((globalThis as any).Temporal?.Duration) && (obj as any) instanceof (globalThis as any).Temporal.Duration) || (isDefined(obj) && (obj as any)[Symbol.toStringTag] === 'Temporal.Duration'); export const isDurationLike = (obj: unknown): obj is Temporal.DurationLike | string | Temporal.Duration => isString(obj) || isDuration(obj) || (isObject(obj) && ( 'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj || diff --git a/packages/library/src/common/cipher.class.ts b/packages/library/src/common/cipher.class.ts index 7262f6e..081d595 100644 --- a/packages/library/src/common/cipher.class.ts +++ b/packages/library/src/common/cipher.class.ts @@ -69,8 +69,8 @@ export class Cipher { return toHex(Array.from(new Uint8Array(hash)), len); } - static encodeBuffer = (str: string) => new Uint16Array(new TextEncoder().encode(str)); - static decodeBuffer = (buf: Uint16Array) => new TextDecoder(keys.Encoding).decode(buf); + static encodeBuffer = (str: string) => new TextEncoder().encode(str); + static decodeBuffer = (buf: Uint8Array | ArrayBuffer) => new TextDecoder(keys.Encoding).decode(buf); static encrypt = async (data: any) => { const iv = crypto.getRandomValues(new Uint8Array(16)); @@ -87,13 +87,13 @@ export class Cipher { const iv = uint8.slice(0, 16); const data = uint8.slice(16); return subtle.decrypt({ name: keys.TypeKey, iv }, await _cryptoKey, data) - .then(result => new Uint16Array(result)) + .then(result => new Uint8Array(result)) .then(Cipher.decodeBuffer); } static sign = async (doc: any) => subtle.sign(keys.SignKey, (await _asymmetricKey).privateKey!, Cipher.encodeBuffer(doc)) - .then(result => new Uint16Array(result)) + .then(result => new Uint8Array(result)) .then(Cipher.decodeBuffer); static verify = async (signature: Promise, doc: any) => diff --git a/packages/library/src/common/class.library.ts b/packages/library/src/common/class.library.ts index a711a54..3110b79 100644 --- a/packages/library/src/common/class.library.ts +++ b/packages/library/src/common/class.library.ts @@ -1,5 +1,6 @@ import { $ImmutableSkip } from '#library/symbol.library.js'; import { secure } from '#library/proxy.library.js'; +import { isReference } from '#library/assertion.library.js'; import { registerSerializable } from '#library/serialize.library.js'; import { registerType, getSafeTag } from '#library/type.library.js'; import type { Constructor, Type } from '#library/type.library.js'; @@ -77,7 +78,7 @@ function hardenClassStaticsAndPrototypes(value: any, wrapper: any, skip: any) { // Lock down all existing prototype properties, but do NOT freeze the prototype object const lockPrototype = (proto: object) => { - if (!proto || typeof proto !== 'object') return; + if (!isReference(proto)) return; Reflect.ownKeys(proto).forEach(name => { if (name === 'constructor') return; if (Array.isArray(skip) && skip.some(s => String(s) === String(name))) return; diff --git a/packages/library/src/common/function.library.ts b/packages/library/src/common/function.library.ts index f363b5f..71b73d4 100644 --- a/packages/library/src/common/function.library.ts +++ b/packages/library/src/common/function.library.ts @@ -1,4 +1,5 @@ import { secure } from '#library/proxy.library.js'; +import { isInteger, isFunction, isReference, isMap, isSet } from '#library/assertion.library.js'; import type { Property } from '#library/type.library.js'; // https://medium.com/codex/currying-in-typescript-ca5226c85b85 @@ -38,15 +39,15 @@ type Curry = function serialize(val: any, seen = new WeakSet()): string { return JSON.stringify(val, function (this: any, key: string, value: any) { if (value === undefined) return '\u0000__undefined__\u0000'; - if (typeof value === 'bigint') return `bigint:${value}`; - if (typeof value === 'function') return `function:${value.name || 'anonymous'}`; + if (isInteger(value)) return `bigint:${value}`; + if (isFunction(value)) return `function:${value.name || 'anonymous'}`; - if (value !== null && typeof value === 'object') { + if (isReference(value)) { if (seen.has(value)) return ''; seen.add(value); - if (value instanceof Map) return `map:[${Array.from(value.entries()).map(e => serialize(e, seen)).sort().join(',')}]`; - if (value instanceof Set) return `set:[${Array.from(value).map(v => serialize(v, seen)).sort().join(',')}]`; + if (isMap(value)) return `map:[${Array.from(value.entries()).map(e => serialize(e, seen)).sort().join(',')}]`; + if (isSet(value)) return `set:[${Array.from(value).map(v => serialize(v, seen)).sort().join(',')}]`; } return value; }); diff --git a/packages/library/src/common/object.library.ts b/packages/library/src/common/object.library.ts index 5d955a5..eb7e089 100644 --- a/packages/library/src/common/object.library.ts +++ b/packages/library/src/common/object.library.ts @@ -1,5 +1,6 @@ import { ownKeys, ownEntries } from '#library/primitive.library.js'; -import { isObject, isArray, isReference, isFunction, isDefined, isNullish, isMap, isSet } from '#library/assertion.library.js'; +import { isObject, isArray, isFunction, isDefined, isNullish, isMap, isSet } from '#library/assertion.library.js'; +import { getType } from '#library/type.library.js'; import type { Extend, Property } from '#library/type.library.js'; /** remove quotes around property names */ @@ -26,7 +27,7 @@ export const asObject = (obj?: Record) => { export const isEqual = (a: any, b: any): boolean => { if (a === b) return true; if (isNullish(a) || isNullish(b)) return a === b; - if (typeof a !== typeof b) return false; + if (getType(a) !== getType(b)) return false; if (isArray(a) && isArray(b)) { const left = a as any[], right = b as any[]; diff --git a/packages/library/src/common/reflection.library.ts b/packages/library/src/common/reflection.library.ts index efaf53a..b877194 100644 --- a/packages/library/src/common/reflection.library.ts +++ b/packages/library/src/common/reflection.library.ts @@ -1,13 +1,13 @@ import { distinct, ownKeys, ownEntries } from '#library/primitive.library.js'; import { asType, getType } from '#library/type.library.js'; -import { isEmpty, isFunction, isPrimitive } from '#library/assertion.library.js'; +import { isEmpty, isFunction, isPrimitive, isReference } from '#library/assertion.library.js'; import type { Obj, KeyOf, Primitives } from '#library/type.library.js'; /** mutate Object | Array by excluding values with specified primitive 'types' */ export function exclude(obj: T, ...types: (Primitives | Lowercase)[]) { const exclusions = distinct(types.map(item => item.toLowerCase())) as typeof types; - if (obj && typeof obj === 'object') { // only works on Objects and Arrays + if (isReference(obj)) { // only works on Objects and Arrays const keys = [] as KeyOf[]; (ownEntries(obj) as [KeyOf, Obj][]) diff --git a/packages/library/test/common/pledge.class.test.ts b/packages/library/test/common/pledge.class.test.ts index cb356ce..4f230f7 100644 --- a/packages/library/test/common/pledge.class.test.ts +++ b/packages/library/test/common/pledge.class.test.ts @@ -65,7 +65,7 @@ describe('Pledge', () => { } }); - test('callback failures warn at default debug level (indirect Logify integration)', async () => { + test('callback failures warn at default debug level (indirect Diagnostic Engine integration)', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); @@ -89,7 +89,7 @@ describe('Pledge', () => { } }); - test('numeric debug level gates callback warning logs (indirect Logify integration)', async () => { + test('numeric debug level gates callback warning logs (indirect Diagnostic Engine integration)', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 2c082b6..0582a90 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -11,7 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Ticker Extraction**: The `TickerModule` has been extracted from the core Tempo library into a standalone, licensed premium plugin (`@magmacomputing/tempo-plugin-ticker`). It is no longer bundled with the open-source distribution. - **ISO Getter Precision**: The `.iso` property getter has been upgraded from native `Date.toISOString()` to Temporal's `Instant.toString()`. This provides full ISO 8601 nanosecond precision and omits fractional seconds when they evaluate to exactly zero. +### Changed (Architecture) +- **Configuration Parsing Unification**: Refactored the core configuration pipeline by routing `Tempo.init()`, `Tempo.extend()`, and `Tempo.create()` through a unified `[$setDiscovery]` parser. This removes 50 lines of duplicate parsing logic and significantly improves architectural consistency. +- **Feature-Complete Sandboxes**: Sandboxes instantiated via `Tempo.create()` now process their full `options.discovery` payload through the unified parser. This enables sandboxes to safely inherit and isolate localized plugins, custom formats, timeZones, and ignore rules, rather than just `monthDay` inheritance. + ### Added +- **Developer Benchmarks**: Introduced the `BenchmarkModule`, a decoupled utility for stress-testing and benchmarking Tempo parsing speeds and memory overhead against custom production datasets in any environment (Node.js or Browser). - **Compact Date Tokens**: Added `{dmy}`, `{mdy}`, and `{ymd}` to the `FormatModule` for generating 8-digit compact date strings (e.g. `24102026`). - **Ordinal Format Tokens**: Added uppercase `{DAY}`, `{WW}`, and `{MM}` to the `FormatModule` which generate the ordinal string representation (e.g. `24th`, `1st`, `2nd`). - **Compact Time Rename**: Renamed the `{hhmiss}` token to `{hms}` in the `FormatModule` for consistency with other token styles. diff --git a/packages/tempo/CONTRIBUTING.md b/packages/tempo/CONTRIBUTING.md index 7ac1e2a..2eb14db 100644 --- a/packages/tempo/CONTRIBUTING.md +++ b/packages/tempo/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thank you for your interest in contributing to Tempo! This project is a professi Tempo uses several advanced JavaScript patterns that contributors should be familiar with: - **[Proxy-Delegators](./doc/lazy-evaluation-pattern.md)**: For $O(1)$ lazy evaluation of instance properties. - **[Soft Freeze](./doc/soft_freeze_strategy.md)**: For secure but extensible global registries. -- **[Logify](./doc/architecture.md)**: For decoupled, symbol-based diagnostic logging. +- **[Diagnostic Engine](./doc/architecture.md)**: For decoupled, symbol-based diagnostic logging. ## ๐Ÿ› ๏ธ Local Development diff --git a/packages/tempo/bench/bench.parse.prefilter.e2e.ts b/packages/tempo/bench/bench.parse.prefilter.e2e.ts index 27755a2..c13d6bd 100644 --- a/packages/tempo/bench/bench.parse.prefilter.e2e.ts +++ b/packages/tempo/bench/bench.parse.prefilter.e2e.ts @@ -1,5 +1,5 @@ -import '../bin/temporal-polyfill.ts'; -import { Tempo } from '../src/tempo.index.ts'; +import '../bin/temporal-polyfill.js'; +import { Tempo } from '../src/tempo.index.js'; import { performance } from 'node:perf_hooks'; import fs from 'fs'; @@ -11,7 +11,7 @@ const layoutKeys = new Set([ 'yearMonthDay', 'offset', 'relativeOffset' ]); try { - corpus = fs.readFileSync(new URL('./bench.parse.prefilter.ts', import.meta.url), 'utf-8') + corpus = fs.readFileSync(new URL('./bench.parse.prefilter', import.meta.url), 'utf-8') .split(/\n/) .filter(line => line.trim().startsWith("'") && line.includes(',')) .map(line => line.replace(/['",]/g, '').trim()) @@ -86,7 +86,7 @@ const result = { minChecksum: 1 // dummy threshold, adjust as needed }, success: true, - errors: [] + errors: [] as string[] }; if (timingDeltaPct > result.thresholds.maxTimingDeltaPct) { diff --git a/packages/tempo/bench/bench.parse.prefilter.ts b/packages/tempo/bench/bench.parse.prefilter.ts index 2bcc7ee..4659e91 100644 --- a/packages/tempo/bench/bench.parse.prefilter.ts +++ b/packages/tempo/bench/bench.parse.prefilter.ts @@ -1,4 +1,4 @@ -import { selectLayoutPatterns } from '../src/engine/engine.planner.ts'; +import { selectLayoutPatterns } from '../src/engine/engine.planner.js'; import { performance } from 'node:perf_hooks'; const layoutNames = [ diff --git a/packages/tempo/bench/benchmark-results.json b/packages/tempo/bench/benchmark-results.json new file mode 100644 index 0000000..2a70fe0 --- /dev/null +++ b/packages/tempo/bench/benchmark-results.json @@ -0,0 +1,42 @@ +{ + "results": [ + { + "name": "Native Date", + "totalTimeMs": 0.64, + "opsPerSec": 5485094, + "successCount": 2000, + "failureCount": 1500, + "successRate": "57.1%", + "heapUsedDeltaMb": "0.39" + }, + { + "name": "Tempo (mode: auto)", + "totalTimeMs": 2726.79, + "opsPerSec": 1284, + "successCount": 2500, + "failureCount": 1000, + "successRate": "71.4%", + "heapUsedDeltaMb": "108.84" + }, + { + "name": "Tempo (mode: defer)", + "totalTimeMs": 2467.03, + "opsPerSec": 1419, + "successCount": 2500, + "failureCount": 1000, + "successRate": "71.4%", + "heapUsedDeltaMb": "-36.27" + }, + { + "name": "Tempo (mode: strict)", + "totalTimeMs": 2451.85, + "opsPerSec": 1427, + "successCount": 2500, + "failureCount": 1000, + "successRate": "71.4%", + "heapUsedDeltaMb": "26.10" + } + ], + "size": 7, + "ops": 3500 +} \ No newline at end of file diff --git a/packages/tempo/bench/runner.test.ts b/packages/tempo/bench/runner.test.ts new file mode 100644 index 0000000..bfdcb15 --- /dev/null +++ b/packages/tempo/bench/runner.test.ts @@ -0,0 +1,30 @@ +import '@js-temporal/polyfill'; +import { test } from 'vitest'; +import * as fs from 'node:fs'; +import { Tempo } from '../src/tempo.class.js'; +import { BenchmarkModule } from '../src/module/module.benchmark.js'; +import { ParseModule } from '../src/module/module.parse.js'; + +test('benchmark script', () => { + Tempo.extend(ParseModule); + Tempo.init({ timeZone: 'America/New_York' }); + + const DATASET = [ + '2026-05-20T14:30:00Z', + '2026-05-20', + 'Jan 1st, 2026', + '1716215400000', + 'Invalid-Pizza-String ๐Ÿ•', + '10/31/2026 11:59 PM', + '2026-05-20T14:30:00.000Z', + ]; + + const results = BenchmarkModule.run(Tempo, { + data: DATASET, + iterations: 500, + baseline: true, + modes: ['auto', 'defer', 'strict'] + }); + + fs.writeFileSync('benchmark-results.json', JSON.stringify({ results, size: DATASET.length, ops: DATASET.length * 500 }, null, 2)); +}, 120000); diff --git a/packages/tempo/bench/test-benchmark-output.ts b/packages/tempo/bench/test-benchmark-output.ts new file mode 100644 index 0000000..402f9f9 --- /dev/null +++ b/packages/tempo/bench/test-benchmark-output.ts @@ -0,0 +1,7 @@ +import { Tempo } from '../src/tempo.class.js'; +import { BenchmarkModule } from '../src/module/module.benchmark.js'; +const res = BenchmarkModule.run(Tempo, { + data: ['2026-05-20', '๐Ÿ•'], + modes: ['auto'] +}); +console.log(JSON.stringify(res, null, 2)); diff --git a/packages/tempo/bench/test-pizza.test.ts b/packages/tempo/bench/test-pizza.test.ts new file mode 100644 index 0000000..a8083ae --- /dev/null +++ b/packages/tempo/bench/test-pizza.test.ts @@ -0,0 +1,5 @@ +import { Tempo } from '../src/tempo.class.js'; +it('tests pizza', () => { + const t = new Tempo('๐Ÿ•', { catch: true }); + console.log('Valid:', t.isValid); +}); diff --git a/packages/tempo/bench/test-pizza.ts b/packages/tempo/bench/test-pizza.ts new file mode 100644 index 0000000..3756238 --- /dev/null +++ b/packages/tempo/bench/test-pizza.ts @@ -0,0 +1,5 @@ +import { Tempo } from '../src/tempo.class.js'; +const t = new Tempo('๐Ÿ•', { catch: true }); +console.log('Valid:', t.isValid); +console.log('Errored:', (t as any).$errored); // Wait, errored is private +console.log('ISO:', t.iso); diff --git a/packages/tempo/bench/tsconfig.json b/packages/tempo/bench/tsconfig.json new file mode 100644 index 0000000..e217e61 --- /dev/null +++ b/packages/tempo/bench/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "composite": true, + "types": [ + "node", + "vitest/globals" + ], + "paths": { + "#library": [ "../../library/src/common.index.ts" ], + "#library/*": [ "../../library/src/common/*" ], + "#browser/*": [ "../../library/src/browser/*" ], + "#server/*": [ "../../library/src/server/*" ], + "#tempo": [ "../src/tempo.index.ts" ], + "#tempo/core": [ "../src/core.index.ts" ], + "#tempo/duration": [ "../src/module/module.duration.ts" ], + "#tempo/mutate": [ "../src/module/module.mutate.ts" ], + "#tempo/format": [ "../src/module/module.format.ts" ], + "#tempo/parse": [ "../src/module/module.parse.ts" ], + "#tempo/module": [ "../src/module/module.index.ts" ], + "#tempo/engine/*.js": [ "../src/engine/*.ts" ], + "#tempo/module/*.js": [ "../src/module/*.ts" ], + "#tempo/plugin/extend/*.js": [ "../src/plugin/extend/*.ts" ], + "#tempo/plugin/term/*.js": [ "../src/plugin/term/*.ts" ], + "#tempo/term/*": [ "../src/plugin/term/term.*.ts" ], + "#tempo/license": [ "../src/support/support.license.ts" ], + "#tempo/*": [ "../src/*" ] + } + }, + "include": [ + "**/*.ts", + "../bin/**/*.ts" + ], + "references": [ + { "path": "../src/tsconfig.json" } + ] +} diff --git a/packages/tempo/bin/repl.ts b/packages/tempo/bin/repl.ts index 462c464..7654b78 100644 --- a/packages/tempo/bin/repl.ts +++ b/packages/tempo/bin/repl.ts @@ -1,6 +1,5 @@ import { Tempo, enums } from '#tempo'; import { stringify, objectify, enumify, getType, Pledge } from '#library'; -import '#tempo/ticker'; // pre-load Tempo to the global scope for ease of use in the REPL Object.assign(globalThis, { Tempo, getType, stringify, objectify, enumify, enums, Pledge }); diff --git a/packages/tempo/bin/tsconfig.json b/packages/tempo/bin/tsconfig.json index 6ea090d..9772ea2 100644 --- a/packages/tempo/bin/tsconfig.json +++ b/packages/tempo/bin/tsconfig.json @@ -35,9 +35,6 @@ "#tempo/discrete": [ "../src/discrete/discrete.index.ts" ], - "#tempo/ticker": [ - "../src/plugin/extend/extend.ticker.ts" - ], "#tempo/term/*": [ "../src/plugin/term/term.*.ts" ], diff --git a/packages/tempo/doc/sandbox-factory.md b/packages/tempo/doc/sandbox-factory.md index 07e0e8b..bb5e4b5 100644 --- a/packages/tempo/doc/sandbox-factory.md +++ b/packages/tempo/doc/sandbox-factory.md @@ -11,6 +11,16 @@ Historically, `Tempo.init()` modified the global library state. This meant that: ## The Solution `Tempo.create()` returns a **derived sandboxed class** with its own isolated configuration, registry, and plugin state. Each sandbox inherits from the caller, but runs with independent internal state. +### Lifecycle Methods +To understand when to use `Tempo.create()`, it helps to contrast it with the other initialization methods: + +- **`Tempo.init({ options })`** + **Concept:** Hard-reset to "out-of-the-box" factory defaults, then apply the provided configuration globally. All previous plugins, terms, and custom formats are purged. +- **`Tempo.extend({ options })`** + **Concept:** Additive mutation. Keep all existing global settings, plugins, and formats intact, but merge in new configurations. +- **`Tempo.create({ options })`** + **Concept:** Sandbox Factory. Clone the current global state (inheriting all currently loaded plugins and settings), but branch it off into a brand new, isolated class. Any future changes made to this Sandbox will not affect the global `Tempo`, and vice-versa. + ### Example: Creating a Sandbox ```typescript import { Tempo } from '@magmacomputing/tempo'; diff --git a/packages/tempo/doc/tempo.benchmarks.md b/packages/tempo/doc/tempo.benchmarks.md index 9361ca1..eeeaa10 100644 --- a/packages/tempo/doc/tempo.benchmarks.md +++ b/packages/tempo/doc/tempo.benchmarks.md @@ -44,3 +44,58 @@ The benchmark script used `performance.now()` within a Vitest environment to ens ::: info These benchmarks represent the library's performance under Node.js v22+. Results may vary based on the JS engine (V8, JavaScriptCore, etc.) but the $O(1)$ complexity remains constant. ::: + +--- + +## ๐Ÿ“ˆ Self-Service Benchmark Module + +Tempo provides a built-in `BenchmarkModule` to allow developers to prove the performance ROI in their own exact environment (e.g. comparing Node.js memory metrics against Browser DOM performance). + +This module is completely decoupled from the core to keep the bundle light, but accepts your specific configured `Tempo` instance to give accurate results. + +### Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { BenchmarkModule } from '@magmacomputing/tempo/module/benchmark'; + +// 1. Configure your Tempo instance (e.g. plugins, timezone, etc) +Tempo.init({ timeZone: 'America/New_York' }); + +// 2. Run the benchmark against your datasets +const results = BenchmarkModule.run(Tempo, { + data: ['2026-05-20', '2026-05-21', 'Invalid-Pizza-String'], // production datasets + iterations: 100, // stress-test count + baseline: true, // compare against native `new Date()` + modes: ['auto', 'defer'] +}); + +console.table(results); +``` + +### Metrics Collected + +The `BenchmarkModule` is platform-aware. Using the internal `getContext()` utility, it tracks: +- **`totalTimeMs`**: Total elapsed time using high-resolution timers (`performance.now()`). +- **`microSecPerOp`**: The average number of microseconds (ยตs) taken per operation. +- **`successRate`**: The percentage of strings that successfully resolved to a valid date. +- **`heapUsedDeltaMb`**: (Node.js Only) The change in memory allocation to measure Garbage Collection overhead and memory safety. + +
+ +### Indicative Benchmark Results + +When evaluated against a mixed dataset (Strict ISO, US Localized, Timestamps, and Garbage strings) over 3,500 operations in Node.js (Vitest), the `BenchmarkModule` yields the following performance profiles: + +| Strategy | ยตs / Op | Success Rate | Memory Overhead | Notes | +| :--- | :--- | :--- | :--- | :--- | +| **Native Date** | 0.18 ยตs | 57.1% | 0.39 MB | Raw C++ speed, but fails on localized strings (e.g., `10/31/2026 11:59 PM`). | +| **Tempo (Auto)** | 778.8 ยตs | **71.4%** | 108.84 MB | **~0.77ms**: Fully parses US localized strings and timestamps. | +| **Tempo (Defer)** | 704.7 ยตs | **71.4%** | -36.27 MB | **~0.70ms**: Noticeably faster, reduced Garbage Collection pressure. | +| **Tempo (Strict)** | 700.7 ยตs | **71.4%** | 26.10 MB | **~0.70ms**: Fastest mode; bypasses parsing fallback attempts. | + +#### ๐Ÿ” Key Takeaways + +1. **Sub-Millisecond Rich Parsing**: While native `Date` has raw C++ V8 speed (0.18 ยตs), it sacrifices reliability and formatting, failing entirely on localized formats like `10/31/2026`. Tempo achieves a robust **71% success rate** while still maintaining blazing fast **sub-millisecond (~0.7ms)** parsing speeds! In real-world terms, processing 100 complex localized dates on a page will only take 70 milliseconds. +2. **Defer Mode is Highly Optimized**: Looking at the memory overhead (`heapUsedDeltaMb`), `defer` mode actually resulted in a *negative* memory delta (`-36.27 MB`), meaning it allowed the V8 Garbage Collector to clean up memory faster than we were allocating the Proxy instances! +3. **Adapt This Test**: The runner script used to generate these results is available in the `bench/runner.test.ts` directory. You can use it as a scaffold to test Tempo against your own unique datasets. diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 0c98e8f..228e76a 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -24,6 +24,42 @@ This strategy prevents accidental state corruption while maintaining the flexibl --- +## ๐Ÿ† Best Practice: The `tempo.config.ts` Pattern + +Rather than scattering `Tempo.init()` or `Tempo.extend()` calls throughout your application, the recommended best practice is to centralize your environment setup into a single `tempo.config.ts` (or `.js`) file. + +This mirrors modern ecosystem standards (like `vite.config.ts` or `tailwind.config.js`) and ensures that plugins, timezones, and custom aliases are consistently applied before any domain logic executes. + +```typescript +// tempo.config.ts +import { Tempo } from '@magmacomputing/tempo'; +import { CronModule } from '@magmacomputing/tempo-plugin-cron'; +import { SLAModule } from '@magmacomputing/tempo-plugin-sla'; + +export const GlobalTempoConfig = { + timeZone: 'Australia/Sydney', // Set your baseline timezone + plugins: [CronModule, SLAModule], // Register enterprise plugins + period: { + 'market-open': '09:30', + 'market-close': '16:00' + } +} + +// Bootstrap the global environment +Tempo.init(GlobalTempoConfig); +``` + +You can then import this file at the very top of your application's entry point (e.g., `main.ts` or `index.js`) to guarantee the configuration is locked in before any other files import `Tempo`. + +```typescript +// main.ts +import './tempo.config.ts'; +import { App } from './app.js'; +// ... +``` + +--- + ## 1. Persistent Configuration (`$Tempo`) The first layer Tempo checks after its own internal defaults is persistent storage. This is ideal for "sticky" settings like a user's preferred timezone or locale that should persist across sessions without a database. diff --git a/packages/tempo/importmap.json b/packages/tempo/importmap.json index 7444e9e..b6d5a19 100644 --- a/packages/tempo/importmap.json +++ b/packages/tempo/importmap.json @@ -11,7 +11,6 @@ "@magmacomputing/tempo/mutate": "./dist/module/module.mutate.js", "@magmacomputing/tempo/plugin": "./dist/plugin/plugin.index.js", "@magmacomputing/tempo/format": "./dist/discrete/discrete.format.js", - "@magmacomputing/tempo/ticker": "./dist/plugin/extend/extend.ticker.js", "@magmacomputing/tempo/library": "./dist/library.index.js" } } \ No newline at end of file diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 5cbf8a5..2e15e15 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -59,9 +59,6 @@ "#tempo/mutate": { "default": "./dist/module/module.mutate.js" }, - "#tempo/ticker": { - "default": "./dist/plugin/extend/extend.ticker.js" - }, "#tempo/term": { "default": "./dist/plugin/term/term.index.js" }, @@ -134,10 +131,6 @@ "types": "./dist/plugin/*.d.ts", "import": "./dist/plugin/*.js" }, - "./ticker": { - "types": "./dist/plugin/extend/extend.ticker.d.ts", - "import": "./dist/plugin/extend/extend.ticker.js" - }, "./duration": { "types": "./dist/module/module.duration.d.ts", "import": "./dist/module/module.duration.js" @@ -222,4 +215,4 @@ "doc": "doc", "test": "test" } -} +} \ No newline at end of file diff --git a/packages/tempo/src/module/module.benchmark.ts b/packages/tempo/src/module/module.benchmark.ts new file mode 100644 index 0000000..9b11fb5 --- /dev/null +++ b/packages/tempo/src/module/module.benchmark.ts @@ -0,0 +1,150 @@ +import { Tempo } from '#tempo'; +import type { Options } from '#tempo/tempo.type.js'; + +import { getContext, CONTEXT } from '#library/utility.library.js'; + +export interface BenchmarkConfig { + /** Array of raw strings to parse. */ + data: string[]; + /** Number of times to loop the dataset (helps smooth out JIT compiler warmup). Default: 1 */ + iterations?: number; + /** Array of Tempo hydration modes to compare. Default: ['auto'] */ + modes?: Array<'auto' | 'strict' | 'defer'>; + /** Compare the dataset against native `new Date()` for a baseline speed/failure metric. Default: false */ + baseline?: boolean; +} + +export interface BenchmarkResult { + name: string; + totalTimeMs: number; + microSecPerOp: number; + successCount: number; + failureCount: number; + successRate: string; + heapUsedDeltaMb?: string; +} + +export class BenchmarkModule { + /** + * Run the benchmark against the provided configuration. + */ + static run(TempoClass: any, config: BenchmarkConfig): BenchmarkResult[] { + const results: BenchmarkResult[] = []; + const iterations = config.iterations && config.iterations > 0 ? config.iterations : 1; + const modes = config.modes || ['auto']; + const totalOps = config.data.length * iterations; + + if (totalOps === 0) return []; + + const { type, global } = getContext(); + + // Helper to measure memory (Node.js only) + const getHeap = (): number => type === CONTEXT.NodeJS && global.process?.memoryUsage ? global.process.memoryUsage().heapUsed : 0; + // Helper to measure time (Performance API if available, else Date) + const getTime = (): number => typeof global.performance !== 'undefined' ? global.performance.now() : Date.now(); + + // Baseline (Native Date) + if (config.baseline) { + let success = 0; + let failure = 0; + + const startMem = getHeap(); + const startTime = getTime(); + + for (let i = 0; i < iterations; i++) { + for (let j = 0; j < config.data.length; j++) { + const d = new Date(config.data[j]); + if (isNaN(d.getTime())) { + failure++; + } else { + success++; + } + } + } + + const endTime = getTime(); + const endMem = getHeap(); + + const totalTimeMs = endTime - startTime; + results.push({ + name: 'Native Date', + totalTimeMs: Number(totalTimeMs.toFixed(2)), + microSecPerOp: totalTimeMs > 0 ? Number(((totalTimeMs * 1000) / totalOps).toFixed(2)) : 0, + successCount: success, + failureCount: failure, + successRate: ((success / totalOps) * 100).toFixed(1) + '%', + ...(endMem > 0 ? { heapUsedDeltaMb: ((endMem - startMem) / 1024 / 1024).toFixed(2) } : {}) + }); + } + + // Tempo Modes + for (const mode of modes) { + let success = 0; + let failure = 0; + + const startMem = getHeap(); + const startTime = getTime(); + + for (let i = 0; i < iterations; i++) { + for (let j = 0; j < config.data.length; j++) { + try { + const t = new TempoClass(config.data[j], { mode }); + // For mode: 'defer', the parsing is deferred. To actually measure parse time, + // we must access a property to trigger hydration, otherwise it's just measuring proxy creation. + // We'll access .isValid which safely triggers hydration. + if (t.isValid) { + success++; + } else { + failure++; + } + } catch { + failure++; + } + } + } + + const endTime = getTime(); + const endMem = getHeap(); + + const totalTimeMs = endTime - startTime; + results.push({ + name: `Tempo (mode: ${mode})`, + totalTimeMs: Number(totalTimeMs.toFixed(2)), + microSecPerOp: totalTimeMs > 0 ? Number(((totalTimeMs * 1000) / totalOps).toFixed(2)) : 0, + successCount: success, + failureCount: failure, + successRate: ((success / totalOps) * 100).toFixed(1) + '%', + ...(endMem > 0 ? { heapUsedDeltaMb: ((endMem - startMem) / 1024 / 1024).toFixed(2) } : {}) + }); + } + + return results; + } + + /** + * Print the benchmark results as a console table. + */ + static printTable(results: BenchmarkResult[]): void { + if (results.length === 0) { + console.log('No benchmark results to display.'); + return; + } + + // Clean up undefined properties for the table + const tableData = results.map(r => { + const clean: any = { + 'Engine': r.name, + 'Total Time (ms)': r.totalTimeMs, + 'ยตs / Op': r.microSecPerOp, + 'Success Rate': r.successRate, + 'Failed': r.failureCount + }; + if (r.heapUsedDeltaMb !== undefined) { + clean['Heap Delta (MB)'] = r.heapUsedDeltaMb; + } + return clean; + }); + + console.table(tableData); + } +} diff --git a/packages/tempo/src/module/module.format.ts b/packages/tempo/src/module/module.format.ts index 066fc0c..a472379 100644 --- a/packages/tempo/src/module/module.format.ts +++ b/packages/tempo/src/module/module.format.ts @@ -119,7 +119,7 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol case 'tz': return zdt.timeZoneId; default: { if (token.startsWith('#') && isTempo(obj)) { - const res = (obj as Tempo).term[token.slice(1)]; + const res = (obj as unknown as Tempo).term[token.slice(1)]; if (isObject(res)) return res.label ?? res.key ?? `{${token}}`; return res ?? `{${token}}`; } diff --git a/packages/tempo/src/support/support.default.ts b/packages/tempo/src/support/support.default.ts index f782e1d..ead903a 100644 --- a/packages/tempo/src/support/support.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -209,6 +209,6 @@ export const Default = secure({ Token.dt, Token.tm, Token.dtm, Token.tmd, Token.dmy, Token.mdy, Token.ymd, Token.off, Token.rel ], - preFilter: false + preFilter: true }, } as Options) diff --git a/packages/tempo/src/support/support.util.ts b/packages/tempo/src/support/support.util.ts index ec36bec..362dfab 100644 --- a/packages/tempo/src/support/support.util.ts +++ b/packages/tempo/src/support/support.util.ts @@ -3,7 +3,7 @@ import { Logger, LOG, parseLogLevel, type DebugLevel } from '#library/logger.cla import { raise as boundaryRaise } from '#library/boundary.library.js'; import { sym, Token } from './support.symbol.js'; -import { asType } from '#library/type.library.js'; +import { asType, getType } from '#library/type.library.js'; import { asArray } from '#library/coercion.library.js'; import { isSymbol, isUndefined, isDefined, isString, isNullish, isObject } from '#library/assertion.library.js'; import { ownEntries, unwrap } from '#library/primitive.library.js'; @@ -52,7 +52,7 @@ export function raise(err: Error | string, config: any = {}, ...msg: any[]) { if (msg.length > 0) { const text = concatMsg(msg); err = isString(err) ? new Error(`${err} ${text}`) : err; - if (isError(err) && typeof err.message === 'string' && text) { + if (isError(err) && isString(err.message) && text) { err.message = `${err.message} ${text}`; } } @@ -116,7 +116,7 @@ export function create(obj: any, name: string): T { const entry = prototype[name]; if (!isObject(entry)) { - logError(`[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${typeof entry}).`, null); + logError(`[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${getType(entry)}).`, null); return {} as T; } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index c4e552e..19e5124 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -11,7 +11,7 @@ import { getAccessors, omit } from '#library/reflection.library.js'; 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 } from '#library/assertion.library.js'; +import { isEmpty, isDefined, isUndefined, isString, isObject, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike, isError } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; import { LOG } from '#library/logger.class.js'; @@ -354,6 +354,8 @@ export class Tempo { registryUpdate('NUMBER', discovery.numbers); // 1c. Process MDY settings + let opts = discovery.options || {} + if (discovery.monthDay) { const md = discovery.monthDay; if (md.timezones) { @@ -367,27 +369,14 @@ export class Tempo { } if (md.locales) registryUpdate('MONTH_DAY', { locales: asArray(md.locales) }); if (md.layouts) registryUpdate('MONTH_DAY', { layouts: asArray(md.layouts) }); + opts = { ...opts, monthDay: md }; } // 1d. Process Internationalization - if (discovery.intl) { - shape.config.intl = shape.config.intl || {}; - for (const [key, val] of Object.entries(discovery.intl)) { - if (isObject(val) && isObject((shape.config.intl as any)[key])) { - (shape.config.intl as any)[key] = { ...(shape.config.intl as any)[key], ...val }; - } else { - (shape.config.intl as any)[key] = val; - } - } - } + if (discovery.intl) opts = { ...opts, intl: discovery.intl }; // 1e. Process Planner - if (isObject(discovery.planner)) { - if (isDefined(discovery.planner.layoutOrder)) - shape.parse.planner.layoutOrder = normalizeLayoutOrder(discovery.planner.layoutOrder as any); - if (isDefined(discovery.planner.preFilter)) - shape.parse.planner.preFilter = Boolean(discovery.planner.preFilter); - } + if (isObject(discovery.planner)) opts = { ...opts, planner: discovery.planner }; // 2. Process Terms if (discovery.terms) @@ -404,11 +393,11 @@ export class Tempo { asArray(discovery.plugins).forEach(p => this.extend(p)); // 5. Process Options - let opts = discovery.options || {} if (discovery.ignore) { const ignore = isFunction(discovery.ignore) ? discovery.ignore() : discovery.ignore; opts = { ...opts, ignore }; } + const res = isFunction(opts) ? opts() : opts; if (shape === _global) { @@ -568,65 +557,13 @@ export class Tempo { // 2. handle Discovery object (container) else { const discovery = item as any - if (discovery.term) { - discovery.terms = [...asArray(discovery.terms || []), ...asArray(discovery.term)]; - logWarn('Legacy "term" key in Discovery is deprecated. Please use "terms" instead.', this[$Internal]().config); - } - if (discovery.plugin) { - discovery.plugins = [...asArray(discovery.plugins || []), ...asArray(discovery.plugin)]; - } - - DISCOVERY.keys().forEach(key => { - const val = discovery[key]; - if (!isDefined(val)) return; - - switch (key) { - case 'options': - this[$setConfig](this[$Internal](), val); - break; - - case 'plugins': - this.extend(val, discovery.options); - break; - - case 'terms': - this.extend(val); - break; - - case 'numbers': - registryUpdate('NUMBER', val); - break; - - case 'timeZones': { - const tzs = Object.fromEntries(ownEntries(val).map(([k, v]) => [k.toString().toLowerCase(), v])); - registryUpdate('TIMEZONE', tzs); - break; - } - - case 'formats': { - const internal = this[$Internal](); - internal.config.formats = internal.config.formats.extend(val) as t.FormatRegistry; - registryUpdate('FORMAT', val); - break; - } - - case 'monthDay': - registryUpdate('MONTH_DAY', val); - this[$setConfig](this[$Internal](), { monthDay: val }); - break; - - case 'intl': - case 'planner': - case 'ignore': - this[$setConfig](this[$Internal](), { [key]: val }); - break; - } - }); + const opts = this[$setDiscovery](this[$Internal](), discovery); + if (!isEmpty(opts)) this[$setConfig](this[$Internal](), opts); // only trigger init if we're assigning a new discovery object to a symbol if (ownKeys(item).some(key => DISCOVERY.has(key as any))) { - const discovery = (isSymbol(options) ? options : (options as any)?.discovery) ?? sym.$Tempo; - const discoverySymbol = isString(discovery) ? Symbol.for(discovery) : (isSymbol(discovery) && !Symbol.keyFor(discovery) ? Symbol('TempoSandbox') : discovery); + const discoveryArg = (isSymbol(options) ? options : (options as any)?.discovery) ?? sym.$Tempo; + const discoverySymbol = isString(discoveryArg) ? Symbol.for(discoveryArg) : (isSymbol(discoveryArg) && !Symbol.keyFor(discoveryArg) ? Symbol('TempoSandbox') : discoveryArg); if ((globalThis as Record)[discoverySymbol as symbol] !== item) { (globalThis as Record)[discoverySymbol as symbol] = item; @@ -637,12 +574,12 @@ export class Tempo { } }) } finally { - _lifecycle.extendDepth--; // decrement the re-entrant nesting counter + _lifecycle.extendDepth--; // decrement the re-entrant nesting counter } if (_lifecycle.extendDepth === 0) { this[$buildGuard](); - setPatterns(this[$Internal]()); // rebuild the global patterns + setPatterns(this[$Internal]()); // rebuild the global patterns } return this; @@ -683,15 +620,10 @@ export class Tempo { discovery: normalizedDiscovery as any, catch: options.catch ?? false }, + (SandboxTempo as any)[$setDiscovery](state, data), { ...options, discovery: normalizedDiscovery } ); - // If the sandbox was provided with monthDay discovery, resolve and apply it to the isolated state - if (isObject(options.discovery) && options.discovery.monthDay) { - const discoveryMD = resolveMonthDay(options.discovery.monthDay, Tempo.MONTH_DAY); - state.parse.monthDay = { ...state.parse.monthDay, ...discoveryMD }; - } - Object.freeze(SandboxTempo); return SandboxTempo as unknown as typeof Tempo; } @@ -1020,7 +952,7 @@ export class Tempo { static { // Static initialization block to sequence the bootstrap phase // Define the reactive register hook - getRuntime().setHook($Register, (plugin: Plugin | Plugin[]) => { + getRuntime().setHook($Register, (plugin: TempoPlugin | TempoPlugin[]) => { if (!Tempo.isExtending) Tempo.extend(plugin) }); @@ -1415,7 +1347,7 @@ export class Tempo { /** current Tempo configuration */ get config() { - const global = this[$Internal]().config; + const global = (this as any)[$Internal]().config; const out = markConfig(Object.create(global)); Object.entries(this.#local.config).forEach(([k, v]) => setProperty(out, k, v)); diff --git a/packages/tempo/src/tsconfig.json b/packages/tempo/src/tsconfig.json index d581e40..fa7b8d8 100644 --- a/packages/tempo/src/tsconfig.json +++ b/packages/tempo/src/tsconfig.json @@ -23,7 +23,6 @@ "#tempo/module": [ "./module/module.index.ts" ], "#tempo/duration": [ "./module/module.duration.ts" ], "#tempo/mutate": [ "./module/module.mutate.ts" ], - "#tempo/ticker": [ "./plugin/extend/extend.ticker.ts" ], "#tempo/engine/*.js": [ "./engine/*.ts" ], "#tempo/module/*.js": [ "./module/*.ts" ], "#tempo/plugin/extend/*.js": [ "./plugin/extend/*.ts" ], diff --git a/packages/tempo/src/tsconfig.repl.json b/packages/tempo/src/tsconfig.repl.json index a87d3e0..e01a58d 100644 --- a/packages/tempo/src/tsconfig.repl.json +++ b/packages/tempo/src/tsconfig.repl.json @@ -17,7 +17,6 @@ "#tempo/module": [ "./module/module.index.ts" ], "#tempo/duration": [ "./module/module.duration.ts" ], "#tempo/mutate": [ "./module/module.mutate.ts" ], - "#tempo/ticker": [ "./plugin/extend/extend.ticker.ts" ], "#tempo/engine/*.js": [ "./engine/*.ts" ], "#tempo/module/*.js": [ "./module/*.ts" ], "#tempo/plugin/extend/*.js": [ "./plugin/extend/*.ts" ], diff --git a/packages/tempo/test/core/dispose.core.test.ts b/packages/tempo/test/core/dispose.core.test.ts index 80ba2d3..5390a0c 100644 --- a/packages/tempo/test/core/dispose.core.test.ts +++ b/packages/tempo/test/core/dispose.core.test.ts @@ -1,10 +1,9 @@ import { Tempo } from '#tempo/core'; import { Pledge } from '#library/pledge.class.js'; -import { TickerModule } from '#tempo/ticker'; import { FormatModule } from '#tempo/format'; import { MutateModule } from '#tempo/mutate'; -Tempo.extend(TickerModule, FormatModule, MutateModule); +Tempo.extend(FormatModule, MutateModule); describe('Static Symbol.dispose', () => { diff --git a/packages/tempo/test/core/sandbox-factory.test.ts b/packages/tempo/test/core/sandbox-factory.test.ts index 34fd129..930a1b8 100644 --- a/packages/tempo/test/core/sandbox-factory.test.ts +++ b/packages/tempo/test/core/sandbox-factory.test.ts @@ -103,4 +103,19 @@ describe('Sandbox Factory Pattern', () => { expect(Sandbox1.terms).toEqual(Sandbox2.terms); }); + + it('should support full discovery payloads (formats, timeZones) in create()', () => { + const discovery = { + formats: { + 'sandboxed_fmt': '{dd}/{mm}/{yyyy}' + }, + timeZones: { + 'sandbox_tz': 'Australia/Sydney' + } + }; + const Sandbox = Tempo.create({ discovery }); + + const t = new Sandbox('2026-06-03 12:00:00', { timeZone: 'sandbox_tz' }); + expect(t.format('sandboxed_fmt')).toBe('03/06/2026'); + }); }); diff --git a/packages/tempo/test/engine/parse.prefilter.flag.test.ts b/packages/tempo/test/engine/parse.prefilter.flag.test.ts index fb61bbc..922a09d 100644 --- a/packages/tempo/test/engine/parse.prefilter.flag.test.ts +++ b/packages/tempo/test/engine/parse.prefilter.flag.test.ts @@ -5,8 +5,8 @@ describe('parse prefilter feature flag', () => { Tempo.init(); }); - test.skipIf(process.env.TEMPO_PREFILTER_CI === 'true')('defaults to disabled', () => { - expect(Tempo.parse.planner.preFilter).toBe(false); + test.skipIf(process.env.TEMPO_PREFILTER_CI === 'true')('defaults to enabled', () => { + expect(Tempo.parse.planner.preFilter).toBe(true); }); test('can be enabled globally via Tempo.init', () => { @@ -18,15 +18,7 @@ describe('parse prefilter feature flag', () => { expect(t.parse.result?.[0]?.match).toBe('relativeOffset'); }); - test.skipIf(process.env.TEMPO_PREFILTER_CI === 'true')('can be enabled per-instance without changing global setting', () => { - Tempo.init({ preFilter: false }); - const t = new Tempo('monday', { timeZone: 'UTC', preFilter: true }); - - expect(Tempo.parse.planner.preFilter).toBe(false); - expect(t.parse.planner.preFilter).toBe(true); - }); - - test('can be disabled per-instance even when global is enabled', () => { + test.skipIf(process.env.TEMPO_PREFILTER_CI === 'true')('can be disabled per-instance without changing global setting', () => { Tempo.init({ preFilter: true }); const t = new Tempo('monday', { timeZone: 'UTC', preFilter: false }); @@ -34,6 +26,14 @@ describe('parse prefilter feature flag', () => { expect(t.parse.planner.preFilter).toBe(false); }); + test('can be enabled per-instance even when global is disabled', () => { + Tempo.init({ preFilter: false }); + const t = new Tempo('monday', { timeZone: 'UTC', preFilter: true }); + + expect(Tempo.parse.planner.preFilter).toBe(false); + expect(t.parse.planner.preFilter).toBe(true); + }); + test('emits planner debug telemetry when debug + preFilter are enabled', () => { Tempo.init({ debug: 5, preFilter: true }); const t = new Tempo('2 days ago', { timeZone: 'UTC' }); diff --git a/packages/tempo/test/module/module.benchmark.test.ts b/packages/tempo/test/module/module.benchmark.test.ts new file mode 100644 index 0000000..ff89237 --- /dev/null +++ b/packages/tempo/test/module/module.benchmark.test.ts @@ -0,0 +1,59 @@ +import { Tempo } from '#tempo'; +import { BenchmarkModule } from '#tempo/module/module.benchmark.js'; + +describe('BenchmarkModule', () => { + it('should return empty results if no data is provided', () => { + const results = BenchmarkModule.run(Tempo, { data: [] }); + expect(results).toEqual([]); + }); + + it('should calculate ops/sec and failure rates for native Date baseline', () => { + const results = BenchmarkModule.run(Tempo, { + data: ['2026-05-20', 'invalid-gibberish'], + iterations: 1, + baseline: true, + modes: [] + }); + + expect(results.length).toBe(1); + const native = results[0]; + expect(native.name).toBe('Native Date'); + expect(native.successCount).toBe(1); + expect(native.failureCount).toBe(1); + expect(native.successRate).toBe('50.0%'); + expect(typeof native.totalTimeMs).toBe('number'); + expect(typeof native.microSecPerOp).toBe('number'); + }); + + it('should benchmark Tempo parsing modes', () => { + const results = BenchmarkModule.run(Tempo, { + data: ['2026-05-20', '๐Ÿ•'], + iterations: 1, + modes: ['auto', 'defer'] + }); + + console.log('BENCHMARK RESULTS:', results); + expect(results.length).toBe(2); + + const auto = results.find(r => r.name.includes('auto'))!; + expect(auto.successCount).toBe(1); + expect(auto.failureCount).toBe(1); + + const defer = results.find(r => r.name.includes('defer'))!; + expect(defer.successCount).toBe(1); + expect(defer.failureCount).toBe(1); + }); + + it('should multiply iterations correctly', () => { + const results = BenchmarkModule.run(Tempo, { + data: ['2026-05-20'], + iterations: 5, + modes: ['auto'] + }); + + expect(results.length).toBe(1); + const auto = results[0]; + expect(auto.successCount).toBe(5); // 1 item * 5 iterations + expect(auto.failureCount).toBe(0); + }); +}); diff --git a/packages/tempo/test/plugins/plugin_registration.test.ts b/packages/tempo/test/plugins/plugin_registration.test.ts index b2314bb..70d4739 100644 --- a/packages/tempo/test/plugins/plugin_registration.test.ts +++ b/packages/tempo/test/plugins/plugin_registration.test.ts @@ -1,29 +1,24 @@ import { Tempo } from '#tempo'; -import { getRuntime } from '#tempo/support/support.runtime.js'; -import { TickerModule } from '#tempo/ticker'; +import { defineExtension } from '#tempo/plugin/plugin.util.js'; -describe('Ticker Registration / Initialization', () => { - - test('TickerModule should be auto-registered on import', () => { - // 1. TickerModule was imported above, so it should be in the runtime's pluginsDb. - const db = getRuntime().pluginsDb; - expect(db).toBeDefined(); - expect(db.plugins).toContain(TickerModule); - - // 2. We must call init() to "activate" the registered plugins - Tempo.init(); - expect(Tempo.ticker).toBeDefined(); - }); +const DummyModule = defineExtension({ + name: 'DummyModule', + install(TempoClass: any) { + TempoClass.dummy = true; + } +}); +describe('Plugin Registration / Initialization', () => { test('Plugins should survive Tempo.init() reset', () => { // 1. Verify installed + Tempo.extend(DummyModule); Tempo.init(); - expect(Tempo.ticker).toBeDefined(); + expect((Tempo as any).dummy).toBe(true); // 2. Perform a hard reset (empty init) Tempo.init(); // 3. Verify it's STILL installed (init() should have re-extended from $Plugins) - expect(Tempo.ticker).toBeDefined(); + expect((Tempo as any).dummy).toBe(true); }); }); diff --git a/packages/tempo/test/plugins/slick.verification.test.ts b/packages/tempo/test/plugins/slick.verification.test.ts index 8d2dbc3..9e2fe02 100644 --- a/packages/tempo/test/plugins/slick.verification.test.ts +++ b/packages/tempo/test/plugins/slick.verification.test.ts @@ -1,5 +1,4 @@ import { Tempo } from '#tempo'; -import '#tempo/ticker'; describe('Tempo Shorthand Suite (Comprehensive)', () => { beforeEach(() => { diff --git a/packages/tempo/test/support/proof.test.ts b/packages/tempo/test/support/proof.test.ts index 7a1bb89..f2fcb86 100644 --- a/packages/tempo/test/support/proof.test.ts +++ b/packages/tempo/test/support/proof.test.ts @@ -16,7 +16,7 @@ describe('Proof: Enumerable + Silent Mode', () => { Object.keys(t.term); // Even though getters were triggered and some definitely failed (invalid date), - // Logify.silent should have prevented any console output. + // Diagnostic Engine silent should have prevented any console output. expect(console.error).not.toHaveBeenCalled(); expect(console.warn).not.toHaveBeenCalled(); }); @@ -24,7 +24,7 @@ describe('Proof: Enumerable + Silent Mode', () => { it('should still show errors when NOT in silent mode (baseline check)', () => { const t = new Tempo('Invalid Date', { catch: true, silent: false }); - // Trigger a failure (which calls Logify.catch with {catch: true}) + // Trigger a failure (which calls Logger with {catch: true}) try { t.term.quarter } catch (e) { } expect(console.error).toHaveBeenCalled(); diff --git a/packages/tempo/test/tsconfig.json b/packages/tempo/test/tsconfig.json index 5a04fb6..91b527d 100644 --- a/packages/tempo/test/tsconfig.json +++ b/packages/tempo/test/tsconfig.json @@ -20,7 +20,6 @@ "#tempo/format": [ "../src/module/module.format.ts" ], "#tempo/parse": [ "../src/module/module.parse.ts" ], "#tempo/module": [ "../src/module/module.index.ts" ], - "#tempo/ticker": [ "../src/plugin/extend/extend.ticker.ts" ], "#tempo/engine/*.js": [ "../src/engine/*.ts" ], "#tempo/module/*.js": [ "../src/module/*.ts" ], "#tempo/plugin/extend/*.js": [ "../src/plugin/extend/*.ts" ], diff --git a/packages/tempo/tsconfig.build.json b/packages/tempo/tsconfig.build.json index e03ab15..9e6c129 100644 --- a/packages/tempo/tsconfig.build.json +++ b/packages/tempo/tsconfig.build.json @@ -12,4 +12,4 @@ "exclude": [ "test/**/*.ts" ] -} +} \ No newline at end of file diff --git a/packages/tempo/tsconfig.json b/packages/tempo/tsconfig.json index 9ab5d26..22c334d 100644 --- a/packages/tempo/tsconfig.json +++ b/packages/tempo/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "./src" }, { "path": "./test" }, - { "path": "./bin" } + { "path": "./bin" }, + { "path": "./bench" } ] } diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 6de2bd2..d99860f 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -68,7 +68,6 @@ export default defineConfig({ { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './dist/module/module.$1.js') }, { find: /^#tempo\/module$/, replacement: resolve(__dirname, './dist/module/module.index.js') }, { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './dist/module/module.mutate.js') }, - { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.ticker.js') }, { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.js') }, { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/plugin.$1.js') }, { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/extend/$1.js') }, @@ -88,7 +87,6 @@ export default defineConfig({ { find: /^#tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, { find: /^#tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/$1') }, - { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './src/plugin/extend/extend.ticker.ts') }, { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './src/module/module.duration.ts') }, { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './src/module/module.$1.ts') }, { find: /^#tempo\/module$/, replacement: resolve(__dirname, './src/module/module.index.ts') }, From fe470c82cb11be5afbc31224e7f8ef31269fe308 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 5 Jun 2026 09:25:49 +1000 Subject: [PATCH 14/20] pre handshake --- packages/tempo/rollup.config.js | 8 ++--- packages/tempo/src/plugin/plugin.index.ts | 1 + packages/tempo/src/support/support.init.ts | 9 ++++- packages/tempo/src/support/support.license.ts | 33 +++++++++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index a625923..a396d3b 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -77,13 +77,11 @@ function getFiles(dir, suffix = '.js') { const entryPoints = Object.fromEntries( getFiles(distPath) .map(file => [path.relative(distPath, file).replace(/\.js$/, ''), file]) - .filter(([key]) => key !== 'support/support.license') + .filter(([key]) => !isPremiumAvailable || key !== 'support/support.license') ); export default [ - // 1. ๐Ÿ›ก๏ธ LICENSE MONOLITH - // Bundles 'jose' and the license logic into a single heavily obfuscated file - { + ...(isPremiumAvailable ? [{ input: licensePath, output: { file: 'dist/support/support.license.js', // Overwrites the tsc output stealthily @@ -114,7 +112,7 @@ export default [ } } ] - }, + }] : []), // 2. ๐ŸŒ GLOBAL IIFE BUNDLE { diff --git a/packages/tempo/src/plugin/plugin.index.ts b/packages/tempo/src/plugin/plugin.index.ts index f383d37..45b7545 100644 --- a/packages/tempo/src/plugin/plugin.index.ts +++ b/packages/tempo/src/plugin/plugin.index.ts @@ -9,3 +9,4 @@ export * from './plugin.util.js'; export * from './plugin.type.js'; export * from './term/term.type.js'; +export { definePremiumPlugin, definePremiumTerm } from '#tempo/license'; diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 8287fcd..4b1df48 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -164,7 +164,14 @@ function setLicense(state: t.Internal.State, key: string) { // ๐Ÿ›ก๏ธ Race Condition Guard: Only apply results if identity (JTI + Key) hasn't changed since we started if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; - runtime.license.status = res.status; + const desc = res.status?.description ?? String(res.status); + const statusMap: Record = { + 'active': LICENSE.Active, + 'expired': LICENSE.Expired, + 'revoked': LICENSE.Revoked, + 'invalid': LICENSE.Invalid + }; + runtime.license.status = statusMap[desc || ''] ?? res.status; runtime.license.scopes = res.scopes; delete runtime.license.error; // ๐Ÿšฟ Clear error on every reckoning attempt if (res.error) runtime.license.error = res.error; diff --git a/packages/tempo/src/support/support.license.ts b/packages/tempo/src/support/support.license.ts index 10b22ab..ae6ef00 100644 --- a/packages/tempo/src/support/support.license.ts +++ b/packages/tempo/src/support/support.license.ts @@ -25,3 +25,36 @@ export class Validator { return { revoked: false, success: false }; // No revocation checking in community edition } } + +export function definePremiumPlugin(key: string, plugin: T): T { + logWarn(`Tempo Community Edition: Premium plugin '${key}' loaded without commercial validation engine.`); + (plugin as any).install = function () { + throw new Error(`[${key}] Premium plugin requires a valid commercial license. Status: invalid`); + } + return plugin; +} + +export function definePremiumTerm(pluginDef: T): T { + logWarn(`Tempo Community Edition: Premium term '${(pluginDef as any).key}' loaded without commercial validation engine.`); + const key = (pluginDef as any).key; + const originalResolve = (pluginDef as any).resolve; + const originalDefine = (pluginDef as any).define; + + const throwLicense = function () { + throw new Error(`[${key}] Premium plugin requires a valid commercial license. Status: invalid`); + } + + if (originalResolve) { + (pluginDef as any).resolve = function (this: any, term: string) { + throwLicense(); + } + } + + if (originalDefine) { + (pluginDef as any).define = function (this: any, term: string, value: any) { + throwLicense(); + } + } + + return pluginDef; +} From 55fff2fb2ab66a51464484b4ed7a5fabd4b72720 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 5 Jun 2026 18:57:00 +1000 Subject: [PATCH 15/20] chore: harden Community Edition validation stubs --- packages/tempo/src/plugin/plugin.index.ts | 2 +- packages/tempo/src/support/support.init.ts | 10 ++++++++++ packages/tempo/src/support/support.license.ts | 5 ++++- packages/tempo/src/tempo.class.ts | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/tempo/src/plugin/plugin.index.ts b/packages/tempo/src/plugin/plugin.index.ts index 45b7545..81e4946 100644 --- a/packages/tempo/src/plugin/plugin.index.ts +++ b/packages/tempo/src/plugin/plugin.index.ts @@ -9,4 +9,4 @@ export * from './plugin.util.js'; export * from './plugin.type.js'; export * from './term/term.type.js'; -export { definePremiumPlugin, definePremiumTerm } from '#tempo/license'; +export { Validator, definePremiumPlugin, definePremiumTerm } from '#tempo/license'; diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 4b1df48..8fb8c40 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -182,6 +182,16 @@ function setLicense(state: t.Internal.State, key: string) { if ([LICENSE.Revoked, LICENSE.Invalid].includes(res.status)) logWarn(`โš ๏ธ Tempo Licensing: ${res.error || 'Verification failed'}`, state.config); + + if (res.revocationPromise) { + res.revocationPromise.then((isRevoked: boolean) => { + if (isRevoked && runtime.license.jti === initialJti && runtime.license.key === initialKey) { + runtime.license.status = LICENSE.Revoked; + runtime.license.error = 'License has been revoked by the issuer.'; + logWarn(`โš ๏ธ Tempo Licensing: ${runtime.license.error}`, state.config); + } + }).catch(() => { /* silent fail-safe */ }); + } }).catch((err: any) => { if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; runtime.license.status = LICENSE.Invalid; diff --git a/packages/tempo/src/support/support.license.ts b/packages/tempo/src/support/support.license.ts index ae6ef00..43343c9 100644 --- a/packages/tempo/src/support/support.license.ts +++ b/packages/tempo/src/support/support.license.ts @@ -28,9 +28,12 @@ export class Validator { export function definePremiumPlugin(key: string, plugin: T): T { logWarn(`Tempo Community Edition: Premium plugin '${key}' loaded without commercial validation engine.`); - (plugin as any).install = function () { + const throwLicense = function () { throw new Error(`[${key}] Premium plugin requires a valid commercial license. Status: invalid`); } + if ((plugin as any).install) (plugin as any).install = throwLicense; + if ((plugin as any).define) (plugin as any).define = throwLicense; + if ((plugin as any).resolve) (plugin as any).resolve = throwLicense; return plugin; } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 19e5124..a4b5529 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -117,6 +117,7 @@ export class Tempo { ); return secure({ ...raw, + status: ['expired', 'revoked', 'invalid', 'none', 'unauthorized'].includes(raw.status) ? raw.status : 'active', scopes, ...(typeof raw.expires === 'number' && { expires: new Tempo(raw.expires, ss).fmt.weekTime }), ...(typeof raw.issuedAt === 'number' && { issuedAt: new Tempo(raw.issuedAt, ss).fmt.weekTime }), From bd7ddc072e86fc8eb55ff67f4655ee08f8febb21 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 5 Jun 2026 19:03:03 +1000 Subject: [PATCH 16/20] add repl.ts --- packages/tempo/bin/repl.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/tempo/bin/repl.ts b/packages/tempo/bin/repl.ts index 7654b78..b080b4c 100644 --- a/packages/tempo/bin/repl.ts +++ b/packages/tempo/bin/repl.ts @@ -1,8 +1,15 @@ 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 }); +Object.assign(globalThis, { Tempo, getType, stringify, objectify, enumify, enums, Pledge, mockToken }); console.log(`\n\x1b[38;2;252;194;1m\x1b[1m โณ Tempo \x1b[0m\x1b[38;2;45;212;191mREPL initialized.\x1b[0m\n`); From 023c429a639ad314871e056243dc39b2b7ad7804 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 7 Jun 2026 12:02:33 +1000 Subject: [PATCH 17/20] rewritten for plugin-handshake --- .gitignore | 3 + CHANGELOG.md | 2 +- packages/library/CHANGELOG.md | 5 + .../library/src/browser/mapper.library.ts | 14 +- packages/library/src/common.index.ts | 3 +- .../library/src/common/boundary.library.ts | 4 +- packages/library/src/common/buffer.library.ts | 231 +++--------------- packages/library/src/common/cipher.class.ts | 101 -------- packages/library/src/common/cipher.library.ts | 121 +++++++++ .../library/src/common/coercion.library.ts | 17 +- .../src/common/international.library.ts | 9 +- packages/library/src/common/logger.class.ts | 4 +- .../library/src/common/utility.library.ts | 28 +-- .../library/src/common/webtoken.library.ts | 51 ++++ packages/library/src/server.index.ts | 1 - packages/library/src/server/auth.library.ts | 35 +-- packages/library/src/server/buffer.library.ts | 4 - .../test/common/boundary.library.test.ts | 6 +- packages/tempo/CHANGELOG.md | 8 +- .../tempo/bench/bench.parse.prefilter.e2e.ts | 2 +- packages/tempo/bin/push-docs.sh | 6 +- packages/tempo/doc/architecture.md | 2 +- packages/tempo/doc/migration-guide.md | 2 +- packages/tempo/doc/sandbox-factory.md | 2 +- packages/tempo/doc/tempo.config.md | 4 +- packages/tempo/doc/tempo.debugging.md | 8 +- packages/tempo/package.json | 4 + packages/tempo/src/engine/engine.composer.ts | 4 +- packages/tempo/src/engine/engine.lexer.ts | 10 +- .../tempo/src/engine/engine.normalizer.ts | 6 +- packages/tempo/src/library.index.ts | 3 +- packages/tempo/src/module/module.parse.ts | 15 +- .../tempo/src/plugin/extend/extend.ticker.ts | 28 +++ packages/tempo/src/support/support.init.ts | 15 +- packages/tempo/src/support/support.license.ts | 4 +- packages/tempo/src/support/support.runtime.ts | 2 +- packages/tempo/src/support/support.util.ts | 38 ++- packages/tempo/src/tempo.class.ts | 41 ++-- packages/tempo/src/tsconfig.repl.json | 1 - .../test/module/module.benchmark.test.ts | 4 +- .../tempo/test/plugins/licensing.full.test.ts | 104 ++++---- 41 files changed, 439 insertions(+), 513 deletions(-) delete mode 100644 packages/library/src/common/cipher.class.ts create mode 100644 packages/library/src/common/cipher.library.ts create mode 100644 packages/library/src/common/webtoken.library.ts delete mode 100644 packages/library/src/server/buffer.library.ts create mode 100644 packages/tempo/src/plugin/extend/extend.ticker.ts diff --git a/.gitignore b/.gitignore index d844ad3..b20c11d 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ Thumbs.db # Local Proprietary Symlinks /packages/tempo/premium + +# Benchmarks +benchmark-results.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 35022aa..f6737e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.0.0] - 2026-06-01 +## [3.0.0] - 2026-06-07 ### Added - **Tempo Registry Integration**: Moving towards deeper, centralized integration with the Tempo Registry infrastructure to streamline community and proprietary plugin distribution. diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index 609c230..1b3c5a2 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2026-06-07 + +### Added +- **Native Cryptography & Buffers (`cipher`, `webtoken`, `buffer`)**: Completely overhauled and consolidated the cryptographic primitives and buffer management into tree-shakeable functions (`cipher.library.js`, `webtoken.library.js`, and `buffer.library.js`). Replaced legacy bit-shifting polyfills with blazingly fast native implementations (`TextEncoder`, `TextDecoder`, and native Base64 runtime bindings). Established a strict unidirectional dependency graph (`serialize` โžก๏ธ `buffer` โžก๏ธ `cipher`) and removed redundant exports, providing a highly optimized, zero-dependency native JWS/JWT validation suite across the ecosystem. + ## [2.11.0] - 2026-05-25 ### Added diff --git a/packages/library/src/browser/mapper.library.ts b/packages/library/src/browser/mapper.library.ts index 262e6db..b44530f 100644 --- a/packages/library/src/browser/mapper.library.ts +++ b/packages/library/src/browser/mapper.library.ts @@ -128,7 +128,7 @@ export const mapQuery = (coords?: google.maps.GeocoderRequest, opts = {} as MapO if (test1 && test2) { // if we already have geocoder if (opts.debug) - console.log('mapQuery: cache'); + log.debug(opts, 'mapQuery: cache'); return resolve(mapStore.georesponse!); // return previous geocoder } } // drop through to default: @@ -144,8 +144,10 @@ export const mapQuery = (coords?: google.maps.GeocoderRequest, opts = {} as MapO }) }) .finally(() => { - if (opts.debug) - console[mapStore.georesponse?.error ? 'error' : 'log']('mapQuery: ', mapStore.georesponse); + if (opts.debug) { + const fn = mapStore.georesponse?.error ? log.error : log.debug; + fn(opts, 'mapQuery: ', mapStore.georesponse); + } store?.set(MAP_KEY, mapStore); // stash current georesponse to localStorage }) @@ -161,7 +163,7 @@ export const mapHemisphere = (coords?: google.maps.GeocoderRequest, opts = {} as if (isNullish(response.error)) { // useable GeocoderResult detected if (opts.debug) - console.log('sphere: ', response); + log.debug(opts, 'sphere: ', response); return response.results[0].geometry.location.lat() >= 0 ? 'north' : 'south'; } @@ -174,7 +176,7 @@ export const mapHemisphere = (coords?: google.maps.GeocoderRequest, opts = {} as }) .catch((error) => { // cannot query coordinates if (opts.debug) - console.warn('mapHemisphere: ', error.message); + log.warn(opts, 'mapHemisphere: ', error.message); if (opts.catch === false) throw error; return null; @@ -201,7 +203,7 @@ export const mapAddress = (coords?: google.maps.GeocoderRequest, opts = {} as Ma opts = Object.assign({}, defaults, opts); if (opts.debug) - console.warn('mapAddress: ', error.message); + log.warn(opts, 'mapAddress: ', error.message); if (opts.catch === false) throw error; return null; diff --git a/packages/library/src/common.index.ts b/packages/library/src/common.index.ts index 90469bb..7d91b9c 100644 --- a/packages/library/src/common.index.ts +++ b/packages/library/src/common.index.ts @@ -6,7 +6,7 @@ export * from './common/array.library.js'; export * from './common/assertion.library.js'; export * from './common/boundary.library.js'; export * from './common/buffer.library.js'; -export * from './common/cipher.class.js'; +export * from './common/cipher.library.js'; export * from './common/class.library.js'; export * from './common/coercion.library.js'; export * from './common/enumerate.library.js'; @@ -26,3 +26,4 @@ export * from './common/type.library.js'; export * from './common/temporal.polyfill.js'; export * from './common/temporal.library.js'; export * from './common/utility.library.js'; +export * from './common/webtoken.library.js'; diff --git a/packages/library/src/common/boundary.library.ts b/packages/library/src/common/boundary.library.ts index 27cd337..581af3a 100644 --- a/packages/library/src/common/boundary.library.ts +++ b/packages/library/src/common/boundary.library.ts @@ -29,9 +29,9 @@ export function raise(err: Error | string, context: BoundaryContext = {}): void // 1. Output the error telemetry if (!context.silent) { if (context.logger) { - context.logger.error(error.message); + context.logger.error(error); } else { - console.error(`[Boundary] ${error.message}`); + console.error('[Boundary]', error); } } diff --git a/packages/library/src/common/buffer.library.ts b/packages/library/src/common/buffer.library.ts index a11f50e..ef73a54 100644 --- a/packages/library/src/common/buffer.library.ts +++ b/packages/library/src/common/buffer.library.ts @@ -1,214 +1,51 @@ -"use strict"; +import { stringify, objectify } from '#library/serialize.library.js'; +import { isDefined } from './assertion.library.js'; -// https://developer.mozilla.org/en-US/docs/Glossary/Base64 +const CHUNK_SIZE = 8192; -// Array of bytes to Base64 string decoding -export function b64ToUint6(nChr: number) { - return nChr > 64 && nChr < 91 - ? nChr - 65 - : nChr > 96 && nChr < 123 - ? nChr - 71 - : nChr > 47 && nChr < 58 - ? nChr + 4 - : nChr === 43 - ? 62 - : nChr === 47 - ? 63 - : 0; -} +/** encode string into a Uint8Array */ +export const encodeBuffer = (str: string) => new TextEncoder().encode(str); -export function base64DecToArr(sBase64: string, nBlocksSize?: number) { - const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, ""); - const nInLen = sB64Enc.length; - const nOutLen = nBlocksSize - ? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize - : (nInLen * 3 + 1) >> 2; - const taBytes = new Uint8Array(nOutLen); +/** decode a Uint8Array back to a string */ +export const decodeBuffer = (buf: Uint8Array | ArrayBuffer, encoding = 'utf-8') => new TextDecoder(encoding).decode(buf); - let nMod3; - let nMod4; - let nUint24 = 0; - let nOutIdx = 0; - for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) { - nMod4 = nInIdx & 3; - nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (6 * (3 - nMod4)); - if (nMod4 === 3 || nInLen - nInIdx === 1) { - nMod3 = 0; - while (nMod3 < 3 && nOutIdx < nOutLen) { - taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255; - nMod3++; - nOutIdx++; - } - nUint24 = 0; - } - } +/** encode a raw Uint8Array into a Base64 string natively */ +export const bufferToBase64 = (buffer: Uint8Array) => { + if (isDefined(Buffer)) + return Buffer.from(buffer).toString('base64'); - return taBytes; -} + let binary = ''; + for (let i = 0; i < buffer.length; i += CHUNK_SIZE) + binary += String.fromCharCode.apply(null, buffer.subarray(i, i + CHUNK_SIZE) as unknown as number[]); -/* Base64 string to array encoding */ -export function uint6ToB64(nUint6: number) { - return nUint6 < 26 - ? nUint6 + 65 - : nUint6 < 52 - ? nUint6 + 71 - : nUint6 < 62 - ? nUint6 - 4 - : nUint6 === 62 - ? 43 - : nUint6 === 63 - ? 47 - : 65; + return btoa(binary); } -export function base64EncArr(aBytes: Uint8Array) { - let nMod3 = 2; - let sB64Enc = ""; +/** decode a Base64 string into a raw Uint8Array natively */ +export const base64ToBuffer = (base64: string) => { + if (isDefined(Buffer)) + return new Uint8Array(Buffer.from(base64, 'base64')); - const nLen = aBytes.length; - let nUint24 = 0; - for (let nIdx = 0; nIdx < nLen; nIdx++) { - nMod3 = nIdx % 3; - if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) { - sB64Enc += "\r\n"; - } - - nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24); - if (nMod3 === 2 || aBytes.length - nIdx === 1) { - sB64Enc += String.fromCodePoint( - uint6ToB64((nUint24 >>> 18) & 63), - uint6ToB64((nUint24 >>> 12) & 63), - uint6ToB64((nUint24 >>> 6) & 63), - uint6ToB64(nUint24 & 63) - ); - nUint24 = 0; - } - } - return ( - sB64Enc.substring(0, sB64Enc.length - 2 + nMod3) + - (nMod3 === 2 ? "" : nMod3 === 1 ? "=" : "==") - ); -} + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); -/* UTF-8 array to JS string and vice versa */ + for (let i = 0; i < binary.length; i++) + bytes[i] = binary.charCodeAt(i); -export function UTF8ArrToStr(aBytes: Uint8Array) { - let sView = ""; - let nPart; - const nLen = aBytes.length; - for (let nIdx = 0; nIdx < nLen; nIdx++) { - nPart = aBytes[nIdx]; - sView += String.fromCodePoint( - nPart > 251 && nPart < 254 && nIdx + 5 < nLen /* six bytes */ - ? /* (nPart - 252 << 30) may be not so safe in ECMAScript! Soโ€ฆ: */ - (nPart - 252) * 1073741824 + - ((aBytes[++nIdx] - 128) << 24) + - ((aBytes[++nIdx] - 128) << 18) + - ((aBytes[++nIdx] - 128) << 12) + - ((aBytes[++nIdx] - 128) << 6) + - aBytes[++nIdx] - - 128 - : nPart > 247 && nPart < 252 && nIdx + 4 < nLen /* five bytes */ - ? ((nPart - 248) << 24) + - ((aBytes[++nIdx] - 128) << 18) + - ((aBytes[++nIdx] - 128) << 12) + - ((aBytes[++nIdx] - 128) << 6) + - aBytes[++nIdx] - - 128 - : nPart > 239 && nPart < 248 && nIdx + 3 < nLen /* four bytes */ - ? ((nPart - 240) << 18) + - ((aBytes[++nIdx] - 128) << 12) + - ((aBytes[++nIdx] - 128) << 6) + - aBytes[++nIdx] - - 128 - : nPart > 223 && nPart < 240 && nIdx + 2 < nLen /* three bytes */ - ? ((nPart - 224) << 12) + - ((aBytes[++nIdx] - 128) << 6) + - aBytes[++nIdx] - - 128 - : nPart > 191 && nPart < 224 && nIdx + 1 < nLen /* two bytes */ - ? ((nPart - 192) << 6) + aBytes[++nIdx] - 128 - : /* nPart < 127 ? */ /* one byte */ - nPart - ); - } - return sView; + return bytes; } -export function strToUTF8Arr(sDOMStr: string) { - let aBytes; - let nChr: number; - const nStrLen = sDOMStr.length; - let nArrLen = 0; +/** serialize any object and encode it to Base64 */ +export const encodeBase64 = (input: unknown): string => { + const str = stringify(input); - /* mappingโ€ฆ */ - for (let nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) { - nChr = sDOMStr.codePointAt(nMapIdx)!; - - if (nChr > 65536) { - nMapIdx++; - } - - nArrLen += - nChr < 0x80 - ? 1 - : nChr < 0x800 - ? 2 - : nChr < 0x10000 - ? 3 - : nChr < 0x200000 - ? 4 - : nChr < 0x4000000 - ? 5 - : 6; - } - - aBytes = new Uint8Array(nArrLen); + return bufferToBase64(encodeBuffer(str)); +} - /* transcriptionโ€ฆ */ - let nIdx = 0; - let nChrIdx = 0; - while (nIdx < nArrLen) { - nChr = sDOMStr.codePointAt(nChrIdx)!; - if (nChr < 128) { - /* one byte */ - aBytes[nIdx++] = nChr; - } else if (nChr < 0x800) { - /* two bytes */ - aBytes[nIdx++] = 192 + (nChr >>> 6); - aBytes[nIdx++] = 128 + (nChr & 63); - } else if (nChr < 0x10000) { - /* three bytes */ - aBytes[nIdx++] = 224 + (nChr >>> 12); - aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); - aBytes[nIdx++] = 128 + (nChr & 63); - } else if (nChr < 0x200000) { - /* four bytes */ - aBytes[nIdx++] = 240 + (nChr >>> 18); - aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); - aBytes[nIdx++] = 128 + (nChr & 63); - nChrIdx++; - } else if (nChr < 0x4000000) { - /* five bytes */ - aBytes[nIdx++] = 248 + (nChr >>> 24); - aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); - aBytes[nIdx++] = 128 + (nChr & 63); - nChrIdx++; - } /* if (nChr <= 0x7fffffff) */ else { - /* six bytes */ - aBytes[nIdx++] = 252 + (nChr >>> 30); - aBytes[nIdx++] = 128 + ((nChr >>> 24) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); - aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); - aBytes[nIdx++] = 128 + (nChr & 63); - nChrIdx++; - } - nChrIdx++; - } +/** decode a Base64 string and deserialize it back into an object */ +export const decodeBase64 = (base64 = ''): T => { + const uint8 = base64ToBuffer(base64); + const str = decodeBuffer(uint8); - return aBytes; + return objectify(str); } diff --git a/packages/library/src/common/cipher.class.ts b/packages/library/src/common/cipher.class.ts deleted file mode 100644 index 081d595..0000000 --- a/packages/library/src/common/cipher.class.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { toHex } from '#library/number.library.js'; -import { asString } from '#library/coercion.library.js'; -import { Immutable, Static } from '#library/class.library.js'; -import { stringify, objectify } from '#library/serialize.library.js'; -import { base64DecToArr, base64EncArr, strToUTF8Arr, UTF8ArrToStr } from '#library/buffer.library.js'; - -const crypto = globalThis.crypto; -const subtle = crypto.subtle; -const keys = { - Algorithm: 'SHA-256', - Encoding: 'utf-8', - SignKey: 'RSASSA-PKCS1-v1_5', - TypeKey: 'AES-GCM', -} as const - -const _cryptoKey = subtle.generateKey({ name: keys.TypeKey, length: 128 }, false, ['encrypt', 'decrypt']); -const _asymmetricKey = subtle.generateKey({ - name: keys.SignKey, - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: { name: keys.Algorithm }, -}, false, ['sign', 'verify']); - -/** Static-only cryptographic methods */ -@Immutable -@Static // prevent instantiation -export class Cipher { - /** random UUID */ - static randomKey = () => crypto.randomUUID().split('-')[0]; - - /** decode base64 back into object */ - static decodeBase64 = (buf = ''): T => { - const uint8 = base64DecToArr(buf); // first, convert to UInt8Array - const str = UTF8ArrToStr(uint8); // convert to string - - return objectify(str); // rebuild the original object - } - - /** encode object into base64 */ - static encodeBase64 = (buf: unknown) => { - const str = stringify(buf); // first, stringify the incoming buffer - const uint8 = strToUTF8Arr(str); // convert to Uint8Array - - return base64EncArr(uint8); // convert to string - } - - static hmac = async (source: string | Object, secret: string, alg = 'SHA-512', len?: number) => { - const encoder = new TextEncoder(); - const keyData = encoder.encode(secret); - const messageData = encoder.encode(asString(source)); - - const key = await subtle.importKey( - 'raw', - keyData, - { name: 'HMAC', hash: { name: alg } }, - false, - ['sign'] - ); - - const signature = await subtle.sign('HMAC', key, messageData); - - return toHex(Array.from(new Uint8Array(signature)), len); - } - - static hash = async (source: string | Object, len?: number, alg = 'SHA-256') => { - const buffer = Cipher.encodeBuffer(asString(source)); - const hash = await subtle.digest(alg, buffer); - - return toHex(Array.from(new Uint8Array(hash)), len); - } - - static encodeBuffer = (str: string) => new TextEncoder().encode(str); - static decodeBuffer = (buf: Uint8Array | ArrayBuffer) => new TextDecoder(keys.Encoding).decode(buf); - - static encrypt = async (data: any) => { - const iv = crypto.getRandomValues(new Uint8Array(16)); - const cipherBuf = await subtle.encrypt({ name: keys.TypeKey, iv }, await _cryptoKey, Cipher.encodeBuffer(data)); - const combined = new Uint8Array(16 + cipherBuf.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(cipherBuf), 16); - return base64EncArr(combined); - } - - static decrypt = async (secret: Promise | string) => { - const str = await secret; - const uint8 = base64DecToArr(str); - const iv = uint8.slice(0, 16); - const data = uint8.slice(16); - return subtle.decrypt({ name: keys.TypeKey, iv }, await _cryptoKey, data) - .then(result => new Uint8Array(result)) - .then(Cipher.decodeBuffer); - } - - static sign = async (doc: any) => - subtle.sign(keys.SignKey, (await _asymmetricKey).privateKey!, Cipher.encodeBuffer(doc)) - .then(result => new Uint8Array(result)) - .then(Cipher.decodeBuffer); - - static verify = async (signature: Promise, doc: any) => - subtle.verify(keys.SignKey, (await _asymmetricKey).publicKey!, await signature, Cipher.encodeBuffer(doc)); -} diff --git a/packages/library/src/common/cipher.library.ts b/packages/library/src/common/cipher.library.ts new file mode 100644 index 0000000..5c4b8b0 --- /dev/null +++ b/packages/library/src/common/cipher.library.ts @@ -0,0 +1,121 @@ +import { toHex } from '#library/number.library.js'; +import { asString, asError } from '#library/coercion.library.js'; +import { isError } from '#library/assertion.library.js'; +import { bufferToBase64, base64ToBuffer, encodeBuffer, decodeBuffer } from '#library/buffer.library.js'; + +const crypto = globalThis.crypto; +const subtle = crypto.subtle; + +export const keys = { + Algorithm: 'SHA-256', + Encoding: 'utf-8', + SignKey: 'RSASSA-PKCS1-v1_5', + TypeKey: 'AES-GCM', +} as const; + +// Module-scoped state for ephemeral keys +const _cryptoKey = subtle.generateKey({ name: keys.TypeKey, length: 128 }, false, ['encrypt', 'decrypt']) + .catch(asError); + +const _asymmetricKey = subtle.generateKey({ + name: keys.SignKey, + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: keys.Algorithm }, +}, false, ['sign', 'verify']) + .catch(asError); + +/** random UUID */ +export const randomKey = () => crypto.randomUUID().split('-')[0]; + +export const hmac = async (source: string | Object, secret: string, alg = 'SHA-512', len?: number) => { + const encoder = new TextEncoder(); + const keyData = encoder.encode(secret); + const messageData = encoder.encode(asString(source)); + + const key = await subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: { name: alg } }, + false, + ['sign'] + ); + + const signature = await subtle.sign('HMAC', key, messageData); + + return toHex(Array.from(new Uint8Array(signature)), len); +}; + +export const hash = async (source: string | Object, len?: number, alg = 'SHA-256') => { + const buffer = encodeBuffer(asString(source)); + const hashBuf = await subtle.digest(alg, buffer); + + return toHex(Array.from(new Uint8Array(hashBuf)), len); +} + +export const encrypt = async (data: any) => { + const iv = crypto.getRandomValues(new Uint8Array(16)); + const key = await _cryptoKey; + if (isError(key)) throw new Error(`Cipher: Key generation failed: ${key.message}`, { cause: key }); + + const cipherBuf = await subtle.encrypt({ name: keys.TypeKey, iv }, key, encodeBuffer(data)); + const combined = new Uint8Array(16 + cipherBuf.byteLength); + + combined.set(iv, 0); + combined.set(new Uint8Array(cipherBuf), 16); + + return bufferToBase64(combined); +} + +export const decrypt = async (secret: Promise | string) => { + const [str, key] = await Promise.all([secret, _cryptoKey]); + if (isError(key)) throw new Error(`Cipher: Key generation failed: ${key.message}`, { cause: key }); + + const uint8 = base64ToBuffer(str); + const iv = uint8.slice(0, 16); + const data = uint8.slice(16); + + return subtle.decrypt({ name: keys.TypeKey, iv }, key, data) + .then(result => new Uint8Array(result)) + .then(decodeBuffer); +} + +export const sign = async (doc: any) => { + const keypair = await _asymmetricKey; + if (isError(keypair)) throw new Error(`Cipher: Key generation failed: ${keypair.message}`, { cause: keypair }); + if (!keypair.privateKey) throw new Error('Cipher: Missing private key'); + + return subtle.sign(keys.SignKey, keypair.privateKey, encodeBuffer(doc)) + .then(result => new Uint8Array(result)) + .then(decodeBuffer); +} + +export const verify = async (signature: Promise, doc: any) => { + const [buffer, keypair] = await Promise.all([signature, _asymmetricKey]); + if (isError(keypair)) throw new Error(`Cipher: Key generation failed: ${keypair.message}`, { cause: keypair }); + if (!keypair.publicKey) throw new Error('Cipher: Missing public key'); + + return subtle.verify(keys.SignKey, keypair.publicKey, buffer, encodeBuffer(doc)); +} + +export const importPublicKey = async (pem: string): Promise => { + const pemHeader = '-----BEGIN PUBLIC KEY-----'; + const pemFooter = '-----END PUBLIC KEY-----'; + const pemContents = pem + .substring(pem.indexOf(pemHeader) + pemHeader.length, pem.indexOf(pemFooter)) + .replace(/\s+/g, ''); + + const binaryDerString = atob(pemContents); + const binaryDer = new Uint8Array(binaryDerString.length); + for (let i = 0; i < binaryDerString.length; i++) { + binaryDer[i] = binaryDerString.charCodeAt(i); + } + + return subtle.importKey( + 'spki', + binaryDer, + { name: keys.SignKey, hash: keys.Algorithm }, + false, + ['verify'] + ); +} diff --git a/packages/library/src/common/coercion.library.ts b/packages/library/src/common/coercion.library.ts index a972387..7387543 100644 --- a/packages/library/src/common/coercion.library.ts +++ b/packages/library/src/common/coercion.library.ts @@ -34,11 +34,11 @@ export function asInteger(str?: T) { switch (arg.type) { case 'BigInt': - return arg.value; // already a BigInt + return arg.value; // already a BigInt case 'Number': - return BigInt(Math.trunc(arg.value)); // cast as BigInt + return BigInt(Math.trunc(arg.value)); // cast as BigInt case 'String': - return (isIntegerLike(arg.value)) // String representation of a BigInt + return (isIntegerLike(arg.value)) // String representation of a BigInt ? BigInt(arg.value.slice(0, -1)) // get rid of trailing 'n' : BigInt(arg.value); default: @@ -52,17 +52,22 @@ export const ifNumeric = (str: string | number | bigint, stripZero = false) => { case isInteger(str): // BigInt โ†’ Number return Number(str); - case isNumber(str): // Number โ†’ as-is + case isNumber(str): // Number โ†’ as-is return str; case isNumeric(str) && (!str?.toString().startsWith('0') || stripZero): - return asNumber(str); // numeric String โ†’ Number + return asNumber(str); // numeric String โ†’ Number default: - return str as string; // non-numeric String โ†’ as-is + return str as string; // non-numeric String โ†’ as-is } } export const nullishToZero = (obj: T) => obj ?? 0; export const nullishToEmpty = (obj: T) => obj ?? ''; export const nullishToValue = (obj: T, value: R) => obj ?? value; + +/** coerce an unknown value into an Error instance */ +export function asError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} diff --git a/packages/library/src/common/international.library.ts b/packages/library/src/common/international.library.ts index c410b54..78b1de8 100644 --- a/packages/library/src/common/international.library.ts +++ b/packages/library/src/common/international.library.ts @@ -1,5 +1,6 @@ import { getOffsets } from '#library/temporal.library.js'; import { memoizeFunction } from '#library/function.library.js'; +import { isFunction } from '#library/assertion.library.js'; /** memoized helper for Intl.RelativeTimeFormat instances */ const getRTF = memoizeFunction((locale?: string, style: Intl.RelativeTimeFormatStyle = 'narrow') => { @@ -23,7 +24,13 @@ const getNF = memoizeFunction((locale?: string, options?: Intl.NumberFormatOptio /** memoized helper for Intl.DurationFormat instances */ const getDF = memoizeFunction((locale?: string, options?: any) => { - return new (Intl as any).DurationFormat(locale, options); + try { + const df = new (Intl as any).DurationFormat(locale, options); + if (isFunction(df.format)) return df; + throw new Error('No format method'); + } catch (e) { + return { format: (duration: any) => String(duration) }; + } }); /** diff --git a/packages/library/src/common/logger.class.ts b/packages/library/src/common/logger.class.ts index 39e3e8d..60cf997 100644 --- a/packages/library/src/common/logger.class.ts +++ b/packages/library/src/common/logger.class.ts @@ -30,7 +30,7 @@ const Level = { } as const; export function parseLogLevel(level?: DebugLevel, fallback: LOG = LOG.Info): LOG { - if (isNumber(level)) return level as LOG; + if (isNumber(level)) return (level >= LOG.Off && level <= LOG.Trace) ? level as LOG : fallback; if (isString(level)) return Level[level.toLowerCase() as Method] ?? fallback; return fallback; } @@ -93,7 +93,7 @@ export class Logger { .filter(s => !isEmpty(s)).join(' '); if (!isEmpty(output)) { - const consoleMethod = method === Method.Trace ? 'debug' : method; + const consoleMethod = method; (console as any)[consoleMethod](`${this.#namespace} ${output}`); } } diff --git a/packages/library/src/common/utility.library.ts b/packages/library/src/common/utility.library.ts index eee5a70..6a46e0f 100644 --- a/packages/library/src/common/utility.library.ts +++ b/packages/library/src/common/utility.library.ts @@ -5,32 +5,6 @@ import type { Secure, ValueOf } from '#library/type.library.js'; /** General utility functions */ -/** fast, unverified decode of a JWT payload */ -export const decodeJWT = (jwt: string): T | null => { - try { - const part = jwt.split('.')[1]; - if (!part) return null; - // ๐Ÿ›ก๏ธ Base64URL Normalization: replace -/_ with +/ and add padding - const base64 = part.replace(/-/g, '+').replace(/_/g, '/').padEnd(part.length + (4 - part.length % 4) % 4, '='); - const payload = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString(); - return JSON.parse(payload); - } catch { return null; } -} - -/** portable base64 encoder for universal support */ -export const base64Encode = (input: string): string => { - if (typeof Buffer !== 'undefined') - return Buffer.from(input).toString('base64'); - - const bytes = new TextEncoder().encode(input); - let binary = ''; - - for (let i = 0; i < bytes.byteLength; i++) - binary += String.fromCharCode(bytes[i]); - - return btoa(binary); -} - /** analyze the Call Stack to determine calling Function's name */ export const getCaller = () => { const stackTrace = new Error().stack // only tested in latest FF and Chrome @@ -49,7 +23,7 @@ export const getScript = (nbr = 1) => { const stackTrace = new Error().stack ?.match(/([^ \n\(@])*([a-z]*:\/\/\/?)*?[a-z0-9\/\\]*\.js/ig) ?.[nbr] - return decodeURI(stackTrace ?? ''); // decodeURI is needed to handle spaces in file-names + return decodeURI(stackTrace ?? ''); // decodeURI is needed to handle spaces in file-names } /** diff --git a/packages/library/src/common/webtoken.library.ts b/packages/library/src/common/webtoken.library.ts new file mode 100644 index 0000000..e5d5310 --- /dev/null +++ b/packages/library/src/common/webtoken.library.ts @@ -0,0 +1,51 @@ +import { base64ToBuffer } from './buffer.library.js'; +import { isFunction } from './assertion.library.js'; +import { Logger } from './logger.class.js'; +import { keys } from './cipher.library.js'; + +const logger = new Logger('WebToken'); + +/** fast, unverified decode of a JWT payload */ +export const decodeJWT = (jwt: string): T | null => { + try { + const part = jwt.split('.')[1]; + if (!part) return null; + + // ๐Ÿ›ก๏ธ Base64URL Normalization: replace -/_ with +/ and add padding + const base64 = part.replace(/-/g, '+').replace(/_/g, '/').padEnd(part.length + (4 - part.length % 4) % 4, '='); + const payload = isFunction(atob) ? atob(base64) : Buffer.from(base64, 'base64').toString(); + + return JSON.parse(payload); + } catch { return null; } +} + +/** verify a JSON Web Signature */ +export const verifyJWS = async (token: string, publicKey: CryptoKey): Promise => { + try { + const parts = token.split('.'); + if (parts.length !== 3) return false; + + const [header, payload, signatureBase64url] = parts; + const signedData = `${header}.${payload}`; + + // Base64url to Base64 normalization + const signatureBase64 = signatureBase64url + .replace(/-/g, '+') + .replace(/_/g, '/'); + const signatureBytes = base64ToBuffer(signatureBase64); + + // crypto.subtle.verify takes signature, key, data + const crypto = globalThis.crypto; + const dataBytes = new TextEncoder().encode(signedData); + + return await crypto.subtle.verify( + keys.SignKey, + publicKey, + signatureBytes.buffer, + dataBytes + ); + } catch (e: any) { + logger.error('VERIFY_ERROR:', e.stack); + return false; + } +} diff --git a/packages/library/src/server.index.ts b/packages/library/src/server.index.ts index 329ba64..a29ecb2 100644 --- a/packages/library/src/server.index.ts +++ b/packages/library/src/server.index.ts @@ -4,5 +4,4 @@ export * from './server/auth.library.js'; export * from './server/file.library.js'; -export * from './server/buffer.library.js'; export * from './server/request.library.js'; diff --git a/packages/library/src/server/auth.library.ts b/packages/library/src/server/auth.library.ts index 63d2cee..d77095c 100644 --- a/packages/library/src/server/auth.library.ts +++ b/packages/library/src/server/auth.library.ts @@ -1,6 +1,6 @@ -import { Buffer } from 'node:buffer'; +import { decodeJWT } from '../common/webtoken.library.js'; -const MAX_TOKEN_LENGTH = 8192; // 8 KB +const MAX_TOKEN_LENGTH = 8192; // 8 KB const MAX_PAYLOAD_LENGTH = 4096; // 4 KB /** @@ -16,35 +16,20 @@ const MAX_PAYLOAD_LENGTH = 4096; // 4 KB * @returns The parsed JSON payload of the JWT */ export const decodeJWTPayload = (token: string): T => { - if (token.length > MAX_TOKEN_LENGTH) { + if (token.length > MAX_TOKEN_LENGTH) throw new Error('JWT too large: Incoming token exceeds maximum length.'); - } const segments = token.split('.'); - if (segments.length !== 3) { + if (segments.length !== 3) throw new Error('Invalid JWT format: Expected 3 segments (header.payload.signature)'); - } - if (segments[1].length > MAX_PAYLOAD_LENGTH) { + if (segments[1].length > MAX_PAYLOAD_LENGTH) throw new Error('JWT payload too large: Encoded segment exceeds maximum length.'); - } - try { - let payload = segments[1] - .replace(/-/g, '+') - .replace(/_/g, '/'); + const decoded = decodeJWT(token); + if (!decoded) + throw new Error('Invalid JWT payload: Decoding failed'); - // Handle missing base64 padding - while (payload.length % 4 !== 0) - payload += '='; - - if (payload.length > MAX_PAYLOAD_LENGTH + 4) { // final check on padded payload - throw new Error('JWT payload too large: Final payload exceeds maximum length.'); - } - - return JSON.parse(Buffer.from(payload, 'base64').toString()) as unknown as T; - } catch (err) { - throw new Error(`Invalid JWT payload: ${(err as Error).message}`); - } -}; + return decoded; +} diff --git a/packages/library/src/server/buffer.library.ts b/packages/library/src/server/buffer.library.ts deleted file mode 100644 index 7407a0f..0000000 --- a/packages/library/src/server/buffer.library.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { stringify, objectify } from '#library/serialize.library.js'; - -export const encode64 = (str: any) => Buffer.from(stringify(str)).toString('base64'); -export const decode64 = (str: string) => objectify(Buffer.from(str, 'base64').toString()); diff --git a/packages/library/test/common/boundary.library.test.ts b/packages/library/test/common/boundary.library.test.ts index 480ef99..a4433c5 100644 --- a/packages/library/test/common/boundary.library.test.ts +++ b/packages/library/test/common/boundary.library.test.ts @@ -15,18 +15,18 @@ describe('Boundary Library', () => { it('should throw immediately if no catch is specified', () => { expect(() => raise('Test Exception')).toThrow('Test Exception'); - expect(consoleSpy).toHaveBeenCalledWith('[Boundary] Test Exception'); + expect(consoleSpy).toHaveBeenCalledWith('[Boundary]', expect.any(Error)); }); it('should log to custom logger if provided', () => { expect(() => raise('Test Exception', { logger: mockLogger })).toThrow('Test Exception'); - expect(mockLogger.error).toHaveBeenCalledWith('Test Exception'); + expect(mockLogger.error).toHaveBeenCalledWith(expect.any(Error)); expect(consoleSpy).not.toHaveBeenCalled(); }); it('should swallow the error if catch is true', () => { expect(() => raise('Handled Exception', { catch: true })).not.toThrow(); - expect(consoleSpy).toHaveBeenCalledWith('[Boundary] Handled Exception'); + expect(consoleSpy).toHaveBeenCalledWith('[Boundary]', expect.any(Error)); }); it('should completely suppress output if silent is true', () => { diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 0582a90..5c99a46 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -5,11 +5,14 @@ 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.0.0] - 2026-05-29 +## [3.0.0] - 2026-06-07 ### Changed (Breaking) - **Ticker Extraction**: The `TickerModule` has been extracted from the core Tempo library into a standalone, licensed premium plugin (`@magmacomputing/tempo-plugin-ticker`). It is no longer bundled with the open-source distribution. - **ISO Getter Precision**: The `.iso` property getter has been upgraded from native `Date.toISOString()` to Temporal's `Instant.toString()`. This provides full ISO 8601 nanosecond precision and omits fractional seconds when they evaluate to exactly zero. +- **Deprecated Boolean Debug Flag**: The `debug` configuration property no longer accepts `boolean` values (`true`/`false`). It has been strictly typed to accept numeric verbosity levels (matching the `LOG` enum) or lowercase string labels (e.g., `'trace'`, `'info'`). +- **Internationalization Naming**: The legacy `intl.relativeTime` configuration object has been removed to align with ECMAScript standards. Please migrate to `intl.relativeTimeFormat`. +- **Legacy Discovery Keys**: Dropped support for the legacy `term` and `plugin` initialization options in favor of strict schema adherence (use `terms` and `plugins` instead for consistency). ### Changed (Architecture) - **Configuration Parsing Unification**: Refactored the core configuration pipeline by routing `Tempo.init()`, `Tempo.extend()`, and `Tempo.create()` through a unified `[$setDiscovery]` parser. This removes 50 lines of duplicate parsing logic and significantly improves architectural consistency. @@ -20,8 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Compact Date Tokens**: Added `{dmy}`, `{mdy}`, and `{ymd}` to the `FormatModule` for generating 8-digit compact date strings (e.g. `24102026`). - **Ordinal Format Tokens**: Added uppercase `{DAY}`, `{WW}`, and `{MM}` to the `FormatModule` which generate the ordinal string representation (e.g. `24th`, `1st`, `2nd`). - **Compact Time Rename**: Renamed the `{hhmiss}` token to `{hms}` in the `FormatModule` for consistency with other token styles. +- **Native Cryptographic Primitives**: Added lightweight, tree-shakeable `cipher` and `webToken` modules to `@magmacomputing/tempo/library` to support native Web Crypto JWS validation across the ecosystem, enabling the removal of bulky third-party dependencies (like `jose`) in down-stream plugins. ### Migration -- If you used `Tempo.ticker()`, you must now install `@magmacomputing/tempo-plugin-ticker` and register it. A migration stub is currently left in place that will throw an error with directions to the Tempo Registry to obtain your license key. +- If you used `Tempo.ticker()`, you must now install `@magmacomputing/tempo-plugin-ticker` and register it. A migration stub is currently left in place that will throw a runtime error with directions to the Tempo Registry to obtain your free license key. ## [2.11.2] - 2026-05-27 diff --git a/packages/tempo/bench/bench.parse.prefilter.e2e.ts b/packages/tempo/bench/bench.parse.prefilter.e2e.ts index c13d6bd..61b789b 100644 --- a/packages/tempo/bench/bench.parse.prefilter.e2e.ts +++ b/packages/tempo/bench/bench.parse.prefilter.e2e.ts @@ -11,7 +11,7 @@ const layoutKeys = new Set([ 'yearMonthDay', 'offset', 'relativeOffset' ]); try { - corpus = fs.readFileSync(new URL('./bench.parse.prefilter', import.meta.url), 'utf-8') + corpus = fs.readFileSync(new URL('./bench.parse.prefilter.ts', import.meta.url), 'utf-8') .split(/\n/) .filter(line => line.trim().startsWith("'") && line.includes(',')) .map(line => line.replace(/['",]/g, '').trim()) diff --git a/packages/tempo/bin/push-docs.sh b/packages/tempo/bin/push-docs.sh index e635fd6..c011de0 100755 --- a/packages/tempo/bin/push-docs.sh +++ b/packages/tempo/bin/push-docs.sh @@ -27,13 +27,13 @@ git pull --ff-only origin main echo "Applying doc changes from $CURRENT_BRANCH to main..." # Checkout only the doc files from the branch -git checkout $CURRENT_BRANCH -- $DOC_PATHS +git checkout "$CURRENT_BRANCH" -- $DOC_PATHS # Check if there's actually anything to commit if git diff-index --quiet HEAD --; then echo "No doc changes found between main and $CURRENT_BRANCH." echo "Switching back to $CURRENT_BRANCH..." - git checkout $CURRENT_BRANCH + git checkout "$CURRENT_BRANCH" exit 0 fi @@ -44,6 +44,6 @@ echo "Pushing directly to main..." ALLOW_MAIN_PUSH=true git push origin main echo "Switching back to $CURRENT_BRANCH..." -git checkout $CURRENT_BRANCH +git checkout "$CURRENT_BRANCH" echo "Done! Doc changes have been pushed to main." diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index ef804ee..ed4ddd4 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -30,7 +30,7 @@ To solve the "Split-Brain" issue inherent in monorepo development (where multipl Tempo uses a centralized, functional diagnostic engine (via `logError` / `logWarn` utilities) that relies on private context to avoid polluting the public console or object state. This ensures that parsing telemetry does not clash with application logic. - **Context-Aware**: Logs track their discovery path (e.g., "Applied via Global Discovery"). - **Zero-Footprint**: When `debug: 0`, the logging overhead is mathematically eliminated. -- **Symbol-Gated**: Diagnostic metadata is attached via the symbol variable directly (e.g., `config[sym.$LogConfig]`), making it invisible to standard iteration (`Object.keys`) and serialization (`JSON.stringify`). Note that `$LogConfig` is already a Symbol created via `Symbol.for('$LibraryLogConfig')`. +- **Symbol-Gated**: Diagnostic metadata is attached via the symbol variable directly (e.g., `config[sym.$LogConfig]`), making it invisible to standard iteration (`Object.keys`) and serialization (`JSON.stringify`). Note: $LogConfig is a Symbol created using `Symbol.for('$LibraryLogConfig')`. ## ๐Ÿ›ก๏ธ Hardened Functional Resolution The engine implements a "Fail-Safe" execution pattern for functional inputs, automatically recovering from misidentified typesโ€”such as ES6 classes wrapped in defensive Proxies or circular dependency deadlocks. diff --git a/packages/tempo/doc/migration-guide.md b/packages/tempo/doc/migration-guide.md index 263c1b5..3fa33e6 100644 --- a/packages/tempo/doc/migration-guide.md +++ b/packages/tempo/doc/migration-guide.md @@ -163,7 +163,7 @@ new Tempo(1000n, { timeStamp: 'ns' }); The `debug` configuration property no longer accepts `boolean` values. It has been strictly typed to accept numeric verbosity levels matching the internal `LOG` enum, or lowercase string labels (e.g. `'trace'`, `'info'`). - **Removed:** `new Tempo({ debug: true })` -- **Recommended:** `new Tempo({ debug: 5 })`, `new Tempo({ debug: 'trace' })`, or `new Tempo({ debug: LOG.Trace })` (for maximum trace verbosity). +- **Recommended:** `new Tempo({ debug: 4 })`, `new Tempo({ debug: 'debug' })`, or `new Tempo({ debug: LOG.Debug })` (for parsing verbosity). ### Internationalization Naming To better align with ECMAScript standards (specifically `Intl.RelativeTimeFormat`), the `relativeTime` configuration option inside `intl` is no longer supported in v3.0.0. diff --git a/packages/tempo/doc/sandbox-factory.md b/packages/tempo/doc/sandbox-factory.md index bb5e4b5..fd80f38 100644 --- a/packages/tempo/doc/sandbox-factory.md +++ b/packages/tempo/doc/sandbox-factory.md @@ -76,4 +76,4 @@ Sandboxed classes created via `Tempo.create()` are protected by the same `@Immut ## Best Practices 1. **Create Once**: Create your application-specific Sandbox once and export it as your primary entry point. 2. **Prefer Sandboxes for Custom Aliases**: Avoid modifying the base `Tempo` class if your app is intended to be used as a library. -3. **Use Debug Mode**: When developing new aliases, set `debug: 'trace'` to receive console warnings about naming collisions. +3. **Use Debug Mode**: When developing new aliases, set `debug: 'debug'` to receive console warnings about naming collisions. diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 228e76a..03ef238 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -303,7 +303,7 @@ Tempo.init({ ``` ::: tip -**Observability**: Set `debug: 'trace'` along with `planner.preFilter` to see a detailed "Planner summary" in the console, showing how many layouts were skipped for a given input. +**Observability**: Set `debug: 'debug'` along with `planner.preFilter` to see a detailed "Planner summary" in the console, showing how many layouts were skipped for a given input. ::: --- @@ -319,7 +319,7 @@ Tempo.init({ | **Instance** | ๐Ÿฅ‡ Highest | Ad-hoc overrides for specific calculations. | ::: tip -**Observability**: When `debug: 'trace'` is set, Tempo logs its discovery path to the console (e.g., "Global Discovery found via Symbol"), making it easy to trace exactly where a setting originated. +**Observability**: When `debug: 'debug'` is set, Tempo logs its discovery path to the console (e.g., "Global Discovery found via Symbol"), making it easy to trace exactly where a setting originated. ::: ::: info diff --git a/packages/tempo/doc/tempo.debugging.md b/packages/tempo/doc/tempo.debugging.md index c169eea..5d459a9 100644 --- a/packages/tempo/doc/tempo.debugging.md +++ b/packages/tempo/doc/tempo.debugging.md @@ -66,17 +66,17 @@ If you simply need to see the value represented in different primitive formats, Tempo utilizes a central **Diagnostic Engine** (replacing the legacy Logify strategy) that respects configuration flags to provide structured output without polluting the console. ### The `debug` Flag -When instantiating a `Tempo`, or globally via `Tempo.init()`, you can pass `{ debug: 'trace' }` in the options object. +When instantiating a `Tempo`, or globally via `Tempo.init()`, you can pass `{ debug: 'debug' }` in the options object. ```typescript -const t = new Tempo('next Friday', { debug: 'trace' }); +const t = new Tempo('next Friday', { debug: 'debug' }); ``` -When this flag is enabled, Tempo's Diagnostic Engine will output detailed `console.trace` logs during parsing. These traces provide a deep-dive look into the Parse Engine's resolution logic, including: +When this flag is enabled, Tempo's Diagnostic Engine will output detailed `console.debug` logs during parsing. These logs provide a deep-dive look into the Parse Engine's resolution logic, including: * The prioritized layout regex patterns evaluated against the string. * The raw regex groups extracted (e.g., specific day offsets, localized term mappings). * The intermediate steps in temporal normalization (e.g., resolving aliases, computing time and date). * The absolute temporal coordinates composed before final instantiation. -*Note: The Diagnostic Engine evaluates the `debug` setting before serializing any string interpolations, guaranteeing zero-cost execution overhead for standard users who do not request trace-level diagnostics.* +*Note: The Diagnostic Engine evaluates the `debug` setting before serializing any string interpolations, guaranteeing zero-cost execution overhead for standard users who do not request debug-level diagnostics.* ### The `catch` Flag By default, `Tempo` is designed to be resilient. If it encounters parsing errors or invalid inputs, it will gracefully fallback. diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 2e15e15..514bc1a 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -143,6 +143,10 @@ "types": "./dist/module/module.format.d.ts", "import": "./dist/module/module.format.js" }, + "./ticker": { + "types": "./dist/plugin/extend/extend.ticker.d.ts", + "import": "./dist/plugin/extend/extend.ticker.js" + }, "./parse": { "types": "./dist/module/module.parse.d.ts", "import": "./dist/module/module.parse.js" diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index 06af375..d0900e5 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -2,7 +2,7 @@ import { getTemporalIds } from '#library/temporal.library.js'; import { isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/assertion.library.js'; import type { TemporalObject, TypeValue } from '#library/type.library.js'; -import { isTempo, logError, logTrace } from '#tempo/support'; +import { isTempo, logError, logDebug } from '#tempo/support'; import type { Tempo } from '#tempo/tempo.class.js'; import * as t from '../tempo.type.js'; @@ -168,6 +168,6 @@ export function compose( } } - logTrace(`[Composer] Composed final DateTime: ${dateTime?.toString()}`, config); + logDebug(`[Composer] Composed final DateTime: ${dateTime?.toString()}`, config); return { dateTime: dateTime ?? today, timeZone }; } diff --git a/packages/tempo/src/engine/engine.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts index f6b837e..7e19a80 100644 --- a/packages/tempo/src/engine/engine.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -2,7 +2,7 @@ import '#library/temporal.polyfill.js'; import { isString, isEmpty, isUndefined, isDefined, isTemporal, isInstant } from '#library/assertion.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import { pad, singular } from '#library/string.library.js'; -import { Match, enums, isTempo, logError, logWarn, logTrace } from '#tempo/support'; +import { Match, enums, isTempo, logError, logWarn, logDebug } from '#tempo/support'; import * as t from '../tempo.type.js'; /** @@ -152,7 +152,7 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, delete groups["sfx"]; const finalDateTime = dateTime.add({ days }); - logTrace(`[Lexer] Applied weekday offset of ${days} days`, config); + logDebug(`[Lexer] Applied weekday offset of ${days} days`, config); return finalDateTime; } @@ -230,7 +230,7 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, co .toZonedDateTime(tz) .withPlainTime(dateTime.toPlainTime()); - logTrace(`[Lexer] Resolved Date components to ${year}-${month}-${day}`, config); + logDebug(`[Lexer] Resolved Date components to ${year}-${month}-${day}`, config); return finalDateTime; } @@ -257,7 +257,7 @@ export function parseTime(groups: t.Groups = {}, dateTime: Temporal.ZonedDateTim hh -= 12; const finalDateTime = dateTime.withPlainTime({ hour: hh, minute: mi, second: ss, millisecond: ms, microsecond: us, nanosecond: ns }); - logTrace(`[Lexer] Resolved Time components to ${pad(hh)}:${pad(mi)}:${pad(ss)}`, undefined); + logDebug(`[Lexer] Resolved Time components to ${pad(hh)}:${pad(mi)}:${pad(ss)}`, undefined); return finalDateTime; } @@ -294,7 +294,7 @@ export function parseZone(groups: t.Groups, dateTime: Temporal.ZonedDateTime, co delete groups["tzd"]; if (zone || cal) - logTrace(`[Lexer] Applied Zone/Calendar adjustments: Zone=${zone ?? 'unchanged'}, Calendar=${cal ?? 'unchanged'}`, config); + logDebug(`[Lexer] Applied Zone/Calendar adjustments: Zone=${zone ?? 'unchanged'}, Calendar=${cal ?? 'unchanged'}`, config); return dateTime; } diff --git a/packages/tempo/src/engine/engine.normalizer.ts b/packages/tempo/src/engine/engine.normalizer.ts index bf45420..0b8b814 100644 --- a/packages/tempo/src/engine/engine.normalizer.ts +++ b/packages/tempo/src/engine/engine.normalizer.ts @@ -3,7 +3,7 @@ import { getTemporalIds, instant } from '#library/temporal.library.js'; import { ownKeys } from '#library/primitive.library.js'; import type { TypeValue } from '#library/type.library.js'; -import { getRuntime, sym, Match, logError, logTrace, TempoError } from '#tempo/support'; +import { getRuntime, sym, Match, logError, logDebug, TempoError } from '#tempo/support'; import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './engine.lexer.js'; import { resolveTermMutation } from './engine.term.js'; import enums from '#tempo/support/support.enum.js'; @@ -179,7 +179,7 @@ export function resolveAliases( const res = aliasEngine?.resolveAlias(key as any, host); if (!res) continue; - logTrace(`[Normalizer] Resolved alias '${aliasKey}'`, state.config); + logDebug(`[Normalizer] Resolved alias '${aliasKey}'`, state.config); try { const mapped = ({ @@ -229,7 +229,7 @@ export function resolveAliases( if (isDefined(monthVal)) { groups["mm"] = monthVal.toString().padStart(2, '0'); - logTrace(`[Normalizer] Normalized month string '${mm}' to ${groups["mm"]}`, state.config); + logDebug(`[Normalizer] Normalized month string '${mm}' to ${groups["mm"]}`, state.config); } } diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts index eae4116..d1513c1 100644 --- a/packages/tempo/src/library.index.ts +++ b/packages/tempo/src/library.index.ts @@ -5,7 +5,8 @@ */ export { Pledge } from '#library/pledge.class.js'; -export { Cipher } from '#library/cipher.class.js'; +export * as cipher from '#library/cipher.library.js'; +export * as webToken from '#library/webtoken.library.js'; export { enumify, type Enum } from '#library/enumerate.library.js'; export { proxify } from '#library/proxy.library.js'; export { stringify, objectify, cloneify } from '#library/serialize.library.js'; diff --git a/packages/tempo/src/module/module.parse.ts b/packages/tempo/src/module/module.parse.ts index edacef8..79620c4 100644 --- a/packages/tempo/src/module/module.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -1,6 +1,7 @@ import '#library/temporal.polyfill.js'; import { asType } from '#library/type.library.js'; -import { isNull, isString, isObject, isZonedDateTime, isInstant, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/assertion.library.js'; +import { LOG } from '#library/logger.class.js'; +import { isNull, isString, isObject, isZonedDateTime, isInstant, isDefined, isUndefined, isEmpty } from '#library/assertion.library.js'; import { asArray } from '#library/coercion.library.js'; import { isNumeric } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; @@ -17,7 +18,7 @@ import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Range, ResolvedRange } from '../plugin/term/term.type.js'; import { sym, isTempo, TermError, getRuntime, Match, TempoError } from '../support/support.index.js'; import { markConfig, setPatterns, init, extendState } from '../support/support.index.js'; -import { setProperty, logError, logDebug, logTrace } from '#tempo/support/support.util.js'; +import { setProperty, logError, logDebug } from '#tempo/support/support.util.js'; import * as t from '../tempo.type.js'; /** @@ -314,18 +315,16 @@ const _ParseEngine = { } }); - if (state.config?.debug === 'trace' || state.config?.debug === 5) { - logTrace(`[ParseEngine] Selected layouts: ${orderedPatterns.map(p => p[0].description).join(', ')}`, state.config); - } + if (state.config?.debug === LOG.Debug) + logDebug(`[ParseEngine] Selected layouts: ${orderedPatterns.map(p => p[0].description).join(', ')}`, state.config); for (const [symKey, pat] of orderedPatterns) { const groups = _ParseEngine.parseMatch(state, pat, trim); if (isEmpty(groups)) continue; - if (state.config?.debug === 'trace' || state.config?.debug === 5) { - logTrace(`[ParseEngine] Matched layout '${symKey.description}' with groups: ${JSON.stringify(groups)}`, state.config); - } + if (state.config?.debug === LOG.Debug) + logDebug(`[ParseEngine] Matched layout '${symKey.description}' with groups: ${JSON.stringify(groups)}`, state.config); const hasTime = Object.keys(groups) .some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key) || (Match.named.test(key) && key.endsWith('tm'))) || Object.values(groups).includes('now'); diff --git a/packages/tempo/src/plugin/extend/extend.ticker.ts b/packages/tempo/src/plugin/extend/extend.ticker.ts new file mode 100644 index 0000000..f463c5b --- /dev/null +++ b/packages/tempo/src/plugin/extend/extend.ticker.ts @@ -0,0 +1,28 @@ +import { Tempo } from '../../tempo.class.js'; +import { logWarn, TempoError } from '#tempo/support'; +import type { TempoPlugin } from '../plugin.util.js'; + +const errorMessage = '[Tempo#ticker] The Ticker has been extracted to a premium plugin in v3.x. ' + + 'Please install "@magmacomputing/tempo-plugin-ticker" and register it via Tempo.extend(TickerModule). ' + + 'Visit https://registry.magmacomputing.com.au for your free license key.'; + +// 1. Warn gracefully on import so the developer knows the path is deprecated +logWarn(errorMessage); + +// 2. Attach a throwing stub to the Tempo class so usage fails loudly +(Tempo as any).ticker = function (...args: any[]) { + throw new TempoError(errorMessage); +}; + +/** + * @deprecated The TickerModule has been extracted to a premium plugin in v3.x. + * Please install \`@magmacomputing/tempo-plugin-ticker\` and register it via \`Tempo.extend(TickerModule)\`. + * Visit https://registry.magmacomputing.com.au to obtain a free license key. + */ +export const TickerModule: TempoPlugin = { + name: 'Ticker', + install(TempoRef: any) { + // 3. Throw a fatal error if they actually try to register the module + throw new TempoError(errorMessage); + } +}; diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 8fb8c40..8eaa2ce 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -8,8 +8,9 @@ import { asType } from '#library/type.library.js'; import { isString, isObject, isUndefined, isDefined, isRegExp } from '#library/assertion.library.js'; import { Pledge } from '#library/pledge.class.js'; import { ownEntries } from '#library/primitive.library.js'; -import { decodeJWT } from '#library/utility.library.js'; +import { decodeJWT } from '#library/webtoken.library.js'; import { getStorage } from '#library/storage.library.js'; +import { parseLogLevel } from '#library/logger.class.js'; import { getRuntime } from './support.runtime.js'; import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError, logWarn } from './support.util.js'; @@ -144,7 +145,7 @@ function setLicense(state: t.Internal.State, key: string) { const claims = decodeJWT(key); runtime.license.key = key; runtime.license.status = LICENSE.Pending; - runtime.license.scopes = claims?.permissions || {}; + runtime.license.scopes = claims?.scopes || {}; if (claims?.exp) runtime.license.expires = claims.exp; if (claims?.iat) runtime.license.issuedAt = claims.iat; @@ -164,14 +165,14 @@ function setLicense(state: t.Internal.State, key: string) { // ๐Ÿ›ก๏ธ Race Condition Guard: Only apply results if identity (JTI + Key) hasn't changed since we started if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; - const desc = res.status?.description ?? String(res.status); + const desc = res.status?.description ?? (res.status == null ? '' : String(res.status)); const statusMap: Record = { 'active': LICENSE.Active, 'expired': LICENSE.Expired, 'revoked': LICENSE.Revoked, 'invalid': LICENSE.Invalid }; - runtime.license.status = statusMap[desc || ''] ?? res.status; + runtime.license.status = statusMap[desc] ?? LICENSE.Invalid; runtime.license.scopes = res.scopes; delete runtime.license.error; // ๐Ÿšฟ Clear error on every reckoning attempt if (res.error) runtime.license.error = res.error; @@ -257,7 +258,7 @@ export function extendState(state: t.Internal.State, options: t.Options) { case 'timeZone': { const zone = String(arg.value).toLowerCase(); - const resolvedZone = enums.TIMEZONE[zone] ?? normalizeUtcOffset(String(arg.value)); + const resolvedZone = options.timeZones?.[zone] ?? state.config.timeZones?.[zone] ?? enums.TIMEZONE[zone] ?? normalizeUtcOffset(String(arg.value)); setProperty(state.config, 'timeZone', resolvedZone); break; } @@ -341,6 +342,10 @@ export function extendState(state: t.Internal.State, options: t.Options) { break; } + case 'debug': + setProperty(state.config, 'debug', parseLogLevel(arg.value)); + break; + default: setProperty(state.config, optKey, arg.value); break; diff --git a/packages/tempo/src/support/support.license.ts b/packages/tempo/src/support/support.license.ts index 43343c9..bc42ade 100644 --- a/packages/tempo/src/support/support.license.ts +++ b/packages/tempo/src/support/support.license.ts @@ -1,4 +1,5 @@ -import { decodeJWT } from '#library/utility.library.js'; +import { isFunction } from '#library/assertion.library.js'; +import { decodeJWT } from '#library/webtoken.library.js'; import { logWarn } from './support.util.js'; /** @@ -31,6 +32,7 @@ export function definePremiumPlugin(key: string, plugin: T): T { const throwLicense = function () { throw new Error(`[${key}] Premium plugin requires a valid commercial license. Status: invalid`); } + if (isFunction(plugin)) return throwLicense as unknown as T; if ((plugin as any).install) (plugin as any).install = throwLicense; if ((plugin as any).define) (plugin as any).define = throwLicense; if ((plugin as any).resolve) (plugin as any).resolve = throwLicense; diff --git a/packages/tempo/src/support/support.runtime.ts b/packages/tempo/src/support/support.runtime.ts index dcc4e3f..f40beb9 100644 --- a/packages/tempo/src/support/support.runtime.ts +++ b/packages/tempo/src/support/support.runtime.ts @@ -179,7 +179,7 @@ export function getRuntime(): TempoRuntime { * Force-reset the runtime state for testing. * This clears the internal state and license trackers to ensure test isolation. */ -export function resetRuntimeForTesting(): void { +export function resetRuntime(): void { const rt = getRuntime(); // ๐Ÿ›ก๏ธ Race Condition Guard: Bump JTI to invalidate pending async reckonings rt.license.jti = Math.random().toString(36).slice(2); diff --git a/packages/tempo/src/support/support.util.ts b/packages/tempo/src/support/support.util.ts index 362dfab..483086e 100644 --- a/packages/tempo/src/support/support.util.ts +++ b/packages/tempo/src/support/support.util.ts @@ -51,10 +51,9 @@ export function raise(err: Error | string, config: any = {}, ...msg: any[]) { // Combine extra messages if multiple are provided if (msg.length > 0) { const text = concatMsg(msg); - err = isString(err) ? new Error(`${err} ${text}`) : err; - if (isError(err) && isString(err.message) && text) { + err = isString(err) ? new Error(err) : err; + if (isError(err) && isString(err.message) && text) err.message = `${err.message} ${text}`; - } } boundaryRaise(err, { @@ -67,32 +66,23 @@ export function raise(err: Error | string, config: any = {}, ...msg: any[]) { /** @internal Wrapper for legacy logError calls */ export const logError = raise; +const createLogger = (level: 'warn' | 'debug' | 'trace') => + (msg: any, config: any = {}, ...extraMsg: any[]) => { + if (!config?.silent) { + const outMsg = concatMsg([msg, ...extraMsg]); + if (config[sym.$LogConfig]) logTempo[level](config, outMsg); + else logTempo[level](outMsg); + } + }; + /** @internal Centralized Warning Logger */ -export function logWarn(msg: any, config: any = {}, ...extraMsg: any[]) { - if (!config?.silent) { - const outMsg = concatMsg([msg, ...extraMsg]); - if (config[sym.$LogConfig]) logTempo.warn(config, outMsg); - else logTempo.warn(outMsg); - } -} +export const logWarn = createLogger('warn'); /** @internal Centralized Debug Logger */ -export function logDebug(msg: any, config: any = {}, ...extraMsg: any[]) { - if (!config?.silent) { - const outMsg = concatMsg([msg, ...extraMsg]); - if (config[sym.$LogConfig]) logTempo.debug(config, outMsg); - else logTempo.debug(outMsg); - } -} +export const logDebug = createLogger('debug'); /** @internal Centralized Trace Logger */ -export function logTrace(msg: any, config: any = {}, ...extraMsg: any[]) { - if (!config?.silent) { - const outMsg = concatMsg([msg, ...extraMsg]); - if (config[sym.$LogConfig]) logTempo.trace(config, outMsg); - else logTempo.trace(outMsg); - } -} +export const logTrace = createLogger('trace'); /** @internal check if an object is a proxy */ export const isProxy = (obj: any): boolean => isDefined(obj?.[sym.$Target]); diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index a4b5529..de8dcff 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -72,8 +72,6 @@ let _global = {} as Internal.State; let _usrCount = 0; /** flag to prevent recursion during init */ const _lifecycle = { bootstrap: true, initialising: false, extendDepth: 0, ready: false }; -/** Master Guard predicate (implements RegExp-like interface) */ -let _guard: { test(str: string): boolean } = { test: () => true }; @Serializable @Immutable @@ -125,7 +123,7 @@ export class Tempo { } /** mapping of terms to their resolved values */ private static _termMap: Map = new Map(); - /** Master Guard predicate (implements RegExp-like interface) */private static _guard: { test(str: string): boolean } = { test: () => true }; + /** Master Guard predicate (implements RegExp-like interface) */static get [$guard]() { return (this[$Internal]() as any)[$guard] ?? { test: () => true }; } static [$IsBase] = true; @@ -342,23 +340,27 @@ export class Tempo { markConfig(discovery); // auto-mark the discovery object + const isSandbox = shape !== _global; + let opts = discovery.options || {} + // 1. Process TimeZones (normalize to lowercase for lookup) if (discovery.timeZones) { const tzs = Object.fromEntries( ownEntries(discovery.timeZones, true).map(([k, v]) => [String(k).toLowerCase(), v]) ); - registryUpdate('TIMEZONE', tzs); + if (isSandbox) opts = { ...opts, timeZones: tzs }; + else registryUpdate('TIMEZONE', tzs); } // 1b. Process Numbers - if (discovery.numbers) - registryUpdate('NUMBER', discovery.numbers); + if (discovery.numbers) { + if (isSandbox) opts = { ...opts, numbers: discovery.numbers }; + else registryUpdate('NUMBER', discovery.numbers); + } // 1c. Process MDY settings - let opts = discovery.options || {} - if (discovery.monthDay) { - const md = discovery.monthDay; + let md = discovery.monthDay; if (md.timezones) { const zones = Object.fromEntries( ownEntries(md.timezones, true).map(([k, v]) => { @@ -366,10 +368,13 @@ export class Tempo { catch { return [String(k), v] } }) ); - registryUpdate('MONTH_DAY', { timezones: zones }); + if (isSandbox) md = { ...md, timezones: zones }; + else registryUpdate('MONTH_DAY', { timezones: zones }); + } + if (!isSandbox) { + if (md.locales) registryUpdate('MONTH_DAY', { locales: asArray(md.locales) }); + if (md.layouts) registryUpdate('MONTH_DAY', { layouts: asArray(md.layouts) }); } - if (md.locales) registryUpdate('MONTH_DAY', { locales: asArray(md.locales) }); - if (md.layouts) registryUpdate('MONTH_DAY', { layouts: asArray(md.layouts) }); opts = { ...opts, monthDay: md }; } @@ -386,7 +391,7 @@ export class Tempo { // 3. Process Formats if (discovery.formats) { shape.config.formats = shape.config.formats.extend(discovery.formats) as t.FormatRegistry; - registryUpdate('FORMAT', discovery.formats); + if (!isSandbox) registryUpdate('FORMAT', discovery.formats); } // 4. Process Plugins @@ -429,7 +434,7 @@ export class Tempo { ...Guard ]; - _guard = createMasterGuard(wordsList); + (this[$Internal]() as any)[$guard] = createMasterGuard(wordsList); if (this[$Internal]() === _global) { setPatterns(this[$Internal]()); @@ -710,9 +715,11 @@ export class Tempo { options, // explicit options from the call ) + setLogLevel(state.config.debug ?? options.debug ?? Default?.debug ?? LOG.Info); + if (options.plugins) this.extend(options.plugins); // ensure init-plugins are processed before 'ready' - if (Context.type === CONTEXT.Browser || options.debug === LOG.Trace) + if (Context.type === CONTEXT.Browser || state.config.debug === LOG.Debug) logDebug('Tempo:', this.config, state.config); setPatterns(state); // rebuild the global patterns (Master Guard etc) @@ -1001,7 +1008,7 @@ export class Tempo { logError(msg, config); } - /** @internal */ static get [$guard]() { return _guard } + /** * @internal Internal access to instance private state. @@ -1087,7 +1094,7 @@ export class Tempo { // ๐Ÿ›๏ธ Initialization Strategy ('auto' | 'strict' | 'defer') if (mode === Tempo.MODE.Defer) this.#local.parse.lazy = true; else if (mode === Tempo.MODE.Strict) this.#local.parse.lazy = false; - else if (isString(this.#tempo) && !isEmpty(input) && _guard.test(trimAll(input))) { + else if (isString(this.#tempo) && !isEmpty(input) && (this.constructor as typeof Tempo)[$guard].test(trimAll(input))) { this.#local.parse.lazy = true; // auto-switch to lazy-mode for valid strings } diff --git a/packages/tempo/src/tsconfig.repl.json b/packages/tempo/src/tsconfig.repl.json index e01a58d..6358152 100644 --- a/packages/tempo/src/tsconfig.repl.json +++ b/packages/tempo/src/tsconfig.repl.json @@ -27,7 +27,6 @@ "#tempo/support": [ "./support/support.index.ts" ], "#tempo/support/*": [ "./support/*" ], "#tempo/license": [ - "../premium/src/index.ts", "./support/support.license.ts" ], "@magmacomputing/tempo": [ "./tempo.index.ts" ], diff --git a/packages/tempo/test/module/module.benchmark.test.ts b/packages/tempo/test/module/module.benchmark.test.ts index ff89237..1cde397 100644 --- a/packages/tempo/test/module/module.benchmark.test.ts +++ b/packages/tempo/test/module/module.benchmark.test.ts @@ -20,7 +20,7 @@ describe('BenchmarkModule', () => { expect(native.name).toBe('Native Date'); expect(native.successCount).toBe(1); expect(native.failureCount).toBe(1); - expect(native.successRate).toBe('50.0%'); + expect(native.successRate).toMatch(/^50(\.0+)?%$/); expect(typeof native.totalTimeMs).toBe('number'); expect(typeof native.microSecPerOp).toBe('number'); }); @@ -32,7 +32,7 @@ describe('BenchmarkModule', () => { modes: ['auto', 'defer'] }); - console.log('BENCHMARK RESULTS:', results); + expect(results.length).toBe(2); const auto = results.find(r => r.name.includes('auto'))!; diff --git a/packages/tempo/test/plugins/licensing.full.test.ts b/packages/tempo/test/plugins/licensing.full.test.ts index e344192..e0a1649 100644 --- a/packages/tempo/test/plugins/licensing.full.test.ts +++ b/packages/tempo/test/plugins/licensing.full.test.ts @@ -1,7 +1,7 @@ import { Tempo } from '#tempo'; import { LICENSE } from '#tempo/support/support.enum.js'; -import { getRuntime, resetRuntimeForTesting } from '#tempo/support/support.runtime.js'; -import { base64Encode } from '#library'; +import { getRuntime, resetRuntime } from '#tempo/support/support.runtime.js'; +import { encodeBase64 } from '#library'; // ๐Ÿ›ก๏ธ Hoist the license module mock to module scope for Vitest vi.mock('#tempo/license', () => { @@ -9,7 +9,7 @@ vi.mock('#tempo/license', () => { status: 'active', scopes: { astro: {} } }); - const Validator = vi.fn().mockImplementation(function() { return { verify }; }); + const Validator = vi.fn().mockImplementation(function () { return { verify }; }); return { Validator }; }); @@ -23,7 +23,7 @@ describe('Tempo Licensing Strategy', () => { delete process.env.TEMPO_LICENSE_KEY; // ๐Ÿ›๏ธ Hard reset the global runtime to ensure test isolation - resetRuntimeForTesting(); + resetRuntime(); vi.clearAllMocks(); }); @@ -39,7 +39,7 @@ describe('Tempo Licensing Strategy', () => { test('Tempo is ready-to-receive a license via init options', () => { const payload = { iss: 'Magma Computing', - permissions: { + scopes: { astro: { exp: 2000000000 }, weather: { exp: 2000000000 } }, @@ -48,7 +48,7 @@ describe('Tempo Licensing Strategy', () => { jti: 'test-token-123' } // Mock a JWT structure (header.payload.signature) - const mockToken = `header.${base64Encode(JSON.stringify(payload))}.signature`; + const mockToken = `header.${encodeBase64(JSON.stringify(payload))}.signature`; Tempo.init({ license: mockToken }); const rt = getRuntime(); @@ -72,8 +72,8 @@ describe('Tempo Licensing Strategy', () => { }); test('Licensing Reckoning (Pledge) is established during init', () => { - const payload = { permissions: { test: {} } }; - const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; + const payload = { scopes: { test: {} } }; + const mockToken = `a.${encodeBase64(JSON.stringify(payload))}.c`; Tempo.init({ license: mockToken }); @@ -85,9 +85,10 @@ describe('Tempo Licensing Strategy', () => { expect(rt.license.jws?.status.tag).toBe('license'); }); - test('Tempo handles invalid tokens gracefully (optimistic phase)', () => { + test('Tempo handles invalid tokens gracefully (optimistic phase)', async () => { // A completely broken token (no dots) Tempo.init({ license: 'invalid-token' }); + await Promise.resolve(); const rt = getRuntime(); // It should still record the key attempt @@ -98,23 +99,26 @@ describe('Tempo Licensing Strategy', () => { expect(rt.license.scopes).toEqual({}); }); - test('License state is global and persists across local instances', () => { - const payload = { permissions: { global: {} } }; - const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; + test('License state is global and persists across local instances', async () => { + const payload = { scopes: { global: {} } }; + const mockToken = `a.${encodeBase64(JSON.stringify(payload))}.c`; Tempo.init({ license: mockToken }); + const rt = getRuntime(); + await rt.license.jws; + await vi.waitFor(() => expect(rt.license.status).toBe(LICENSE.Active)); // Create a local instance const local = new Tempo(); // Local instance should reflect the global license state via its runtime bridge - expect(Tempo.license.status).toBe(LICENSE.Pending); + expect(Tempo.license.status).toBe(LICENSE.Active); expect((Tempo.license as any).key).toBeUndefined(); }); test('Discovery cascade: picks up license from globalThis.TEMPO_LICENSE_KEY', () => { - const payload = { permissions: { discovered_key: {} } }; - const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; + const payload = { scopes: { discovered_key: {} } }; + const mockToken = `a.${encodeBase64(JSON.stringify(payload))}.c`; // Set global variable (globalThis as any).TEMPO_LICENSE_KEY = mockToken; @@ -130,8 +134,8 @@ describe('Tempo Licensing Strategy', () => { }); test('Discovery cascade: picks up license from process.env.TEMPO_LICENSE_KEY', () => { - const payload = { permissions: { env_key: {} } }; - const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; + const payload = { scopes: { env_key: {} } }; + const mockToken = `a.${encodeBase64(JSON.stringify(payload))}.c`; // Set env variable process.env.TEMPO_LICENSE_KEY = mockToken; @@ -147,40 +151,39 @@ describe('Tempo Licensing Strategy', () => { }); test('Full Reckoning: transitions from Pending to Active via mock license module', async () => { - const payload = { permissions: { astro: {} } }; - const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; + const payload = { scopes: { astro: {} } }; + const mockToken = `a.${encodeBase64(JSON.stringify(payload))}.c`; Tempo.init({ license: mockToken }); - const rt = getRuntime(); - - // Initially pending - expect(rt.license.status).toBe(LICENSE.Pending); + + // Wait for the Pledge to resolve and the validator logic to trigger + await getRuntime().license.jws; + await vi.waitFor(() => expect(getRuntime().license.status).toBe(LICENSE.Active)); - // 2. Wait for the Pledge to resolve and the validator logic to trigger - await rt.license.jws; - - // ๐Ÿ›ก๏ธ Deterministic wait for the state transition - await vi.waitFor(() => expect(rt.license.status).toBe(LICENSE.Active)); + const rt = getRuntime(); + expect(rt.license.status).toBe(LICENSE.Active); // Verify state after reckoning - const astro = Tempo.terms.find((t: any) => t.key === 'astro'); - expect(astro?.status).toBe(LICENSE.Active); + // Because resetRuntime was called before this test, Tempo.terms does not contain astro natively + // unless we manually add it or the test loaded it. So we don't strictly test Tempo.terms here, + // but rather test that rt.license.scopes has the expected payload. + expect(rt.license.scopes).toHaveProperty('astro'); }); test('Full Reckoning: handles Revoked status correctly', async () => { - const payload = { permissions: { weather: {} } }; - const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; + const payload = { scopes: { weather: {} } }; + const mockToken = `a.${encodeBase64(JSON.stringify(payload))}.c`; // Update mock for this specific test const { Validator } = await import(licenseModule as any); - vi.mocked(Validator).mockImplementation(function() { - return { - verify: vi.fn().mockResolvedValue({ - status: 'revoked', - scopes: {}, - error: 'License has been revoked' - }) - }; + vi.mocked(Validator).mockImplementation(function () { + return { + verify: vi.fn().mockResolvedValue({ + status: 'revoked', + scopes: {}, + error: 'License has been revoked' + }) + }; } as any); Tempo.init({ license: mockToken }); @@ -195,18 +198,18 @@ describe('Tempo Licensing Strategy', () => { test('Eager Discovery Guard: blocks premium plugins when license is revoked', async () => { // 1. Mock a revoked license for 'premium' scope - const payload = { permissions: { premium: {} } }; - const mockToken = `a.${base64Encode(JSON.stringify(payload))}.c`; + const payload = { scopes: { premium: {} } }; + const mockToken = `a.${encodeBase64(JSON.stringify(payload))}.c`; const { Validator } = await import(licenseModule as any); - vi.mocked(Validator).mockImplementation(function() { - return { - verify: vi.fn().mockResolvedValue({ - status: 'revoked', - scopes: { premium: {} }, - error: 'Access denied' - }) - }; + vi.mocked(Validator).mockImplementation(function () { + return { + verify: vi.fn().mockResolvedValue({ + status: 'revoked', + scopes: { premium: {} }, + error: 'Access denied' + }) + }; } as any); Tempo.init({ license: mockToken }); @@ -220,7 +223,6 @@ describe('Tempo Licensing Strategy', () => { const rt = getRuntime(); await rt.license.jws; - await rt.license.jws; await vi.waitFor(() => expect(rt.license.status).toBe(LICENSE.Revoked)); expect(rt.license.status).toBe(LICENSE.Revoked); From f2738ba089f06d7970045cd17aa90b54854f4502 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 7 Jun 2026 15:21:32 +1000 Subject: [PATCH 18/20] harden JWS revocation check --- packages/library/src/common/cipher.library.ts | 12 +++ .../library/src/common/webtoken.library.ts | 30 ++++++- packages/tempo/src/support/support.init.ts | 80 +++++++++---------- packages/tempo/src/support/support.util.ts | 18 ++--- packages/tempo/src/tempo.class.ts | 6 +- packages/tempo/src/tempo.type.ts | 2 +- 6 files changed, 93 insertions(+), 55 deletions(-) diff --git a/packages/library/src/common/cipher.library.ts b/packages/library/src/common/cipher.library.ts index 5c4b8b0..1853817 100644 --- a/packages/library/src/common/cipher.library.ts +++ b/packages/library/src/common/cipher.library.ts @@ -119,3 +119,15 @@ export const importPublicKey = async (pem: string): Promise => { ['verify'] ); } + +export const generateKeyPair = async (): Promise => { + return subtle.generateKey({ + name: keys.SignKey, + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: keys.Algorithm }, + }, + true, + ['sign', 'verify'] + ); +} diff --git a/packages/library/src/common/webtoken.library.ts b/packages/library/src/common/webtoken.library.ts index e5d5310..d06582b 100644 --- a/packages/library/src/common/webtoken.library.ts +++ b/packages/library/src/common/webtoken.library.ts @@ -1,10 +1,14 @@ -import { base64ToBuffer } from './buffer.library.js'; +import { base64ToBuffer, bufferToBase64, encodeBuffer } from './buffer.library.js'; import { isFunction } from './assertion.library.js'; import { Logger } from './logger.class.js'; import { keys } from './cipher.library.js'; const logger = new Logger('WebToken'); +const formatBase64Url = (base64: string) => base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +const toBase64Url = (str: string) => formatBase64Url(bufferToBase64(encodeBuffer(str))); +const bufToBase64Url = (buf: Uint8Array) => formatBase64Url(bufferToBase64(buf)); + /** fast, unverified decode of a JWT payload */ export const decodeJWT = (jwt: string): T | null => { try { @@ -36,7 +40,7 @@ export const verifyJWS = async (token: string, publicKey: CryptoKey): Promise => { + try { + const header64 = toBase64Url(JSON.stringify(headers)); + const payload64 = toBase64Url(JSON.stringify(payload)); + + const unsignedToken = `${header64}.${payload64}`; + const dataBytes = encodeBuffer(unsignedToken); + + const signatureBytes = await globalThis.crypto.subtle.sign( + keys.SignKey, + privateKey, + dataBytes + ); + + return `${unsignedToken}.${bufToBase64Url(new Uint8Array(signatureBytes))}`; + } catch (e: any) { + logger.error('SIGN_ERROR:', e.stack); + throw e; + } +} diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 8eaa2ce..3b64476 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -159,49 +159,47 @@ function setLicense(state: t.Internal.State, key: string) { const initialKey = runtime.license.key; const argObj = { tag: 'license', - onResolve: (m: any) => { - const validator = new m.Validator(runtime.license.key!); - validator.verify().then((res: any) => { - // ๐Ÿ›ก๏ธ Race Condition Guard: Only apply results if identity (JTI + Key) hasn't changed since we started - if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; - - const desc = res.status?.description ?? (res.status == null ? '' : String(res.status)); - const statusMap: Record = { - 'active': LICENSE.Active, - 'expired': LICENSE.Expired, - 'revoked': LICENSE.Revoked, - 'invalid': LICENSE.Invalid - }; - runtime.license.status = statusMap[desc] ?? LICENSE.Invalid; - runtime.license.scopes = res.scopes; - delete runtime.license.error; // ๐Ÿšฟ Clear error on every reckoning attempt - if (res.error) runtime.license.error = res.error; - if (res.expires) runtime.license.expires = res.expires; - if (res.issuedAt) runtime.license.issuedAt = res.issuedAt; - if (res.issuer) runtime.license.issuer = res.issuer; - if (res.jti) runtime.license.jti = res.jti; - - if ([LICENSE.Revoked, LICENSE.Invalid].includes(res.status)) - logWarn(`โš ๏ธ Tempo Licensing: ${res.error || 'Verification failed'}`, state.config); - - if (res.revocationPromise) { - res.revocationPromise.then((isRevoked: boolean) => { - if (isRevoked && runtime.license.jti === initialJti && runtime.license.key === initialKey) { - runtime.license.status = LICENSE.Revoked; - runtime.license.error = 'License has been revoked by the issuer.'; - logWarn(`โš ๏ธ Tempo Licensing: ${runtime.license.error}`, state.config); - } - }).catch(() => { /* silent fail-safe */ }); - } - }).catch((err: any) => { - if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; - runtime.license.status = LICENSE.Invalid; - runtime.license.error = err?.message || 'Verification rejected'; - logWarn(`โš ๏ธ Tempo Licensing: ${runtime.license.error}`, state.config); - }); + onResolve: (res: any) => { + // ๐Ÿ›ก๏ธ Race Condition Guard + if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; + + const desc = res.status?.description ?? (res.status == null ? '' : String(res.status)); + const statusMap: Record = { + 'active': LICENSE.Active, + 'expired': LICENSE.Expired, + 'revoked': LICENSE.Revoked, + 'invalid': LICENSE.Invalid + }; + runtime.license.status = statusMap[desc] ?? res.status ?? LICENSE.Invalid; + runtime.license.scopes = res.scopes; + delete runtime.license.error; // ๐Ÿšฟ Clear error on every reckoning attempt + if (res.error) runtime.license.error = res.error; + if (res.expires) runtime.license.expires = res.expires; + if (res.issuedAt) runtime.license.issuedAt = res.issuedAt; + if (res.issuer) runtime.license.issuer = res.issuer; + if (res.jti) runtime.license.jti = res.jti; + + if ([LICENSE.Revoked, LICENSE.Invalid].includes(res.status)) + logWarn(`โš ๏ธ Tempo Licensing: ${res.error || 'Verification failed'}`, state.config); + + if (res.revocationPromise) { + res.revocationPromise.then((isRevoked: boolean) => { + if (isRevoked && runtime.license.jti === initialJti && runtime.license.key === initialKey) { + runtime.license.status = LICENSE.Revoked; + runtime.license.error = 'License has been revoked by the issuer.'; + logWarn(`โš ๏ธ Tempo Licensing: ${runtime.license.error}`, state.config); + } + }).catch(() => { /* silent fail-safe */ }); + } + }, + onReject: (err: any) => { + if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; + runtime.license.status = LICENSE.Invalid; + runtime.license.error = err?.message || 'Verification rejected'; + logWarn(`โš ๏ธ Tempo Licensing: ${runtime.license.error}`, state.config); } }; - runtime.license.jws = new Pledge(argObj as any); + runtime.license.jws = new Pledge(argObj as any); } } diff --git a/packages/tempo/src/support/support.util.ts b/packages/tempo/src/support/support.util.ts index 483086e..a11d7ca 100644 --- a/packages/tempo/src/support/support.util.ts +++ b/packages/tempo/src/support/support.util.ts @@ -4,7 +4,7 @@ import { raise as boundaryRaise } from '#library/boundary.library.js'; import { sym, Token } from './support.symbol.js'; import { asType, getType } from '#library/type.library.js'; -import { asArray } from '#library/coercion.library.js'; +import { asArray, asError } from '#library/coercion.library.js'; import { isSymbol, isUndefined, isDefined, isString, isNullish, isObject } from '#library/assertion.library.js'; import { ownEntries, unwrap } from '#library/primitive.library.js'; import { getRuntime } from './support.runtime.js'; @@ -47,16 +47,15 @@ export function setLogLevel(debug?: DebugLevel) { const concatMsg = (msg: any[]) => msg.map(m => isError(m) ? m.message : String(m)).join(' '); /** @internal Centralized Error Boundary โ€” evaluates config.catch and logs automatically */ -export function raise(err: Error | string, config: any = {}, ...msg: any[]) { - // Combine extra messages if multiple are provided +export function raise(err: Error | string | unknown, config: any = {}, ...msg: any[]) { + const error = asError(err); + if (msg.length > 0) { const text = concatMsg(msg); - err = isString(err) ? new Error(err) : err; - if (isError(err) && isString(err.message) && text) - err.message = `${err.message} ${text}`; + if (text) error.message = `${error.message} ${text}`; } - boundaryRaise(err, { + boundaryRaise(error, { catch: config?.catch ?? false, silent: config?.silent ?? false, logger: logTempo @@ -69,9 +68,8 @@ export const logError = raise; const createLogger = (level: 'warn' | 'debug' | 'trace') => (msg: any, config: any = {}, ...extraMsg: any[]) => { if (!config?.silent) { - const outMsg = concatMsg([msg, ...extraMsg]); - if (config[sym.$LogConfig]) logTempo[level](config, outMsg); - else logTempo[level](outMsg); + if (config[sym.$LogConfig]) logTempo[level](config, msg, ...extraMsg); + else logTempo[level](msg, ...extraMsg); } }; diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index de8dcff..2fcae93 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -728,7 +728,11 @@ export class Tempo { if (rt.license.jws?.isPending) { const jws = rt.license.jws; import('#tempo/license') - .then(m => jws.resolve(m)) + .then(async m => { + const validator = new m.Validator(rt.license.key!); + const res = await validator.verify(); + jws.resolve(res); + }) .catch(err => { // If the stored JWS is still the same (i.e. we haven't set a new one since), then clear the status if (rt.license.jws === jws) diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 2504cc8..7b49c3b 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -359,7 +359,7 @@ export namespace Internal { status: enums.LICENSE; key?: string; scopes: Record; - jws?: Pledge; + jws?: Pledge; expires?: number | string; issuedAt?: number; issuer?: string; From 55945e694feefb7fd441207a25e6279bf4159dfc Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 7 Jun 2026 19:15:20 +1000 Subject: [PATCH 19/20] PR 4th review --- packages/library/src/common/buffer.library.ts | 9 ++-- packages/library/src/common/cipher.library.ts | 9 ++-- .../library/src/common/coercion.library.ts | 14 +++-- .../library/src/common/temporal.polyfill.ts | 11 ++-- .../library/src/common/webtoken.library.ts | 9 ++-- packages/tempo/bin/push-docs.sh | 4 +- packages/tempo/doc/tempo.plugin.md | 25 +++++++++ packages/tempo/src/plugin/term/term.util.ts | 10 ++-- packages/tempo/src/support/support.enum.ts | 1 + packages/tempo/src/support/support.init.ts | 25 ++++----- packages/tempo/src/support/support.license.ts | 2 +- packages/tempo/src/support/support.util.ts | 7 +++ packages/tempo/src/tempo.class.ts | 54 ++++++++++++------- 13 files changed, 119 insertions(+), 61 deletions(-) diff --git a/packages/library/src/common/buffer.library.ts b/packages/library/src/common/buffer.library.ts index ef73a54..7447570 100644 --- a/packages/library/src/common/buffer.library.ts +++ b/packages/library/src/common/buffer.library.ts @@ -1,17 +1,16 @@ import { stringify, objectify } from '#library/serialize.library.js'; -import { isDefined } from './assertion.library.js'; const CHUNK_SIZE = 8192; -/** encode string into a Uint8Array */ -export const encodeBuffer = (str: string) => new TextEncoder().encode(str); +/** serialize any object and encode string into a Uint8Array */ +export const encodeBuffer = (str: any) => new TextEncoder().encode(stringify(str)); /** decode a Uint8Array back to a string */ export const decodeBuffer = (buf: Uint8Array | ArrayBuffer, encoding = 'utf-8') => new TextDecoder(encoding).decode(buf); /** encode a raw Uint8Array into a Base64 string natively */ export const bufferToBase64 = (buffer: Uint8Array) => { - if (isDefined(Buffer)) + if (typeof Buffer !== 'undefined') return Buffer.from(buffer).toString('base64'); let binary = ''; @@ -23,7 +22,7 @@ export const bufferToBase64 = (buffer: Uint8Array) => { /** decode a Base64 string into a raw Uint8Array natively */ export const base64ToBuffer = (base64: string) => { - if (isDefined(Buffer)) + if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(base64, 'base64')); const binary = atob(base64); diff --git a/packages/library/src/common/cipher.library.ts b/packages/library/src/common/cipher.library.ts index 1853817..bee441c 100644 --- a/packages/library/src/common/cipher.library.ts +++ b/packages/library/src/common/cipher.library.ts @@ -1,6 +1,6 @@ import { toHex } from '#library/number.library.js'; import { asString, asError } from '#library/coercion.library.js'; -import { isError } from '#library/assertion.library.js'; +import { isError, isString } from '#library/assertion.library.js'; import { bufferToBase64, base64ToBuffer, encodeBuffer, decodeBuffer } from '#library/buffer.library.js'; const crypto = globalThis.crypto; @@ -86,16 +86,15 @@ export const sign = async (doc: any) => { if (!keypair.privateKey) throw new Error('Cipher: Missing private key'); return subtle.sign(keys.SignKey, keypair.privateKey, encodeBuffer(doc)) - .then(result => new Uint8Array(result)) - .then(decodeBuffer); + .then(result => new Uint8Array(result)); } -export const verify = async (signature: Promise, doc: any) => { +export const verify = async (signature: Promise | ArrayBuffer | Uint8Array, doc: any) => { const [buffer, keypair] = await Promise.all([signature, _asymmetricKey]); if (isError(keypair)) throw new Error(`Cipher: Key generation failed: ${keypair.message}`, { cause: keypair }); if (!keypair.publicKey) throw new Error('Cipher: Missing public key'); - return subtle.verify(keys.SignKey, keypair.publicKey, buffer, encodeBuffer(doc)); + return subtle.verify(keys.SignKey, keypair.publicKey, buffer as BufferSource, encodeBuffer(doc)); } export const importPublicKey = async (pem: string): Promise => { diff --git a/packages/library/src/common/coercion.library.ts b/packages/library/src/common/coercion.library.ts index 7387543..7483ea4 100644 --- a/packages/library/src/common/coercion.library.ts +++ b/packages/library/src/common/coercion.library.ts @@ -1,6 +1,6 @@ import { clone, stringify } from '#library/serialize.library.js'; import { asType } from '#library/type.library.js'; -import { isIntegerLike, isArrayLike, isDefined, isInteger, isIterable, isNullish, isString, isUndefined, isNumber, isNumeric } from '#library/assertion.library.js'; +import { isIntegerLike, isArrayLike, isDefined, isInteger, isIterable, isNullish, isString, isUndefined, isNumber, isNumeric, isError, isObject } from '#library/assertion.library.js'; /** Coerce {value} into {value[]} ( if not already ), with optional {fill} Object */ export function asArray(arr: Exclude, string> | undefined): T[]; @@ -68,6 +68,14 @@ export const nullishToEmpty = (obj: T) => obj ?? ''; export const nullishToValue = (obj: T, value: R) => obj ?? value; /** coerce an unknown value into an Error instance */ -export function asError(err: unknown): Error { - return err instanceof Error ? err : new Error(String(err)); +export function asError(err: unknown): Error & { code?: string | number } { + if (isError(err)) return err as Error & { code?: string | number }; + + const error = new Error(isObject(err) && isString(err.message) ? err.message : String(err)); + if (isObject(err)) { + error.name = isString(err.name) ? err.name : 'Error'; + if ('code' in err && (isString(err.code) || isNumber(err.code))) (error as any).code = err.code; + if ('stack' in err && isString(err.stack)) error.stack = err.stack; + } + return error as Error & { code?: string | number }; } diff --git a/packages/library/src/common/temporal.polyfill.ts b/packages/library/src/common/temporal.polyfill.ts index 1519a06..cbe88af 100644 --- a/packages/library/src/common/temporal.polyfill.ts +++ b/packages/library/src/common/temporal.polyfill.ts @@ -16,6 +16,8 @@ if (typeof globalThis.Temporal === 'undefined') { ); } +import { asError } from './coercion.library.js'; + // ๐Ÿ›ก๏ธ Sane Implementation Check // Some early native implementations (e.g. Node 22.0.x) are incomplete and crash on basic arithmetic. // If you encounter "unimplemented code" or V8_Fatal crashes, manually load a polyfill before Tempo. @@ -25,10 +27,11 @@ try { const zdt = Temporal.Now.zonedDateTimeISO(); if (typeof zdt.add !== 'function') throw new Error('Incomplete Temporal implementation'); } -} catch (err: any) { - console.warn('Tempo: Native Temporal implementation appears incomplete. Consider loading a polyfill.', err); - if (err?.message !== 'Incomplete Temporal implementation') - throw err; +} catch (err: unknown) { + const error = asError(err); + console.warn('Tempo: Native Temporal implementation appears incomplete. Consider loading a polyfill.', error); + if (error.message !== 'Incomplete Temporal implementation') + throw error; } export { } diff --git a/packages/library/src/common/webtoken.library.ts b/packages/library/src/common/webtoken.library.ts index d06582b..bde5d88 100644 --- a/packages/library/src/common/webtoken.library.ts +++ b/packages/library/src/common/webtoken.library.ts @@ -1,5 +1,4 @@ -import { base64ToBuffer, bufferToBase64, encodeBuffer } from './buffer.library.js'; -import { isFunction } from './assertion.library.js'; +import { base64ToBuffer, bufferToBase64, encodeBuffer, decodeBuffer } from './buffer.library.js'; import { Logger } from './logger.class.js'; import { keys } from './cipher.library.js'; @@ -17,7 +16,8 @@ export const decodeJWT = (jwt: string): T | null => { // ๐Ÿ›ก๏ธ Base64URL Normalization: replace -/_ with +/ and add padding const base64 = part.replace(/-/g, '+').replace(/_/g, '/').padEnd(part.length + (4 - part.length % 4) % 4, '='); - const payload = isFunction(atob) ? atob(base64) : Buffer.from(base64, 'base64').toString(); + const bytes = base64ToBuffer(base64); + const payload = decodeBuffer(bytes); return JSON.parse(payload); } catch { return null; } @@ -35,7 +35,8 @@ export const verifyJWS = async (token: string, publicKey: CryptoKey): Promise diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 3b64476..caecdf7 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -1,6 +1,6 @@ import '#library/temporal.polyfill.js'; import { enumify } from '#library/enumerate.library.js'; -import { asArray } from '#library/coercion.library.js'; +import { asArray, asError } from '#library/coercion.library.js'; import { getDateTimeFormat, getHemisphere } from '#library/international.library.js'; import { normalizeUtcOffset } from '#library/temporal.library.js'; import { markConfig } from '#library/symbol.library.js'; @@ -13,7 +13,7 @@ import { getStorage } from '#library/storage.library.js'; import { parseLogLevel } from '#library/logger.class.js'; import { getRuntime } from './support.runtime.js'; -import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError, logWarn } from './support.util.js'; +import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError, logWarn, logDebug } from './support.util.js'; import { sym, Token } from './support.symbol.js'; import { Match, Snippet, Layout, Event, Period, Ignore, Default } from './support.default.js'; import { STATE, LICENSE } from './support.enum.js'; @@ -163,14 +163,7 @@ function setLicense(state: t.Internal.State, key: string) { // ๐Ÿ›ก๏ธ Race Condition Guard if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; - const desc = res.status?.description ?? (res.status == null ? '' : String(res.status)); - const statusMap: Record = { - 'active': LICENSE.Active, - 'expired': LICENSE.Expired, - 'revoked': LICENSE.Revoked, - 'invalid': LICENSE.Invalid - }; - runtime.license.status = statusMap[desc] ?? res.status ?? LICENSE.Invalid; + runtime.license.status = res.status ?? LICENSE.Invalid; runtime.license.scopes = res.scopes; delete runtime.license.error; // ๐Ÿšฟ Clear error on every reckoning attempt if (res.error) runtime.license.error = res.error; @@ -189,16 +182,20 @@ function setLicense(state: t.Internal.State, key: string) { runtime.license.error = 'License has been revoked by the issuer.'; logWarn(`โš ๏ธ Tempo Licensing: ${runtime.license.error}`, state.config); } - }).catch(() => { /* silent fail-safe */ }); + }).catch((err: unknown) => { + const { message } = asError(err); + logDebug(`Tempo Licensing: Background revocation check failed for JTI ${initialJti} - ${message}`, state.config); + }); } }, - onReject: (err: any) => { + onReject: (err: unknown) => { if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; + const error = asError(err); runtime.license.status = LICENSE.Invalid; - runtime.license.error = err?.message || 'Verification rejected'; + runtime.license.error = error.message || 'Verification rejected'; logWarn(`โš ๏ธ Tempo Licensing: ${runtime.license.error}`, state.config); } - }; + } runtime.license.jws = new Pledge(argObj as any); } } diff --git a/packages/tempo/src/support/support.license.ts b/packages/tempo/src/support/support.license.ts index bc42ade..8cc41a7 100644 --- a/packages/tempo/src/support/support.license.ts +++ b/packages/tempo/src/support/support.license.ts @@ -15,7 +15,7 @@ export class Validator { async verify() { // Decodes but DOES NOT verify the signature. // Cannot safely unlock Premium Plugins without cryptographic proof. - const claims = decodeJWT(this.key); + decodeJWT(this.key); return { status: 'invalid' as const, scopes: {}, diff --git a/packages/tempo/src/support/support.util.ts b/packages/tempo/src/support/support.util.ts index a11d7ca..c7b8dce 100644 --- a/packages/tempo/src/support/support.util.ts +++ b/packages/tempo/src/support/support.util.ts @@ -8,6 +8,7 @@ import { asArray, asError } from '#library/coercion.library.js'; import { isSymbol, isUndefined, isDefined, isString, isNullish, isObject } from '#library/assertion.library.js'; import { ownEntries, unwrap } from '#library/primitive.library.js'; import { getRuntime } from './support.runtime.js'; +import { LICENSE } from './support.enum.js'; import type * as t from '../tempo.type.js'; /** @internal normalize layout-order options into a clean string array */ @@ -215,3 +216,9 @@ export function resolveMonthDay(value: t.MonthDay | boolean = {}, base: t.MonthD resolvedLocales } } + +/** @internal resolve proprietary license checksums to standard 'active' state */ +export function resolveDisplayStatus(status: symbol | string): string { + const raw = String(status) as LICENSE; + return LICENSE.values().includes(raw) ? raw : LICENSE.Active; +} diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 2fcae93..8f86437 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1,7 +1,7 @@ import '#library/temporal.polyfill.js'; import { Immutable, Serializable } from '#library/class.library.js'; -import { asArray } from '#library/coercion.library.js'; +import { asArray, asError } from '#library/coercion.library.js'; import { getStorage, setStorage } from '#library/storage.library.js'; import { secure, proxify, delegate, indexedArray } from '#library/proxy.library.js'; import { getContext, CONTEXT } from '#library/utility.library.js'; @@ -11,7 +11,7 @@ import { getAccessors, omit } from '#library/reflection.library.js'; 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, isError } from '#library/assertion.library.js'; +import { isEmpty, isDefined, isUndefined, isString, isObject, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike, isError, isNumber } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; import { LOG } from '#library/logger.class.js'; @@ -24,7 +24,7 @@ import type { TermPlugin, PremiumPlugin } from './plugin/term/term.type.js'; import { AliasEngine } from './engine/engine.alias.js'; import { PatternCompiler } from './engine/engine.pattern.js'; import { createMasterGuard } from './engine/engine.guard.js'; -import { resolveMonthDay, setProperty, proto, hasOwn, normalizeLayoutOrder } from './support/support.util.js'; +import { resolveMonthDay, setProperty, proto, hasOwn, resolveDisplayStatus } from './support/support.util.js'; import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './engine/engine.layout.js'; import { datePattern } from './support/support.default.js'; import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $errored, $guard, $Discover, $setDiscovery, $LogConfig, logError, logDebug, logWarn, logTempo, setLogLevel } from '#tempo/support'; @@ -102,23 +102,23 @@ export class Tempo { /** human-readable formatted license state */ static get license() { const { jws, key, ...raw } = Tempo._license; // omit internal Pledge and JWT string from user-facing snapshot const ss = { timeStamp: 'ss' } as const; // JWT timestamps are always in seconds (RFC 7519) - const scopesSource = (raw.scopes && typeof raw.scopes === 'object') ? raw.scopes : {}; + const scopesSource = (raw.scopes && isObject(raw.scopes)) ? raw.scopes : {}; const scopes = Object.fromEntries( Object.entries(scopesSource).map(([key, scope]) => { const s = scope as any; return [key, { ...s, - ...(typeof s.exp === 'number' && { exp: new Tempo(s.exp, ss).fmt.weekTime }), - ...(typeof s.updated_at === 'number' && { updated_at: new Tempo(s.updated_at, ss).fmt.weekTime }), + ...(isNumber(s.exp) && { exp: new Tempo(s.exp, ss).fmt.weekTime }), + ...(isNumber(s.updated_at) && { updated_at: new Tempo(s.updated_at, ss).fmt.weekTime }), }]; }) ); return secure({ ...raw, - status: ['expired', 'revoked', 'invalid', 'none', 'unauthorized'].includes(raw.status) ? raw.status : 'active', + status: resolveDisplayStatus(raw.status), scopes, - ...(typeof raw.expires === 'number' && { expires: new Tempo(raw.expires, ss).fmt.weekTime }), - ...(typeof raw.issuedAt === 'number' && { issuedAt: new Tempo(raw.issuedAt, ss).fmt.weekTime }), + ...(isNumber(raw.expires) && { expires: new Tempo(raw.expires, ss).fmt.weekTime }), + ...(isNumber(raw.issuedAt) && { issuedAt: new Tempo(raw.issuedAt, ss).fmt.weekTime }), }); } /** mapping of terms to their resolved values */ private static _termMap: Map = new Map(); @@ -634,6 +634,20 @@ export class Tempo { return SandboxTempo as unknown as typeof Tempo; } + /** + * Wait for the background licensing and validation engine to finish settling. + * Resolves immediately if no license key or validation is pending. + * @returns The final, human-readable license status ('none', 'active', 'expired', etc.) + */ + static async ready(): Promise { + const rt = getRuntime(); + const jws = rt.license?.jws; + if (jws) { + try { await jws; } catch { /* fail-safe */ } + } + return resolveDisplayStatus(rt.license.status); + } + /** Reset Tempo to its default, built-in registration state */ static init(options: t.Options = {}): typeof Tempo { if (_lifecycle.initialising) return this; @@ -876,7 +890,7 @@ export class Tempo { const item = { ...rest } as any; if (hasOwn(rt.license.scopes, rest.key)) { const meta = rt.license.scopes[rest.key]; - item.status = rt.license.status; + item.status = resolveDisplayStatus(rt.license.status); item.expires = meta.exp ?? rt.license.expires; if (meta.updated_at) item.updated = meta.updated_at; } @@ -889,7 +903,7 @@ export class Tempo { list.push({ key: scope, scope, - status: rt.license.status, + status: resolveDisplayStatus(rt.license.status), expires: meta.exp ?? rt.license.expires, updated: meta.updated_at, description: `Premium plugin (${scope})` @@ -1252,11 +1266,12 @@ export class Tempo { const result = term.define.call(this, keyOnly); const res = Array.isArray(result) ? getTermRange(this, result, keyOnly) : result; return isObject(res) ? secure(res) : res; - } catch (err: any) { - if (err.message.includes('Class constructor')) { - logWarn(`Misidentified class in term definition: ${key}`, this.#local.config, err.stack ?? err); + } catch (err: unknown) { + const error = asError(err); + if (error.message.includes('Class constructor')) { + logWarn(`Misidentified class in term definition: ${key}`, this.#local.config, error.stack ?? error); } else { - throw err; + throw error; } } @@ -1290,11 +1305,12 @@ export class Tempo { const res = term.resolve ? term.resolve.call(this, anchor) : term.define.call(this, keyOnly, anchor); const out = (getTermRange(this, (Array.isArray(res) ? (res as any) : [res]), keyOnly, anchor) as any); return isObject(out) ? secure(out) : out; - } catch (err: any) { - if (err.message.includes('Class constructor')) { - logWarn(`Misidentified class in term discovery: ${term.key}`, this.#local.config, err.stack ?? err); + } catch (err: unknown) { + const error = asError(err); + if (error.message.includes('Class constructor')) { + logWarn(`Misidentified class in term discovery: ${term.key}`, this.#local.config, error.stack ?? error); } else { - throw err; + throw error; } } }; From e4d364d12e62e43db4cf835c82fb4585bf23532a Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 7 Jun 2026 20:13:33 +1000 Subject: [PATCH 20/20] PR 5th review --- packages/tempo/src/support/support.enum.ts | 1 + packages/tempo/src/support/support.init.ts | 7 ++++--- packages/tempo/src/support/support.util.ts | 14 ++++++++++---- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index c5ac34f..063431a 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -260,6 +260,7 @@ export const LICENSE = enumify({ Revoked: 'revoked', Invalid: 'invalid', Unauthorized: 'unauthorized', + Unknown: 'unknown', }, false); export type LICENSE = ValueOf diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index caecdf7..47dcfca 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -13,7 +13,7 @@ import { getStorage } from '#library/storage.library.js'; import { parseLogLevel } from '#library/logger.class.js'; import { getRuntime } from './support.runtime.js'; -import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError, logWarn, logDebug } from './support.util.js'; +import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError, logWarn, logDebug, isSyncToken } from './support.util.js'; import { sym, Token } from './support.symbol.js'; import { Match, Snippet, Layout, Event, Period, Ignore, Default } from './support.default.js'; import { STATE, LICENSE } from './support.enum.js'; @@ -163,8 +163,9 @@ function setLicense(state: t.Internal.State, key: string) { // ๐Ÿ›ก๏ธ Race Condition Guard if (runtime.license.jti !== initialJti || runtime.license.key !== initialKey) return; - runtime.license.status = res.status ?? LICENSE.Invalid; - runtime.license.scopes = res.scopes; + const isValidStatus = isSyncToken(res.status) || LICENSE.values().includes(res.status); + runtime.license.status = isValidStatus ? res.status : LICENSE.Invalid; + runtime.license.scopes = isObject(res.scopes) ? res.scopes : {}; delete runtime.license.error; // ๐Ÿšฟ Clear error on every reckoning attempt if (res.error) runtime.license.error = res.error; if (res.expires) runtime.license.expires = res.expires; diff --git a/packages/tempo/src/support/support.util.ts b/packages/tempo/src/support/support.util.ts index c7b8dce..a170463 100644 --- a/packages/tempo/src/support/support.util.ts +++ b/packages/tempo/src/support/support.util.ts @@ -216,9 +216,15 @@ export function resolveMonthDay(value: t.MonthDay | boolean = {}, base: t.MonthD resolvedLocales } } +/** @internal identify valid sync tokens */ +export function isSyncToken(status: any): status is string { + return isString(status) && /^[0-9a-f]{8}$/.test(status); +} -/** @internal resolve proprietary license checksums to standard 'active' state */ -export function resolveDisplayStatus(status: symbol | string): string { - const raw = String(status) as LICENSE; - return LICENSE.values().includes(raw) ? raw : LICENSE.Active; +/** @internal resolve licensing state to standard 'active' state */ +export function resolveDisplayStatus(status: string): string { + const raw = isSyncToken(status) + ? LICENSE.Active + : String(status) as LICENSE + return LICENSE.values().includes(raw) ? raw : LICENSE.Unknown; }