diff --git a/CHANGELOG.md b/CHANGELOG.md index fd139be4..259b0411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.2.5] - 2026-04-20 + +### Added +- **Cross-Bundle Singleton Stability**: Implemented a symbol-based brand check for `TempoRuntime` to ensure reliable singleton resolution even when multiple versions of the library are loaded. + +### Changed +- **Consolidated Internal Storage**: Merged redundant internal term/plugin arrays into a unified, validated `pluginsDb` within `TempoRuntime`, reducing memory overhead and improving consistency. +- **Refined Year Semantics**: Normalized the `year` component in term templates to intelligently distinguish between relative offsets (e.g., year `0`) and absolute historical years (e.g., year `2000`). +- **Improved Type Safety**: Introduced `MatchResult` as a type alias for `Internal.Match` to resolve naming conflicts with the `Match` runtime class, while maintaining the public `Match` export for backward compatibility. + +### Fixed +- **Term Resolution Accuracy**: Fixed a sorting bug in the yearly-cycle resolution engine that caused incorrect anchor identification for non-calendar-ordered term groups (e.g., seasons). +- **Documentation Integrity**: Updated architecture and README guides to point to the correct `#tempo/support` module and provided functional, complete importmap examples for browser environments. +- **HTML Standards Compliance**: Wrapped library demonstration and test pages in proper HTML5 skeletons to ensure consistent rendering and prevent quirks-mode issues. +- **Package Optimization**: Refined `sideEffects` in `package.json` to exclude non-published source files, improving tree-shaking for consumer builds. + + ## [2.2.4] - 2026-04-19 ### Fixed diff --git a/package-lock.json b/package-lock.json index 8332c32a..7a5b3cd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "2.2.2", + "version": "2.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "2.2.2", + "version": "2.2.4", "workspaces": [ "packages/*" ], @@ -9972,7 +9972,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "2.2.2", + "version": "2.2.4", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -9991,15 +9991,16 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "2.2.2", + "version": "2.2.4", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.2.2", + "@magmacomputing/library": "2.2.4", "@rollup/plugin-alias": "^6.0.0", + "cross-env": "^7.0.3", "magic-string": "^0.30.21", "typedoc": "^0.28.19", "typedoc-plugin-markdown": "^4.11.0", diff --git a/package.json b/package.json index 7428809e..62a470c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.2.4", + "version": "2.2.5", "private": true, "description": "Magma Computing Monorepo", "repository": { @@ -41,4 +41,4 @@ "typescript": "^6.0.2", "vitest": "^2.1.8" } -} +} \ No newline at end of file diff --git a/packages/library/package.json b/packages/library/package.json index a6d51b36..98bc325e 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.2.4", + "version": "2.2.5", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/array.library.ts b/packages/library/src/common/array.library.ts index 3b8baaf7..d586c0f0 100644 --- a/packages/library/src/common/array.library.ts +++ b/packages/library/src/common/array.library.ts @@ -1,7 +1,7 @@ import { asString } from '#library/coercion.library.js'; import { extract, ownEntries } from '#library/primitive.library.js'; import { stringify } from '#library/serialize.library.js'; -import { isNumber, isDate, isTempo, isObject, isDefined, isUndefined, isFunction, nullToValue } from '#library/type.library.js'; +import { isNumber, isDate, isObject, isDefined, isUndefined, isFunction, nullToValue } from '#library/type.library.js'; import type { Property } from '#library/type.library.js'; // adapted from https://jsbin.com/insert/4/edit?js,output @@ -54,8 +54,8 @@ export function sortBy>(...keys: (PropertyKey | SortBy)[]) switch (true) { case isNumber(valueA) && isNumber(valueB): case isDate(valueA) && isDate(valueB): - case isTempo(valueA) && isTempo(valueB): - result = dir * (valueA - valueB); + case isObject(valueA) && isObject(valueB) && typeof valueA.valueOf() === 'number' && typeof valueB.valueOf() === 'number': + result = (dir as any) * ((valueA as any) - (valueB as any)); break; default: diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index d59eb982..b77a256c 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -1,6 +1,5 @@ import lib from '#library/symbol.library.js'; -const $isTempo = Symbol.for('$isTempo'); const registry: Instance[] = []; // global types for getType /** the primitive type reported by toStringTag() */ @@ -115,10 +114,6 @@ export const isPlainYearMonth = (obj: T): obj is Extract(obj: T): obj is Extract => isType(obj, 'Temporal.PlainMonthDay') || (!!(globalThis as any).Temporal?.PlainMonthDay && (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay); // non-standard Objects -export const isTempo = (obj?: T): obj is Extract> => { - const raw = (obj as any)?.[lib.$Target] ?? obj; // bypass Proxy traps - return !!(raw?.[$isTempo]); -} export const isEnum = >(obj?: T): obj is Extract> => isType(obj, 'Enumify'); export const isPledge = (obj?: T): obj is Extract> => isType(obj, 'Pledge'); @@ -172,7 +167,7 @@ const isClassConstructor = (obj: any): boolean => { const tag = raw?.[Symbol.toStringTag] ?? raw?.prototype?.[Symbol.toStringTag]; // Absolute bypass for Tempo and Temporal identities (using global brands) - if (raw?.[$isTempo] || name === 'Tempo' || tag === 'Tempo' || (typeof tag === 'string' && (tag.startsWith('Temporal.') || tag.startsWith('Tempo.')))) return true; + if (name === 'Tempo' || tag === 'Tempo' || (typeof tag === 'string' && (tag.startsWith('Temporal.') || tag.startsWith('Tempo.')))) return true; if (typeof tag === 'string' && tag.endsWith('Function')) return false; // check the tag directly to avoid misidentifying function as class const globalRegistry = (globalThis as any)[lib.$Registry] ?? []; diff --git a/packages/tempo/README.md b/packages/tempo/README.md index 4d3cf3af..d30dde7c 100644 --- a/packages/tempo/README.md +++ b/packages/tempo/README.md @@ -38,7 +38,7 @@ Working with `Date` in JavaScript has historically been painful. The new `Tempor - **Natural Language**: Supports word-based numbers (0-10) in relative parsing (e.g., "two days ago"). - **Fluent API**: Chainable methods for adding, subtracting, and setting date-times (similar to Moment.js). - **Formatting**: Use custom tokens to format date-times in a way that is both intuitive and flexible. -- **Plugin**: Extend core functionality safely; built-ins (like the Ticker) are ready-to-use in the full package, or can be opted-into via side-effect imports when using the lean Core engine. +- **Plugin**: Extend core functionality safely; all extensions (including the Ticker) are opted-into via side-effect imports or explicit registration, ensuring a lean footprint even in the full package. - **Terms**: Access complex date ranges (Quarters, Seasons, Zodiacs) easily. - **Immutable**: Operations (like `set` and `add`) return a new `Tempo` instance, ensuring thread safety and predictability. ## πŸ€” Why Tempo? @@ -87,12 +87,14 @@ Since Tempo is a native ESM package, you can use it directly in modern browsers @@ -102,7 +104,8 @@ Since Tempo is a native ESM package, you can use it directly in modern browsers For environments without `importmap` support or simple prototypes, use the global bundle. This automatically attaches the `Tempo` class to the `window` object. ```html - + + ``` +> [!TIP] +> **CDN Versioning**: The examples above use pinned versions (`@magmacomputing/tempo@2`, `@magmacomputing/library@2`, `@js-temporal/polyfill@0.5`) for production stability. To use the latest releases, you can omit the version string from every URL (e.g., remove `@2` from all Magma entries and `@0.5` from the polyfill). Ensure all `@magmacomputing/...` entries resolve to the same release to avoid mixed-version loading. + --- ## πŸ“š Documentation diff --git a/packages/tempo/bin/core.ts b/packages/tempo/bin/core.ts index 9bbb03f9..ce1255da 100644 --- a/packages/tempo/bin/core.ts +++ b/packages/tempo/bin/core.ts @@ -1,9 +1,8 @@ import { Tempo, enums } from '#tempo/core'; -import { stringify, objectify, enumify, getType } from '#library'; -import { Token, Snippet } from '#tempo/tempo.default.js'; +import { stringify, objectify, enumify, getType, Pledge } from '#library'; -// Pre-load Tempo and Token to the global scope for ease of use in the core REPL -Object.assign(globalThis, { Tempo, Token, Snippet, getType, stringify, objectify, enumify, enums }); +// Pre-load core symbols (Tempo, getType, stringify, objectify, enumify, Pledge, enums) to the global scope +Object.assign(globalThis, { Tempo, getType, stringify, objectify, enumify, Pledge, enums }); console.log(`\n\x1b[38;2;252;194;1m\x1b[1m ⏳ Tempo (core) \x1b[0m\x1b[38;2;45;212;191mREPL initialized (core only).\x1b[0m\n`); diff --git a/packages/tempo/doc/Tempo.md b/packages/tempo/doc/Tempo.md index da8ed722..7611dd99 100644 --- a/packages/tempo/doc/Tempo.md +++ b/packages/tempo/doc/Tempo.md @@ -29,16 +29,10 @@ Tempo is an ESM-first library. You can use it in the browser without a build ste @@ -49,12 +43,15 @@ Tempo is an ESM-first library. You can use it in the browser without a build ste For legacy environments or simple prototypes, use the single-file bundle: ```html - + ``` +> [!TIP] +> **CDN Versioning**: The examples above use pinned versions (`@magmacomputing/tempo@2`, `@magmacomputing/library@2`, `@js-temporal/polyfill@0.5`) for production stability. To use the latest releases, you can omit the version string from every URL (e.g., remove `@2` from all Magma entries and `@0.5` from the polyfill). Ensure all `@magmacomputing/...` entries resolve to the same release to avoid mixed-version loading. + --- ## Installation diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index f3c3ae54..7fbcb175 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -3,6 +3,27 @@ Tempo v2.0.1 introduces several industry-leading architectural patterns designed for maximum resilience in complex Monorepo and Proxy-wrapped environments. ## 🌐 Shared Global Registry + +### TempoRuntime β€” single hardened bridge (v2.2+) + +Prior to v2.2, Tempo spread its inter-module state across many `globalThis[Symbol.for(…)]` slots (`$terms`, `$extends`, `$modules`, `$installed`, `$reset`, `$Plugins`, `$Register`). Each slot was a potential tamper target and the scattered writes made the global namespace harder to audit. + +As of v2.2, all of that bookkeeping is consolidated inside a single **`TempoRuntime`** object (`#tempo/support`). The runtime is stored on `globalThis` under one hardened property: + +```typescript +Symbol.for('magmacomputing/tempo/runtime') +``` + +The property descriptor is `enumerable: false, configurable: false, writable: false`. External code can neither replace nor delete the runtime. + +**Benefits:** +- **Reduced global footprint** β€” one slot instead of seven. +- **Centralised hardening** β€” input validation (`addTerm`, `addPlugin`) and hook management (`setRegisterHook`, `fireRegisterHook`) live in one place. +- **Scoped runtimes (Experimental)** β€” `TempoRuntime.createScoped()` returns a fresh, isolated runtime that is *not* stored on `globalThis`, enabling clean test isolation without globalThis manipulation. **Note**: Scoped runtimes are currently an experimental internal feature and are not yet fully threaded through all core utilities. Scoped runtimes are not pinned to `globalThis`, lack the `defineProperty` descriptor protections of the primary instance, and instead rely solely on the lexical reference returned (contrasting with the hardened `getRuntime()` and `globalThis[BRIDGE]` behavior). Implementation examples of this test-scoping pattern can be found in [plugin_registration.test.ts](../test/plugin_registration.test.ts) and [duration.core.test.ts](../test/duration.core.test.ts). +- **Multi-bundle / HMR safety** β€” `getRuntime()` checks `globalThis[BRIDGE]` before constructing, so two bundle copies of Tempo always share the same runtime object, preserving the original split-brain guarantee. + +**User-facing "Global Discovery" slots remain on `globalThis`.** The `sym.$Tempo` slot (and custom discovery symbols passed to `Tempo.init`) are intentionally user-readable, so they stay as ordinary writable properties. Only internal bookkeeping moved into the runtime. + 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) diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index 27a4a5a7..aca6f08a 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -1,6 +1,25 @@ # πŸ“œ Version 2.x History -## [v2.2.3] - 2026-04-20 +## [v2.2.5] - 2026-04-20 +### πŸ—οΈ Modular Hardening +- **Singleton Resilience**: Replaced `instanceof` checks in `TempoRuntime` with a cross-bundle brand check, ensuring singletons are correctly adopted across bundle boundaries and HMR reloads. +- **Unified Discovery**: Consolidated internal storage for term and extension plugins into a single, validated `pluginsDb` structure. +- **Resolved Shadowing**: Eliminated parameter shadowing in runtime hook methods to ensure reliable event emission. + +### πŸ” Term Resolution Refinements +- **Chronological Stability**: Enforced chronological sorting in `resolveCycleWindow` for accurate cycle anchor identification. +- **Absolute vs Relative Years**: Normalized year handling to allow templates to mix relative offsets and absolute historical years seamlessly. + +### πŸ“š Documentation & UX +- **Standards Mode**: Modernized all public demonstration files with proper HTML5 skeletons and metadata. +- **Contextual Guidance**: Updated architecture docs and READMEs with accurate module paths and functional importmap examples. + +## [v2.2.4] - 2026-04-19 +### πŸ›‘οΈ Production Safety +- **Immutable Compatibility**: Added redefinition guards to `TickerModule` to prevent errors on already-frozen classes. +- **ESM Integrity**: Bundled `tslib` into granular ESM builds to resolve resolution failures in standard browser environments. + +## [v2.2.3] - 2026-04-19 ### New Features - Modular parse engine and standalone parsing support diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 4cdcb1c4..212a13bb 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "2.2.4", + "version": "2.2.5", "description": "The Tempo core library", "author": "Magma Computing Solutions", "license": "MIT", @@ -27,11 +27,13 @@ "**/*-polyfill.ts", "**/module.*.js", "**/module.*.ts", - "**/tempo.index.js", - "src/tempo.index.ts" + "**/tempo.index.js" ], "main": "dist/tempo.index.js", "types": "dist/tempo.index.d.ts", + "browser": "dist/tempo.bundle.js", + "unpkg": "dist/tempo.bundle.js", + "jsdelivr": "dist/tempo.bundle.js", "imports": { "#library": "@magmacomputing/library", "#library/*.js": "@magmacomputing/library/common/*.js", @@ -91,6 +93,14 @@ "development": "./src/plugin/term/term.*.ts", "default": "./dist/plugin/term/term.*.js" }, + "#tempo/support": { + "development": "./src/support/support.index.ts", + "default": "./dist/support/support.index.js" + }, + "#tempo/support/*.js": { + "development": "./src/support/*.ts", + "default": "./dist/support/*.js" + }, "#tempo/*.js": { "development": "./src/*.ts", "default": "./dist/*.js" @@ -102,8 +112,8 @@ "import": "./dist/tempo.index.js" }, "./enums": { - "types": "./dist/tempo.enum.d.ts", - "import": "./dist/tempo.enum.js" + "types": "./dist/support/tempo.enum.d.ts", + "import": "./dist/support/tempo.enum.js" }, "./extend/*": { "types": "./dist/plugin/extend/extend.*.d.ts", @@ -199,7 +209,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.2.4", + "@magmacomputing/library": "2.2.5", "@rollup/plugin-alias": "^6.0.0", "cross-env": "^7.0.3", "magic-string": "^0.30.21", diff --git a/packages/tempo/public/bundle.index.html b/packages/tempo/public/bundle.index.html new file mode 100644 index 00000000..57303da9 --- /dev/null +++ b/packages/tempo/public/bundle.index.html @@ -0,0 +1,25 @@ + + + + + + Tempo Bundle Test + + + + + +

Open the console to see the output.

+ + \ No newline at end of file diff --git a/packages/tempo/public/esm.index.html b/packages/tempo/public/esm.index.html new file mode 100644 index 00000000..80d655de --- /dev/null +++ b/packages/tempo/public/esm.index.html @@ -0,0 +1,36 @@ + + + + + Tempo ESM Test + + + + + + +

Open the console to see the output.

+ + \ No newline at end of file diff --git a/packages/tempo/public/script.index.html b/packages/tempo/public/script.index.html new file mode 100644 index 00000000..3d930a04 --- /dev/null +++ b/packages/tempo/public/script.index.html @@ -0,0 +1,16 @@ + + + + + Tempo Script Test + + + + + +

Open the console to see the output.

+ + \ No newline at end of file diff --git a/packages/tempo/src/core.index.ts b/packages/tempo/src/core.index.ts index 26ccb2e5..d429bc43 100644 --- a/packages/tempo/src/core.index.ts +++ b/packages/tempo/src/core.index.ts @@ -1,5 +1,2 @@ export * from './tempo.class.js'; -export { default as enums } from './tempo.enum.js'; - -// export common patterns and symbols for custom Layouts -export { Token, Snippet, Match, Default, Guard } from './tempo.default.js'; +export { enums, Token, Snippet, Match, Default, Guard } from '#tempo/support'; diff --git a/packages/tempo/src/plugin/extend/extend.ticker.ts b/packages/tempo/src/plugin/extend/extend.ticker.ts index a902e197..658fcc3b 100644 --- a/packages/tempo/src/plugin/extend/extend.ticker.ts +++ b/packages/tempo/src/plugin/extend/extend.ticker.ts @@ -4,9 +4,9 @@ import { asArray, isNumeric } from '#library/coercion.library.js' import { instant, normaliseFractionalDurations } from '#library/temporal.library.js' import { markConfig } from '#library/symbol.library.js' -import { DURATIONS } from '../../tempo.enum.js' -import { defineExtension } from '../plugin.util.js' -import sym from '../../tempo.symbol.js'; +import { DURATIONS } from '../../support/tempo.enum.js' +import { defineExtension, attachStatics } from '../plugin.util.js' +import sym from '../../support/tempo.symbol.js'; import type { Tempo } from '../../tempo.class.js' import type { TempoType } from '../plugin.type.js' @@ -356,10 +356,8 @@ class TickerInstance implements Ticker.Descriptor { export const TickerModule: Tempo.Extension = defineExtension({ name: 'TickerModule', install(this: Tempo, TempoClass: TempoType) { - if (Object.hasOwn(TempoClass, 'ticker')) return; - - Object.defineProperty(TempoClass, 'ticker', { - value: function (this: TempoType, arg1: any, arg2?: any): Ticker.Instance { + attachStatics(TempoClass, { + ticker: function (this: TempoType, arg1: any, arg2?: any): Ticker.Instance { const instance = new TickerInstance(this as unknown as TempoType, arg1, arg2); const proxy = new Proxy((() => instance.stop()) as any, { get: (_, prop) => { @@ -380,17 +378,9 @@ export const TickerModule: Tempo.Extension = defineExtension({ return instance.bootstrap(proxy); }, - writable: true, - configurable: true, - enumerable: true - }); - - if (Object.hasOwn(TempoClass, 'tickers')) return; - - Object.defineProperty(TempoClass, 'tickers', { - get: () => Ticker.active, - enumerable: true, - configurable: true + tickers: { + get: () => Ticker.active + } }); }, }); diff --git a/packages/tempo/src/plugin/module/module.composer.ts b/packages/tempo/src/plugin/module/module.composer.ts index 921dee97..62fce4c0 100644 --- a/packages/tempo/src/plugin/module/module.composer.ts +++ b/packages/tempo/src/plugin/module/module.composer.ts @@ -1,6 +1,6 @@ import { isNumeric } from '#library/coercion.library.js'; -import { Match } from '../../tempo.default.js'; -import { TemporalObject, TypeValue, isInstant, isZonedDateTime, isPlainDate, isPlainDateTime, isTempo } from '#library/type.library.js'; +import { isTempo, Match } from '#tempo/support'; +import { TemporalObject, TypeValue, isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/type.library.js'; import type { Tempo } from '#tempo/tempo.class.js'; /** diff --git a/packages/tempo/src/plugin/module/module.duration.ts b/packages/tempo/src/plugin/module/module.duration.ts index e89cf143..d9e4bedd 100644 --- a/packages/tempo/src/plugin/module/module.duration.ts +++ b/packages/tempo/src/plugin/module/module.duration.ts @@ -4,9 +4,8 @@ import { getAccessors } from '#library/reflection.library.js'; import { ifDefined } from '#library/object.library.js'; import { getRelativeTime } from '#library/international.library.js'; -import { defineModule, interpret } from '../plugin.util.js'; -import enums from '../../tempo.enum.js'; -import sym from '../../tempo.symbol.js'; +import { defineInterpreterModule, interpret } from '../plugin.util.js'; +import enums from '../../support/tempo.enum.js'; import type { Tempo } from '../../tempo.class.js'; declare module '../../tempo.class.js' { @@ -18,7 +17,7 @@ declare module '../../tempo.class.js' { interface Tempo { /** time duration until (returns Duration) */ until(dateTimeOrOpts?: Tempo.DateTime | Tempo.Options, opts?: Tempo.Options): Tempo.Duration; /** time duration until (with unit, returns number) */ until(unit: Tempo.Unit, opts?: Tempo.Options): number; - /** time duration until another date-time (with unit ) */until(dateTimeOrOpts: Tempo.DateTime | Tempo.Options, unit: Tempo.Unit): number; + /** time duration until another date-time (with unit) */until(dateTimeOrOpts: Tempo.DateTime | Tempo.Options, unit: Tempo.Unit): number; /** fallback: union of possible returns */ until(optsOrDate?: Tempo.DateTime | Tempo.Until | Tempo.Options, optsOrUntil?: Tempo.Options | Tempo.Until): number | Tempo.Duration; /** time elapsed since (with unit) */ since(until: Tempo.Until, opts?: Tempo.Options): string; @@ -28,6 +27,12 @@ declare module '../../tempo.class.js' { } } +declare module '#library/type.library.js' { + interface TypeValueMap { + 'Tempo.Duration': { type: 'Tempo.Duration', value: Tempo.Duration }; + } +} + /** * Convert a Temporal.Duration to a full Tempo.Duration object (EDO). */ @@ -149,18 +154,8 @@ duration.toDuration = (input: string | Temporal.DurationLikeObject) => { /** * Functional Module to attach duration methods to Tempo. */ -export const DurationModule: Tempo.Module = defineModule({ - name: 'duration', - install(this: Tempo, TempoClass: typeof Tempo) { - // 1. Register logic in the global interpreter registry - const modules = (globalThis as any)[sym.$modules] ??= {}; - if (isUndefined(modules['DurationModule'])) { - modules['DurationModule'] = duration; - } - - // 2. Inject the static helper - (TempoClass as any).duration = function (this: typeof Tempo, input: any) { - return interpret(this, 'DurationModule', 'toDuration', false, input); - }; +export const DurationModule: Tempo.Module = defineInterpreterModule('DurationModule', duration, { + duration(this: typeof Tempo, input: any) { + return interpret(this, 'DurationModule', 'toDuration', false, input); } }); diff --git a/packages/tempo/src/plugin/module/module.format.ts b/packages/tempo/src/plugin/module/module.format.ts index 8ee7db09..ac8f1f8f 100644 --- a/packages/tempo/src/plugin/module/module.format.ts +++ b/packages/tempo/src/plugin/module/module.format.ts @@ -3,8 +3,8 @@ import { pad } from '#library/string.library.js'; import { ifNumeric } from '#library/coercion.library.js'; import { defineInterpreterModule } from '../plugin.util.js'; -import { Match } from '../../tempo.default.js'; -import { NumericPattern } from '../../tempo.enum.js'; +import { Match } from '../../support/tempo.default.js'; +import { NumericPattern } from '../../support/tempo.enum.js'; import type { Tempo } from '../../tempo.class.js'; declare module '../../tempo.class.js' { diff --git a/packages/tempo/src/plugin/module/module.lexer.ts b/packages/tempo/src/plugin/module/module.lexer.ts index f49f8ab3..18550848 100644 --- a/packages/tempo/src/plugin/module/module.lexer.ts +++ b/packages/tempo/src/plugin/module/module.lexer.ts @@ -2,8 +2,8 @@ import '#library/temporal.polyfill.js'; import { isString, isEmpty, isUndefined, isDefined, isTemporal } from '#library/type.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import { pad, singular } from '#library/string.library.js'; -import { Match } from '../../tempo.default.js'; -import enums from '../../tempo.enum.js'; +import { Match } from '../../support/tempo.default.js'; +import enums from '../../support/tempo.enum.js'; import * as t from '../../tempo.type.js'; /** diff --git a/packages/tempo/src/plugin/module/module.mutate.ts b/packages/tempo/src/plugin/module/module.mutate.ts index a90a4f04..c29ea7b8 100644 --- a/packages/tempo/src/plugin/module/module.mutate.ts +++ b/packages/tempo/src/plugin/module/module.mutate.ts @@ -1,13 +1,19 @@ import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#library/type.library.js'; import { singular } from '#library/string.library.js'; -import { sym } from '../../tempo.symbol.js'; -import enums from '../../tempo.enum.js'; -import { REGISTRY, _MODULES } from '../../tempo.register.js'; -import { defineInterpreterModule, findTermPlugin, getHost } from '../plugin.util.js'; +import sym from '../../support/tempo.symbol.js'; +import enums from '../../support/tempo.enum.js'; +import { defineInterpreterModule } from '../plugin.util.js'; +import { findTermPlugin } from '../term.util.js'; import { resolveTermMutation } from './module.term.js'; import type { Tempo } from '../../tempo.class.js'; import type * as t from '../../tempo.type.js'; +declare module '#library/type.library.js' { + interface TypeValueMap { + Mutation: { type: 'Mutation', value: any }; + } +} + /** * MutateModule logic for Tempo.add and Tempo.set */ @@ -31,7 +37,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options let zdt = selfZdt.withTimeZone(overrides.timeZone).withCalendar(overrides.calendar); state.parseDepth++; const isRoot = state.parseDepth === 1; - if (isRoot) state.matches = Array.isArray(this.parse?.result) ? [...this.parse.result] : []; + const matches = Array.isArray(this.parse?.result) ? Array.from(this.parse.result) : []; try { if (isDefined(args)) { @@ -92,11 +98,10 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options })(key, adjust, type); const slug = `${op}.${single}`; - const parseInner = (input: any, anchor?: any) => { const res = (this.constructor as any).from(input, { ...this.config, anchor }); if (res.isValid) { - state.matches.push(...res.parse.result); + matches.push(...res.parse.result); return res.toDateTime(); } return undefined; @@ -170,20 +175,22 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options 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, result: state.matches, anchor: zdt, [sym.$errored]: state.errored, [sym.$mutateDepth]: state.mutateDepth }); + 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, result: state.matches, [sym.$errored]: true, [sym.$mutateDepth]: state.mutateDepth }); + return new (this.constructor as any)(null, { ...state.options, ...overrides, ...options, [sym.$Internal]: { ...state, matches } }); } // @ts-ignore - return new (this.constructor as any)(zdt, { ...state.options, ...overrides, ...options, result: state.matches, anchor: zdt, [sym.$errored]: state.errored, [sym.$mutateDepth]: state.mutateDepth }); + 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 { - if (isRoot) state.matches = undefined; state.parseDepth--; } } diff --git a/packages/tempo/src/plugin/module/module.parse.ts b/packages/tempo/src/plugin/module/module.parse.ts index 3b76036d..c2906548 100644 --- a/packages/tempo/src/plugin/module/module.parse.ts +++ b/packages/tempo/src/plugin/module/module.parse.ts @@ -1,18 +1,16 @@ import '#library/temporal.polyfill.js'; -import { asType, isNull, isString, isObject, isZonedDateTime, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/type.library.js'; -import { asInteger, isNumeric } from '#library/coercion.library.js'; +import { asType, isNull, isString, isObject, isZonedDateTime, isDefined, isUndefined, isIntegerLike, isEmpty, type TypeValue } from '#library/type.library.js'; +import { asArray, asInteger, isNumeric } from '#library/coercion.library.js'; import { instant } from '#library/temporal.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import type { Tempo } from '../../tempo.class.js'; -import { isTempo } from '../../tempo.symbol.js'; import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './module.lexer.js'; -import { _MODULES } from '../../tempo.register.js'; -import { Match } from '../../tempo.default.js'; +import sym, { isTempo, Match, getRuntime } from '#tempo/support'; import { resolveTermMutation, resolveTermValue } from './module.term.js'; import { compose } from './module.composer.js'; -import { getRange, getTermRange, defineInterpreterModule } from '../plugin.util.js'; -import { sym } from '../../tempo.symbol.js'; +import { defineInterpreterModule } from '../plugin.util.js'; +import { getRange, getTermRange } from '../term.util.js'; import * as t from '../../tempo.type.js'; /** @@ -43,10 +41,11 @@ const ParseEngine = { today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : instant().toZonedDateTimeISO(tz).withCalendar(cal)); const TempoClass = this.constructor as typeof Tempo; + const terms = getRuntime().pluginsDb.terms; if (term) { const ident = term.startsWith('#') ? term.slice(1) : term; - const termObj = (TempoClass as any)[sym.$terms].find((t: any) => t.key === ident || t.scope === ident); + const termObj = terms.find((termEntry: any) => termEntry.key === ident || termEntry.scope === ident); if (!termObj) { (TempoClass as any)[sym.$termError](state.config, term); return undefined as any; @@ -76,8 +75,8 @@ const ParseEngine = { if (tempo === term) { const range = termObj.define.call(this, false, today); - const list = isUndefined(range) ? [] : (Array.isArray(range) ? range : [range]); - const current = (getTermRange(this, list, false, today) as any); + const list = isUndefined(range) ? [] : asArray(range as t.Range | t.Range[]); + const current = getTermRange(this, list, false, today) as t.ResolvedRange | undefined; if (current?.start) return current.start.toDateTime().withTimeZone(tz).withCalendar(cal); } } @@ -88,15 +87,19 @@ const ParseEngine = { return undefined as any; } - if (isUndefined(term) && isObject(tempo) && Object.keys(tempo).some(k => k.startsWith('#'))) { - const msg = `Unsupported Syntax: Term-based mutations (#) cannot be passed to the constructor. Use new Tempo().set(${JSON.stringify(tempo)}) instead.`; - (TempoClass as any)[sym.$logError](state.config, msg); - throw new Error(msg); - } - - if (isObject(tempo) && Object.keys(tempo).some(k => k.startsWith('#')) && (TempoClass as any)[sym.$terms].length === 0) { - (TempoClass as any)[sym.$termError](state.config, Object.keys(tempo).find(k => k.startsWith('#'))!); - return undefined as any; + if (isObject(tempo)) { + const termKey = Object.keys(tempo).find(k => k.startsWith('#')); + 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.`; + (TempoClass as any)[sym.$logError](state.config, msg); + throw new Error(msg); + } + if (terms.length === 0) { + (TempoClass as any)[sym.$termError](state.config, termKey); + return undefined as any; + } + } } const isAnchored = isDefined(dateTime) || isDefined(state.anchor); @@ -136,11 +139,12 @@ const ParseEngine = { }, /** conform input to a Temporal.ZonedDateTime */ - conform(this: any, tempo: t.DateTime, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): t.TypeValue { + conform(this: any, tempo: t.DateTime, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): TypeValue { const state = this[sym.$Internal](); const arg = asType(tempo); const { type, value } = arg; const TempoClass = this.constructor as typeof Tempo; + const terms = getRuntime().pluginsDb.terms; if (!isZonedDateTime(dateTime)) { @@ -153,9 +157,9 @@ const ParseEngine = { if (ParseEngine.isZonedDateTimeLike.call(this, tempo)) { const { timeZone, calendar, value: _, ...options } = tempo as t.Options; - const keys = Object.keys(options); - if (keys.some(k => k.startsWith('#')) && (TempoClass as any)[sym.$terms].length === 0) { - (TempoClass as any)[sym.$termError](state.config, keys.find(k => k.startsWith('#'))!); + const termKey = Object.keys(options).find(k => k.startsWith('#')); + if (termKey && terms.length === 0) { + (TempoClass as any)[sym.$termError](state.config, termKey); return undefined as any; } @@ -210,7 +214,7 @@ const ParseEngine = { }, /** match a string or number against known layouts */ - parseLayout(this: any, value: string | number, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): t.TypeValue { + parseLayout(this: any, value: string | number, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): TypeValue { const state = this[sym.$Internal](); const arg = asType(value); const { type } = arg; diff --git a/packages/tempo/src/plugin/module/module.term.ts b/packages/tempo/src/plugin/module/module.term.ts index 4e022a88..9d354423 100644 --- a/packages/tempo/src/plugin/module/module.term.ts +++ b/packages/tempo/src/plugin/module/module.term.ts @@ -2,10 +2,10 @@ import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; import { isDefined, isString, isZonedDateTime } from '#library/type.library.js'; import { isNumeric } from '#library/coercion.library.js'; -import sym from '../../tempo.symbol.js'; -import { getSafeFallbackStep } from '../../tempo.util.js'; -import { Match } from '../../tempo.default.js'; -import { getRange, getTermRange, resolveTermShift, findTermPlugin } from '../plugin.util.js'; +import sym from '../../support/tempo.symbol.js'; +import { getSafeFallbackStep } from '../../support/tempo.util.js'; +import { Match } from '../../support/tempo.default.js'; +import { getRange, getTermRange, resolveTermShift, findTermPlugin } from '../term.util.js'; import { parseModifier } from './module.lexer.js'; /** diff --git a/packages/tempo/src/plugin/plugin.type.ts b/packages/tempo/src/plugin/plugin.type.ts index a618b648..04fdcf4b 100644 --- a/packages/tempo/src/plugin/plugin.type.ts +++ b/packages/tempo/src/plugin/plugin.type.ts @@ -49,13 +49,10 @@ export interface Extension extends Plugin { /** * ## Range * Discrete time interval within a specific term. + * + * When Range.year is a number it is interpreted as a relative offset if |year| ≀ 10 + * and as an absolute year otherwise. */ -// export interface Range { -// key: string; -// start: bigint; -// end: bigint; -// cycle?: number; -// } export type Range = Prettify<{ key: string; group?: string; // categorization marker (e.g. 'western', 'chinese', 'fiscal') diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 3b8df55f..4f4623ed 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -1,15 +1,9 @@ -import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; -import { isDefined, isFunction, isString, isUndefined, isNumber, isClass, isObject, isEmpty, isZonedDateTime } from '#library/type.library.js'; -import { secure } from '#library/utility.library.js'; -import { sortKey, byKey } from '#library/array.library.js'; +import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from '#library/type.library.js'; import { secureRef } from '#library/proxy.library.js'; -import lib from '#library/symbol.library.js'; -import { REGISTRY, _INTERNAL_REGISTRY, _MODULES } from '../tempo.register.js'; -import { SCHEMA, getLargestUnit } from '../tempo.util.js'; -import { sym, isTempo } from '../tempo.symbol.js'; +import sym, { getRuntime } from '#tempo/support'; import type { Tempo } from '../tempo.class.js'; -import type { TermPlugin, Range, ResolvedRange, Plugin } from './plugin.type.js'; +import type { Plugin } from './plugin.type.js'; export function getHost(t: any): any { return isFunction(t) || isClass(t) ? t : (t as any).constructor; @@ -21,12 +15,18 @@ export function getHost(t: any): any { */ export function ensureModule(t: any, module: string, silent: boolean = false): boolean { const host = getHost(t); - const hostLogic = (REGISTRY.modules as any)[module]; - const isTermsLoaded = (module === 'term' || module === 'TermsModule') && REGISTRY.terms.length > 0; + const rt = getRuntime(); + const mod = module === 'term' ? 'TermsModule' : module; + const hostLogic = (rt.modules as any)[mod]; + + // terms fallback only applies when the canonical module entry actually exists in the discovery database + const isTermsLoaded = (mod === 'TermsModule') && + (isDefined(hostLogic) || rt.installed.has('TermsModule') || rt.pluginsDb.plugins.some(p => p.name === 'TermsModule')) && + rt.pluginsDb.terms.length > 0; if (!isDefined(hostLogic) && !isTermsLoaded) { - const baseName = module.endsWith('Module') ? module.slice(0, -6) : module; - const msg = `Tempo: ${module} not loaded. (Did you forget to Tempo.extend(${module}) or import '#tempo/${baseName.toLowerCase()}'?)`; + const baseName = mod.endsWith('Module') ? mod.slice(0, -6) : mod; + const msg = `Tempo: ${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) return false; @@ -49,40 +49,32 @@ export function interpret(t: any, module: string, methodOrFallback?: any, silent return undefined; } - const hostLogic = (REGISTRY.modules as any)[module]; + const rt = getRuntime(); + const hostLogic = (rt.modules as any)[module]; // 2. Resolve the specific logic (either the module itself or a sub-method) - const logic = isString(methodOrFallback) ? hostLogic[methodOrFallback] : hostLogic; - - // 3. Logic Not Found or Not a Function - if (!isFunction(logic)) { - // Fallback to calling the function if provided - if (isFunction(methodOrFallback)) return methodOrFallback.apply(t, args); + const logic = isString(methodOrFallback) ? hostLogic[methodOrFallback] : hostLogic; - // Special case: if hostLogic is an object and the first arg is a valid method name - if (isObject(hostLogic) && isString(args[0]) && isFunction((hostLogic as any)[args[0]])) { - const method = args.shift(); - return (hostLogic as any)[method].apply(t, args); - } + // 3. Logic Not Found or Not a Function + if (!isFunction(logic)) { + // Fallback to calling the function if provided + if (isFunction(methodOrFallback)) return methodOrFallback.apply(t, args); - const msg = `Tempo: ${module} method '${String(methodOrFallback)}' not found`; - if (isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg); - throw new Error(msg); + // Special case: if hostLogic is an object and the first arg is a valid method name + if (isObject(hostLogic) && isString(args[0]) && isFunction((hostLogic as any)[args[0]])) { + const method = args.shift(); + return (hostLogic as any)[method].apply(t, args); } + const msg = `Tempo: ${module} method '${String(methodOrFallback)}' not found`; + if (isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg); + throw new Error(msg); + } + // 4. Execute the logic return logic.apply(t, args); } -/** - * ## defineTerm - * Helper to register a Term plugin. - */ -export const defineTerm = (term: T): T => { - registerTerm(term); - return term; -} - /** * ## defineModule * Used to register an internal modularization component. @@ -92,15 +84,52 @@ export const defineModule = (module: T): T => { return module; } +/** + * ## attachStatics + * Safely attach static properties to a class, ensuring they are non-enumerable + * to prevent @Immutable from freezing them. + */ +export function attachStatics(TempoClass: any, props: Record) { + for (const [key, val] of Object.entries(props)) { + if (Object.hasOwn(TempoClass, key)) { + const msg = `Tempo: 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); + } + console.error(msg); + continue; + } + + const isDescriptor = isObject(val) && ( + (val as any)[sym.$Descriptor] === true || + ( + (isDefined(val.get) || isDefined(val.set) || isDefined(val.value) || isDefined(val.writable) || isDefined(val.configurable) || isDefined(val.enumerable)) && + (!isDefined(val.get) || isFunction(val.get)) && + (!isDefined(val.set) || isFunction(val.set)) + ) + ); + + // attachStatics: Intentional ordering in Object.defineProperty overrides any caller-provided flags in isDescriptor to force non-enumerable behavior (avoiding @Immutable exposure). + Object.defineProperty(TempoClass, key, { + ...(isDescriptor ? val : { value: val, writable: true }), + enumerable: false, + configurable: true + }); + } +} + /** * ## defineInterpreterModule * Used to register a module that attaches methods to the Tempo sym.$Interpreter registry. */ -export const defineInterpreterModule = (name: string, logic: any) => +export const defineInterpreterModule = (name: string, logic: any, statics?: Record) => defineModule({ name, install(this: Tempo, TempoClass: typeof Tempo) { - const modules = (_INTERNAL_REGISTRY.modules as any)[lib.$Target] ?? _MODULES; + const rt = getRuntime(); + const modules = rt.modules; + // 1. Secure the Global Registry if (isUndefined(modules[name])) { modules[name] = logic; @@ -109,12 +138,23 @@ export const defineInterpreterModule = (name: string, logic: any) => } // 2. Fallback for legacy class-local access - (TempoClass as any)[sym.$Interpreter] ??= secureRef({}); + if (isUndefined((TempoClass as any)[sym.$Interpreter])) { + Object.defineProperty(TempoClass, sym.$Interpreter, { + value: secureRef({}), + enumerable: false, + configurable: true, + writable: true + }); + } + 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.`); } else { (TempoClass as any)[sym.$Interpreter][name] = logic; } + + // 3. Attach static methods if provided + if (isDefined(statics)) attachStatics(TempoClass, statics); }, }); @@ -127,362 +167,19 @@ export const defineExtension = (extension: T): T => { return extension; } -/** - * ## findTermPlugin - * Find a Term plugin by key, scope, or sub-key. - */ -export function findTermPlugin(ident: string): TermPlugin | undefined { - if (!isString(ident)) return undefined; - const id = (ident.startsWith('#') ? ident.slice(1) : ident).toLowerCase(); - const [termPart] = id.split('.'); - - return REGISTRY.terms.find((t: TermPlugin) => { - if (t.key?.toLowerCase() === termPart || t.scope?.toLowerCase() === termPart) return true; - if (t.groups) { - const list = Array.isArray(t.groups) ? t.groups : Object.values(t.groups).flat(Infinity) as Range[]; - return list.some((r: Range) => r.key?.toLowerCase() === id || r.key?.toLowerCase() === termPart); - } - return false; - }); -} - -/** - * ## defineRange - * Factory to normalize and group Term ranges for efficient lookup. - */ -export function defineRange(ranges: T[], ...keys: (keyof T)[]) { - return byKey(ranges, ...keys); -} - -/** - * find where a Tempo fits within a range of DateTime - */ -export function getTermRange(tempo: Tempo, list: Range[], keyOnly: boolean | number = true, anchor?: any): string | ResolvedRange | undefined { - const chronological = sortKey([...list], 'year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'); - if (chronological.length === 0) return undefined; - - const zdt = anchor ?? (tempo as any).toDateTime(); - - // determine the largest unit defined in the range list, and use the unit above it as rollover - const unit = getLargestUnit(list); - const unitIndex = SCHEMA.findIndex(([u]) => u === unit); - const rolloverIndex = Math.max(0, unitIndex - 1); - const rolloverUnit = SCHEMA[rolloverIndex][0]; - - const resolve = (range: Range, anchor: Temporal.ZonedDateTime) => { - const obj: any = {}; - for (let i = 0; i < SCHEMA.length; i++) { - const [u] = SCHEMA[i]; - const val = range[u]; - if (isNumber(val)) { - obj[u] = val; - } else if (i > rolloverIndex) { - obj[u] = (i <= 2) ? 1 : 0; - } else { - const fallback = (anchor as any)[u]; - obj[u] = isNumber(fallback) ? fallback : (i <= 2 ? 1 : 0); - } - } - // @ts-ignore - const resZdt = toZonedDateTime({ ...obj, timeZone: anchor.timeZoneId, calendar: anchor.calendarId }); - // @ts-ignore - return new tempo.constructor(resZdt, (tempo as any).config); - } - - const matchIndex = chronological.findLastIndex(range => { - const date = resolve(range, zdt); - return (date.toDateTime().epochNanoseconds as bigint) <= (zdt.epochNanoseconds as bigint); - }); - - if (isNumber(keyOnly)) { - const cycle = Math.floor(chronological.length / 3); - if (cycle === 0 || !Number.isInteger(keyOnly) || keyOnly < 1 || keyOnly > cycle) return undefined; - - const offset = matchIndex === -1 ? cycle : Math.floor(matchIndex / cycle) * cycle; - const match = chronological[offset + (keyOnly - 1)]; - if (!match) return undefined; - - const start = resolve(match, zdt); - let end: Tempo; - const i = offset + (keyOnly - 1); - const next = chronological[i + 1]; - - if (next) { - end = resolve(next, zdt); - } else { - const roll = { ...match }; - if (isNumber(roll.year)) roll.year++; - end = resolve(roll, zdt.add({ [`${rolloverUnit}s`]: 1 } as any)); - } - - return { - start, - end, - ...match - } - } - - const match = chronological[matchIndex === -1 ? 0 : matchIndex]; - if (keyOnly === true) return match.key; - - const i = chronological.indexOf(match); - const next = chronological[i + 1]; - - const start = resolve(match, zdt); - let end: Tempo; - - if (next) { - end = resolve(next, zdt); - } else { - const roll = { ...match }; - if (isNumber(roll.year)) roll.year++; - end = resolve(roll, zdt.add({ [`${rolloverUnit}s`]: 1 } as any)); - } - - return { - start, - end, - ...match - } -} - -/** - * # getRange - * Resolve the full list of candidates for a term, passing an anchor to prevent recursion. - */ -export function getRange(entry: any, t: Tempo, anchor?: any, group?: string): Range[] { - const term = (entry.plugin ?? entry) as TermPlugin; - let res: any; - - try { - if (isDefined(anchor)) { - const host = new (getHost(t))(anchor, (t as any).config); - res = isFunction(term.resolve) ? term.resolve.call(host, anchor) : term.define.call(host, false, anchor); - } else { - res = isFunction(term.resolve) ? term.resolve.call(t) : term.define.call(t, false); - } - } catch (err: any) { - if (err.message.includes('Class constructor')) { - return []; - } - throw err; - } - - let list = (res == null) ? [] : (Array.isArray(res) ? res : [res]); - - const keys = (term as any).groupBy ?? []; - if (keys.length > 0) { - list = list.filter(r => keys.every((key: string) => r[key] === (t.config as any)[key])); - } - - if (group) { - const meta: any = (term as any).groups ?? (term as any).ranges; - const isPlainObject = (val: any) => typeof val === 'object' && val !== null && !Array.isArray(val) && !isFunction(val); - - if (isPlainObject(meta)) { - const source = Object.values(meta).flat(Infinity); - list = (source as any[]).filter(r => r.group === group); - } else { - list = list.filter(r => r.group === group); - } - } - - return secure(list) as Range[]; -} - -/** - * Resolve a term to a specific boundary based on a mutation. - */ -export function resolveTermAnchor(tempo: Tempo, terms: any[], offset: string, mutate: string): any { - const ident = offset.startsWith('#') ? offset.slice(1) : offset; - const termObj = terms.find(t => t.key === ident || t.scope === ident); - if (!termObj) return undefined; - - const anchor = (tempo as any).toDateTime(); - const list = getRange(termObj, tempo, anchor); - const range = (getTermRange(tempo, list, false, anchor) as any); - if (!range) return undefined; - - if (mutate === 'start') return range.start; - if (mutate === 'mid') { - const startNs = range.start.toDateTime().epochNanoseconds as bigint; - const endNs = range.end.toDateTime().epochNanoseconds as bigint; - const midNs = startNs + (endNs - startNs) / BigInt(2); - // @ts-ignore - return new tempo.constructor(toInstant(midNs).toZonedDateTimeISO((range.start as any).tz).withCalendar((range.start as any).cal), (tempo as any).config); - } - if (mutate === 'end') return range.end.subtract({ nanoseconds: 1 }); - - return undefined; -} - -/** - * Resolve a term shift. - */ -export function resolveTermShift(tempo: Tempo, source: any[], offset: string, shift: number): any { - const anchor = (tempo as any).toDateTime(); - let list: Range[] = []; - - // If source is a list of plugins, find the right one and resolve it. - // Otherwise, it's a pre-resolved list of ranges. - if (source.length > 0 && 'define' in source[0]) { - const ident = offset.startsWith('#') ? offset.slice(1) : offset; - const termObj = source.find(t => t.key === ident || t.scope === ident); - if (!termObj) return undefined; - list = getRange(termObj, tempo, anchor); - } else { - list = source; - } - - const range = (getTermRange(tempo, list, false, anchor) as any); - if (!range) return undefined; - - // find index in list (matching key and major components for identity) - const idx = list.findIndex(r => { - return r.key === range.key && - (isUndefined(r.year) || isUndefined(range.year) || r.year === range.year) && - (isUndefined(r.month) || isUndefined(range.month) || r.month === range.month) && - (isUndefined(r.day) || isUndefined(range.day) || r.day === range.day); - }); - - if (idx === -1) return undefined; - - const targetIdx = idx + shift; - const target = list[targetIdx]; - if (!target) return undefined; - - // resolve target range - const res = (getTermRange(tempo, [target], false) as any); - if (!res) return undefined; - return res.start; -} - -type resolveOptions = { - anchor?: any; - groupBy?: string[]; - [key: string]: any; -} -/** - * # resolveCycleWindow - * Resolves a window of ranges (prev, current, next) around a source date, - * ensuring all returned ranges are detached clones and validated against the context. - */ -export function resolveCycleWindow(source: Tempo | any, template: Range[] | Record, { anchor, groupBy = [], ...options }: resolveOptions = {}): Range[] { - // ensure we have a valid Tempo instance to work with - const t = isTempo(source) ? source : (isDefined(source) ? new (getHost(source))(source) : source); - if (!isTempo(t)) return []; - - // 1. Resolve Template (supporting optional dynamic grouping) - let list: Range[] = []; - if (!isDefined(template)) { - (t.constructor as any)[sym.$termError](t.config, 'template'); - return []; - } - - if (!Array.isArray(template) && groupBy.length > 0) { - const groupKey = groupBy - .map(key => options[key] ?? anchor?.[key] ?? t.config[key] ?? (t as any)[key] ?? '') - .join('.'); - - list = (template as any)[groupKey] ?? []; - - if (list.length === 0) { - const missing = groupBy.filter(k => isUndefined(options[k]) && isUndefined(anchor?.[k]) && isUndefined(t.config[k])); - const msg = missing.length > 0 ? `Missing grouping criteria: ${missing.join(', ')}` : `No ranges found for group: ${groupKey}`; - (t.constructor as any)[sym.$termError](t.config, msg); - return []; - } - } else { - list = Array.isArray(template) ? template : Object.values(template).flat() as Range[]; - } - - if (list.length === 0) return []; - - // 2. Resolve Window (Sub-Yearly vs Yearly) - const unit = getLargestUnit(list); - if (!['year', 'month', 'day'].includes(unit as any)) { - const results: Range[] = []; - for (const offset of [-1, 0, 1]) { - const date = t.add({ days: offset }); - list.forEach(itm => { - results.push({ - ...itm, - year: date.yy, - month: date.mm, - day: date.dd - }); - }); - } - return results; - } - - // Handle Yearly Cycles (Default) - const yy = t.yy; - const mm = t.mm; - const dd = t.dd; - - const startItem = list[0]; - const startMm = startItem.month ?? 1; - const startDd = startItem.day ?? 1; - - let baseYear = yy; - if (mm < startMm || (mm === startMm && dd < startDd)) baseYear--; - - const window: Range[] = []; - for (const offset of [-1, 0, 1]) { - const targetYY = baseYear + offset; - list.forEach(itm => { - const clone = { ...itm }; - if (isDefined(itm.year)) clone.year = itm.year + targetYY; - else clone.year = targetYY; - window.push(clone); - }); - } - return window; -} - -/** - * ## registerTerm - * Registration hook for Term plugins. - */ -export function registerTerm(term: TermPlugin) { - const db = (globalThis as any)[sym.$Plugins] ??= secureRef({ - terms: [] as TermPlugin[], - plugins: [] as Plugin[] - }); - db.terms ??= secureRef([] as TermPlugin[]); - - if (!db.terms.some((t: any) => t.key === term.key)) { - db.terms.push(term); - } - - if (!REGISTRY.terms.find((t: TermPlugin) => t.key === term.key)) { - REGISTRY.terms.push(term); - } - - (globalThis as any)[sym.$Register]?.(term); -} - /** * ## registerPlugin * Registration hook for general plugins. */ export function registerPlugin(plugin: any) { - const db = (globalThis as any)[sym.$Plugins] ??= secureRef({ - terms: [] as TermPlugin[], - plugins: [] as Plugin[] - }); - db.plugins ??= secureRef([] as Plugin[]); + const rt = getRuntime(); - if (!db.plugins.includes(plugin)) { - db.plugins.push(plugin); - } + // Validate and persist in the runtime's discovery database. + rt.addPlugin(plugin); - if (!REGISTRY.extends.includes(plugin)) { - REGISTRY.extends.push(plugin); - } + rt.addExtension(plugin); - (globalThis as any)[sym.$Register]?.(plugin); + rt.emit(sym.$Register, plugin); return plugin; } diff --git a/packages/tempo/src/plugin/term.util.ts b/packages/tempo/src/plugin/term.util.ts new file mode 100644 index 00000000..0545ae68 --- /dev/null +++ b/packages/tempo/src/plugin/term.util.ts @@ -0,0 +1,354 @@ +import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; +import { isDefined, isFunction, isString, isUndefined, isNumber } from '#library/type.library.js'; +import { secure } from '#library/utility.library.js'; +import { sortKey, byKey } from '#library/array.library.js'; +import sym, { SCHEMA, getLargestUnit, isTempo, getRuntime } from '#tempo/support'; +import type { Tempo } from '../tempo.class.js'; +import type { TermPlugin, Range, ResolvedRange } from './plugin.type.js'; +import { getHost } from './plugin.util.js'; + +/** + * ## defineTerm + * Helper to register a Term plugin. + */ +export const defineTerm = (term: T): T => { + registerTerm(term); + return term; +} + +/** + * ## findTermPlugin + * Find a Term plugin by key, scope, or sub-key. + */ +export function findTermPlugin(ident: string): TermPlugin | undefined { + if (!isString(ident)) return undefined; + const id = (ident.startsWith('#') ? ident.slice(1) : ident).toLowerCase(); + const [termPart] = id.split('.'); + + return getRuntime().pluginsDb.terms.find((t: TermPlugin) => { + if (t.key?.toLowerCase() === termPart || t.scope?.toLowerCase() === termPart) return true; + if (t.groups) { + const list = Array.isArray(t.groups) ? t.groups : Object.values(t.groups).flat(Infinity) as Range[]; + return list.some((r: Range) => r.key?.toLowerCase() === id || r.key?.toLowerCase() === termPart); + } + return false; + }); +} + +/** + * ## defineRange + * Factory to normalize and group Term ranges for efficient lookup. + */ +export function defineRange(ranges: T[], ...keys: (keyof T)[]) { + return byKey(ranges, ...keys); +} + +/** + * find where a Tempo fits within a range of DateTime + */ +export function getTermRange(tempo: Tempo, list: Range[], keyOnly: boolean | number = true, anchor?: any): string | ResolvedRange | undefined { + const chronological = sortKey([...list], 'year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'); + if (chronological.length === 0) return undefined; + + const zdt = anchor ?? (tempo as any).toDateTime(); + + // determine the largest unit defined in the range list, and use the unit above it as rollover + const unit = getLargestUnit(list); + const unitIndex = SCHEMA.findIndex(([u]) => u === unit); + const rolloverIndex = Math.max(0, unitIndex - 1); + const rolloverUnit = SCHEMA[rolloverIndex][0]; + + const resolve = (range: Range, anchor: Temporal.ZonedDateTime) => { + const obj: any = {}; + for (let i = 0; i < SCHEMA.length; i++) { + const [u] = SCHEMA[i]; + const val = range[u]; + if (isNumber(val)) { + obj[u] = val; + } else if (i > rolloverIndex) { + obj[u] = (i <= 2) ? 1 : 0; + } else { + const fallback = (anchor as any)[u]; + obj[u] = isNumber(fallback) ? fallback : (i <= 2 ? 1 : 0); + } + } + // @ts-ignore + const resZdt = toZonedDateTime({ ...obj, timeZone: anchor.timeZoneId, calendar: anchor.calendarId }); + // @ts-ignore + return new tempo.constructor(resZdt, (tempo as any).config); + } + + const matchIndex = chronological.findLastIndex(range => { + const date = resolve(range, zdt); + return (date.toDateTime().epochNanoseconds as bigint) <= (zdt.epochNanoseconds as bigint); + }); + + if (isNumber(keyOnly)) { + const cycle = Math.floor(chronological.length / 3); + if (cycle === 0 || !Number.isInteger(keyOnly) || keyOnly < 1 || keyOnly > cycle) return undefined; + + const offset = matchIndex === -1 ? cycle : Math.floor(matchIndex / cycle) * cycle; + const match = chronological[offset + (keyOnly - 1)]; + if (!match) return undefined; + + const start = resolve(match, zdt); + let end: Tempo; + const i = offset + (keyOnly - 1); + const next = chronological[i + 1]; + + if (next) { + end = resolve(next, zdt); + } else { + const roll = { ...match }; + if (isNumber(roll.year)) roll.year++; + end = resolve(roll, zdt.add({ [`${rolloverUnit}s`]: 1 } as any)); + } + + return { + start, + end, + ...match + } + } + + const match = chronological[matchIndex === -1 ? 0 : matchIndex]; + if (keyOnly === true) return match.key; + + const i = chronological.indexOf(match); + const next = chronological[i + 1]; + + const start = resolve(match, zdt); + let end: Tempo; + + if (next) { + end = resolve(next, zdt); + } else { + const roll = { ...match }; + if (isNumber(roll.year)) roll.year++; + end = resolve(roll, zdt.add({ [`${rolloverUnit}s`]: 1 } as any)); + } + + return { + start, + end, + ...match + } +} + +/** + * # getRange + * Resolve the full list of candidates for a term, passing an anchor to prevent recursion. + */ +export function getRange(entry: any, t: Tempo, anchor?: any, group?: string): Range[] { + const term = (entry.plugin ?? entry) as TermPlugin; + let res: any; + + try { + if (isDefined(anchor)) { + const host = new (getHost(t))(anchor, (t as any).config); + res = isFunction(term.resolve) ? term.resolve.call(host, anchor) : term.define.call(host, false, anchor); + } else { + res = isFunction(term.resolve) ? term.resolve.call(t) : term.define.call(t, false); + } + } catch (err: any) { + if (err.message.includes('Class constructor')) { + return []; + } + throw err; + } + + let list = (res == null) ? [] : (Array.isArray(res) ? res : [res]); + + const keys = (term as any).groupBy ?? []; + if (keys.length > 0) { + list = list.filter(r => keys.every((key: string) => r[key] === (t.config as any)[key])); + } + + if (group) { + const meta: any = (term as any).groups ?? (term as any).ranges; + const isPlainObject = (val: any) => typeof val === 'object' && val !== null && !Array.isArray(val) && !isFunction(val); + + if (isPlainObject(meta)) { + const source = Object.values(meta).flat(Infinity); + list = (source as any[]).filter(r => r.group === group); + } else { + list = list.filter(r => r.group === group); + } + } + + return secure(list) as Range[]; +} + +/** + * Resolve a term to a specific boundary based on a mutation. + */ +export function resolveTermAnchor(tempo: Tempo, terms: any[], offset: string, mutate: string): any { + const ident = offset.startsWith('#') ? offset.slice(1) : offset; + const termObj = terms.find(t => t.key === ident || t.scope === ident); + if (!termObj) return undefined; + + const anchor = (tempo as any).toDateTime(); + const list = getRange(termObj, tempo, anchor); + const range = (getTermRange(tempo, list, false, anchor) as any); + if (!range) return undefined; + + if (mutate === 'start') return range.start; + if (mutate === 'mid') { + const startNs = range.start.toDateTime().epochNanoseconds as bigint; + const endNs = range.end.toDateTime().epochNanoseconds as bigint; + const midNs = startNs + (endNs - startNs) / BigInt(2); + // @ts-ignore + return new tempo.constructor(toInstant(midNs).toZonedDateTimeISO((range.start as any).tz).withCalendar((range.start as any).cal), (tempo as any).config); + } + if (mutate === 'end') return range.end.subtract({ nanoseconds: 1 }); + + return undefined; +} + +/** + * Resolve a term shift. + */ +export function resolveTermShift(tempo: Tempo, source: any[], offset: string, shift: number): any { + const anchor = (tempo as any).toDateTime(); + let list: Range[] = []; + + // If source is a list of plugins, find the right one and resolve it. + // Otherwise, it's a pre-resolved list of ranges. + if (source.length > 0 && 'define' in source[0]) { + const ident = offset.startsWith('#') ? offset.slice(1) : offset; + const termObj = source.find(t => t.key === ident || t.scope === ident); + if (!termObj) return undefined; + list = getRange(termObj, tempo, anchor); + } else { + list = source; + } + + const range = (getTermRange(tempo, list, false, anchor) as any); + if (!range) return undefined; + + // find index in list (matching key and major components for identity) + const idx = list.findIndex(r => { + return r.key === range.key && + (isUndefined(r.year) || isUndefined(range.year) || r.year === range.year) && + (isUndefined(r.month) || isUndefined(range.month) || r.month === range.month) && + (isUndefined(r.day) || isUndefined(range.day) || r.day === range.day); + }); + + if (idx === -1) return undefined; + + const targetIdx = idx + shift; + const target = list[targetIdx]; + if (!target) return undefined; + + // resolve target range + const res = (getTermRange(tempo, [target], false) as any); + if (!res) return undefined; + return res.start; +} + +type resolveOptions = { + anchor?: any; + groupBy?: string[]; + [key: string]: any; +} +/** + * # resolveCycleWindow + * Resolves a window of ranges (prev, current, next) around a source date, + * ensuring all returned ranges are detached clones and validated against the context. + */ +export function resolveCycleWindow(source: Tempo | any, template: Range[] | Record, { anchor, groupBy = [], ...options }: resolveOptions = {}): Range[] { + // ensure we have a valid Tempo instance to work with + const t = isTempo(source) ? source : (isDefined(source) ? new (getHost(source))(source) : source); + if (!isTempo(t)) return []; + + // 1. Resolve Template (supporting optional dynamic grouping) + let list: Range[] = []; + if (!isDefined(template)) { + (t.constructor as any)[sym.$termError](t.config, 'template'); + return []; + } + + if (!Array.isArray(template) && groupBy.length > 0) { + const groupKey = groupBy + .map(key => options[key] ?? anchor?.[key] ?? t.config[key] ?? (t as any)[key] ?? '') + .join('.'); + + list = (template as any)[groupKey] ?? []; + + if (list.length === 0) { + const missing = groupBy.filter(k => isUndefined(options[k]) && isUndefined(anchor?.[k]) && isUndefined(t.config[k])); + const msg = missing.length > 0 ? `Missing grouping criteria: ${missing.join(', ')}` : `No ranges found for group: ${groupKey}`; + (t.constructor as any)[sym.$termError](t.config, msg); + return []; + } + } else { + list = Array.isArray(template) ? template : Object.values(template).flat() as Range[]; + } + + if (list.length === 0) return []; + + // Ensure chronological order for reliable anchor/window calculation + list = [...list]; // Defensive copy to avoid mutating the original template + sortKey(list, 'month', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'); + + // 2. Resolve Window (Sub-Yearly vs Yearly) + const unit = getLargestUnit(list); + if (!['year', 'month', 'day'].includes(unit as any)) { + const results: Range[] = []; + for (const offset of [-1, 0, 1]) { + const date = t.add({ days: offset }); + list.forEach(itm => { + results.push({ + ...itm, + year: date.yy, + month: date.mm, + day: date.dd + }); + }); + } + return results; + } + + // Handle Yearly Cycles (Default) + const yy = t.yy; + const mm = t.mm; + const dd = t.dd; + + const startItem = list[0]; + const startMm = startItem.month ?? 1; + const startDd = startItem.day ?? 1; + + let baseYear = yy; + if (mm < startMm || (mm === startMm && dd < startDd)) baseYear--; + + const window: Range[] = []; + for (const offset of [-1, 0, 1]) { + const targetYY = baseYear + offset; + list.forEach(itm => { + const clone = { ...itm }; + // Normalize year semantics: Treat small offsets as relative to the cycle, + // while treating larger numbers as absolute years (e.g. for fixed historical dates). + if (isNumber(itm.year)) { + clone.year = (itm.year >= -10 && itm.year <= 10) ? itm.year + targetYY : itm.year; // See Range JSDoc in plugin.type.ts (|year| ≀ 10 is relative) + } else { + clone.year = targetYY; + } + window.push(clone); + }); + } + + return window; +} + +/** + * ## registerTerm + * Registration hook for Term plugins. + */ +export function registerTerm(term: TermPlugin) { + const rt = getRuntime(); + + // Validate and persist in the runtime's discovery database. + rt.addTerm(term); + + rt.emit(sym.$Register, term); +} diff --git a/packages/tempo/src/plugin/term/standard.index.ts b/packages/tempo/src/plugin/term/standard.index.ts index 3674a603..93bf5c4b 100644 --- a/packages/tempo/src/plugin/term/standard.index.ts +++ b/packages/tempo/src/plugin/term/standard.index.ts @@ -1,5 +1,5 @@ import { Tempo } from '../../tempo.class.js'; -import { onRegistryReset } from '../../tempo.register.js'; +import { onRegistryReset } from '../../support/tempo.register.js'; import { TermsModule } from './term.index.js'; // Side-effect: Automatically register all standard terms diff --git a/packages/tempo/src/plugin/term/term.index.ts b/packages/tempo/src/plugin/term/term.index.ts index 24276713..57dc0491 100644 --- a/packages/tempo/src/plugin/term/term.index.ts +++ b/packages/tempo/src/plugin/term/term.index.ts @@ -1,10 +1,11 @@ import { defineModule } from '../plugin.util.js' +import sym, { getRuntime, onRegistryReset } from '#tempo/support'; import { QuarterTerm } from './term.quarter.js' import { SeasonTerm } from './term.season.js' import { ZodiacTerm } from './term.zodiac.js' import { TimelineTerm } from './term.timeline.js' -import type {Tempo} from '../../tempo.class.js'; +import type { Tempo } from '../../tempo.class.js'; /** collection of built-in terms for initial registration */ export const StandardTerms = [QuarterTerm, SeasonTerm, ZodiacTerm, TimelineTerm]; @@ -13,6 +14,8 @@ export const StandardTerms = [QuarterTerm, SeasonTerm, ZodiacTerm, TimelineTerm] export const TermsModule = defineModule({ name: 'TermsModule', install(this: Tempo, TempoClass: typeof Tempo) { + getRuntime().modules['TermsModule'] = true; // mark as canonical module + onRegistryReset(() => { TempoClass.extend(StandardTerms); }); TempoClass.extend(StandardTerms); }, }); diff --git a/packages/tempo/src/plugin/term/term.quarter.ts b/packages/tempo/src/plugin/term/term.quarter.ts index 06744431..20e6d561 100644 --- a/packages/tempo/src/plugin/term/term.quarter.ts +++ b/packages/tempo/src/plugin/term/term.quarter.ts @@ -1,5 +1,5 @@ -import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../plugin.util.js'; -import { COMPASS } from '../../tempo.enum.js'; +import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; +import { COMPASS } from '../../support/tempo.enum.js'; import { type Tempo } from '../../tempo.class.js'; import { isNumber } from '#library/type.library.js'; import { asArray } from '#library'; diff --git a/packages/tempo/src/plugin/term/term.season.ts b/packages/tempo/src/plugin/term/term.season.ts index 5016b434..5677a3ba 100644 --- a/packages/tempo/src/plugin/term/term.season.ts +++ b/packages/tempo/src/plugin/term/term.season.ts @@ -1,5 +1,5 @@ -import { getTermRange, defineTerm, defineRange, resolveCycleWindow } from '../plugin.util.js'; -import { COMPASS } from '../../tempo.enum.js'; +import { getTermRange, defineTerm, defineRange, resolveCycleWindow } from '../term.util.js'; +import { COMPASS } from '../../support/tempo.enum.js'; import type { Tempo } from '../../tempo.class.js'; /** definition of meteorological season ranges */ diff --git a/packages/tempo/src/plugin/term/term.timeline.ts b/packages/tempo/src/plugin/term/term.timeline.ts index eb3113f8..b23f49ce 100644 --- a/packages/tempo/src/plugin/term/term.timeline.ts +++ b/packages/tempo/src/plugin/term/term.timeline.ts @@ -1,4 +1,4 @@ -import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../plugin.util.js'; +import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; import type { Tempo } from '../../tempo.class.js'; /** definition of daily time periods */ diff --git a/packages/tempo/src/plugin/term/term.zodiac.ts b/packages/tempo/src/plugin/term/term.zodiac.ts index 208f6da1..b5f2d098 100644 --- a/packages/tempo/src/plugin/term/term.zodiac.ts +++ b/packages/tempo/src/plugin/term/term.zodiac.ts @@ -1,4 +1,4 @@ -import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../plugin.util.js'; +import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; import { type Tempo } from '../../tempo.class.js'; import { isNumber } from '#library/type.library.js'; @@ -45,9 +45,9 @@ function resolve(t: Tempo, anchor?: any) { const list = resolveCycleWindow(t, groups, { anchor, groupBy: ['group'], group: 'western' }); // calculate the Chinese Zodiac based on the year of the candidate sign - list.forEach(itm => { + list.forEach(itm => { const year = itm.year ?? (anchor?.year); - if (isNumber(year)) itm['CN'] = getChineseZodiac(year); + if (isNumber(year)) itm['CN'] = getChineseZodiac(year); }); return list; diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts new file mode 100644 index 00000000..5d590533 --- /dev/null +++ b/packages/tempo/src/support/support.index.ts @@ -0,0 +1,37 @@ +import sym from './tempo.symbol.js'; + +export { + default as enums, + STATE, + DEFAULTS, + REGISTRIES, + DISCOVERY, + MODE, + COMPASS, + WEEKDAY, + WEEKDAYS, + MONTH, + MONTHS, + DURATION, + DURATIONS, + SEASON, + ELEMENT, + FORMAT, + NUMBER, + LIMIT, + TIMEZONE, + MUTATION, + ZONED_DATE_TIME, + OPTION, + PARSE, + NumericPattern +} from './tempo.enum.js'; + +export { isTempo } from './tempo.symbol.js'; +export { registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; +export { getRuntime, TempoRuntime } from './tempo.runtime.js'; +export { Match, Token, Snippet, Layout, Event, Period, Guard, Default } from './tempo.default.js'; +export { SCHEMA, getLargestUnit, getSafeFallbackStep } from './tempo.util.js'; + +export { default as lib } from '#library/symbol.library.js'; +export default sym; diff --git a/packages/tempo/src/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts similarity index 99% rename from packages/tempo/src/tempo.default.ts rename to packages/tempo/src/support/tempo.default.ts index 36383cb5..f5d391b4 100644 --- a/packages/tempo/src/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -2,9 +2,9 @@ import { looseIndex } from '#library/object.library.js'; import { secure } from '#library/utility.library.js'; import { proxify } from '#library/proxy.library.js'; import { NUMBER, MODE } from './tempo.enum.js'; -import type { Options } from './tempo.type.js'; +import type { Options } from '../tempo.type.js'; import { getDateTimeFormat } from '#library/international.library.js'; -import type { Tempo } from './tempo.class.js'; +import type { Tempo } from '../tempo.class.js'; // BE VERY CAREFUL NOT TO BREAK THE REGEXP PATTERNS BELOW // TEMPO functionality heavily depends on these patterns diff --git a/packages/tempo/src/tempo.enum.ts b/packages/tempo/src/support/tempo.enum.ts similarity index 99% rename from packages/tempo/src/tempo.enum.ts rename to packages/tempo/src/support/tempo.enum.ts index 2c50e6c8..bcb20719 100644 --- a/packages/tempo/src/tempo.enum.ts +++ b/packages/tempo/src/support/tempo.enum.ts @@ -227,11 +227,10 @@ const discoveryKeys = ['options', 'timeZones', 'terms', 'plugins', 'numbers', 'f export const DISCOVERY = proxify(enumify(discoveryKeys, false), true, false); export type Discovery = KeyOf - /** @internal LIVE Registries mapping (STATE key -> Enum/Proxy) */ export const REGISTRIES: Record = { NUMBER, DURATION, TIMEZONE, DURATIONS, FORMAT, LIMIT, -}; +} /** public-reachable enums */ export default { diff --git a/packages/tempo/src/tempo.register.ts b/packages/tempo/src/support/tempo.register.ts similarity index 61% rename from packages/tempo/src/tempo.register.ts rename to packages/tempo/src/support/tempo.register.ts index 078703f1..c9b818e3 100644 --- a/packages/tempo/src/tempo.register.ts +++ b/packages/tempo/src/support/tempo.register.ts @@ -5,34 +5,15 @@ import { secureRef } from '#library/proxy.library.js'; import lib from '#library/symbol.library.js'; import type { Property } from '#library/type.library.js'; -import { sym } from './tempo.symbol.js'; -import type { TermPlugin, Extension } from './plugin/plugin.type.js'; +import { getRuntime } from './tempo.runtime.js'; // Import the live enums and their mutable state from the enum module import { STATE, REGISTRIES, DEFAULTS } from './tempo.enum.js'; -/** @internal storage for plugin/module registry */ -const _terms = (globalThis as any)[sym.$terms] ??= [] as TermPlugin[]; -const _extends = (globalThis as any)[sym.$extends] ??= [] as Extension[]; -const _modules = (globalThis as any)[sym.$modules] ??= {} as Record; -const _installed = (globalThis as any)[sym.$installed] ??= new Set(); - -const _REGISTRY = { - terms: secureRef(_terms), - extends: secureRef(_extends), - modules: secureRef(_modules), - installed: _installed -} - -/** - * # REGISTRY - * Internal registry for registered components. - * Closed for modification, Open for extension. - */ -export const REGISTRY = secureRef(_REGISTRY); +const rt = getRuntime(); -/** @internal storage for reset hooks */ -const resetHooks = (): Set<() => void> => (globalThis as any)[sym.$reset] ??= new Set(); +/** @internal Return the runtime's reset-hook set */ +const resetHooks = (): Set<() => void> => rt.resetHooks; /** @internal Register a hook to be called when the registry is reset */ export function onRegistryReset(hook: () => void) { @@ -69,17 +50,9 @@ export function registryReset() { clearCache(state); }); - // 3. Clear all plugin/module storage via raw targets - const internal = (_REGISTRY as any); - const terms = internal.terms[lib.$Target] ?? internal.terms; - const extensions = internal.extends[lib.$Target] ?? internal.extends; - const modules = internal.modules[lib.$Target] ?? internal.modules; - - terms.length = 0; - extensions.length = 0; - - for (const key in modules) delete modules[key]; - internal.installed.clear(); + rt.pluginsDb.terms.length = 0; + rt.pluginsDb.plugins.length = 0; + rt.extensions.length = 0; // Trigger all registered reset hooks const hooks = resetHooks(); @@ -109,10 +82,3 @@ export function registryUpdate(name: keyof typeof STATE, data: Record = {}; + /** set of installed plugin identifiers β€” consumed by REGISTRY */ + readonly installed: Set = new Set(); + /** decentralized reset hooks β€” fired on every registryReset() call */ + readonly resetHooks: Set<() => void> = new Set(); + /** + * Persistent plugin/term discovery database. + * Replaces the `globalThis[sym.$Plugins]` slot. + * Kept as a plain object (not a secureRef) so callers can push() into the arrays. + */ + readonly pluginsDb: { terms: TermPlugin[]; plugins: Plugin[] } = { terms: [], plugins: [] }; + readonly #hooks: Map void> = new Map(); + + + // ─── Register hook ──────────────────────────────────────────────────────── + + /** Set a registration hook for a given symbol. Returns the previous hook. */ + setHook(key: symbol, cb: (val: any) => void): ((val: any) => void) | undefined { + const prev = this.#hooks.get(key); + this.#hooks.set(key, cb); + return prev; + } + + /** Get a registration hook for a given symbol. */ + getHook(key: symbol): ((val: any) => void) | undefined { + return this.#hooks.get(key); + } + + /** Invoke the hook for a given symbol. */ + emit(key: symbol, val: any): void { + this.#hooks.get(key)?.(val); + } + + // ─── Validated mutation helpers ─────────────────────────────────────────── + + /** + * Record a Term in the discovery database. + * Validates the shape before storing so malformed entries cannot corrupt state. + */ + addTerm(term: TermPlugin): void { + if (!term || typeof term.key !== 'string') return; + if (!this.pluginsDb.terms.some(t => t.key === term.key)) + this.pluginsDb.terms.push(term); + } + + /** + * Record a Plugin in the discovery database. + * Guards against duplicate entries. + */ + addPlugin(plugin: any): void { + if (!plugin) return; + if (!this.pluginsDb.plugins.includes(plugin)) + this.pluginsDb.plugins.push(plugin); + } + + /** + * Record an Extension in the raw storage. + * Guards against duplicate entries and ensures validation. + */ + addExtension(extension: any): void { + if (!extension) return; + if (!this.extensions.includes(extension)) + this.extensions.push(extension); + } + + // ─── Factory helpers ────────────────────────────────────────────────────── + + /** + * @internal @experimental + * Create a fresh, **scoped** runtime that is NOT stored on `globalThis`. + * NOTE: Scoped runtimes are currently experimental and not yet fully threaded + * through all internal helpers. Use for manual state isolation only. + */ + static createScoped(): TempoRuntime { + return new TempoRuntime(); + } +} + +let localFallbackRuntime: TempoRuntime | undefined; + +/** + * Return the singleton `TempoRuntime`. + * + * On the first call the runtime is created and pinned to `globalThis` under the BRIDGE + * symbol with a hardened property descriptor (non-enumerable, non-configurable, + * non-writable). Subsequent calls β€” even from other bundle copies β€” retrieve the same + * object via `globalThis[BRIDGE]`, preserving the single-source-of-truth guarantee. + */ +export function getRuntime(): TempoRuntime { + const existing = (globalThis as any)[sym.$Bridge]; + if (existing && existing[sym.$RuntimeBrand] === true) return existing; + + if (localFallbackRuntime) return localFallbackRuntime; + + const rt = new TempoRuntime(); + + // Pin as a single hardened slot if it doesn't already exist. + // This avoids Redefinition errors if multiple bundles are loaded. + if (!existing) { + Object.defineProperty(globalThis, sym.$Bridge, { + value: rt, + enumerable: false, + configurable: false, + writable: false, + }); + } else { + const desc = Object.getOwnPropertyDescriptor(globalThis, sym.$Bridge); + if (desc && desc.configurable !== false) { + Object.defineProperty(globalThis, sym.$Bridge, { + value: rt, + enumerable: false, + configurable: false, + writable: false, + }); + } else { + // Cannot overwrite a non-configurable global, use local fallback + localFallbackRuntime = rt; + } + } + + return rt; +} diff --git a/packages/tempo/src/support/tempo.symbol.ts b/packages/tempo/src/support/tempo.symbol.ts new file mode 100644 index 00000000..2b95298a --- /dev/null +++ b/packages/tempo/src/support/tempo.symbol.ts @@ -0,0 +1,31 @@ +import type { Tempo } from '../tempo.class.js'; + +/** + * Centralized registry for all Tempo-specific Global Symbols. + * These symbols utilize Symbol.for() to ensure consistency across module boundaries. + * Tempo-specific symbols are kept here (rather than @magmacomputing/library) to maintain + * clean separation of concerns. + */ + +/** @internal Tempo Symbol Registry */ +const sym = { + /** key for Global Discovery of Tempo configuration */ $Tempo: Symbol.for('$Tempo'), + /** key for Reactive Plugin Registration */ $Register: Symbol.for('magmacomputing/tempo/register'), + /** key for Global Identity Brand for Tempo */ $isTempo: Symbol.for('magmacomputing/tempo/isTempo'), + /** key for Internal Interpreter Service */ $Interpreter: Symbol.for('magmacomputing/tempo/interpreter'), + /** key for contextual Error Logging */ $logError: Symbol.for('magmacomputing/tempo/logError'), + /** key for contextual Debug Logging */ $logDebug: Symbol.for('magmacomputing/tempo/logDebug'), + /** key for centralized Term Error dispatching */ $termError: Symbol.for('magmacomputing/tempo/termError'), + /** key for contextual Debugger */ $dbg: Symbol.for('magmacomputing/tempo/dbg'), + /** key for Master Guard */ $guard: Symbol.for('magmacomputing/tempo/guard'), + /** internal key for signaling pre-errored state */ $errored: Symbol.for('magmacomputing/tempo/errored'), + /** internal key for accessing private instance state */$Internal: Symbol.for('magmacomputing/tempo/internal'), + /** hardened globalThis bridge key for the TempoRuntime */$Bridge: Symbol.for('magmacomputing/tempo/runtime'), + /** cross-bundle brand check for TempoRuntime */ $RuntimeBrand: Symbol.for('magmacomputing/tempo/runtime/brand'), + /** branding for explicit PropertyDescriptors */ $Descriptor: Symbol.for('magmacomputing/tempo/descriptor'), +} as const; + +/** check valid Tempo instance */ +export const isTempo = (tempo?: any): tempo is Tempo => tempo?.[sym.$isTempo] === true; + +export default sym; diff --git a/packages/tempo/src/tempo.util.ts b/packages/tempo/src/support/tempo.util.ts similarity index 92% rename from packages/tempo/src/tempo.util.ts rename to packages/tempo/src/support/tempo.util.ts index 24253de3..7f8b3f8c 100644 --- a/packages/tempo/src/tempo.util.ts +++ b/packages/tempo/src/support/tempo.util.ts @@ -1,7 +1,7 @@ import { isDefined } from '#library/type.library.js'; import { asArray } from '#library/coercion.library.js'; -import type { Tempo } from './tempo.class.js'; -import type { Range, DateTimeUnit } from './tempo.type.js'; +import type { Tempo } from '../tempo.class.js'; +import type { Range, DateTimeUnit } from '../tempo.type.js'; /** internal schema for Temporal units and their Tempo property aliases */ export const SCHEMA = [ diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index c7e02267..088d35ca 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1,4 +1,5 @@ import '#library/temporal.polyfill.js'; + import { Logify } from '#library/logify.class.js'; import { secure } from '#library/utility.library.js'; import { Immutable, Serializable } from '#library/class.library.js'; @@ -11,18 +12,22 @@ import { enumify } from '#library/enumerate.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import { getAccessors, omit } from '#library/reflection.library.js'; import { pad, trimAll } from '#library/string.library.js'; -import { getType, asType, isEmpty, isNullish, isDefined, isUndefined, isString, isObject, isNumber, isRegExp, isRegExpLike, isIntegerLike, isSymbol, isFunction, isClass, isZonedDateTime, isPlainDate, isPlainTime } from '#library/type.library.js'; +import { getType, asType, isEmpty, isNullish, isDefined, isUndefined, isString, isObject, isRegExp, isRegExpLike, isSymbol, isFunction, isClass, isZonedDateTime } from '#library/type.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; import { instant } from '#library/temporal.library.js'; import type { Property, Secure } from '#library/type.library.js'; -import { registerPlugin, registerTerm, getTermRange, interpret, ensureModule } from './plugin/plugin.util.js' +import { registerPlugin, interpret, ensureModule } from './plugin/plugin.util.js' +import { registerTerm, getTermRange } from './plugin/term.util.js'; -import sym, { isTempo, registerHook } from './tempo.symbol.js'; -import { REGISTRY, registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; -import { Match, Token, Snippet, Layout, Event, Period, Default, Guard } from './tempo.default.js'; -import enums, { STATE, DISCOVERY } from './tempo.enum.js'; +import sym, { getRuntime, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Default, Guard, enums, STATE, DISCOVERY } from '#tempo/support'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) + +declare module '#library/type.library.js' { + interface TypeValueMap { + Tempo: { type: 'Tempo', value: Tempo }; + } +} // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ const Context = getContext(); // current execution context @@ -44,7 +49,7 @@ const create = (obj: object, name: string): T => Object.create namespace Internal { export type State = t.Internal.State; export type Parse = t.Internal.Parse; - export type Match = t.Internal.Match; + export type MatchResult = t.Internal.Match; export type Config = t.Internal.Config; export type Discovery = t.Internal.Discovery; export type Registry = t.Internal.Registry; @@ -92,7 +97,7 @@ export class Tempo { /** 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 #terms: t.TermPlugin[] = REGISTRY.terms; + /** mutable list of registered term plugins */ static get #terms(): t.TermPlugin[] { return getRuntime().pluginsDb.terms } /** 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 }; @@ -380,7 +385,7 @@ export class Tempo { }) const isMonthDay = Tempo.#isMonthDay(shape); - if (isMonthDay !== proto(shape.parse).isMonthDay) // this will always set on 'global', conditionally on 'local' + if (isMonthDay !== proto(shape.parse).isMonthDay) // this will always set on 'global', conditionally on 'local' shape.parse.isMonthDay = isMonthDay; shape.config.sphere = Tempo.#setSphere(shape, mergedOptions); @@ -403,12 +408,10 @@ export class Tempo { } /** support "Global Discovery" of user-options */ - static #setDiscovery(shape: Internal.State, key: string | symbol = shape.config.discovery ?? sym.$Tempo) { - const sym = isString(key) ? Symbol.for(key) : key; - const discovery = (globalThis as Record)[sym] as Internal.Discovery; + static #setDiscovery(shape: Internal.State, discovery?: Internal.Discovery) { if (!isObject(discovery)) return {} - markConfig(discovery); // auto-mark the discovery object + markConfig(discovery); // auto-mark the discovery object // 1. Process TimeZones (normalize to lowercase for lookup) if (discovery.timeZones) { @@ -423,6 +426,10 @@ export class Tempo { registryUpdate('NUMBER', discovery.numbers); // 2. Process Terms + if ((discovery as any).term) { + discovery.terms = [...asArray(discovery.terms || []), ...asArray((discovery as any).term)]; + Tempo.#dbg.warn(shape.config, 'Tempo: Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); + } if (discovery.terms) this.extend(asArray(discovery.terms)); @@ -454,16 +461,16 @@ export class Tempo { // ensure we have our own Map to mutate (shadow if local) if (!hasOwn(shape.parse, 'pattern')) - shape.parse.pattern = new Map(shape.parse.pattern); // preserve inherited entries while shadowing + shape.parse.pattern = new Map(shape.parse.pattern); // preserve inherited entries while shadowing - const layouts = { ...shape.parse.layout }; // shallow-copy to include inherited properties + const layouts = { ...shape.parse.layout }; // shallow-copy to include inherited properties for (const [sym, layout] of ownEntries(layouts, true)) { const reg = Tempo.regexp(layout, snippet); - shape.parse.pattern.set(sym, reg); // merge/update compiled RegExp + shape.parse.pattern.set((sym as symbol), reg); // merge/update compiled RegExp } if (shape === Tempo.#global) - Tempo.#buildGuard(); // build the high-performance 'Master Guard' ONLY for global changes + Tempo.#buildGuard(); // build the high-performance 'Master Guard' ONLY for global changes } static #buildGuard() { @@ -596,9 +603,10 @@ export class Tempo { try { items.forEach(item => { const arg = item as any; - if (isFunction(arg)) { // Standard Plugin registration - if (REGISTRY.installed.has(arg)) return; - REGISTRY.installed.add(arg); // mark as installed (BEFORE side-effects) + if (isFunction(arg)) { // Standard Plugin registration + const rt = getRuntime(); + if (rt.installed.has(arg)) return; + rt.installed.add(arg); // mark as installed (BEFORE side-effects) registerPlugin(arg); try { @@ -615,11 +623,12 @@ export class Tempo { else if (isObject(item) && isString((item as any).name) && isFunction((item as any).install)) { // Plugin object form { name, install } const name = (item as any).name; - if (REGISTRY.installed.has(name)) { + const rt = getRuntime(); + if (rt.installed.has(name)) { Tempo.#dbg.debug(Tempo.#global.config, `Plugin already installed by name: ${name}`); return; } - REGISTRY.installed.add(name); + rt.installed.add(name); registerPlugin(item); (item as t.Plugin).install.call(this as any, this); @@ -630,7 +639,6 @@ export class Tempo { const config = item as t.TermPlugin; if (Tempo.#termMap.has(config.key)) return; - Tempo.#terms.push(config); // update registry (BEFORE side-effects) Tempo.#termMap.set(config.key, config); if (config.scope) Tempo.#termMap.set(config.scope, config); @@ -654,6 +662,10 @@ 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)]; + Tempo.#dbg.warn(Tempo.#global.config, 'Tempo: Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); + } if (discovery.options) Tempo.#setConfig(Tempo.#global, discovery.options) if (discovery.plugins) this.extend(discovery.plugins, discovery.options) if (discovery.terms) this.extend(discovery.terms) @@ -737,11 +749,14 @@ export class Tempo { const discoveryKey = options.discovery ?? Symbol.keyFor(sym.$Tempo) as string; const storeKey = Symbol.keyFor(sym.$Tempo) as string; + const rt = getRuntime(); + const userDiscovery = (globalThis as any)[isString(discoveryKey) ? Symbol.for(discoveryKey) : discoveryKey] as Internal.Discovery; + Tempo.#setConfig(Tempo.#global, { store: storeKey, discovery: storeKey, scope: 'global' }, Tempo.readStore(storeKey), // allow for storage-values to overwrite - Tempo.#setDiscovery(Tempo.#global, sym.$Plugins), // persistent library extensions - Tempo.#setDiscovery(Tempo.#global, discoveryKey), // user Discovery (Configuration bootstrapping) + Tempo.#setDiscovery(Tempo.#global, rt.pluginsDb as any), // persistent library extensions + Tempo.#setDiscovery(Tempo.#global, userDiscovery), // user Discovery (Configuration bootstrapping) options, // explicit options from the call ) @@ -947,12 +962,12 @@ export class Tempo { static { // Static initialization block to sequence the bootstrap phase // Define the reactive register hook - registerHook(sym.$Register, (plugin: t.Plugin | t.Plugin[]) => { + getRuntime().setHook(sym.$Register, (plugin: t.Plugin | t.Plugin[]) => { if (!Tempo.isExtending) Tempo.extend(plugin) }); onRegistryReset(() => { - (Tempo as any)[sym.$rebuildGuard](); + Tempo.#buildGuard(); }); Tempo.init(); // synchronously initialize the library @@ -966,22 +981,18 @@ export class Tempo { /** temporary anchor used during parsing */ #anchor: Temporal.ZonedDateTime | undefined; /** prebuilt formats, for convenience */ #fmt!: any; /** mapping of terms to their resolved values */ #term!: any; - /** a collection of parse rule-matches */ #matches: Internal.Match[] | undefined; + /** a collection of parse rule-matches */ #matches: Internal.MatchResult[] | undefined; /** 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: { [lib.$Logify]: true } as unknown as Internal.Config, - /** instance parse rules (only populated if provided) */ parse: { result: [] as Internal.Match[] } as Internal.Parse + /** instance parse rules (only populated if provided) */ parse: { result: [] as Internal.MatchResult[] } as Internal.Parse } as Internal.State; /** @internal internal key for signaling pre-errored state in constructor */ static [sym.$errored] = sym.$errored; - /** @internal guard against infinite mutation recursion */ - static [sym.$mutateDepth] = 0; - /** @internal hook to re-validate the Master Guard */ - static [sym.$rebuildGuard]() { Tempo.#buildGuard() } /** @internal */ static [sym.$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)?)" : ""; @@ -989,7 +1000,7 @@ export class Tempo { Tempo.#dbg.error(config, msg); if (config.catch !== true) throw new Error(msg); } - /** @internal */ static get [sym.$terms](): t.TermPlugin[] { return REGISTRY.terms as t.TermPlugin[] } + /** @internal */ static get [sym.$dbg](): Logify { return Tempo.#dbg } /** @internal */ static get [sym.$guard]() { return (Tempo as any).#guard } @@ -998,7 +1009,7 @@ export class Tempo { * This surface is not part of the public contract and is subject to change. */ [sym.$Internal]() { - const self = this as any; + const self: Tempo = (this as any)[lib.$Target] ?? this; return { get zdt() { return self.#zdt }, set zdt(val: any) { self.#zdt = val }, @@ -1058,6 +1069,7 @@ export class Tempo { constructor(tempo?: t.DateTime | t.Options, options: t.Options = {}) { this.#now = instant(); // stash current Instant [this.#tempo, this.#options] = this.#swap(tempo, options); // swap arguments around + if (isZonedDateTime(this.#tempo)) this.#zdt = this.#tempo; this.#setLocal(this.#options); // parse local options const { mode } = this.#local.parse; @@ -1074,8 +1086,16 @@ export class Tempo { this.#term = this.#setDelegator('term'); // initialize the term-delegator this.#anchor = this.#options.anchor; - if ((this.#options as any)[sym.$errored]) this.#errored = true; - if (isNumber((this.#options as any)[sym.$mutateDepth])) this.#mutateDepth = (this.#options as any)[sym.$mutateDepth]; + // 🧬 Unified State Hand-off (from clone / mutate) + const handoff = (this.#options as any)[sym.$Internal]; + if (isObject(handoff)) { + this.#errored = handoff.errored ?? false; + this.#mutateDepth = handoff.mutateDepth ?? 0; + this.#parseDepth = 0; + this.#matches = handoff.matches; + } else if ((this.#options as any)[sym.$errored]) { + this.#errored = true; + } if (!this.#local.parse.lazy) this.#ensureParsed(); // attempt to interpret immediately (if not lazy) } @@ -1086,7 +1106,7 @@ export class Tempo { try { this.#zdt = this.#parse(this.#tempo as t.DateTime, this.#anchor); secure(this.#local.config); - const skip = [this.#local.parse.format, this.#local.parse.term].filter(v => v !== undefined); + const skip = [this.#local.parse.format, this.#local.parse.term, this.#local.parse.result].filter(v => v !== undefined); secure(this.#local.parse, new WeakSet(skip as any)); } catch (err) { const msg = `Cannot create Tempo: ${(err as Error).message}\n${(err as Error).stack}`; @@ -1295,9 +1315,15 @@ export class Tempo { } /** Instance-specific parse rules (merged with global) */ - get parse() { - this.#ensureParsed(); - return this.#local.parse; + get parse(): Internal.Parse { + const self: Tempo = (this as any)[lib.$Target] ?? this; + self.#ensureParsed(); + // Return a shadowed view so we can safely inject matches without breaking the freeze on the original state + const out = Object.create(self.#local.parse); + if (self.#matches !== undefined) { + Object.defineProperty(out, 'result', { value: self.#matches, enumerable: true, configurable: true }); + } + return out as t.Internal.Parse; } /** Keyed results for all resolved terms */ get term() { return this.#term } @@ -1406,11 +1432,11 @@ export class Tempo { #swap(tempo?: t.DateTime | t.Options, options: t.Options = {}): [t.DateTime | undefined, t.Options] { if (isTempo(tempo)) { // preserve parse result history when creating new instance from an existing one - return [tempo, { result: [...tempo.parse.result], ...options }]; + return [tempo, Object.assign({ result: [...tempo.parse.result] }, options)]; } return this.#isOptions(tempo) - ? [tempo.value, { ...tempo }] - : [tempo, { ...options }]; + ? [tempo.value, Object.assign({}, tempo)] + : [tempo, options]; } /** check if we've been given a Tempo Options object */ @@ -1441,8 +1467,8 @@ export class Tempo { .every((key: string) => enums.ZONED_DATE_TIME.has(key)) } - #result(...rest: Partial[]) { - const match = Object.assign({}, ...rest) as Internal.Match; // collect all object arguments + #result(...rest: Partial[]) { + const match = Object.assign({}, ...rest) as Internal.MatchResult; // collect all object arguments if (isDefined(this.#anchor) && !match.isAnchored) match.isAnchored = true; diff --git a/packages/tempo/src/tempo.index.ts b/packages/tempo/src/tempo.index.ts index 9afa1d2a..770fe66b 100644 --- a/packages/tempo/src/tempo.index.ts +++ b/packages/tempo/src/tempo.index.ts @@ -1,5 +1,5 @@ import { Tempo } from './tempo.class.js'; -import { onRegistryReset } from './tempo.register.js'; +import { onRegistryReset, enums } from '#tempo/support'; import { ParseModule } from '#tempo/parse'; import { MutateModule } from '#tempo/mutate'; @@ -15,4 +15,4 @@ onRegistryReset(() => { Tempo.extend(core); }); Tempo.extend(core); export * from './tempo.class.js'; -export { default as enums } from './tempo.enum.js'; +export { enums }; diff --git a/packages/tempo/src/tempo.symbol.ts b/packages/tempo/src/tempo.symbol.ts deleted file mode 100644 index a1f296d6..00000000 --- a/packages/tempo/src/tempo.symbol.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Tempo } from './tempo.class.js'; - -/** - * Centralized registry for all Tempo-specific Global Symbols. - * These symbols utilize Symbol.for() to ensure consistency across module boundaries. - * Tempo-specific symbols are kept here (rather than @magmacomputing/library) to maintain - * clean separation of concerns. - */ - -/** @internal Tempo Symbol Registry */ -export const sym = { - /** key for Global Discovery of Tempo configuration */ $Tempo: Symbol.for('$Tempo'), - /** key for Global Discovery of Tempo Plugins */ $Plugins: Symbol.for('$TempoPlugin'), - /** key for Reactive Plugin Registration */ $Register: Symbol.for('$TempoRegister'), - /** key for Global Identity Brand for Tempo */ $isTempo: Symbol.for('$isTempo'), - /** key for Internal Interpreter Service */ $Interpreter: Symbol.for('$TempoInterpreter'), - /** key for contextual Error Logging */ $logError: Symbol.for('$TempoLogError'), - /** key for contextual Debug Logging */ $logDebug: Symbol.for('$TempoLogDebug'), - /** key for centralized Term Error dispatching */ $termError: Symbol.for('$TempoTermError'), - /** key for contextual Debugger */ $dbg: Symbol.for('$TempoDbg'), - /** key for Master Guard */ $guard: Symbol.for('$TempoGuard'), - /** internal key for signaling pre-errored state in constructor */ $errored: Symbol.for('$TempoErrored'), - /** internal key for accessing private instance state */ $Internal: Symbol.for('$TempoInternal'), - /** internal key for tracking mutation recursion depth */ $mutateDepth: Symbol.for('$TempoMutateDepth'), - /** internal key for tracking parser recursion depth */ $parseDepth: Symbol.for('$TempoParseDepth'), - /** internal key for tracking discovered matches during parsing */ $matches: Symbol.for('$TempoMatches'), - /** internal key for instance-level anchor baseline */ $anchor: Symbol.for('$TempoAnchor'), - /** internal key for the underlying Temporal ZonedDateTime */ $zdt: Symbol.for('$TempoZDT'), - /** internal key for re-validating the Master Guard */ $rebuildGuard: Symbol.for('$TempoRebuildGuard'), - /** internal key for decentralized registry resets */ $reset: Symbol.for('$TempoReset'), - /** internal key for tracking installed plugins */ $installed: Symbol.for('$TempoInstalled'), - /** internal key for tracking registered terms */ $terms: Symbol.for('$TempoTerms'), - /** internal key for tracking registered extensions */ $extends: Symbol.for('$TempoExtends'), - /** internal key for tracking registered modules */ $modules: Symbol.for('$TempoModules'), -} as const; - -/** - * Define a reactive registration hook on a global symbol. - */ -export function registerHook(symbol: symbol, cb: (val: any) => void) { - const existing = (globalThis as any)[symbol]; - - if (existing !== undefined && typeof existing === 'function') - console.warn(`Overwriting existing hook for symbol: ${symbol.description}`); - (globalThis as any)[symbol] = cb; - return existing; // allow chaining or cleanup -} - -/** check valid Tempo instance */ -export const isTempo = (tempo?: any): tempo is Tempo => tempo?.[sym.$isTempo] === true; - -export default sym; diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index b910088a..6e1b1af0 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -7,12 +7,11 @@ * Inside `tempo.class.ts` these are accessed via `import * as t`. */ -import * as enums from '#tempo/tempo.enum.js'; -import sym from '#tempo/tempo.symbol.js'; -import type { Snippet, Layout, Event, Period, Token } from '#tempo/tempo.default.js'; +import * as enums from '#tempo/support/tempo.enum.js'; +import sym from '#tempo/support/tempo.symbol.js'; +import type { Snippet, Layout, Event, Period, Token } from '#tempo/support/tempo.default.js'; import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue } from '#library/type.library.js'; -export type { TypeValue }; -import type { Range, TermPlugin, ResolvedRange, Plugin, Terms, Module, Extension } from './plugin/plugin.type.js'; +import type { Range, TermPlugin, ResolvedRange, Plugin, Terms, Module, Extension } from '#tempo/plugin/plugin.type.js'; /** * Structural forward-reference to the Tempo class. @@ -20,18 +19,15 @@ import type { Range, TermPlugin, ResolvedRange, Plugin, Terms, Module, Extension */ import type { Tempo } from '#tempo/tempo.class.js'; -declare module '#library/type.library.js' { - interface TypeValueMap { - Tempo: { type: 'Tempo', value: Tempo }; - 'Tempo.Duration': { type: 'Tempo.Duration', value: Duration }; - } -} - declare global { interface globalThis { + /** + * User-facing Global Discovery slot. + * Applications place a Discovery object here (keyed by the string returned by + * `Symbol.keyFor(sym.$Tempo)`, or by a custom symbol passed to `Tempo.init`). + * Internal machinery now lives inside the TempoRuntime β€” see `tempo.runtime.ts`. + */ [sym.$Tempo]?: Internal.Discovery; - [sym.$Plugins]?: Internal.Discovery; - [sym.$Register]?: () => void; } } @@ -195,6 +191,14 @@ export namespace Internal { /** parsing rules */ parse: Parse; } + /** debug a Tempo instantiation */ + export type MatchExtend = { type: 'Event' | 'Period', value: string | number | Function } + export type Match = { + /** pattern which matched the input */ match?: string | undefined; + /** groups from the pattern match */ groups?: Groups; + /** was this a nested/anchored parse? */ isAnchored?: boolean; + } & (TypeValue | MatchExtend) + /** Debugging results of a parse operation. See `doc/tempo.api.md`. */ export interface Parse { /** Locales which prefer 'mm-dd-yyyy' date-order */ mdyLocales: { locale: string, timeZones: string[] }[]; @@ -216,14 +220,6 @@ export namespace Internal { /** @internal localized Master Guard scanner */ guard?: { test(str: string): boolean }; } - /** debug a Tempo instantiation */ - export type MatchExtend = { type: 'Event' | 'Period', value: string | number | Function } - export type Match = { - /** pattern which matched the input */ match?: string | undefined; - /** groups from the pattern match */ groups?: Groups; - /** was this a nested/anchored parse? */ isAnchored?: boolean; - } & (TypeValue | MatchExtend) - /** drop the parse-only Options */ export type OptionsKeep = Omit @@ -239,9 +235,10 @@ export namespace Internal { /** pre-defined config options for Tempo.#global */ options?: Options | (() => Options); /** aliases to merge in the TimeZone dictionary */ timeZones?: Record; /** aliases to merge in the Number-Word dictionary */ numbers?: Record; + /** term plugins to be registered via Tempo.addTerm() */terms?: TermPlugin | TermPlugin[]; /** custom format strings to merge in the FORMAT dictionary */formats?: Property; - /** term plugins to be registered via Tempo.addTerm() */term?: TermPlugin | TermPlugin[]; /** plugins to be automatically extended via Tempo.extend() */plugins?: Plugin | Plugin[]; - /** @deprecated use term instead */ terms?: TermPlugin | TermPlugin[]; } } + +export type MatchResult = Internal.Match; diff --git a/packages/tempo/src/tsconfig.json b/packages/tempo/src/tsconfig.json index 48a3c043..4e06f9dd 100644 --- a/packages/tempo/src/tsconfig.json +++ b/packages/tempo/src/tsconfig.json @@ -18,6 +18,8 @@ "#tempo/format": ["./plugin/module/module.format.ts"], "#tempo/ticker": ["./plugin/extend/extend.ticker.ts"], "#tempo/term/*": ["./plugin/term/term.*.ts"], + "#tempo/support": ["./support/support.index.ts"], + "#tempo/support/*": ["./support/*"], "#tempo/*": ["./*"] } }, diff --git a/packages/tempo/test/discovery_security.test.ts b/packages/tempo/test/discovery_security.test.ts index 9ed2aff3..ea975f5d 100644 --- a/packages/tempo/test/discovery_security.test.ts +++ b/packages/tempo/test/discovery_security.test.ts @@ -1,6 +1,6 @@ -import lib from '#library/symbol.library.js'; import { Tempo } from '#tempo'; -import { registryUpdate } from '#tempo/tempo.register.js'; +import { registryUpdate } from '#tempo/support'; +import lib from '#library/symbol.library.js'; describe('Discovery Security (Direct Registry Check)', () => { diff --git a/packages/tempo/test/duration.core.test.ts b/packages/tempo/test/duration.core.test.ts index 8fbed18c..422d6956 100644 --- a/packages/tempo/test/duration.core.test.ts +++ b/packages/tempo/test/duration.core.test.ts @@ -1,20 +1,24 @@ import { Tempo } from '#tempo/core'; -import sym from '#tempo/tempo.symbol.js'; +import { getRuntime } from '#tempo/support'; -let originalReset: Set; +describe('Tempo.duration() (Core)', () => { + // Preserve the existing reset hooks and give this test suite a clean slate. + // Using the runtime API instead of the legacy globalThis[sym.$reset] slot. + let savedHooks: Array<() => void> = []; -beforeAll(() => { - // Preserve original reset hooks and provide a clean slate for this core test - originalReset = (globalThis as any)[sym.$reset]; - (globalThis as any)[sym.$reset] = new Set(); -}); + beforeAll(() => { + const hooks = getRuntime().resetHooks; + hooks.forEach(h => savedHooks.push(h)); + hooks.clear(); + }); -afterAll(() => { - // Restore original state to avoid polluting other tests - (globalThis as any)[sym.$reset] = originalReset; -}); + afterAll(() => { + // Restore original state to avoid polluting other tests + const hooks = getRuntime().resetHooks; + hooks.clear(); + savedHooks.forEach(h => hooks.add(h)); + }); -describe('Tempo.duration() (Core)', () => { beforeEach(() => { Tempo.init(); }); afterEach(() => vi.restoreAllMocks()) @@ -27,7 +31,7 @@ describe('Tempo.duration() (Core)', () => { // @ts-ignore const { DurationModule } = await import('#tempo/duration'); Tempo.extend(DurationModule); - + const d = Tempo.duration('P1Y'); expect(d.years).toBe(1); expect(d.iso).toBe('P1Y'); diff --git a/packages/tempo/test/instance.add.test.ts b/packages/tempo/test/instance.add.test.ts index 2a3e097f..1890efae 100644 --- a/packages/tempo/test/instance.add.test.ts +++ b/packages/tempo/test/instance.add.test.ts @@ -40,8 +40,8 @@ describe(`${label} add method`, () => { const history2 = t2.parse.result.length; expect(history2).toBeGreaterThan(history1); - // Verify the mutation was recorded (it appears as a ZonedDateTime in the history) - expect(t2.parse.result.some(r => r.type?.includes('ZonedDateTime'))).toBe(true); + // Verify the mutation was recorded (it appears as a Mutation in the history) + expect(t2.parse.result.some(r => r.type === 'Mutation')).toBe(true); expect(t2.parse.result[0].match).toBeDefined(); }); diff --git a/packages/tempo/test/instance.result.test.ts b/packages/tempo/test/instance.result.test.ts index 0ac14c1b..3464f6dd 100644 --- a/packages/tempo/test/instance.result.test.ts +++ b/packages/tempo/test/instance.result.test.ts @@ -17,7 +17,6 @@ describe(`${label} parse result accumulation`, () => { const len1 = t1.parse.result.length; const len2 = t2.parse.result.length; const len3 = t3.parse.result.length; - expect(len3).toBeGreaterThan(len2); expect(len2).toBeGreaterThan(len1); expect(len3).toBeGreaterThanOrEqual(5); @@ -28,9 +27,6 @@ describe(`${label} parse result accumulation`, () => { .add({ day: 1 }) .set({ period: 'noon' }); - // 1 for constr, 1 for .set (add doesn't push new parsing but preserves) - // Actually user said: "expect that #add will now push a 'ZonedDateTime' rule-match" - // Let's check current implementation of #add expect(t.parse.result.length).toBeGreaterThanOrEqual(2); }); diff --git a/packages/tempo/test/plugin.test.ts b/packages/tempo/test/plugin.test.ts index 696aa4b3..88ac7e2f 100644 --- a/packages/tempo/test/plugin.test.ts +++ b/packages/tempo/test/plugin.test.ts @@ -19,7 +19,7 @@ describe('Tempo Plugin System', () => { const instancePlugin: Plugin = { name: 'InstancePlugin', install(this: Tempo, TempoClass) { - (TempoClass.prototype as any).instanceMethod = function() { + (TempoClass.prototype as any).instanceMethod = function () { return 'instance'; }; }, @@ -77,8 +77,8 @@ describe('Tempo Plugin System', () => { test('should protect existing members but allow new ones', () => { // 1. Try to overwrite existing (should throw in strict mode) // Note: Tempo.now is a static method we want to protect - expect(() => { - (Tempo as any).now = () => 'hacked'; + expect(() => { + (Tempo as any).now = () => 'hacked'; }).toThrow(); // 2. Try to add new (should succeed) @@ -93,8 +93,8 @@ describe('Tempo Plugin System', () => { }); test('should protect Symbol properties (like Symbol.dispose)', () => { - expect(() => { - (Tempo as any)[Symbol.dispose] = () => {}; + expect(() => { + (Tempo as any)[Symbol.dispose] = () => { }; }).toThrow(); }); }); diff --git a/packages/tempo/test/plugin_registration.test.ts b/packages/tempo/test/plugin_registration.test.ts index fa19efd7..631c81cf 100644 --- a/packages/tempo/test/plugin_registration.test.ts +++ b/packages/tempo/test/plugin_registration.test.ts @@ -1,12 +1,12 @@ import { Tempo } from '#tempo'; -import sym from '#tempo/tempo.symbol.js'; +import { getRuntime } from '#tempo/support/tempo.runtime.js'; import { TickerModule } from '#tempo/ticker'; describe('Ticker Registration / Initialization', () => { test('TickerModule should be auto-registered on import', () => { - // 1. TickerModule was imported above, so it should be in $Plugins - const db = (globalThis as any)[sym.$Plugins]; + // 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); diff --git a/packages/tempo/test/repro_hang.test.ts b/packages/tempo/test/repro_hang.test.ts index aeca21bc..7f44b369 100644 --- a/packages/tempo/test/repro_hang.test.ts +++ b/packages/tempo/test/repro_hang.test.ts @@ -1,6 +1,6 @@ import { getType } from '#library/type.library.js' import { Tempo } from '#tempo' -import { isTempo } from '#tempo/tempo.symbol.js'; +import { isTempo } from '#tempo/support'; describe('Tempo Initialization Hang Repro', () => { it('should initialize without hanging when accessed via Proxy', () => { diff --git a/packages/tempo/test/runtime_brand.test.ts b/packages/tempo/test/runtime_brand.test.ts new file mode 100644 index 00000000..fb4d63ba --- /dev/null +++ b/packages/tempo/test/runtime_brand.test.ts @@ -0,0 +1,132 @@ +import sym, { getRuntime, TempoRuntime } from '#tempo/support'; + +describe('TempoRuntime Cross-Bundle Adoption', () => { + /** + * Helper to safely attempt to set the global bridge slot. + * If the slot is non-configurable (locked by a previous getRuntime call), + * we may not be able to run adoption tests on the real symbol. + */ + function trySetupBridge(mock: any) { + try { + const desc = Object.getOwnPropertyDescriptor(globalThis, sym.$Bridge); + if (desc && !desc.configurable) return false; + + delete (globalThis as any)[sym.$Bridge]; + (globalThis as any)[sym.$Bridge] = mock; + return true; + } catch { + return false; + } + } + + test('getRuntime() should adopt an existing object if it has the correct brand symbol', () => { + const original = (globalThis as any)[sym.$Bridge]; + + // 1. Create a mock object with the brand symbol + const mockRuntime = { + [sym.$RuntimeBrand]: true, + isMock: true, + terms: [], + pluginsDb: { terms: [], plugins: [] } + }; + + // 2. Pre-populate the global bridge slot + const ok = trySetupBridge(mockRuntime); + if (!ok) { + // If we can't setup the bridge (already locked), check if it's already branded. + // If it's already branded, we verify that it's correctly returned. + if (original && original[sym.$RuntimeBrand] === true) { + const rt = getRuntime(); + expect(rt).toBe(original); + return; + } + console.warn('Skipping mock adoption test: globalThis bridge is already locked by a different runtime instance.'); + return; + } + + try { + // 3. Call getRuntime() + const rt = getRuntime(); + + // 4. Verify it adopted the mock + expect(rt).toBe(mockRuntime); + expect((rt as any).isMock).toBe(true); + } finally { + // Cleanup (if possible) + try { + delete (globalThis as any)[sym.$Bridge]; + if (original) (globalThis as any)[sym.$Bridge] = original; + } catch { + /* ignore */ + } + } + }); + + test('TempoRuntime constructor sets the brand symbol', () => { + const rt = new TempoRuntime(); + expect((rt as any)[sym.$RuntimeBrand]).toBe(true); + }); + + test('getRuntime() should NOT adopt an object lacking the brand symbol', () => { + const original = (globalThis as any)[sym.$Bridge]; + + // 1. Create an object WITHOUT the brand symbol + const fake = { isFake: true }; + const ok = trySetupBridge(fake); + if (!ok) { + // If locked, we can't test "not adopting a fake" on this symbol. + return; + } + + try { + // 2. Call getRuntime() + const rt = getRuntime(); + + // 3. Verify it ignored the fake and created a new one + expect(rt).not.toBe(fake); + expect(rt).toBeInstanceOf(TempoRuntime); + + // 4. Verify consecutive calls return the same instance (singleton stability) + const rt2 = getRuntime(); + expect(rt2).toBe(rt); + expect(rt2).toBeInstanceOf(TempoRuntime); + } finally { + try { + delete (globalThis as any)[sym.$Bridge]; + if (original) (globalThis as any)[sym.$Bridge] = original; + } catch { + /* ignore */ + } + } + }); + + test('getRuntime() should maintain singleton stability even if the global slot is occupied by an unbranded non-configurable object', () => { + const original = (globalThis as any)[sym.$Bridge]; + + // 1. Force a non-configurable unbranded object into the bridge slot + const fake = { isFake: true }; + try { + Object.defineProperty(globalThis, sym.$Bridge, { + value: fake, + configurable: false, + writable: false + }); + } catch (e) { + // If already locked by a real runtime, we can't run this specific regression + return; + } + + try { + // 2. Call getRuntime() twice + const rt1 = getRuntime(); + const rt2 = getRuntime(); + + // 3. Verify stability + expect(rt1).not.toBe(fake); + expect(rt1).toBeInstanceOf(TempoRuntime); + expect(rt2).toBe(rt1); + } finally { + // Note: We cannot cleanup the non-configurable 'fake' from globalThis in this test + } + }); +}); diff --git a/packages/tempo/test/tempo_regexp.test.ts b/packages/tempo/test/tempo_regexp.test.ts index b5974bd9..ea9ae06d 100644 --- a/packages/tempo/test/tempo_regexp.test.ts +++ b/packages/tempo/test/tempo_regexp.test.ts @@ -1,5 +1,5 @@ import { Tempo } from '#tempo'; -import { Token } from '#tempo/tempo.default.js'; +import { Token } from '#tempo/support/tempo.default.js'; describe('Tempo.regexp', () => { test('should expand snippets and handle nested named capture groups', () => { diff --git a/packages/tempo/test/ticker.patterns.test.ts b/packages/tempo/test/ticker.patterns.test.ts index f27dbd95..b6f55d07 100644 --- a/packages/tempo/test/ticker.patterns.test.ts +++ b/packages/tempo/test/ticker.patterns.test.ts @@ -1,5 +1,5 @@ import { Tempo } from '#tempo'; -import { isTempo } from '#tempo/tempo.symbol.js'; +import { isTempo } from '#tempo/support'; import '#tempo/ticker' // TickerModule self-registers on import via definePlugin diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index b0a18e60..37a2ef0f 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ { find: /^#tempo\/plugin\/extend\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.$1.js') }, { find: /^#tempo\/plugin\/module\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/module/module.$1.js') }, { find: /^#tempo\/plugin\/term\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/term/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') }, @@ -53,6 +54,7 @@ export default defineConfig({ { find: /^#tempo\/plugin\/extend\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/extend/extend.$1.ts') }, { find: /^#tempo\/plugin\/module\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/module/module.$1.ts') }, { find: /^#tempo\/plugin\/term\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/term/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') }, diff --git a/vitest.config.ts b/vitest.config.ts index d717843f..33985237 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,8 +14,9 @@ export default defineConfig({ { find: /^#tempo\/plugins\/plugin\.util\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugins/plugin.util.ts') }, { find: /^#tempo\/plugins\/plugin\.type\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugins/plugin.type.ts') }, { find: /^#tempo\/plugins\/plugin\.(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugins/extend/plugin.$1.ts') }, - { find: /^#tempo\/core$/, replacement: path.resolve(__dirname, './packages/tempo/src/tempo.core.ts') }, + { find: /^#tempo\/core$/, replacement: path.resolve(__dirname, './packages/tempo/src/core.index.ts') }, { find: /^#tempo\/tempo\.class\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/tempo.index.ts') }, + { find: /^#tempo\/support$/, replacement: path.resolve(__dirname, './packages/tempo/src/support/support.index.ts') }, { find: /^#tempo\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/$1.ts') }, { find: /^#tempo\/(.*)$/, replacement: path.resolve(__dirname, './packages/tempo/src/$1.ts') } ]