From f299b7a05d25b0fbaa3cbf81aa523743ddf16593 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 23 Apr 2026 15:57:22 +1000 Subject: [PATCH 01/13] code clean-up --- .../library/src/browser/mapper.library.ts | 2 +- packages/library/src/browser/tapper.class.ts | 3 +- .../library/src/browser/webstore.class.ts | 3 +- packages/library/src/common.index.ts | 1 + packages/library/src/common/array.library.ts | 4 +- .../library/src/common/assertion.library.ts | 108 ++++++++ .../library/src/common/coercion.library.ts | 26 +- .../library/src/common/enumerate.library.ts | 14 +- .../library/src/common/function.library.ts | 6 +- packages/library/src/common/logify.class.ts | 28 ++- packages/library/src/common/object.library.ts | 2 +- packages/library/src/common/pledge.class.ts | 4 +- .../library/src/common/primitive.library.ts | 25 +- packages/library/src/common/proxy.library.ts | 231 +++++++++--------- .../library/src/common/reflection.library.ts | 3 +- .../library/src/common/serialize.library.ts | 8 +- .../library/src/common/storage.library.ts | 2 +- packages/library/src/common/string.library.ts | 8 +- packages/library/src/common/symbol.library.ts | 6 +- .../library/src/common/temporal.library.ts | 12 +- packages/library/src/common/type.library.ts | 124 ++-------- .../library/src/common/utility.library.ts | 12 +- packages/tempo/plan/.WISHLIST.md | 4 - .../tempo/src/discrete/discrete.format.ts | 32 +-- packages/tempo/src/discrete/discrete.parse.ts | 44 ++-- .../tempo/src/plugin/extend/extend.ticker.ts | 13 +- .../src/plugin/module/module.composer.ts | 4 +- .../src/plugin/module/module.duration.ts | 4 +- .../tempo/src/plugin/module/module.lexer.ts | 5 +- .../tempo/src/plugin/module/module.mutate.ts | 5 +- .../tempo/src/plugin/module/module.term.ts | 35 +-- packages/tempo/src/plugin/plugin.type.ts | 2 +- packages/tempo/src/plugin/plugin.util.ts | 2 +- packages/tempo/src/plugin/term.util.ts | 31 ++- .../tempo/src/plugin/term/term.quarter.ts | 4 +- packages/tempo/src/plugin/term/term.zodiac.ts | 4 +- packages/tempo/src/support/support.index.ts | 7 +- packages/tempo/src/support/tempo.default.ts | 13 +- packages/tempo/src/support/tempo.enum.ts | 48 ++-- packages/tempo/src/support/tempo.init.ts | 11 +- packages/tempo/src/support/tempo.register.ts | 26 +- packages/tempo/src/support/tempo.symbol.ts | 23 +- packages/tempo/src/support/tempo.util.ts | 12 +- packages/tempo/src/tempo.class.ts | 60 ++--- packages/tempo/src/tempo.type.ts | 6 +- .../tempo/test/constructor.shorthand.test.ts | 37 +++ .../tempo/test/discovery_security.test.ts | 8 +- packages/tempo/test/runtime_brand.test.ts | 1 - packages/tempo/test/symbol-import.test.ts | 4 +- packages/tempo/test/term_unified.test.ts | 7 + 50 files changed, 584 insertions(+), 500 deletions(-) create mode 100644 packages/library/src/common/assertion.library.ts create mode 100644 packages/tempo/test/constructor.shorthand.test.ts diff --git a/packages/library/src/browser/mapper.library.ts b/packages/library/src/browser/mapper.library.ts index 1e4e81bb..0ad3618a 100644 --- a/packages/library/src/browser/mapper.library.ts +++ b/packages/library/src/browser/mapper.library.ts @@ -1,6 +1,6 @@ import { asObject } from '#library/object.library.js'; import { CONTEXT, getContext } from '#library/utility.library.js'; -import { isNullish } from '#library/type.library.js'; +import { isNullish } from '#library/assertion.library.js'; import { instant } from '#library/temporal.library.js'; import { getHemisphere } from '#library/international.library.js'; diff --git a/packages/library/src/browser/tapper.class.ts b/packages/library/src/browser/tapper.class.ts index 5d8566c2..d94ffca8 100644 --- a/packages/library/src/browser/tapper.class.ts +++ b/packages/library/src/browser/tapper.class.ts @@ -1,5 +1,6 @@ import { enumify } from '#library/enumerate.library.js'; -import { isEmpty, isFunction, type ValueOf } from '#library/type.library.js'; +import { isEmpty, isFunction } from '#library/assertion.library.js'; +import type { ValueOf } from '#library/type.library.js'; /** * A Wrapper Class around HammerJS. diff --git a/packages/library/src/browser/webstore.class.ts b/packages/library/src/browser/webstore.class.ts index 9b10a6dc..25fbeca8 100644 --- a/packages/library/src/browser/webstore.class.ts +++ b/packages/library/src/browser/webstore.class.ts @@ -1,6 +1,7 @@ import { distinct, ownEntries } from '#library/primitive.library.js'; import { stringify, objectify } from '#library/serialize.library.js'; -import { asType, isEmpty, isNullish, isString } from '#library/type.library.js'; +import { asType } from '#library/type.library.js'; +import { isEmpty, isNullish, isString } from '#library/assertion.library.js'; import type { Property, ValueOf } from '#library/type.library.js'; const STORAGE = { diff --git a/packages/library/src/common.index.ts b/packages/library/src/common.index.ts index 01774210..708a9afe 100644 --- a/packages/library/src/common.index.ts +++ b/packages/library/src/common.index.ts @@ -3,6 +3,7 @@ */ export * from './common/array.library.js'; +export * from './common/assertion.library.js'; export * from './common/buffer.library.js'; export * from './common/cipher.class.js'; export * from './common/class.library.js'; diff --git a/packages/library/src/common/array.library.ts b/packages/library/src/common/array.library.ts index d586c0f0..2fecb3f8 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 { asString, nullToValue } from '#library/coercion.library.js'; import { extract, ownEntries } from '#library/primitive.library.js'; import { stringify } from '#library/serialize.library.js'; -import { isNumber, isDate, isObject, isDefined, isUndefined, isFunction, nullToValue } from '#library/type.library.js'; +import { isNumber, isDate, isObject, isDefined, isUndefined, isFunction } from '#library/assertion.library.js'; import type { Property } from '#library/type.library.js'; // adapted from https://jsbin.com/insert/4/edit?js,output diff --git a/packages/library/src/common/assertion.library.ts b/packages/library/src/common/assertion.library.ts new file mode 100644 index 00000000..81d53b1a --- /dev/null +++ b/packages/library/src/common/assertion.library.ts @@ -0,0 +1,108 @@ +import { sym } from '#library/symbol.library.js'; +import { getType, protoType, asType } from '#library/type.library.js'; +import type { Type, Primitive, Nullish, Temporals, Property, GetType } from '#library/type.library.js'; + +/** assert value is one of a list of Types */ +export const isType = (obj: unknown, ...types: Type[]): obj is T => types.includes(getType(obj)); + +/** Type-Guards: assert \ is of \ */ +export const isPrimitive = (obj?: unknown): obj is Primitive => isType(obj, 'String', 'Number', 'BigInt', 'Boolean', 'Symbol', 'Undefined', 'Void', 'Null', 'Empty'); +export const isReference = (obj?: unknown): obj is Object => !isPrimitive(obj); +export const isIterable = (obj: unknown): obj is Iterable => Symbol.iterator in Object(obj) && !isString(obj); + +export const isString = (obj?: T): obj is Extract => isType(obj, 'String'); +export const isNumber = (obj?: T): obj is Extract => isType(obj, 'Number') && isFinite(obj as number); + +/** test if can convert String to Numeric */ +export function isNumeric(str?: any): boolean { + const type = typeof str; + switch (type) { + case 'number': return isFinite(str); + case 'bigint': return true; + case 'string': { + const val = str.trim(); + if (val.length === 0) return false; + return /^-?[0-9]+n$/.test(val) || (!isNaN(parseFloat(val)) && isFinite(Number(val))); + } + default: return false; + } +} +export const isInteger = (obj?: T): obj is Extract => isType(obj, 'BigInt'); +export const isIntegerLike = (obj?: T): obj is Extract => isType(obj, 'String') && /^-?[0-9]+n$/.test(obj as string); +export const isDigit = (obj?: T): obj is Extract => isType(obj, 'Number', 'BigInt'); +export const isBoolean = (obj?: T): obj is Extract => isType(obj, 'Boolean'); +export const isArray = (obj: unknown): obj is T[] => isType(obj, 'Array'); +export const isArrayLike = (obj: any): obj is ArrayLike => protoType(obj) === 'Object' && 'length' in obj && Object.keys(obj).every(key => key === 'length' || !isNaN(Number(key))); +export const isObject = (obj?: T): obj is Extract => isType(obj, 'Object'); +export const isDate = (obj?: T): obj is Extract => isType(obj, 'Date'); +export const isRegExp = (obj?: T): obj is Extract => isType(obj, 'RegExp'); +export const isRegExpLike = (obj?: T): obj is Extract => isType(obj, 'String') && /^\/.*\/$/.test(obj as string); +export const isSymbol = (obj?: T): obj is Extract => isType(obj, 'Symbol'); +export const isSymbolFor = (obj?: T): obj is Extract => isType(obj, 'Symbol') && Symbol.keyFor(obj) !== undefined; +export const isPropertyKey = (obj?: unknown): obj is PropertyKey => isType(obj, 'String', 'Number', 'Symbol'); + +export const isNull = (obj?: T): obj is Extract => isType(obj, 'Null'); +export const isNullish = (obj: T): obj is Extract => isType(obj, 'Null', 'Undefined', 'Void', 'Empty'); +export const isUndefined = (obj?: T): obj is undefined => isType(obj, 'Undefined', 'Void', 'Empty'); +export const isDefined = (obj: T): obj is NonNullable => !isNullish(obj); + +export const isClass = (obj?: T): obj is Extract => isType(obj, 'Class'); +export const isFunction = (obj?: T): obj is Extract => isType(obj, 'Function', 'AsyncFunction'); +export const isPromise = (obj?: T): obj is Extract> => isType(obj, 'Promise'); +export const isMap = (obj?: T): obj is Extract> => isType(obj, 'Map'); +export const isSet = (obj?: T): obj is Extract> => isType(obj, 'Set'); +export const isError = (err?: T): err is Extract => isType(err, 'Error'); + +export const isTemporal = (obj: T): obj is Extract => protoType(obj).startsWith('Temporal.') || (!!(globalThis as any).Temporal && ( + (obj as any) instanceof (globalThis as any).Temporal.Instant || + (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime || + (obj as any) instanceof (globalThis as any).Temporal.PlainDate || + (obj as any) instanceof (globalThis as any).Temporal.PlainTime || + (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime || + (obj as any) instanceof (globalThis as any).Temporal.Duration || + (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth || + (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay +)); + +export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant); +export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime); +export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate); +export const isPlainTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime); +export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime); +export const isDuration = (obj: T): obj is Extract => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration); +export const isDurationLike = (obj: T): obj is Extract => isString(obj) || isDuration(obj) || (isObject(obj) && ( + 'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj || + 'hours' in obj || 'minutes' in obj || 'seconds' in obj || + 'milliseconds' in obj || 'microseconds' in obj || 'nanoseconds' in obj +)); +export const isZonedDateTimeLike = (obj: T): obj is Extract => isString(obj) || isZonedDateTime(obj) || (isObject(obj) && ( + 'year' in obj || 'month' in obj || 'day' in obj || 'hour' in obj || 'minute' in obj || 'second' in obj || + 'millisecond' in obj || 'microsecond' in obj || 'nanosecond' in obj || 'monthCode' in obj || 'offset' in obj || 'timeZone' in obj || 'calendar' in obj +)); +export const isPlainYearMonth = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainYearMonth') || (!!(globalThis as any).Temporal?.PlainYearMonth && (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth); +export const isPlainMonthDay = (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 isEnum = >(obj?: T): obj is Extract> => isType(obj, 'Enumify'); +export const isPledge = (obj?: T): obj is Extract> => isType(obj, 'Pledge'); + +/** assert value for secure() */ +export const isExtensible = (obj: any): obj is any => !!(obj?.[sym.$Extensible]); +export const isTarget = (obj: any): obj is any => !!(obj?.[sym.$Target]); + +/** object has no values */ +export const isEmpty = (obj?: T) => false + || isNullish(obj) + || (isObject(obj) && (Reflect.ownKeys(obj).length === 0)) + || (isString(obj) && (obj.trim().length === 0)) + || (isNumber(obj) && (isNaN(obj as any) === true)) + || (isArray(obj) && (obj.length === 0)) + || (isSet(obj) && (obj.size === 0)) + || (isMap(obj) && (obj.size === 0)) + +export function assertCondition(condition: boolean, message?: string): asserts condition { + if (!condition) + throw new Error(message); +} +export function assertString(str: unknown): asserts str is string { assertCondition(isString(str), `Invalid string: ${str}`) }; +export function assertNever(val: never): asserts val is never { throw new Error(`Unexpected object: ${val}`) }; diff --git a/packages/library/src/common/coercion.library.ts b/packages/library/src/common/coercion.library.ts index 542ad1e9..4a4e850f 100644 --- a/packages/library/src/common/coercion.library.ts +++ b/packages/library/src/common/coercion.library.ts @@ -1,5 +1,6 @@ import { clone, stringify } from '#library/serialize.library.js'; -import { isIntegerLike, isArrayLike, isDefined, isInteger, isIterable, isNullish, isString, isUndefined, asType, isNumber } from '#library/type.library.js'; +import { asType } from '#library/type.library.js'; +import { isIntegerLike, isArrayLike, isDefined, isInteger, isIterable, isNullish, isString, isUndefined, isNumber, isNumeric } from '#library/assertion.library.js'; /** Coerce {value} into {value[]} ( if not already ), with optional {fill} Object */ export function asArray(arr: Exclude, string> | undefined): T[]; @@ -52,25 +53,6 @@ export function asInteger(str?: T) { } } -/** test if can convert String to Numeric */ -export function isNumeric(str?: string | number | bigint) { - const arg = asType(str); - - switch (arg.type) { - case 'Number': - case 'BigInt': - return true; - - case 'String': - return isIntegerLike(arg.value) - ? true // is Number | BigInt - : !isNaN(asNumber(str)) && isFinite(str as number) // test if Number - - default: - return false; - } -} - /** return as Number if possible, else original String */ export const ifNumeric = (str: string | number | bigint, stripZero = false) => { switch (true) { @@ -87,3 +69,7 @@ export const ifNumeric = (str: string | number | bigint, stripZero = false) => { return str as string; // non-numeric String โ†’ as-is } } + +export const nullToZero = (obj: T) => obj ?? 0; +export const nullToEmpty = (obj: T) => obj ?? ''; +export const nullToValue = (obj: T, value: R) => obj ?? value; diff --git a/packages/library/src/common/enumerate.library.ts b/packages/library/src/common/enumerate.library.ts index aaca13ef..7b031732 100644 --- a/packages/library/src/common/enumerate.library.ts +++ b/packages/library/src/common/enumerate.library.ts @@ -1,10 +1,10 @@ -import { secure } from '#library/utility.library.js'; -import { asType, isNumber } from '#library/type.library.js'; +import { asType } from '#library/type.library.js'; +import { isNumber } from '#library/assertion.library.js'; import { ownEntries } from '#library/primitive.library.js'; -import { proxify } from '#library/proxy.library.js'; +import { secure, proxify } from '#library/proxy.library.js'; import { Serializable } from '#library/class.library.js'; import { memoizeMethod } from '#library/function.library.js'; -import lib from '#library/symbol.library.js'; +import { sym } from '#library/symbol.library.js'; import type { Property, Index, KeyOf, ValueOf, EntryOf, Invert, LooseKey } from '#library/type.library.js'; declare module '#library/type.library.js' { @@ -79,7 +79,7 @@ function value(val: any) { * ```typescript * const Status = enumify(['Active', 'Inactive', 'Pending']); * console.log(Status.Active); // 0 - * console.log(Status.has('Active')); // true + * console.log(Status.has('Active')); // true * console.log(Status.keys()); // ['Active', 'Inactive', 'Pending'] * ``` */ @@ -109,8 +109,8 @@ export function enumify(this: any, list: T, frozen = true): any { } const target = Object.create(proto, Object.getOwnPropertyDescriptors(stash)); - if (!frozen) Object.defineProperty(target, lib.$Extensible, { value: true, enumerable: false }); - return proxify(target, true, frozen); // proxy is ALWAYS frozen (read-only), but target is only 'locked' if requested + if (!frozen) Object.defineProperty(target, sym.$Extensible, { value: true, enumerable: false }); + return proxify(target, true, frozen); // proxy is ALWAYS frozen (read-only), but target is only 'locked' if requested } /** create an entry in the Serialization Registry to describe how to rebuild an Enum */ diff --git a/packages/library/src/common/function.library.ts b/packages/library/src/common/function.library.ts index 6d27ffdd..f363b5f1 100644 --- a/packages/library/src/common/function.library.ts +++ b/packages/library/src/common/function.library.ts @@ -1,5 +1,5 @@ -import { secure } from '#library/utility.library.js'; -import { isUndefined, type Property } from '#library/type.library.js'; +import { secure } from '#library/proxy.library.js'; +import type { Property } from '#library/type.library.js'; // https://medium.com/codex/currying-in-typescript-ca5226c85b85 type PartialTuple = @@ -36,7 +36,7 @@ type Curry = * Handles BigInt, Map, Set, Function, Undefined, and Circular refs. */ function serialize(val: any, seen = new WeakSet()): string { - return JSON.stringify(val, function(this: any, key: string, value: any) { + return JSON.stringify(val, function (this: any, key: string, value: any) { if (value === undefined) return '\u0000__undefined__\u0000'; if (typeof value === 'bigint') return `bigint:${value}`; if (typeof value === 'function') return `function:${value.name || 'anonymous'}`; diff --git a/packages/library/src/common/logify.class.ts b/packages/library/src/common/logify.class.ts index 0cec5dd4..75d9d10a 100644 --- a/packages/library/src/common/logify.class.ts +++ b/packages/library/src/common/logify.class.ts @@ -1,6 +1,8 @@ import { Immutable } from '#library/class.library.js'; -import lib, { markConfig } from '#library/symbol.library.js'; -import { asType, isObject, isEmpty, type ValueOf } from '#library/type.library.js'; +import { sym, markConfig } from '#library/symbol.library.js'; +import { asType } from '#library/type.library.js'; +import { isObject, isEmpty } from '#library/assertion.library.js'; +import type { ValueOf } from '#library/type.library.js'; const Method = { Log: 'log', @@ -10,22 +12,34 @@ const Method = { Error: 'error', } as const +const Level = { + [Method.Error]: 1, + [Method.Warn]: 2, + [Method.Info]: 3, + [Method.Log]: 3, + [Method.Debug]: 4, +} as const; + /** * provide standard logging methods to the console for a class */ @Immutable export class Logify { #name: string; - #opts: Logify.Constructor = { [lib.$Logify]: true }; + #opts: Logify.Constructor = { [sym.$Logify]: true }; /** * if {catch:true} then show a warning on the console and return * otherwise show an error on the console and re-throw the error */ #trap(method: Logify.Method, ...msg: any[]) { - const config = (isObject(msg[0]) && (msg[0] as any)[lib.$Logify] === true) ? msg.shift() : this.#opts; + const config = (isObject(msg[0]) && (msg[0] as any)[sym.$Logify] === true) ? msg.shift() : this.#opts; + + const currentLevel = (typeof config.debug === 'number') ? config.debug : (config.debug ? Level[Method.Debug] : Level[Method.Error]); + const methodLevel = Level[method] ?? 0; + + if (methodLevel > currentLevel) return; - if (method === Method.Debug && !config.debug) return; const output = msg.map(m => { if (m instanceof Error) return m.message; @@ -89,9 +103,9 @@ export namespace Logify { export type Method = ValueOf export interface Constructor { - debug?: boolean | undefined, + debug?: boolean | number | undefined, catch?: boolean | undefined, silent?: boolean | undefined, - [lib.$Logify]?: boolean | undefined + [sym.$Logify]?: boolean | undefined } } \ No newline at end of file diff --git a/packages/library/src/common/object.library.ts b/packages/library/src/common/object.library.ts index 0eb3728f..378ddd39 100644 --- a/packages/library/src/common/object.library.ts +++ b/packages/library/src/common/object.library.ts @@ -1,5 +1,5 @@ import { ownKeys, ownEntries } from '#library/primitive.library.js'; -import { isObject, isArray, isReference, isFunction, isDefined, isNullish } from '#library/type.library.js'; +import { isObject, isArray, isReference, isFunction, isDefined, isNullish } from '#library/assertion.library.js'; import type { Extend, Property } from '#library/type.library.js'; /** remove quotes around property names */ diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/pledge.class.ts index 64bcfd40..103efacd 100644 --- a/packages/library/src/common/pledge.class.ts +++ b/packages/library/src/common/pledge.class.ts @@ -2,10 +2,10 @@ import { Logify } from '#library/logify.class.js'; import { markConfig } from '#library/symbol.library.js'; import { asArray } from '#library/coercion.library.js'; import { ifDefined } from '#library/object.library.js'; -import { secure } from '#library/utility.library.js'; +import { secure } from '#library/proxy.library.js'; import { cleanify } from '#library/serialize.library.js'; import { Immutable } from '#library/class.library.js'; -import { isEmpty, isObject } from '#library/type.library.js'; +import { isEmpty, isObject } from '#library/assertion.library.js'; declare module '#library/type.library.js' { interface TypeValueMap { diff --git a/packages/library/src/common/primitive.library.ts b/packages/library/src/common/primitive.library.ts index 08704595..fd74b4a7 100644 --- a/packages/library/src/common/primitive.library.ts +++ b/packages/library/src/common/primitive.library.ts @@ -1,5 +1,5 @@ -import sym from '#library/symbol.library.js'; -import { isEmpty } from '#library/type.library.js'; +import { sym } from '#library/symbol.library.js'; +import { isEmpty } from '#library/assertion.library.js'; import type { Obj, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; /** @@ -9,19 +9,24 @@ import type { Obj, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; * These functions have NO dependencies on array, object, or reflection libraries. */ +/** + * ## unwrap + * Traverse a Proxy chain and return the underlying raw target object. + * Hardened against prototype-climbing bugs. + */ +export function unwrap(obj: T): T { + let curr = (obj as any)?.[sym.$Target] ?? obj; + while (Object.prototype.hasOwnProperty.call(curr, sym.$Target)) { + curr = curr[sym.$Target]; + } + return curr; +} + /** Tuple of enumerable entries with string | symbol keys */ export function ownEntries(json: T, all = false): EntryOf[] { if (!json || typeof json !== 'object') return [] as EntryOf[]; - const unwrap = (obj: any): any => { - let curr = obj; - while (curr && curr[sym.$Target]) { - curr = curr[sym.$Target]; - } - return curr; - } - const getOwn = (obj: any): [PropertyKey, any][] => { const tgt = unwrap(obj); return Reflect.ownKeys(tgt) diff --git a/packages/library/src/common/proxy.library.ts b/packages/library/src/common/proxy.library.ts index 5df1998d..2f15c7fb 100644 --- a/packages/library/src/common/proxy.library.ts +++ b/packages/library/src/common/proxy.library.ts @@ -1,152 +1,149 @@ -import lib from '#library/symbol.library.js'; +import { sym } from '#library/symbol.library.js'; import { allObject } from '#library/reflection.library.js'; -import { secure } from '#library/utility.library.js'; -import { isDefined, isFunction, isSymbol, registerType, type Constructor, type Type } from '#library/type.library.js'; +import { deepFreeze } from '#library/utility.library.js'; +import { unwrap } from '#library/primitive.library.js'; +import { isFunction, isSymbol, isDefined } from '#library/assertion.library.js'; +import { registerType, type Constructor } from '#library/type.library.js'; + +/** internal options for the unified proxy engine */ +type ProxyOptions = { + frozen?: boolean; // read-only Proxy (throws on set/delete) + lock?: boolean; // deep-freeze the target object + appendOnly?: boolean; // allow adding properties, but not changing existing ones + onGet?: (key: string | symbol, target: any) => any; // callback for property discovery + keys?: (string | symbol)[]; // fixed set of keys (for virtual objects) + bind?: boolean; // bind methods to the target + skip?: WeakSet; // objects to skip during deep-freeze +} -/** Stealth Proxy pattern to allow for iteration and logging over a Frozen object */ -export function proxify(target: T, frozen = true, lock = frozen) { - const tgt = (target as any)[lib.$Target] ?? target; // unwrap if it's already a proxy +/** + * ## factory + * The unified internal engine for all Proxy creation in the library. + * Handles unwrapping, Proxy invariants, discovery, and security. + */ +function factory(target: T, options: ProxyOptions = {}): T { + const { frozen, lock, appendOnly, onGet, keys, bind, skip } = options; + const pending = new Set(); let cachedJSON: any; - registerType(tgt as Constructor); // auto-register with global type system + // 1. Unwrap recursive proxies and resolve the true target + const tgt = unwrap(target); - if (lock) secure(tgt); + // 2. Harden the target if requested + if (lock) deepFreeze(tgt, skip); + registerType(tgt as Constructor); - return new Proxy(tgt, { + const handler: ProxyHandler = { isExtensible: (t) => Reflect.isExtensible(t), - preventExtensions: (t) => Reflect.preventExtensions(t), - getOwnPropertyDescriptor: (_, key) => Reflect.getOwnPropertyDescriptor(tgt, key), - getPrototypeOf: () => Reflect.getPrototypeOf(tgt), - ownKeys: () => Reflect.ownKeys(tgt), - has: (_, key) => Reflect.has(tgt, key), - deleteProperty: (_, key) => { - if (frozen) throw new TypeError(`Cannot delete property '${String(key)}' from a frozen object.`); - return Reflect.deleteProperty(tgt, key); - }, - set: (_, key, val) => { - if (frozen) throw new TypeError(`Cannot set property '${String(key)}' on a frozen object.`); - return Reflect.set(tgt, key, val); + getPrototypeOf: (t) => Reflect.getPrototypeOf(t), + setPrototypeOf: (t, proto) => { + if (frozen) throw new TypeError('Security: Prototype mutation attempt on protected object'); + return Reflect.setPrototypeOf(t, proto); }, - get: (_, key) => { - if (key === lib.$Target) - return tgt; // found the 'stop' marker - - if (frozen && (key === lib.$Inspect || key === 'toJSON')) { // two special properties require virtual closures - const own = Object.getOwnPropertyDescriptor(tgt, key); - if (own && isFunction(own.value)) // if object already has its own toJSON, return - return own.value; - if (!cachedJSON) // otherwise, create a virtual closure - cachedJSON = () => allObject(tgt); // resolve & memoize for subsequent calls + getOwnPropertyDescriptor: (t, k) => { + if (keys && !keys.includes(k)) return undefined; + if (keys) return { enumerable: true, configurable: true }; + return Reflect.getOwnPropertyDescriptor(t, k); + }, - return cachedJSON; // return the memoized closure + ownKeys: (t) => { + if (keys) return keys as string[]; + if (onGet && !pending.has(sym.$Discover)) { + pending.add(sym.$Discover); + try { onGet(sym.$Discover, t); } finally { pending.delete(sym.$Discover); } } - - const val = Reflect.get(tgt, key); - return (frozen && isFunction(val)) // if the value is a function - ? val.bind(tgt) // bind it to the target - : val; // else return the value + return Reflect.ownKeys(t); }, - }) as T -} -/** Stealth Proxy pattern to allow for on-demand lazy property discovery and registration */ -export function delegate(target: T, onGet: (key: string | symbol, target: T) => any, readonly = true) { - const pending = new Set(); // recursion guard + has: (t, k) => (keys ? keys.includes(k) : Reflect.has(t, k)), - return new Proxy(target, { - isExtensible: (t) => Reflect.isExtensible(t), - preventExtensions: (t) => Reflect.preventExtensions(t), - getOwnPropertyDescriptor: (t, key) => Reflect.getOwnPropertyDescriptor(t, key), - getPrototypeOf: (t) => Reflect.getPrototypeOf(t), - setPrototypeOf: (t, proto) => { - if (readonly) throw new TypeError('Cannot set prototype of a read-only delegator.'); - return Reflect.setPrototypeOf(t, proto); + deleteProperty: (t, k) => { + if (frozen) throw new TypeError(`Cannot delete property '${String(k)}' from a protected object.`); + if (appendOnly && Reflect.has(t, k)) throw new Error(`Security: Deletion attempt on protected key '${String(k)}'`); + return Reflect.deleteProperty(t, k); }, - deleteProperty: (t, key) => { - if (readonly) throw new TypeError(`Cannot delete property '${String(key)}' from a read-only delegator.`); - return Reflect.deleteProperty(t, key); + defineProperty: (t, k, d) => { + if (frozen) throw new TypeError(`Cannot define property '${String(k)}' on a frozen object.`); + if (appendOnly && Reflect.has(t, k)) throw new Error(`Security: Mutation attempt on protected key '${String(k)}'`); + return Reflect.defineProperty(t, k, d); }, - defineProperty: (t, key, desc) => { - if (readonly) throw new TypeError(`Cannot define property '${String(key)}' on a read-only delegator.`); - return Reflect.defineProperty(t, key, desc); + set: (t, k, v, r) => { + if (frozen && r === result) throw new TypeError(`Cannot set property '${String(k)}' on a frozen object.`); + if (appendOnly) { + const isTruncating = Array.isArray(t) && k === 'length' && v < t.length; + if (isTruncating) throw new Error('Security: Truncation attempt on protected array.'); + if (!(Array.isArray(t) && k === 'length') && Reflect.has(t, k)) + throw new Error(`Security: Mutation attempt on protected key '${String(k)}'`); + } + return Reflect.set(t, k, v, r); }, - set: (t, key, val) => { - if (readonly) throw new TypeError(`Cannot set property '${String(key)}' on a read-only delegator.`); - return Reflect.set(t, key, val); - }, + get: (t, k, r) => { + if (k === sym.$Target) return r === result ? t : undefined; - ownKeys: (t) => { - if (!pending.has(lib.$Discover)) { - pending.add(lib.$Discover); + // Virtualization for serialization + if (frozen && (k === sym.$Inspect || k === 'toJSON')) { + const own = Object.getOwnPropertyDescriptor(t, k); + if (own && isFunction(own.value)) return own.value; + if (!cachedJSON) cachedJSON = () => allObject(t); + return cachedJSON; + } + + if (keys && !keys.includes(k)) return undefined; + + // Property Discovery + if (onGet && !isSymbol(k) && !Reflect.has(t, k) && !pending.has(k)) { + pending.add(k); try { - onGet(lib.$Discover, t); // full discovery phase + const val = onGet(k, t); + if (isDefined(val)) return val; } finally { - pending.delete(lib.$Discover); + pending.delete(k); } + // silent mark to avoid redundant discovery + if (Reflect.isExtensible(t) && !Reflect.has(t, k)) + Object.defineProperty(t, k, { value: undefined, enumerable: false, configurable: true }); } - return Reflect.ownKeys(t); - }, - get: (t, key) => { - // bypass for symbols or properties already in the chain (or currently resolving) - if (isSymbol(key) || Reflect.has(t, key) || pending.has(key)) - return Reflect.get(t, key); - - pending.add(key); // mark as resolving - try { - const val = onGet(key, t); // discovery phase - if (val !== undefined) return val; // return early if evaluation was handled - } finally { - pending.delete(key); // resolve complete + + const val = Reflect.get(t, k, r); + if (bind && isFunction(val)) { + const desc = Object.getOwnPropertyDescriptor(t, k); + if (desc && !desc.configurable && !desc.writable) return val; + return val.bind(t); } + return val; + } + }; - // silently mark on target to avoid redundant discovery even if not found - if (Reflect.isExtensible(t) && !Reflect.has(t, key)) - Object.defineProperty(t, key, { value: undefined, enumerable: false, configurable: true }); + const result = new Proxy(tgt, handler) as T; + return result; +} - return Reflect.get(t, key); - } - }) as T; +/** Stealth Proxy pattern to allow for on-demand lazy property discovery and registration */ +export function proxify(target: T, frozen = true, lock = frozen, skip = new WeakSet()) { + return factory(target, { frozen, lock, skip, bind: frozen }); } +/** Create a dynamic Proxy where property access is forwarded to a discovery callback */ +export function delegate(target: T, onGet: (key: string | symbol, target: T) => any, readonly = true) { + return factory(target, { onGet, frozen: readonly }); +} -/** internal helper to check for array truncation attempts */ -const isTruncating = (t: any, k: PropertyKey, v: any) => Array.isArray(t) && k === 'length' && v < t.length; +/** Wrap an object in a protective Proxy that allows extension but prevents modification */ +export function secureRef(target: T): T { + return factory(target, { appendOnly: true }); +} -/** internal helper to verify that a mutation is safe (Closed for Modification, Open for Extension) */ -const assertSafe = (t: any, k: PropertyKey, v: any) => { - if (isTruncating(t, k, v)) throw new Error('Security: Truncation attempt on protected array.'); - if (Array.isArray(t) && k === 'length') return; - if (Reflect.has(t, k)) throw new Error(`Security: Mutation attempt on protected key '${String(k)}'`); +/** Deep-freeze an object and wrap it in a loudly-throwing read-only Proxy */ +export function secure(obj: T, skip = new WeakSet()): T { + return factory(obj, { frozen: true, lock: true, skip, bind: true }); } -/** - * ## secureRef - * Wrap an object or array in a protective Proxy that follows 'Closed for Modification, Open for Extension'. - * Allows adding new properties/elements, but prevents overwriting or deleting existing ones. - */ -export function secureRef(target: T): T { - return new Proxy(target, { - get(t, k) { - if (k === lib.$Target) return t; - return Reflect.get(t, k); - }, - set(t, k, v) { - assertSafe(t, k, v); - return Reflect.set(t, k, v); - }, - defineProperty(t, k, d) { - assertSafe(t, k, d.value); - return Reflect.defineProperty(t, k, d); - }, - deleteProperty(t, k) { - throw new Error(`Security: Deletion attempt on protected key '${String(k)}'`); - }, - setPrototypeOf() { - throw new Error(`Security: Prototype mutation attempt on protected object`); - } - }); +/** Create a virtual Proxy where fixed keys are mapped to a callback function */ +export function delegator(keys: K[] | Record, fn: (prop: K) => any): Record { + const keyList = Array.isArray(keys) ? keys : Object.keys(keys) as K[]; + return factory({} as any, { keys: keyList, onGet: fn as any }); } diff --git a/packages/library/src/common/reflection.library.ts b/packages/library/src/common/reflection.library.ts index d62b6ab3..efaf53ae 100644 --- a/packages/library/src/common/reflection.library.ts +++ b/packages/library/src/common/reflection.library.ts @@ -1,5 +1,6 @@ import { distinct, ownKeys, ownEntries } from '#library/primitive.library.js'; -import { asType, getType, isEmpty, isFunction, isPrimitive } from '#library/type.library.js'; +import { asType, getType } from '#library/type.library.js'; +import { isEmpty, isFunction, isPrimitive } from '#library/assertion.library.js'; import type { Obj, KeyOf, Primitives } from '#library/type.library.js'; /** mutate Object | Array by excluding values with specified primitive 'types' */ diff --git a/packages/library/src/common/serialize.library.ts b/packages/library/src/common/serialize.library.ts index 7286cd51..f6464212 100644 --- a/packages/library/src/common/serialize.library.ts +++ b/packages/library/src/common/serialize.library.ts @@ -1,8 +1,9 @@ import { curry } from '#library/function.library.js'; import { ownKeys, ownValues, ownEntries } from '#library/primitive.library.js'; -import { isType, asType, isEmpty, isDefined, isUndefined, isNullish, isString, isObject, isArray, isFunction, isSymbolFor, isSymbol } from '#library/type.library.js'; -import sym from '#library/symbol.library.js'; +import { asType } from '#library/type.library.js'; +import { isType, isEmpty, isDefined, isUndefined, isNullish, isString, isObject, isArray, isFunction, isSymbolFor, isSymbol } from '#library/assertion.library.js'; +import { sym } from '#library/symbol.library.js'; import type { Obj, Type } from '#library/type.library.js'; export const Registry = (globalThis as any)[sym.$SerializerRegistry] ??= new Map(); @@ -11,9 +12,8 @@ export const Registry = (globalThis as any)[sym.$SerializerRegistry] ??= new Map export const registerSerializable = (name: string, cls: Function) => { const key = name.startsWith('$') ? name : `$${name}`; - if (Registry.has(key)) { + if (Registry.has(key)) throw new Error(`[registerSerializable] Collision: '${key}' is already registered with ${Registry.get(key)?.name || 'anonymous constructor'}`); - } Registry.set(key, cls); } diff --git a/packages/library/src/common/storage.library.ts b/packages/library/src/common/storage.library.ts index dfb27208..e9361fc3 100644 --- a/packages/library/src/common/storage.library.ts +++ b/packages/library/src/common/storage.library.ts @@ -1,6 +1,6 @@ import { objectify, stringify } from '#library/serialize.library.js'; import { CONTEXT, getContext } from '#library/utility.library.js'; -import { isDefined, isUndefined, isString } from '#library/type.library.js'; +import { isDefined, isUndefined, isString } from '#library/assertion.library.js'; const context = getContext(); diff --git a/packages/library/src/common/string.library.ts b/packages/library/src/common/string.library.ts index 2a32bad1..fded7759 100644 --- a/packages/library/src/common/string.library.ts +++ b/packages/library/src/common/string.library.ts @@ -1,6 +1,6 @@ -import { asNumber, asString, isNumeric } from '#library/coercion.library.js'; +import { asNumber, asString, nullToValue } from '#library/coercion.library.js'; import { stringify } from '#library/serialize.library.js'; -import { isString, isObject, assertCondition, assertString, nullToValue } from '#library/type.library.js'; +import { isString, isObject, isNumeric, assertCondition, assertString } from '#library/assertion.library.js'; // General functions @@ -93,8 +93,8 @@ export const plural = (val: string | number | Record, word: stri type SingularUnit = T extends `${infer S}s` ? T extends `${string}${string}${string}${string}` - ? S - : T + ? S + : T : T; /** strip a plural suffix, if endsWith 's' */ diff --git a/packages/library/src/common/symbol.library.ts b/packages/library/src/common/symbol.library.ts index 9095bad4..a4cf28f6 100644 --- a/packages/library/src/common/symbol.library.ts +++ b/packages/library/src/common/symbol.library.ts @@ -3,7 +3,7 @@ * These symbols utilize Symbol.for() to ensure consistency across module boundaries. */ -const sym = { +export const sym = { /** key to use for identifying the raw target of a Proxy */ $Target: Symbol.for('$LibraryTarget'), /** key to trigger full discovery of all lazy properties */ @@ -20,6 +20,8 @@ const sym = { $Register: Symbol.for('$LibraryRegister'), /** key to identify the global serialization registry */ $SerializerRegistry: Symbol.for('$LibrarySerializerRegistry'), + /** universal key to identify a trusted Class or Instance brand */ + $Identity: Symbol.for('$LibraryIdentity'), } as const; /** identify and mark a Logify configuration object */ @@ -29,5 +31,3 @@ export function markConfig(obj: T): T { return obj; } - -export default sym; diff --git a/packages/library/src/common/temporal.library.ts b/packages/library/src/common/temporal.library.ts index 41fdf465..d99b4365 100644 --- a/packages/library/src/common/temporal.library.ts +++ b/packages/library/src/common/temporal.library.ts @@ -4,7 +4,7 @@ */ import '#library/temporal.polyfill.js'; // ensure Temporal is available -import { isNumber } from '#library/type.library.js'; +import { isNumber, isString } from '#library/assertion.library.js'; /** return the current Temporal.Now.instant */ export function instant() { @@ -105,3 +105,13 @@ export function toInstant(epochNanoseconds: bigint): Temporal.Instant { return Temporal.Instant.fromEpochNanoseconds(epochNanoseconds); } +/** + * ## getTemporalIds + * Normalize TimeZone and Calendar inputs into a [timeZoneId, calendarId] tuple. + */ +export function getTemporalIds(tz: any, cal: any): [string, string] { + const tzId = isString(tz) ? tz : (tz as any)?.timeZoneId ?? (tz as any)?.id; + const calId = isString(cal) ? cal : (cal as any)?.calendarId ?? (cal as any)?.id; + + return [tzId, calId]; +} diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index b77a256c..dfbeb064 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -1,10 +1,10 @@ -import lib from '#library/symbol.library.js'; +import { sym } from '#library/symbol.library.js'; const registry: Instance[] = []; // global types for getType /** the primitive type reported by toStringTag() */ -const protoType = (obj?: unknown) => { - const raw = (obj as any)?.[lib.$Target] ?? obj; // bypass Proxy traps +export const protoType = (obj?: unknown) => { + const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps return Object.prototype.toString.call(raw).slice(8, -1) as Type; } @@ -18,7 +18,7 @@ const protoType = (obj?: unknown) => { * before calling getType() to ensure custom types are correctly identified. */ export const getType = (obj?: any, ...instances: Instance[]): Type => { - const raw = (obj as any)?.[lib.$Target] ?? obj; // bypass Proxy traps + const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps const type = protoType(raw); switch (true) { @@ -29,16 +29,17 @@ export const getType = (obj?: any, ...instances: Instance[]): Type => { case typeof raw === 'function': return type; // catch all functional types (including AsyncFunction) case type === 'Object': { - if (isArrayLike(raw)) return 'ArrayLike'; + // check for ArrayLike (e.g. {0:'a', 1:'b', length:2}) + if ('length' in raw && Object.keys(raw).every(key => key === 'length' || !isNaN(Number(key)))) return 'ArrayLike'; for (const inst of instances) { - const instRaw = (inst.class as any)?.[lib.$Target] ?? inst.class; + const instRaw = (inst.class as any)?.[sym.$Target] ?? inst.class; if (raw === instRaw || (instRaw && raw instanceof instRaw)) return inst.type as Type; } - const globalRegistry = (globalThis as any)[lib.$Registry] ?? []; + const globalRegistry = (globalThis as any)[sym.$Registry] ?? []; for (const inst of [...registry, ...globalRegistry]) { - const instRaw = (inst.class as any)?.[lib.$Target] ?? inst.class; + const instRaw = (inst.class as any)?.[sym.$Target] ?? inst.class; if (raw === instRaw || (instRaw && raw instanceof instRaw)) return inst.type as Type; } @@ -52,104 +53,14 @@ export const getType = (obj?: any, ...instances: Instance[]): Type => { /** return TypeValue object */ export const asType = (value?: T, ...instances: Instance[]) => ({ type: getType(value, ...instances), value } as TypeValue); -/** assert value is one of a list of Types */ -export const isType = (obj: unknown, ...types: Type[]): obj is T => types.includes(getType(obj)); - -/** Type-Guards: assert \ is of \ */ -export const isPrimitive = (obj?: unknown): obj is Primitive => isType(obj, 'String', 'Number', 'BigInt', 'Boolean', 'Symbol', 'Undefined', 'Void', 'Null', 'Empty'); -export const isReference = (obj?: unknown): obj is Object => !isPrimitive(obj); -export const isIterable = (obj: unknown): obj is Iterable => Symbol.iterator in Object(obj) && !isString(obj); - -export const isString = (obj?: T): obj is Extract => isType(obj, 'String'); -export const isNumber = (obj?: T): obj is Extract => isType(obj, 'Number') && isFinite(obj as number); -export const isInteger = (obj?: T): obj is Extract => isType(obj, 'BigInt'); -export const isIntegerLike = (obj?: T): obj is Extract => isType(obj, 'String') && /^-?[0-9]+n$/.test(obj as string); -export const isDigit = (obj?: T): obj is Extract => isType(obj, 'Number', 'BigInt'); -export const isBoolean = (obj?: T): obj is Extract => isType(obj, 'Boolean'); -export const isArray = (obj: unknown): obj is T[] => isType(obj, 'Array'); -export const isArrayLike = (obj: any): obj is ArrayLike => protoType(obj) === 'Object' && 'length' in obj && Object.keys(obj).every(key => key === 'length' || !isNaN(Number(key))); -export const isObject = (obj?: T): obj is Extract => isType(obj, 'Object'); -export const isDate = (obj?: T): obj is Extract => isType(obj, 'Date'); -export const isRegExp = (obj?: T): obj is Extract => isType(obj, 'RegExp'); -export const isRegExpLike = (obj?: T): obj is Extract => isType(obj, 'String') && /^\/.*\/$/.test(obj as string); -export const isSymbol = (obj?: T): obj is Extract => isType(obj, 'Symbol'); -export const isSymbolFor = (obj?: T): obj is Extract => isType(obj, 'Symbol') && Symbol.keyFor(obj) !== undefined; -export const isPropertyKey = (obj?: unknown): obj is PropertyKey => isType(obj, 'String', 'Number', 'Symbol'); - -export const isNull = (obj?: T): obj is Extract => isType(obj, 'Null'); -export const isNullish = (obj: T): obj is Extract => isType(obj, 'Null', 'Undefined', 'Void', 'Empty'); -export const isUndefined = (obj?: T): obj is undefined => isType(obj, 'Undefined', 'Void', 'Empty'); -export const isDefined = (obj: T): obj is NonNullable => !isNullish(obj); - -export const isClass = (obj?: T): obj is Extract => isType(obj, 'Class'); -export const isFunction = (obj?: T): obj is Extract => isType(obj, 'Function', 'AsyncFunction'); -export const isPromise = (obj?: T): obj is Extract> => isType(obj, 'Promise'); -export const isMap = (obj?: T): obj is Extract> => isType(obj, 'Map'); -export const isSet = (obj?: T): obj is Extract> => isType(obj, 'Set'); -export const isError = (err?: T): err is Extract => isType(err, 'Error'); - -export const isTemporal = (obj: T): obj is Extract => protoType(obj).startsWith('Temporal.') || (!!(globalThis as any).Temporal && ( - (obj as any) instanceof (globalThis as any).Temporal.Instant || - (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime || - (obj as any) instanceof (globalThis as any).Temporal.PlainDate || - (obj as any) instanceof (globalThis as any).Temporal.PlainTime || - (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime || - (obj as any) instanceof (globalThis as any).Temporal.Duration || - (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth || - (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay -)); - -export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant); -export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime); -export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate); -export const isPlainTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime); -export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime); -export const isDuration = (obj: T): obj is Extract => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration); -export const isDurationLike = (obj: T): obj is Extract => isString(obj) || isDuration(obj) || (isObject(obj) && ( - 'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj || - 'hours' in obj || 'minutes' in obj || 'seconds' in obj || - 'milliseconds' in obj || 'microseconds' in obj || 'nanoseconds' in obj -)); -export const isPlainYearMonth = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainYearMonth') || (!!(globalThis as any).Temporal?.PlainYearMonth && (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth); -export const isPlainMonthDay = (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 isEnum = >(obj?: T): obj is Extract> => isType(obj, 'Enumify'); -export const isPledge = (obj?: T): obj is Extract> => isType(obj, 'Pledge'); - -/** assert value for secure() */ -export const isExtensible = (obj: any): obj is any => !!(obj?.[lib.$Extensible]); -export const isTarget = (obj: any): obj is any => !!(obj?.[lib.$Target]); - -export const nullToZero = (obj: T) => obj ?? 0; -export const nullToEmpty = (obj: T) => obj ?? ''; -export const nullToValue = (obj: T, value: R) => obj ?? value; - -/** object has no values */ -export const isEmpty = (obj?: T) => false - || isNullish(obj) - || (isObject(obj) && (Reflect.ownKeys(obj).length === 0)) - || (isString(obj) && (obj.trim().length === 0)) - || (isNumber(obj) && (isNaN(obj) === true)) - || (isArray(obj) && (obj.length === 0)) - || (isSet(obj) && (obj.size === 0)) - || (isMap(obj) && (obj.size === 0)) - -export function assertCondition(condition: boolean, message?: string): asserts condition { - if (!condition) - throw new Error(message); -} -export function assertString(str: unknown): asserts str is string { assertCondition(isString(str), `Invalid string: ${str}`) }; -export function assertNever(val: never): asserts val is never { throw new Error(`Unexpected object: ${val}`) }; - /** * # resetRegistry * Clear the global type registry for test isolation and deterministic behavior. */ export const resetRegistry = () => { registry.length = 0; - if (Array.isArray((globalThis as any)[lib.$Registry])) { - (globalThis as any)[lib.$Registry].length = 0; + if (Array.isArray((globalThis as any)[sym.$Registry])) { + (globalThis as any)[sym.$Registry].length = 0; } }; const classRegex = /^\s*(class\s|\[native code\])/; // match class keyword OR native constructor @@ -158,7 +69,7 @@ const classRegex = /^\s*(class\s|\[native code\])/; // match class keyword O const isClassConstructor = (obj: any): boolean => { if (typeof obj !== 'function') return false; - const raw = (obj as any)?.[lib.$Target] ?? obj; // bypass Proxy traps + const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps // Arrow functions do NOT have a prototype property, whereas traditional functions and classes DO. // This is a high-performance check to immediately classify arrow functions as non-classes. @@ -166,12 +77,13 @@ const isClassConstructor = (obj: any): boolean => { const name = (raw as any)?.name; const tag = raw?.[Symbol.toStringTag] ?? raw?.prototype?.[Symbol.toStringTag]; - // Absolute bypass for Tempo and Temporal identities (using global brands) - if (name === 'Tempo' || tag === 'Tempo' || (typeof tag === 'string' && (tag.startsWith('Temporal.') || tag.startsWith('Tempo.')))) return true; + // Absolute bypass for branded identities (using universal brand) + if (raw?.[sym.$Identity] || raw?.prototype?.[sym.$Identity]) return true; + if (typeof tag === 'string' && tag.startsWith('Temporal.')) 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] ?? []; - if (globalRegistry.some((inst: any) => ((inst.class as any)?.[lib.$Target] ?? inst.class) === raw || (name && inst.type === name) || (tag && inst.type === tag))) return true; + const globalRegistry = (globalThis as any)[sym.$Registry] ?? []; + if (globalRegistry.some((inst: any) => ((inst.class as any)?.[sym.$Target] ?? inst.class) === raw || (name && inst.type === name) || (tag && inst.type === tag))) return true; // 3. Last resort: Inspection of constructor property & prototype (Transpilation-Safe) @@ -252,7 +164,7 @@ type toName = T extends undefined ? "Undefined" : T extends null ? "Null" : never -type Primitive = string | number | bigint | boolean | symbol | void | undefined | null // TODO: add composite (record & tuple) ? +export type Primitive = string | number | bigint | boolean | symbol | void | undefined | null // TODO: add composite (record & tuple) ? export type Primitives = toName /** Generic constructor type */ diff --git a/packages/library/src/common/utility.library.ts b/packages/library/src/common/utility.library.ts index 2c13e6a0..c29b2f12 100644 --- a/packages/library/src/common/utility.library.ts +++ b/packages/library/src/common/utility.library.ts @@ -1,5 +1,6 @@ import { ownValues } from '#library/primitive.library.js'; -import { isDefined, isPrimitive } from '#library/type.library.js'; +import { isDefined, isPrimitive } from '#library/assertion.library.js'; +import { sym } from '#library/symbol.library.js'; import type { Secure, ValueOf } from '#library/type.library.js'; /** General utility functions */ @@ -64,13 +65,16 @@ export const getContext = (): Context => { } /** deep-freeze an Array | Object to make it immutable (with recursion guard) */ -export function secure(obj: T, seen = new WeakSet()) { - if (isPrimitive(obj) || Object.isFrozen(obj) || seen.has(obj)) +export function deepFreeze(obj: T, skip = new WeakSet(), seen = new WeakSet()) { + if (isPrimitive(obj) || Object.isFrozen(obj) || seen.has(obj) || skip.has(obj)) + return obj as Secure; + + if ((obj as any)[sym.$Extensible]) return obj as Secure; seen.add(obj); - ownValues(obj as any).forEach(val => secure(val, seen)); + ownValues(obj as any).forEach(val => deepFreeze(val, skip, seen)); return Object.freeze(obj) as Secure; } diff --git a/packages/tempo/plan/.WISHLIST.md b/packages/tempo/plan/.WISHLIST.md index e43575b4..50cb9858 100644 --- a/packages/tempo/plan/.WISHLIST.md +++ b/packages/tempo/plan/.WISHLIST.md @@ -16,10 +16,6 @@ ## look at making the 'debug' value an enum instead of boolean ? - this would allow us to set a debug level, e.g. none, parse, format, all -## better tracking of Temporal types -- rather than isTemporal() checking if the type starts with 'Temporal.'. -We need to use the symbols defined in the Temporal API for a more robust and reliable way to identify Temporal objects. - ## support for extended Term syntax -- for example, .set({'+quarter': 1}) or .set({'#quarter': 'Q2'}) -- i am concerned that resolveCycleWindow relies on hard-coded .month and .day properties on the range, which is presuming we have date-component ranges ? and that no checking is done if there already is a 'year' component in the range. diff --git a/packages/tempo/src/discrete/discrete.format.ts b/packages/tempo/src/discrete/discrete.format.ts index 3908e63f..21a145cb 100644 --- a/packages/tempo/src/discrete/discrete.format.ts +++ b/packages/tempo/src/discrete/discrete.format.ts @@ -1,11 +1,11 @@ import '#library/temporal.polyfill.js'; import { pad } from '#library/string.library.js'; import { ifNumeric } from '#library/coercion.library.js'; -import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined, isDefined } from '#library/type.library.js'; +import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined } from '#library/assertion.library.js'; +import { delegator } from '#library/proxy.library.js'; -import { isTempo, enums, Match, getRuntime } from '#tempo/support'; +import { isTempo, enums, Match, getRuntime, NumericPattern } from '#tempo/support'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; -import { NumericPattern } from '../support/tempo.enum.js'; import type { Tempo } from '../tempo.class.js'; declare module '../tempo.class.js' { @@ -32,6 +32,7 @@ export function format(obj: Temporal.ZonedDateTime | any, fmt: string | symbol): export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol): string | number | any { const state = getRuntime().state; const config = isTempo(obj) ? obj.config : state?.config; + const formats = Object.assign(Object.create(enums.FORMAT), config?.formats); const tz = config?.timeZone ?? 'UTC'; let zdt: any; @@ -59,32 +60,11 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol zdt = obj; } - if (isUndefined(fmt)) { - const formats = config?.formats ?? enums.FORMAT; - return new Proxy({} as any, { - get(_, prop: string) { - if (!isString(prop) || prop === 'constructor' || prop === 'then') return undefined; - return format(zdt, prop); - }, - ownKeys() { - return Object.keys(formats); - }, - getOwnPropertyDescriptor(t, prop: string) { - if (isString(prop) && isDefined(formats[prop])) { - return { - enumerable: true, - configurable: true - }; - } - return undefined; - } - }); - } + if (isUndefined(fmt)) + return delegator(formats, (prop) => format(zdt, prop)); if (!isZonedDateTime(zdt)) return ''; - const formats = config?.formats ?? enums.FORMAT; - let template = (isString(fmt) && formats && (typeof (formats as any).has === 'function' ? (formats as any).has(fmt as string) : Object.prototype.hasOwnProperty.call(formats, fmt as string))) ? (formats as Record)[fmt as string] : String(fmt); diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index f828f658..f8cd7260 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -1,14 +1,13 @@ import '#library/temporal.polyfill.js'; -import { asType, isNull, isString, isObject, isZonedDateTime, isInstant, 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 { asType } from '#library/type.library.js'; +import { isNull, isString, isObject, isFunction, isZonedDateTime, isInstant, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/assertion.library.js'; +import { asArray, asInteger } from '#library/coercion.library.js'; +import { isNumeric } from '#library/assertion.library.js'; +import { instant, getTemporalIds } from '#library/temporal.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; -import { markConfig } from '#library/symbol.library.js'; - -import { sym, enums, isTempo, Match, getRuntime } from '../support/support.index.js'; -import { init, extendState } from '../support/tempo.init.js'; -import { setPatterns } from '../support/tempo.util.js'; +import type { TypeValue } from '#library/type.library.js'; +import { sym, markConfig, TermError, enums, isTempo, Match, getRuntime, init, extendState, setPatterns } from '#tempo/support'; import { prefix, parseWeekday, parseDate, parseTime, parseZone } from '../plugin/module/module.lexer.js'; import { resolveTermMutation, resolveTermValue } from '../plugin/module/module.term.js'; import { compose } from '../plugin/module/module.composer.js'; @@ -19,11 +18,7 @@ import type { Range, ResolvedRange } from '../plugin/plugin.type.js'; import type { Tempo } from '../tempo.class.js'; import * as t from '../tempo.type.js'; -/** - * Internal helpers to normalize TimeZone and Calendar IDs - */ -const tzId = (v: any): string => typeof v === 'string' ? v : (v?.id ?? v?.timeZoneId); -const calId = (v: any): string => typeof v === 'string' ? v : (v?.id ?? v?.calendarId); + /** * Internal helper to resolve state from 'this' context or first argument @@ -51,8 +46,7 @@ const _ParseEngine = { if (!term && (isZonedDateTime(tempo) || isInstant(tempo))) { const { config } = state; - const tz = tzId(config.timeZone); - const cal = calId(config.calendar); + const [tz, cal] = getTemporalIds(config.timeZone, config.calendar); const dt = isZonedDateTime(tempo) ? tempo : (tempo as Temporal.Instant).toZonedDateTimeISO(tz); return dt.withTimeZone(tz).withCalendar(cal); } @@ -67,8 +61,7 @@ const _ParseEngine = { const val = dateTime ?? state.anchor ?? (isTempo(tempo) ? (tempo as any).toDateTime() : (isZonedDateTime(tempo) ? tempo : (isInstant(tempo) ? tempo.toZonedDateTimeISO(config.timeZone) : undefined))); const basis = isDefined(val) ? val : instant().toZonedDateTimeISO(config.timeZone); - const tz = isTempo(basis) ? (basis as any).tz : tzId(basis ?? config.timeZone); - const cal = isTempo(basis) ? (basis as any).cal : calId(basis ?? config.calendar); + const [tz, cal] = isTempo(basis) ? [(basis as any).tz, (basis as any).cal] : getTemporalIds(basis ?? config.timeZone, basis ?? config.calendar); today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : instant().toZonedDateTimeISO(tz).withCalendar(cal)); @@ -80,7 +73,7 @@ const _ParseEngine = { const termObj = terms.find((termEntry: any) => termEntry.key === ident || termEntry.scope === ident); if (!termObj) { if (TempoClass) - (TempoClass as any)[sym.$termError](state.config, term); + (TempoClass as any)[TermError](state.config, term); return undefined as any; } @@ -129,7 +122,7 @@ const _ParseEngine = { throw new Error(msg); } if (terms.length === 0) { - if (TempoClass) (TempoClass as any)[sym.$termError](state.config, termKey); + if (TempoClass) (TempoClass as any)[TermError](state.config, termKey); return undefined as any; } } @@ -140,8 +133,7 @@ const _ParseEngine = { const res = _ParseEngine.conform(state, tempo, today, isAnchored, resolvingKeys); const { timeZone: tz2, calendar: cal2 } = state.config; - const targetTz = tzId(tz2); - const targetCal = calId(cal2); + const [targetTz, targetCal] = getTemporalIds(tz2, cal2); const { dateTime: dt, timeZone } = compose(res, today, tz, targetTz, targetCal); @@ -191,7 +183,7 @@ const _ParseEngine = { const termKey = Object.keys(options).find(k => k.startsWith('#')); if (termKey && terms.length === 0) { - if (TempoClass) (TempoClass as any)[sym.$termError](state.config, termKey); + if (TempoClass) (TempoClass as any)[TermError](state.config, termKey); return undefined as any; } @@ -402,7 +394,7 @@ const _ParseEngine = { const definition = entry[1]; let res: string = ''; - if (typeof definition === 'function') { + if (isFunction(definition)) { try { state.anchor = dateTime; state.zdt = dateTime; @@ -419,7 +411,7 @@ const _ParseEngine = { get yy() { return dateTime.year }, get mm() { return dateTime.month }, get dd() { return dateTime.day }, - [sym.$isTempo]: true, + [sym.$Identity]: true, config: state.config }; @@ -448,7 +440,7 @@ const _ParseEngine = { const type = isEvent ? 'Event' : 'Period'; const val = entry![0]; const pat = (isEvent ? 'dt' : 'tm'); - const resolveVal = typeof definition === 'function' ? res : definition; + const resolveVal = isFunction(definition) ? res : definition; _ParseEngine.result(state, { type, value: val as any, match: pat, groups: { [key]: resolveVal as string } }); const resolving = new Set(resolvingKeys); @@ -524,7 +516,7 @@ export const ParseEngine = { result: withState(_ParseEngine.result) }; -const isFunction = (v: any): v is Function => typeof v === 'function'; + /** * # ParseModule diff --git a/packages/tempo/src/plugin/extend/extend.ticker.ts b/packages/tempo/src/plugin/extend/extend.ticker.ts index 67325004..4b8c1e6f 100644 --- a/packages/tempo/src/plugin/extend/extend.ticker.ts +++ b/packages/tempo/src/plugin/extend/extend.ticker.ts @@ -1,12 +1,11 @@ -import { isObject, isFunction, isDefined, isUndefined, isEmpty, isNumber } from '#library/type.library.js' +import { isObject, isFunction, isDefined, isUndefined, isEmpty, isNumber } from '#library/assertion.library.js' import { Pledge } from '#library/pledge.class.js' -import { asArray, isNumeric } from '#library/coercion.library.js' +import { asArray } from '#library/coercion.library.js' +import { isNumeric } from '#library/assertion.library.js' import { instant, normaliseFractionalDurations } from '#library/temporal.library.js' -import { markConfig } from '#library/symbol.library.js' -import { DURATIONS } from '../../support/tempo.enum.js' +import { sym, markConfig, enums } from '#tempo/support'; import { defineExtension, attachStatics } from '../plugin.util.js' -import { sym } from '../../support/tempo.symbol.js'; import type { Tempo } from '../../tempo.class.js' import type { Extension, TempoType } from '../plugin.type.js' @@ -152,7 +151,7 @@ class TickerInstance implements Ticker.Descriptor { this.#until = stopAt ? new this.#TempoClass(isOptions(stopAt) ? undefined : stopAt, isOptions(stopAt) ? { ...rest, ...stopAt } : rest) : undefined; if (cb) this.#listeners.add(cb); - const durationKeys = new Set(Object.keys(DURATIONS)); + const durationKeys = new Set(Object.keys(enums.DURATIONS)); for (const [key, val] of Object.entries(rest)) if (isDefined(val) && (durationKeys.has(key) || key.startsWith('#'))) this.#payload[key] = val; @@ -364,10 +363,10 @@ export const TickerModule: Extension = defineExtension({ if (prop === 'pulse') return instance.pulse.bind(instance); if (prop === 'on') return instance.on.bind(instance); if (prop === 'stop') return instance.stop.bind(instance); - if (prop === 'info') return instance.info; if (prop === 'next') return instance.next.bind(instance); if (prop === 'return') return instance.return.bind(instance); if (prop === 'throw') return instance.throw.bind(instance); + if (prop === 'info') return instance.info; if (prop === Symbol.asyncIterator) return () => proxy; if (prop === Symbol.asyncDispose) return instance[Symbol.asyncDispose].bind(instance); if (prop === Symbol.dispose) return instance[Symbol.dispose].bind(instance); diff --git a/packages/tempo/src/plugin/module/module.composer.ts b/packages/tempo/src/plugin/module/module.composer.ts index 62fce4c0..821751c2 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 { isTempo, Match } from '#tempo/support'; -import { TemporalObject, TypeValue, isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/type.library.js'; +import { isNumeric, isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/assertion.library.js'; +import type { TemporalObject, TypeValue } 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 9c0245a2..93210f66 100644 --- a/packages/tempo/src/plugin/module/module.duration.ts +++ b/packages/tempo/src/plugin/module/module.duration.ts @@ -1,11 +1,11 @@ -import { isString, isObject, isDefined, isUndefined } from '#library/type.library.js'; +import { isString, isObject, isDefined, isUndefined } from '#library/assertion.library.js'; import { singular } from '#library/string.library.js'; import { getAccessors } from '#library/reflection.library.js'; import { ifDefined } from '#library/object.library.js'; import { getRelativeTime } from '#library/international.library.js'; import { defineInterpreterModule, interpret } from '../plugin.util.js'; -import enums from '../../support/tempo.enum.js'; +import { enums } from '#tempo/support'; import type { Module } from '../plugin.type.js'; import type { Tempo } from '../../tempo.class.js'; diff --git a/packages/tempo/src/plugin/module/module.lexer.ts b/packages/tempo/src/plugin/module/module.lexer.ts index ae21fa2a..2ad95da1 100644 --- a/packages/tempo/src/plugin/module/module.lexer.ts +++ b/packages/tempo/src/plugin/module/module.lexer.ts @@ -1,9 +1,8 @@ import '#library/temporal.polyfill.js'; -import { isString, isEmpty, isUndefined, isDefined, isTemporal } from '#library/type.library.js'; +import { isString, isEmpty, isUndefined, isDefined, isTemporal } from '#library/assertion.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import { pad, singular } from '#library/string.library.js'; -import { Match } from '../../support/tempo.default.js'; -import enums from '../../support/tempo.enum.js'; +import { Match, enums } from '#tempo/support'; 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 64cfdca6..d214b223 100644 --- a/packages/tempo/src/plugin/module/module.mutate.ts +++ b/packages/tempo/src/plugin/module/module.mutate.ts @@ -1,8 +1,7 @@ -import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#library/type.library.js'; +import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#library/assertion.library.js'; import { singular } from '#library/string.library.js'; -import { sym } from '../../support/tempo.symbol.js'; -import enums from '../../support/tempo.enum.js'; +import { sym, enums } from '#tempo/support'; import { defineInterpreterModule } from '../plugin.util.js'; import { findTermPlugin } from '../term.util.js'; import { resolveTermMutation } from './module.term.js'; diff --git a/packages/tempo/src/plugin/module/module.term.ts b/packages/tempo/src/plugin/module/module.term.ts index c32e71e9..dfaf83ab 100644 --- a/packages/tempo/src/plugin/module/module.term.ts +++ b/packages/tempo/src/plugin/module/module.term.ts @@ -1,8 +1,9 @@ import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; -import { isDefined, isString, isZonedDateTime } from '#library/type.library.js'; -import { asArray, isNumeric } from '#library/coercion.library.js'; +import { isDefined, isString, isZonedDateTime } from '#library/assertion.library.js'; +import { asArray } from '#library/coercion.library.js'; +import { isNumeric } from '#library/assertion.library.js'; -import { sym, getLargestUnit, SCHEMA, Match, isTempo } from '#tempo/support'; +import { TermError, getLargestUnit, SCHEMA, Match, isTempo } from '#tempo/support'; import { getRange, getTermRange, resolveTermShift, findTermPlugin } from '../term.util.js'; import { getHost } from '../plugin.util.js'; import { parseModifier } from './module.lexer.js'; @@ -36,7 +37,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s const termObj = findTermPlugin(termPart); if (!termObj) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -81,7 +82,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s const rawList = getRange(termObj, instance, zdt); const currentRange = getTermRange(instance, rawList, false, zdt) as any; if (!currentRange) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -118,7 +119,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s } if (!target || remaining > 0) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -147,7 +148,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s if (rKey) { const found = rawList.some(r => r.key?.toLowerCase() === rKey.toLowerCase()); if (!found) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } candidates = rawList.filter(r => r.key?.toLowerCase() === rKey.toLowerCase()); @@ -191,7 +192,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s if (next) return next.start.withTimeZone(tz).withCalendar(cal); - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -209,7 +210,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s if (rKey) { const found = rawList.some(r => r.key?.toLowerCase() === rKey.toLowerCase()); if (!found) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } list = list.filter(r => r.key?.toLowerCase() === rKey.toLowerCase()); @@ -241,7 +242,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s const candidates = resolved.filter(c => rKey ? c.key?.toLowerCase() === rKey.toLowerCase() : true); // prefer latest start <= cursor (zdt) const prev = candidates - .filter(it => (toZdt(it.start).epochNanoseconds) <= (zdt.epochNanoseconds )) + .filter(it => (toZdt(it.start).epochNanoseconds) <= (zdt.epochNanoseconds)) .sort((a, b) => { const sa = toZdt(a.start).epochNanoseconds; const sb = toZdt(b.start).epochNanoseconds; @@ -385,7 +386,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s } if (remaining > 0) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -393,7 +394,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s if (mutate === 'mid' || mutate === 'end') { const finalRange = (getTermRange(instance, getRange(termObj, instance, jump), false, jump) as any); if (!finalRange) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } if (mutate === 'mid') { @@ -436,7 +437,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s let iterations = 0; while (next.epochNanoseconds <= zdt.epochNanoseconds) { if (++iterations > 50) { // Safety-Valve: prevent infinite look-ahead - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } else { const currentRange = termObj.define.call(new (getHost(instance))(jump, { ...instance.config, mode: 'strict' }), false); @@ -454,7 +455,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s const target = getTermRange(instance, rawList, Number(offset), zdt) as any; if (target) return toZdt(target.start).withTimeZone(tz).withCalendar(cal); - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -468,7 +469,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s let iterations = 0; while (remaining > 0) { if (++iterations > 100) { // Safety-Valve: prevent infinite shift - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -480,7 +481,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s } if (list.length === 0) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -492,7 +493,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s // if we hit the edge of the current list, jump to the end of the current cycle and try again const current = (getTermRange(instance, list, false, jump) as any); if (!current) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } diff --git a/packages/tempo/src/plugin/plugin.type.ts b/packages/tempo/src/plugin/plugin.type.ts index 8bfd4979..094ac76f 100644 --- a/packages/tempo/src/plugin/plugin.type.ts +++ b/packages/tempo/src/plugin/plugin.type.ts @@ -1,6 +1,6 @@ import type { Prettify, Property } from '#library/type.library.js'; import type { Tempo } from '../tempo.class.js'; -import { TermError } from '../support/tempo.symbol.js'; +import { TermError } from '#tempo/support'; export type TempoType = typeof Tempo & { [TermError]?: (config: any, term: string) => void; diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 3f4d1e36..945f3720 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -1,4 +1,4 @@ -import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from '#library/type.library.js'; +import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from '#library/assertion.library.js'; import { secureRef } from '#library/proxy.library.js'; import { sym, getRuntime, isTempo } from '#tempo/support'; diff --git a/packages/tempo/src/plugin/term.util.ts b/packages/tempo/src/plugin/term.util.ts index a3c319fe..7e5c97a7 100644 --- a/packages/tempo/src/plugin/term.util.ts +++ b/packages/tempo/src/plugin/term.util.ts @@ -1,8 +1,8 @@ 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 { isDefined, isFunction, isString, isUndefined, isNumber } from '#library/assertion.library.js'; +import { secure } from '#library/proxy.library.js'; import { sortKey, byKey } from '#library/array.library.js'; -import { sym, SCHEMA, getLargestUnit, isTempo, getRuntime } from '#tempo/support'; +import { sym, TermError, 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'; @@ -161,7 +161,14 @@ export function getRange(entry: any, t: Tempo, anchor?: any, group?: string): Ra 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])); + list = list.filter(r => keys.every((key: string) => { + if (key === 'sphere') { + const valA = String(r[key] ?? '').toLowerCase(); + const valB = String((t.config as any)[key] ?? '').toLowerCase(); + return valB.includes(valA); + } + return r[key] === (t.config as any)[key]; + })); } if (group) { @@ -264,7 +271,7 @@ export function resolveCycleWindow(source: Tempo | any, template: Range[] | Reco // 1. Resolve Template (supporting optional dynamic grouping) let list: Range[] = []; if (!isDefined(template)) { - (t.constructor as any)[sym.$termError](t.config, 'template'); + (t.constructor as any)[TermError](t.config, 'template'); return []; } @@ -274,11 +281,23 @@ export function resolveCycleWindow(source: Tempo | any, template: Range[] | Reco .join('.'); list = (template as any)[groupKey] ?? []; + if (list.length === 0 && groupBy.includes('sphere')) { + const sphereIdx = groupBy.indexOf('sphere'); + const targetParts = groupKey.split('.'); + const targetSphere = targetParts[sphereIdx].toLowerCase(); + + const bestKey = Object.keys(template).find(k => { + const kParts = k.split('.'); + if (kParts.length !== targetParts.length) return false; + return kParts.every((p, i) => i === sphereIdx ? targetSphere.includes(p.toLowerCase()) : p === targetParts[i]); + }); + if (bestKey) list = (template as any)[bestKey]; + } 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); + (t.constructor as any)[TermError](t.config, msg); return []; } } else { diff --git a/packages/tempo/src/plugin/term/term.quarter.ts b/packages/tempo/src/plugin/term/term.quarter.ts index 20e6d561..90901670 100644 --- a/packages/tempo/src/plugin/term/term.quarter.ts +++ b/packages/tempo/src/plugin/term/term.quarter.ts @@ -1,8 +1,8 @@ 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 { isNumber } from '#library/assertion.library.js'; import { asArray } from '#library'; +import type { Tempo } from '../../tempo.class.js'; /** definition of fiscal quarter ranges */ const groups = defineRange([ diff --git a/packages/tempo/src/plugin/term/term.zodiac.ts b/packages/tempo/src/plugin/term/term.zodiac.ts index b5f2d098..1ce0ad2b 100644 --- a/packages/tempo/src/plugin/term/term.zodiac.ts +++ b/packages/tempo/src/plugin/term/term.zodiac.ts @@ -1,6 +1,6 @@ import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; -import { type Tempo } from '../../tempo.class.js'; -import { isNumber } from '#library/type.library.js'; +import { isNumber } from '#library/assertion.library.js'; +import type { Tempo } from '../../tempo.class.js'; /** definition of astrological zodiac ranges */ const groups = defineRange([ diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 86c54f48..f7e7a224 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -25,11 +25,10 @@ export { NumericPattern } from './tempo.enum.js'; +export { markConfig } from '#library/symbol.library.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './tempo.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; export { getRuntime, TempoRuntime } from './tempo.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './tempo.default.js'; -export { SCHEMA, getLargestUnit } from './tempo.util.js'; -export { init, extendState } from './tempo.init.js'; - -export { default as lib } from '#library/symbol.library.js'; +export { SCHEMA, getLargestUnit, setPatterns } from './tempo.util.js'; +export { init, extendState } from './tempo.init.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 19090343..770f168f 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -1,6 +1,5 @@ import { looseIndex } from '#library/object.library.js'; -import { secure } from '#library/utility.library.js'; -import { proxify } from '#library/proxy.library.js'; +import { secure, proxify } from '#library/proxy.library.js'; import { getDateTimeFormat } from '#library/international.library.js'; import { NUMBER, MODE } from './tempo.enum.js'; @@ -81,9 +80,10 @@ export type Snippet = typeof Snippet */ /** @internal Tempo Layout registry */ export const Layout = looseIndex()({ - [Token.dt]: '({dd}{sep}?{mm}({sep}?{yy})?|{mod}?({evt})|(?{slk}))',// calendar, event or slick + [Token.dt]: '({dd}{sep}?{mm}({sep}?{yy})?|{mod}?({evt})|(?{slk})|{wkd})',// calendar, event, slick or weekday [Token.tm]: '({hh}{mi}?{ss}?{ff}?{mer}?|{per})', // clock or period [Token.dtm]: '({dt})(?:(?:{sep}+|T)({tm}))?{tzd}?{brk}?', // calendar/event and clock/period + [Token.tmd]: '({tm})(?:(?:{sep}+|T)({dt}))?{tzd}?{brk}?', // clock/period and calendar/event [Token.dmy]: '({wkd}{sep}+)?{dd}{sep}?{mm}({sep}?{yy})?{sfx}?{brk}?',// day-month(-year) [Token.mdy]: '({wkd}{sep}+)?{mm}{sep}?{dd}({sep}?{yy})?{sfx}?{brk}?',// month-day(-year) [Token.ymd]: '({wkd}{sep}+)?{yy}{sep}?{mm}({sep}?{dd})?{sfx}?{brk}?',// year-month(-day) @@ -124,6 +124,10 @@ export const Event = looseIndex()({ // RELATIVE: Offsets the current anchor by one day return this.add({ days: -1 }); }, + 'fortnight': function (this: Tempo) { + // RELATIVE: Offsets the current anchor by two weeks + return this.add({ weeks: 2 }); + }, }); /** @internal Tempo Event type */ export type Event = typeof Event @@ -143,6 +147,9 @@ export const Period = looseIndex()({ 'after[ -]?noon': '3:00pm', 'evening': '18:00', 'night': '20:00', + 'half[ -]?hour': function (this: Tempo) { + return this.add({ minutes: 30 }); + }, }) /** @internal Tempo Period type */ export type Period = typeof Period diff --git a/packages/tempo/src/support/tempo.enum.ts b/packages/tempo/src/support/tempo.enum.ts index 40ca4530..c1810fb2 100644 --- a/packages/tempo/src/support/tempo.enum.ts +++ b/packages/tempo/src/support/tempo.enum.ts @@ -1,4 +1,4 @@ -import lib from '#library/symbol.library.js'; +import { sym } from './tempo.symbol.js'; import { enumify, Enum } from '#library/enumerate.library.js'; import { proxify } from '#library/proxy.library.js'; import { allDescriptors } from '#library/reflection.library.js'; @@ -14,13 +14,25 @@ export const SEASON = enumify({ }, false); export type SEASON = ValueOf +/** logging levels */ +export const LOG = enumify({ + Off: 0, + Error: 1, + Warn: 2, + Info: 3, + Debug: 4, + Trace: 5, +}, false); +export type LOG = ValueOf +export type Log = KeyOf + /** cardinal directions */ -export const COMPASS = enumify({ +export const COMPASS = looseIndex()(enumify({ North: 'north', South: 'south', East: 'east', West: 'west' -}, false); +}, false)); export type COMPASS = ValueOf /** @@ -104,13 +116,13 @@ export const STATE = { DURATIONS: allDescriptors(DEFAULTS.DURATIONS), FORMAT: allDescriptors(DEFAULTS.FORMAT), LIMIT: allDescriptors(DEFAULTS.LIMIT), -} as const; +}; -(STATE.NUMBER as any)[lib.$Extensible] = true; -(STATE.FORMAT as any)[lib.$Extensible] = true; -(STATE.TIMEZONE as any)[lib.$Extensible] = true; -(STATE.DURATION as any)[lib.$Extensible] = true; -(STATE.DURATIONS as any)[lib.$Extensible] = true; +(STATE.NUMBER as any)[sym.$Extensible] = true; +(STATE.FORMAT as any)[sym.$Extensible] = true; +(STATE.TIMEZONE as any)[sym.$Extensible] = true; +(STATE.DURATION as any)[sym.$Extensible] = true; +(STATE.DURATIONS as any)[sym.$Extensible] = true; /** Gregorian calendar week-days (short-form) */ export const WEEKDAY = enumify(['All', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); @@ -133,7 +145,7 @@ export type MONTHS = KeyOf export type Months = ValueOf /** number names (0-10) */ -export const NUMBER = looseIndex()(proxify(enumify(STATE.NUMBER, false), true, false)); +export const NUMBER = looseIndex()(enumify(STATE.NUMBER, false)); export type NUMBER = typeof NUMBER; export type Number = KeyOf @@ -151,7 +163,7 @@ export const DURATIONS = enumify(STATE.DURATIONS, false); export type DURATIONS = KeyOf /** common format aliases */ -export const FORMAT = looseIndex()(proxify(enumify(STATE.FORMAT, false), true, false)); +export const FORMAT = looseIndex()(enumify(STATE.FORMAT, false)); export type FORMAT = typeof FORMAT; export type Format = LooseUnion & string> @@ -179,7 +191,7 @@ export const LIMIT = proxify(STATE.LIMIT, true, false); /** date-time element tokens */ const elementKeys = ['yy', 'mm', 'ww', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns'] as const; -export const ELEMENT = proxify(enumify({ +export const ELEMENT = enumify({ yy: 'year', mm: 'month', ww: 'week', @@ -190,25 +202,25 @@ export const ELEMENT = proxify(enumify({ ms: 'millisecond', us: 'microsecond', ns: 'nanosecond', -}, false), true, false); +}, false); export type ELEMENT = ValueOf export type Element = KeyOf /** allowed mutation keys for .set() and .add() */ const mutationKeys = [...elementKeys, 'event', 'period', 'clock', 'time', 'date', 'start', 'mid', 'end'] as const; -export const MUTATION = proxify(enumify(mutationKeys, false), true, false); +export const MUTATION = enumify(mutationKeys, false); export type MUTATION = ValueOf export type Mutation = KeyOf /** allowed keys for ZonedDateTime-like objects */ const zonedDateTimeKeys = ['value', 'timeZoneId', 'calendarId', 'monthCode', 'offset', 'timeZone', ...elementKeys] as const; -export const ZONED_DATE_TIME = proxify(enumify(zonedDateTimeKeys, false), true, false); +export const ZONED_DATE_TIME = enumify(zonedDateTimeKeys, false); export type ZONED_DATE_TIME = ValueOf export type ZonedDateTime = KeyOf /** allowed keys for Tempo configuration options */ const optionKeys = ['value', 'mode', 'mdyLocales', 'mdyLayouts', 'store', 'discovery', 'debug', 'catch', 'timeZone', 'calendar', 'locale', 'pivot', 'sphere', 'timeStamp', 'snippet', 'layout', 'event', 'period', 'formats', 'plugins'] as const; -export const OPTION = proxify(enumify(optionKeys, false), true, false); +export const OPTION = enumify(optionKeys, false); export type Option = KeyOf /** initialization strategies */ @@ -217,12 +229,12 @@ export type MODE = ValueOf /** allowed keys for internal parse state */ const parseKeys = ['mdyLocales', 'mdyLayouts', 'formats', 'pivot', 'snippet', 'layout', 'event', 'period', 'anchor', 'value', 'discovery', 'plugins', 'mode'] as const; -export const PARSE = proxify(enumify(parseKeys, false), true, false); +export const PARSE = enumify(parseKeys, false); export type Parse = KeyOf /** allowed keys for global discovery objects */ const discoveryKeys = ['options', 'timeZones', 'terms', 'plugins', 'numbers', 'formats'] as const; -export const DISCOVERY = proxify(enumify(discoveryKeys, false), true, false); +export const DISCOVERY = enumify(discoveryKeys, false); export type Discovery = KeyOf /** @internal LIVE Registries mapping (STATE key -> Enum/Proxy) */ diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index a1d28116..143e1de7 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -3,6 +3,8 @@ import { enumify } from '#library/enumerate.library.js'; import { asArray } from '#library/coercion.library.js'; import { getDateTimeFormat, getHemisphere } from '#library/international.library.js'; import { markConfig } from '#library/symbol.library.js'; +import { asType } from '#library/type.library.js'; +import { isString, isObject, isUndefined, isRegExp } from '#library/assertion.library.js'; import { ownEntries } from '#library/primitive.library.js'; import { getRuntime } from './tempo.runtime.js'; @@ -66,15 +68,8 @@ export function init(options: t.Options = {}): t.Internal.State { /** @internal Extend a Tempo state with new options (Shadowing) */ export function extendState(state: t.Internal.State, options: t.Options) { - const { - isString = (v: any) => typeof v === 'string', - isObject = (v: any) => typeof v === 'object' && v !== null, - isUndefined = (v: any) => v === undefined, - isRegExp = (v: any) => v instanceof RegExp, - asType = (v: any) => ({ type: Object.prototype.toString.call(v).slice(8, -1), value: v }) - } = getRuntime().modules['Library'] ?? {}; - let patternsDirty = false; + ownEntries(options).forEach(([optKey, optVal]) => { if (isUndefined(optVal)) return; const arg = asType(optVal); diff --git a/packages/tempo/src/support/tempo.register.ts b/packages/tempo/src/support/tempo.register.ts index c9b818e3..f74ff238 100644 --- a/packages/tempo/src/support/tempo.register.ts +++ b/packages/tempo/src/support/tempo.register.ts @@ -1,11 +1,11 @@ import { clearCache } from '#library/function.library.js'; -import { isDefined, isUndefined } from '#library/type.library.js'; +import { isDefined, isUndefined } from '#library/assertion.library.js'; import { ownKeys } from '#library/primitive.library.js'; -import { secureRef } from '#library/proxy.library.js'; -import lib from '#library/symbol.library.js'; +import { unwrap } from '#library/primitive.library.js'; import type { Property } from '#library/type.library.js'; import { getRuntime } from './tempo.runtime.js'; +import { setProperty } from './tempo.util.js'; // Import the live enums and their mutable state from the enum module import { STATE, REGISTRIES, DEFAULTS } from './tempo.enum.js'; @@ -24,11 +24,11 @@ export function onRegistryReset(hook: () => void) { export function registryReset() { ownKeys(STATE).forEach(name => { const state = STATE[name as keyof typeof STATE] as Property; - const target = REGISTRIES[name]?.[lib.$Target] as Property; const defaults = DEFAULTS[name as keyof typeof DEFAULTS] as Property; + const target = unwrap(REGISTRIES[name] as any); // 1. Purge all own-properties from state and target (if configurable) - [state, target].filter(isDefined).forEach(obj => { + [state, target].filter(obj => obj != null).forEach(obj => { Reflect.ownKeys(obj).forEach(key => { const desc = Object.getOwnPropertyDescriptor(obj, key); if (desc?.configurable) delete obj[key]; @@ -40,7 +40,7 @@ export function registryReset() { const desc = Object.getOwnPropertyDescriptor(defaults, key); if (desc) { - [state, target].filter(isDefined).forEach(obj => { + [state, target].filter(obj => obj != null).forEach(obj => { Object.defineProperty(obj, key, desc); }); } @@ -62,19 +62,15 @@ export function registryReset() { /** update a global registry with new discoverable data */ export function registryUpdate(name: keyof typeof STATE, data: Record) { const registry = REGISTRIES[name]; - if (!isDefined(registry) || !isDefined(registry[lib.$Target])) return; - - const target = registry[lib.$Target] as Property; const state = STATE[name] as Property; + const target = unwrap(registry) as Property; + + if (!isDefined(target) || target === registry) + return; Object.entries(data).forEach(([key, val]) => { if (isUndefined(target[key])) { // only add if key does not exist - Object.defineProperty(target, key, { - value: val, - enumerable: true, - writable: true, - configurable: true - }); + setProperty(target, key, val); if (isDefined(state)) state[key] = val; } }); diff --git a/packages/tempo/src/support/tempo.symbol.ts b/packages/tempo/src/support/tempo.symbol.ts index 01d7a7bb..961e2b8a 100644 --- a/packages/tempo/src/support/tempo.symbol.ts +++ b/packages/tempo/src/support/tempo.symbol.ts @@ -1,24 +1,20 @@ import { looseIndex } from '#library/object.library.js'; +import { sym as lib } from '#library/symbol.library.js'; /** check valid Tempo instance */ -export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym.$isTempo]); +export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym.$Identity]); /** * 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. */ -export const IsTempo: unique symbol = Symbol.for('magmacomputing/tempo/isTempo') as any; export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termError') as any; -/** @internal Tempo Symbol Registry */ -export const sym = { +/** @internal Tempo Symbol Registry (Local Keys) */ +const local = { /** 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: IsTempo, - /** key for centralized Term Error dispatching */ $termError:TermError, /** 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'), @@ -31,11 +27,15 @@ export const sym = { /** branding for explicit PropertyDescriptors */ $Descriptor: Symbol.for('magmacomputing/tempo/descriptor'), } as const; -/** @internal Local interface for brand checking without circular imports */ -export interface TempoBrand { - [sym.$isTempo]: true; +/** @internal Unified Symbol Registry (Inherits from #library via Prototype Chain) */ +export const sym = Object.assign(Object.create(lib), local) as typeof lib & typeof local; + +/** @internal Local type for brand checking without circular imports */ +export type TempoBrand = { + [sym.$Identity]: true; toDateTime(): Temporal.ZonedDateTime; config: any; + parse: any; } /** @internal Tempo Token registry */ @@ -65,6 +65,7 @@ export const Token = looseIndex()({ /** date */ dt: Symbol('date'), /** time */ tm: Symbol('time'), /** date and time */ dtm: Symbol('dateTime'), + /** time and date */ tmd: Symbol('timeDate'), /** day-month-year */ dmy: Symbol('dayMonthYear'), /** month-day-year */ mdy: Symbol('monthDayYear'), /** year-month-day */ ymd: Symbol('yearMonthDay'), diff --git a/packages/tempo/src/support/tempo.util.ts b/packages/tempo/src/support/tempo.util.ts index 77de5800..3f5cc4b8 100644 --- a/packages/tempo/src/support/tempo.util.ts +++ b/packages/tempo/src/support/tempo.util.ts @@ -1,5 +1,6 @@ import { sym, Token } from './tempo.symbol.js'; -import { isSymbol, isUndefined, isString, isRegExp, isNullish, isRegExpLike, asType } from '#library/type.library.js'; +import { asType } from '#library/type.library.js'; +import { isSymbol, isUndefined, isString, isRegExp, isNullish, isObject, isEmpty } from '#library/assertion.library.js'; import { ownEntries, ownKeys } from '#library/primitive.library.js'; import { getRuntime } from './tempo.runtime.js'; import { Match, Snippet, Layout } from './tempo.default.js'; @@ -19,7 +20,7 @@ export const hasOwn = (obj: object, key: string) => Object.hasOwn(obj, key); /** @internal create an object based on a prototype */ export const create = (obj: object, name: string): T => { const entry = proto(obj)[name]; - if (typeof entry !== 'object' || entry === null) { + if (!isObject(entry)) { throw new TypeError(`[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${typeof entry}).`); } return { ...entry } as T; @@ -124,7 +125,7 @@ export function compileRegExp(layout: string | RegExp, state: t.Internal.State, } } -const isEmpty = (v: any) => !v || (Array.isArray(v) && v.length === 0) || (typeof v === 'object' && Object.keys(v).length === 0); + /** @internal build RegExp patterns into the state */ export function setPatterns(state: t.Internal.State) { @@ -138,6 +139,7 @@ export function setPatterns(state: t.Internal.State) { if (enums?.NUMBER) { const keys = Object.keys(enums.NUMBER).map(w => Match.escape(w)); // escape each key const nbr = new RegExp(`(?[0-9]+|${keys.sort((a, b) => b.length - a.length).join('|')})`); + snippet[Token.nbr] = nbr; snippet[Token.mod] = new RegExp(`((?${Match.modifier.source})?${nbr.source}? *)`); snippet[Token.afx] = new RegExp(`((s)? (?${Match.affix.source}))?${snippet[Token.sep].source}?`); @@ -145,11 +147,13 @@ export function setPatterns(state: t.Internal.State) { // 2. build ignore pattern const ignores = ownKeys(state.parse.ignore, true); + if (!isEmpty(ignores)) { const words = ignores .filter(isString) .map(w => Match.escape(w.toLowerCase())) .join('|'); + state.parse.ignorePattern = new RegExp(`\\b(${words})\\b`, 'gi'); } else { delete state.parse.ignorePattern; @@ -159,7 +163,7 @@ export function setPatterns(state: t.Internal.State) { ownEntries(state.parse.layout).forEach(([key, layout]) => { const symbol = getSymbol(key); const compiled = compileRegExp(layout, state, snippet); + state.parse.pattern.set(symbol, compiled); - // console.log(`DEBUG Compiled [${String(symbol)}]:`, compiled.source.substring(0, 50) + '...'); }); } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 584fec69..06f80c6b 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1,25 +1,26 @@ 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'; import { asArray } from '#library/coercion.library.js'; import { getStorage, setStorage } from '#library/storage.library.js'; -import { proxify, delegate } from '#library/proxy.library.js'; -import lib, { markConfig } from '#library/symbol.library.js'; +import { secure, proxify, delegate } from '#library/proxy.library.js'; import { getContext, CONTEXT } from '#library/utility.library.js'; import { enumify } from '#library/enumerate.library.js'; -import { ownKeys, ownEntries } from '#library/primitive.library.js'; +import { ownKeys, ownEntries, unwrap } 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, isDefined, isUndefined, isString, isObject, isRegExp, isSymbol, isFunction, isClass, isZonedDateTime, Property, Secure } from '#library/type.library.js'; +import { getType, asType } from '#library/type.library.js'; +import { isEmpty, isDefined, isUndefined, isString, isObject, isRegExp, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike, isZonedDateTimeLike } from '#library/assertion.library.js'; +import type { Property, Secure } from '#library/type.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; + import { registerPlugin, interpret, ensureModule } from './plugin/plugin.util.js' import { registerTerm, getTermRange } from './plugin/term.util.js'; import type { TermPlugin, Plugin } from './plugin/plugin.type.js'; import { setProperty, proto, hasOwn, create, compileRegExp, setPatterns } from './support/tempo.util.js'; -import { sym, TermError, getRuntime, init, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, type TempoBrand } from '#tempo/support'; +import { sym, markConfig, TermError, getRuntime, init, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, type TempoBrand } from '#tempo/support'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) import { instant } from '#library/temporal.library.js'; @@ -58,7 +59,7 @@ namespace Internal { */ @Serializable @Immutable -export class Tempo implements TempoBrand { +export class Tempo { /** Weekday names (short-form) */ static get WEEKDAY() { return enums.WEEKDAY } /** Weekday names (long-form) */ static get WEEKDAYS() { return enums.WEEKDAYS } /** Month names (short-form) */ static get MONTH() { return enums.MONTH } @@ -94,17 +95,19 @@ export class Tempo implements TempoBrand { return Tempo.#global; } + /** @internal brand check to distinguish Tempo objects from other objects */ + [sym.$Identity] = true as const; /** @internal handle internal errors using the global config */ static [sym.$logError](...msg: any[]): void { - const config = (isObject(msg[0]) && (msg[0] as any)[lib.$Logify] === true) ? msg.shift() : Tempo.#global.config; + const config = (isObject(msg[0]) && (msg[0] as any)[sym.$Logify] === true) ? msg.shift() : Tempo.#global.config; markConfig(config); // ensure config is marked for Logify Tempo.#dbg.error(config, ...msg); } /** @internal handle internal debug logs */ static [sym.$logDebug](...args: any[]): void { - const config = (isObject(args[0]) && (args[0] as any)[lib.$Logify] === true) ? args.shift() : Tempo.#global.config; + const config = (isObject(args[0]) && (args[0] as any)[sym.$Logify] === true) ? args.shift() : Tempo.#global.config; markConfig(config); Tempo.#dbg.debug(config, ...args); } @@ -660,7 +663,7 @@ export class Tempo implements TempoBrand { // only trigger init if we're assigning a new discovery object to a symbol if (ownKeys(item).some(key => DISCOVERY.has(key as any))) { - const discoverySymbol = (typeof options === 'symbol' ? options : options?.discovery) ?? sym.$Tempo + const discoverySymbol = (isSymbol(options) ? options : (options as any)?.discovery) ?? sym.$Tempo if ((globalThis as Record)[discoverySymbol] !== item) { ; (globalThis as Record)[discoverySymbol] = item Tempo.#setConfig(Tempo.#global, { discovery: discoverySymbol }) @@ -902,14 +905,14 @@ export class Tempo implements TempoBrand { } /** allow instanceof to work across module boundaries via the local brand symbol */ - static [sym.$isTempo] = true; + static [sym.$Identity] = true; static [Symbol.hasInstance](instance: any) { - return !!(instance?.[sym.$isTempo]) + return !!(instance?.[sym.$Identity]) } /** check if a supplied variable is a valid Tempo instance */ static isTempo(instance?: any): instance is Tempo { - return !!(instance?.[sym.$isTempo]) + return !!(instance?.[sym.$Identity]) } static { // Static initialization block to sequence the bootstrap phase @@ -937,7 +940,7 @@ export class Tempo implements TempoBrand { /** 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 configuration */ config: { [sym.$Logify]: true } as unknown as Internal.Config, /** instance parse rules (only populated if provided) */ parse: { result: [] as Internal.MatchResult[] } as Internal.Parse } as Internal.State; @@ -959,7 +962,7 @@ export class Tempo implements TempoBrand { * This surface is not part of the public contract and is subject to change. */ [sym.$Internal]() { - const self: Tempo = (this as any)[lib.$Target] ?? this; + const self: Tempo = unwrap(this); return { get zdt() { return self.#zdt }, set zdt(val: any) { self.#zdt = val }, @@ -1001,7 +1004,7 @@ export class Tempo implements TempoBrand { return 'Tempo'; // hard-coded to avoid minification mangling } - get [sym.$isTempo](): true { return true } + get [sym.$Identity](): true { return true } /** * Instantiates a new `Tempo` object with configuration only. @@ -1021,6 +1024,10 @@ export class Tempo implements TempoBrand { [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 + if (!this.#zdt && isObject(this.#tempo) && isDurationLike(this.#tempo)) { + // relative shorthand for "now plus duration" + this.#zdt = this.#now.toZonedDateTimeISO(this.#local.config.timeZone).add(this.#tempo as Temporal.DurationLike); + } const { mode } = this.#local.parse; const input = String(this.#tempo ?? ''); @@ -1139,7 +1146,7 @@ export class Tempo implements TempoBrand { #setDelegator(host: 'term' | 'fmt') { const target = Object.create(null); const proxy = delegate(target, (key) => { - if (key === lib.$Discover) return this.#discover(host, target); + if (key === sym.$Discover) return this.#discover(host, target); if (!isString(key)) return; // discovery phase @@ -1156,7 +1163,7 @@ export class Tempo implements TempoBrand { try { const result = term.define.call(this, keyOnly); const res = Array.isArray(result) ? getTermRange(this, result, keyOnly) : result; - return (typeof res === 'object' && res !== null) ? secure(res) : res; + return isObject(res) ? secure(res) : res; } catch (err: any) { if (err.message.includes('Class constructor')) { Tempo.#dbg.warn(this.#local.config, `Misidentified class in term definition: ${key}`, err.stack ?? err); @@ -1189,7 +1196,7 @@ export class Tempo implements TempoBrand { try { const res = term.resolve ? term.resolve.call(this, anchor) : term.define.call(this, keyOnly, anchor); const out = (getTermRange(this, (Array.isArray(res) ? (res as any) : [res]), keyOnly, anchor) as any); - return (typeof out === 'object' && out !== null) ? secure(out) : out; + return isObject(out) ? secure(out) : out; } catch (err: any) { if (err.message.includes('Class constructor')) { Tempo.#dbg.warn(this.#local.config, `Misidentified class in term discovery: ${term.key}`, err.stack ?? err); @@ -1279,7 +1286,7 @@ export class Tempo implements TempoBrand { /** Instance-specific parse rules (merged with global) */ get parse(): Internal.Parse { - const self: Tempo = (this as any)[lib.$Target] ?? this; + const self: Tempo = unwrap(this); self.#resolve(); // 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); @@ -1404,18 +1411,13 @@ export class Tempo implements TempoBrand { /** check if we've been given a ZonedDateTimeLike object */ #isZonedDateTimeLike(tempo: t.DateTime | t.Options | undefined): tempo is Temporal.ZonedDateTimeLike & { value?: any } { - if (!isObject(tempo) || isEmpty(tempo)) - return false; + if (!isObject(tempo) || isEmpty(tempo) || isTempo(tempo)) return false; - // if it contains any 'options' keys, it's not a ZonedDateTime + // if it contains any 'options' keys (other than value), it's likely an Options object const keys = ownKeys(tempo); - if (keys.some(key => enums.OPTION.has(key) && key !== 'value')) - return false; + if (keys.some(key => enums.OPTION.has(key) && key !== 'value')) return false; - // we include {value} to allow for Tempo instances - return keys - .filter(isString) - .every((key: string) => enums.ZONED_DATE_TIME.has(key)) + return isZonedDateTimeLike(tempo); } #result(...rest: Partial[]) { diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index e9e62fa5..fb8f6719 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -7,7 +7,7 @@ * Inside `tempo.class.ts` these are accessed via `import * as t`. */ -import { sym } from '#tempo/support/tempo.symbol.js'; +import { sym, TempoBrand } from '#tempo/support/tempo.symbol.js'; import * as enums from '#tempo/support/tempo.enum.js'; import type { Snippet, Layout, Event, Period, Ignore } from '#tempo/support/tempo.default.js'; import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue } from '#library/type.library.js'; @@ -28,7 +28,7 @@ declare global { } /** the value that Tempo will attempt to interpret as a valid ISO date / time */ -export type DateTime = string | number | bigint | Date | Tempo | TemporalObject | Temporal.ZonedDateTimeLike | undefined | null +export type DateTime = string | number | bigint | Date | Tempo | TempoBrand | TemporalObject | Temporal.ZonedDateTimeLike | undefined | null; export type Pattern = string | RegExp export type Logic = string | number | Function @@ -145,7 +145,7 @@ export namespace Internal { export interface BaseOptions { /** localStorage key */ store: string; /** globalThis Discovery Symbol */ discovery: string | symbol; - /** additional console.log for tracking */ debug: boolean | undefined; + /** additional console.log for tracking */ debug: boolean | enums.LOG | undefined; /** catch or throw Errors */ catch: boolean | undefined; /** suppress console output during catch */ silent: boolean | undefined; /** Temporal timeZone */ timeZone: Temporal.TimeZoneLike; diff --git a/packages/tempo/test/constructor.shorthand.test.ts b/packages/tempo/test/constructor.shorthand.test.ts new file mode 100644 index 00000000..88d4067d --- /dev/null +++ b/packages/tempo/test/constructor.shorthand.test.ts @@ -0,0 +1,37 @@ +import { Tempo } from '#tempo/core'; +import '#tempo/parse'; +import '#tempo/format'; + +describe('Tempo Shorthand Constructor', () => { + beforeEach(() => { + Tempo.init(); + }); + + it('should support DurationLike objects as shorthand for "now plus duration"', () => { + const t = new Tempo({ hours: 2 }); + + // Since we can't easily mock the internal 'instant()' call in the constructor + // without deeper integration, we'll verify it's approximately 2 hours from now. + const now = Temporal.Now.zonedDateTimeISO('UTC'); + const expected = now.add({ hours: 2 }); + + // Allow for 1 second of drift during test execution + const diff = t.toDateTime().epochMilliseconds - expected.epochMilliseconds; + expect(Math.abs(diff)).toBeLessThanOrEqual(1000); + }); + + it('should support negative durations as shorthand for "now minus duration"', () => { + const t = new Tempo({ days: -1 }); + const now = Temporal.Now.zonedDateTimeISO('UTC'); + const expected = now.subtract({ days: 1 }); + + const diff = t.toDateTime().epochMilliseconds - expected.epochMilliseconds; + expect(Math.abs(diff)).toBeLessThanOrEqual(1000); + }); + + it('should still support standard date strings along with shorthand options', () => { + const t = new Tempo('2024-05-20', { sphere: 'south' }); + expect(t.format('{yyyy}-{mm}-{dd}')).toBe('2024-05-20'); + expect(t.config.sphere).toBe('south'); + }); +}); diff --git a/packages/tempo/test/discovery_security.test.ts b/packages/tempo/test/discovery_security.test.ts index ea975f5d..56392a69 100644 --- a/packages/tempo/test/discovery_security.test.ts +++ b/packages/tempo/test/discovery_security.test.ts @@ -1,6 +1,6 @@ import { Tempo } from '#tempo'; import { registryUpdate } from '#tempo/support'; -import lib from '#library/symbol.library.js'; +import { unwrap } from '#library/primitive.library.js'; describe('Discovery Security (Direct Registry Check)', () => { @@ -42,17 +42,17 @@ describe('Discovery Security (Direct Registry Check)', () => { afterAll(() => { // Cleanup added keys to keep environment clean for other tests - const numTarget = (Tempo.NUMBER as any)[lib.$Target]; + const numTarget = unwrap(Tempo.NUMBER as any); if (numTarget) { delete numTarget.eleven; } - const fmtTarget = (Tempo.FORMAT as any)[lib.$Target]; + const fmtTarget = unwrap(Tempo.FORMAT as any); if (fmtTarget) { delete fmtTarget.custom; } - const tzTarget = (Tempo.TIMEZONE as any)[lib.$Target]; + const tzTarget = unwrap(Tempo.TIMEZONE as any); if (tzTarget) { delete tzTarget.myzone; } diff --git a/packages/tempo/test/runtime_brand.test.ts b/packages/tempo/test/runtime_brand.test.ts index c85e56ce..efd0ce93 100644 --- a/packages/tempo/test/runtime_brand.test.ts +++ b/packages/tempo/test/runtime_brand.test.ts @@ -40,7 +40,6 @@ describe('TempoRuntime Cross-Bundle Adoption', () => { expect(rt).toBe(original); return; } - console.warn('Skipping mock adoption test: globalThis bridge is already locked by a different runtime instance.'); return; } diff --git a/packages/tempo/test/symbol-import.test.ts b/packages/tempo/test/symbol-import.test.ts index 4092e86b..7d280633 100644 --- a/packages/tempo/test/symbol-import.test.ts +++ b/packages/tempo/test/symbol-import.test.ts @@ -1,5 +1,5 @@ -import lib from '#library/symbol.library.js' +import { sym } from '#library/symbol.library.js' test('symbol import', () => { - expect(lib.$Logify).toBeDefined() + expect(sym.$Logify).toBeDefined() }) diff --git a/packages/tempo/test/term_unified.test.ts b/packages/tempo/test/term_unified.test.ts index 78e1fbd4..8f26bb12 100644 --- a/packages/tempo/test/term_unified.test.ts +++ b/packages/tempo/test/term_unified.test.ts @@ -88,6 +88,13 @@ describe('Term Unified Logic (Mutation & Identity)', () => { expect(t.set({ start: '#quarter' }).format('{yyyy}-{mm}-{dd}')).toBe('2024-04-01'); }); + it('should support granular sphere matching (e.g. SouthSouthWest)', () => { + // Even though ranges are defined for 'south', 'SouthSouthWest' should match it via .includes() + const t = new Tempo(testDate, { catch: true, sphere: 'SouthSouthWest' }); + expect(t.format('{#qtr}')).toBe('Q4'); + expect(t.set({ start: '#quarter' }).format('{yyyy}-{mm}-{dd}')).toBe('2024-04-01'); + }); + describe('Term Range Boundaries (Fluent & Immutable)', () => { it('should return start and end as Tempo instances', () => { const t = new Tempo(testDate, { catch: true, sphere: 'north' }); From 5e551af32d2abdb0a7543eba2bb508bd93ad0aed Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 23 Apr 2026 18:08:29 +1000 Subject: [PATCH 02/13] still trying to test --- .../library/src/common/assertion.library.ts | 14 +-- .../library/src/common/primitive.library.ts | 51 +++++--- packages/library/src/common/type.library.ts | 23 +++- .../test/common/temporal_guards.test.ts | 3 +- packages/tempo/plan/shorthand.md | 110 ---------------- packages/tempo/plan/standalone_parse.md | 58 --------- packages/tempo/src/discrete/discrete.parse.ts | 117 ++++++++---------- packages/tempo/src/support/tempo.default.ts | 4 +- packages/tempo/src/support/tempo.enum.ts | 2 +- packages/tempo/src/tempo.class.ts | 13 +- vitest.config.ts | 1 + 11 files changed, 126 insertions(+), 270 deletions(-) delete mode 100644 packages/tempo/plan/shorthand.md delete mode 100644 packages/tempo/plan/standalone_parse.md diff --git a/packages/library/src/common/assertion.library.ts b/packages/library/src/common/assertion.library.ts index 81d53b1a..606927e1 100644 --- a/packages/library/src/common/assertion.library.ts +++ b/packages/library/src/common/assertion.library.ts @@ -47,7 +47,7 @@ export const isUndefined = (obj?: T): obj is undefined => isType(o export const isDefined = (obj: T): obj is NonNullable => !isNullish(obj); export const isClass = (obj?: T): obj is Extract => isType(obj, 'Class'); -export const isFunction = (obj?: T): obj is Extract => isType(obj, 'Function', 'AsyncFunction'); +export const isFunction = (obj?: T): obj is Extract => isType(obj, 'Function', 'AsyncFunction', 'Class'); export const isPromise = (obj?: T): obj is Extract> => isType(obj, 'Promise'); export const isMap = (obj?: T): obj is Extract> => isType(obj, 'Map'); export const isSet = (obj?: T): obj is Extract> => isType(obj, 'Set'); @@ -64,12 +64,12 @@ export const isTemporal = (obj: T): obj is Extract => protoType (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay )); -export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant); -export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime); -export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate); -export const isPlainTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime); -export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime); -export const isDuration = (obj: T): obj is Extract => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration); +export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant'); +export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime'); +export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate'); +export const isPlainTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime'); +export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime'); +export const isDuration = (obj: T): obj is Extract => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Duration'); export const isDurationLike = (obj: T): obj is Extract => isString(obj) || isDuration(obj) || (isObject(obj) && ( 'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj || 'hours' in obj || 'minutes' in obj || 'seconds' in obj || diff --git a/packages/library/src/common/primitive.library.ts b/packages/library/src/common/primitive.library.ts index fd74b4a7..252527b0 100644 --- a/packages/library/src/common/primitive.library.ts +++ b/packages/library/src/common/primitive.library.ts @@ -1,5 +1,4 @@ import { sym } from '#library/symbol.library.js'; -import { isEmpty } from '#library/assertion.library.js'; import type { Obj, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; /** @@ -16,8 +15,9 @@ import type { Obj, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; */ export function unwrap(obj: T): T { let curr = (obj as any)?.[sym.$Target] ?? obj; - while (Object.prototype.hasOwnProperty.call(curr, sym.$Target)) { - curr = curr[sym.$Target]; + // Hardened against different Symbol instances across module boundaries + while (curr && (Object.prototype.hasOwnProperty.call(curr, sym.$Target) || (curr as any).$Target)) { + curr = curr[sym.$Target] ?? (curr as any).$Target; } return curr; } @@ -27,28 +27,39 @@ export function ownEntries(json: T, all = false): EntryOf[] { if (!json || typeof json !== 'object') return [] as EntryOf[]; - const getOwn = (obj: any): [PropertyKey, any][] => { - const tgt = unwrap(obj); - return Reflect.ownKeys(tgt) - .filter(key => Object.getOwnPropertyDescriptor(tgt, key)?.enumerable) - .map(key => [key, tgt[key]]); + const tgt = unwrap(json); + if (!all) { + const keys = Reflect.ownKeys(tgt).reverse(); + const entries: [PropertyKey, any][] = []; + for (const k of keys) { + const desc = Object.getOwnPropertyDescriptor(tgt, k); + if (desc && desc.enumerable) entries.push([k, (tgt as any)[k]]); + } + return entries as EntryOf[]; } - if (!all) return getOwn(json) as EntryOf[]; - - const levels: [PropertyKey, any][][] = []; + const entries: [PropertyKey, any][] = []; + const seen = new Set(); const limit = 50; let depth = 0; - let proto: any = json; + let proto: any = tgt; + + while (proto && proto !== Object.prototype && depth++ < limit) { + const current = unwrap(proto); + const keys = Reflect.ownKeys(current).reverse(); - do { - const t = unwrap(proto); - const lvl = getOwn(proto); - if (lvl.length) levels.push(lvl); - proto = Object.getPrototypeOf(t); - } while (proto && proto !== Object.prototype && ++depth < limit); + for (const k of keys) { + if (seen.has(k)) continue; + const desc = Object.getOwnPropertyDescriptor(current, k); + if (!desc || !desc.enumerable) continue; + seen.add(k); + entries.push([k, (current as any)[k]]); + } + + proto = Object.getPrototypeOf(current); + } - return [...new Map(levels.reverse().flat()).entries()] as EntryOf[]; + return entries as EntryOf[]; } /** Array of all enumerable PropertyKeys */ @@ -63,7 +74,7 @@ export function ownValues(json: T, all = false): ValueOf[] { /** Get nested value using dot or bracket notation */ export function extract(obj: any, path: string | number, dflt?: T): T { - if (isEmpty(path)) return obj as T; + if (path === undefined || path === null || path === '') return obj as T; if (obj === null || typeof obj !== 'object') return dflt as T; return path diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index dfbeb064..fa7912be 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -1,10 +1,11 @@ import { sym } from '#library/symbol.library.js'; +import { unwrap } from '#library/primitive.library.js'; const registry: Instance[] = []; // global types for getType /** the primitive type reported by toStringTag() */ export const protoType = (obj?: unknown) => { - const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps + const raw = (obj && typeof obj === 'object') ? unwrap(obj as object) : obj; return Object.prototype.toString.call(raw).slice(8, -1) as Type; } @@ -78,26 +79,36 @@ const isClassConstructor = (obj: any): boolean => { const tag = raw?.[Symbol.toStringTag] ?? raw?.prototype?.[Symbol.toStringTag]; // Absolute bypass for branded identities (using universal brand) - if (raw?.[sym.$Identity] || raw?.prototype?.[sym.$Identity]) return true; + if (raw?.[sym.$Identity] || raw?.prototype?.[sym.$Identity]) { + return true; + } if (typeof tag === 'string' && tag.startsWith('Temporal.')) 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)[sym.$Registry] ?? []; - if (globalRegistry.some((inst: any) => ((inst.class as any)?.[sym.$Target] ?? inst.class) === raw || (name && inst.type === name) || (tag && inst.type === tag))) return true; + if (globalRegistry.some((inst: any) => ((inst.class as any)?.[sym.$Target] ?? inst.class) === raw || (name && inst.type === name) || (tag && inst.type === tag))) { + return true; + } // 3. Last resort: Inspection of constructor property & prototype (Transpilation-Safe) try { const str = Function.prototype.toString.call(raw); - if (classRegex.test(str)) return true; + if (classRegex.test(str)) { + return true; + } // ES6 classes have a non-writable prototype descriptor const descriptor = Object.getOwnPropertyDescriptor(raw, 'prototype'); - if (descriptor && descriptor.writable === false) return true; + if (descriptor && descriptor.writable === false) { + return true; + } // if it's a function with a 'prototype' that is NOT an empty object (excluding its constructor), it's likely a class const proto = raw.prototype; - if (proto && Object.getOwnPropertyNames(proto).length > 2) return true; + if (proto && Object.getOwnPropertyNames(proto).length > 2) { + return true; + } } catch { return false; diff --git a/packages/library/test/common/temporal_guards.test.ts b/packages/library/test/common/temporal_guards.test.ts index 2d760882..16d96caa 100644 --- a/packages/library/test/common/temporal_guards.test.ts +++ b/packages/library/test/common/temporal_guards.test.ts @@ -1,5 +1,4 @@ -import * as t from '#library/type.library.js'; -import '#library/temporal.polyfill.js'; +import * as t from '#library/assertion.library.js'; describe('Temporal Type Guards', () => { it('should identify Temporal.Instant', () => { diff --git a/packages/tempo/plan/shorthand.md b/packages/tempo/plan/shorthand.md deleted file mode 100644 index 1a266ceb..00000000 --- a/packages/tempo/plan/shorthand.md +++ /dev/null @@ -1,110 +0,0 @@ -Here are my thoughts, and why I'm not happy with this feature. -1) it has introduced regression errors -2) it has introduced non-deterministic results (the first match wins ?! not good enough in a public utility) -3) it already has similar-enough syntax available {start:'#period'} -4) I'm not convinced that this will be a high-hit feature, so am wary about the effort that will go into dev / test / maintaining it. - -Because we've gone quite a ways down this path, I'd like to 'firm-up' the requirements (although I'm still not committed to delivering this feature). -Important to remember that we have a TermKey (like 'qtr'), a TermScope (like 'quarter'), and a RangeKey (like 'q1') -All lookups for a TermKey or TermScope will be performed against the current Tempo's Term definition, all lookups mapped to lowercase for matching. -Important to retain the assigned Range-list when using a TermKey or TermScope (for example, the existing list should already be keyed by 'sphere' for zodiac) - -## .set() -1) {start: '#quarter'} returns a Tempo at the beginning of the current quarter -2) {start: '#period' } returns a Tempo based at the beginning of the current period (e.g. if 'morning' at 08:15, returns 08:00) -3) {start: '#zodiac' } returns a Tempo based at the beginning of the current zodiac sign -4) {start: '#season' } returns a Tempo based at the beginning of the current season -** the above is already established and working and remains the gold-standard -5) {start: '#quarter.q1' } names the Term and RangeKey that will be used to set the new Tempo -6) {start: '#period.afternoon' } names the Term and RangeKey that will be used to set the new Tempo -7) {start: '#zodiac.aries' } names the Term and RangeKey -8) {start: '#season.summer' } names the Term and RangeKey that will be used to set the new Tempo (within the fiscal year, so it might be greater or lesser than current season) -** allowable to use the TermKey ('#qtr.q3') or TermScope ('#quarter.q3') interchangeably in the above. -** allowable to use the string value from the above as a shorthand only to denote 'start' e.g. .set('#zodiac.taurus') -** each of the above keys can be 'start' | 'mid' | 'end' to determine where the Tempo date lands - -## .add() -1) {'#quarter':1} remains the gold-standard... returns a new Tempo at the beginning date of the next quarter -2) {'#period': 2} returns a new Tempo offsetting the current Period (e.g. morning) by two terms, so effectively two days later at 08:00 -3) { '#zodiac': 3} returns a new Tempo at the beginning date of the next three zodiac signs, wrapping around the year boundary -4) { '#season': 2 } returns a new Tempo offsetting the current Season (e.g. Winter) by two terms, so effectively two years later -** the above is already established and working -5) { '#quarter.q1': 1 } returns a new Tempo at the beginning of the next quarter-one (today, or in the in the future) -6) { '#period.afternoon': 2 } returns a new Tempo at the beginning of the 'afternoon' on the clock two days from current Tempo -7) { '#zodiac.taurus': 3 } returns a new Tempo at the beginning of the 'Taurus' term three cycles from current Tempo -8) { '#season.autumn': 4 } returns a new Tempo at the beginning of the next 'Autumn' term -** allowable to use the TermKey ('#qtr.q3') or TermScope ('#quarter.q3') interchangeably in the above. -** if the offset values are negative, then new Tempos are returned prior to the current Tempo -** allowable to use the string key from the above as a shorthand only to denote '1' term e.g. .add('#zodiac.aries') - - -## Extended Term Syntax - -Term PlugIns and Extension PlugIns are where Tempo will really shine. -If we can make the syntax for these clear, concise, and easy to remember, then we'll have a winner. - -Extended Term usage involves combining a Term Key (or Term Scope as an alias, if defined) with a Range Key, separated by a '.' to do some interesting things. Combine this with 'shorthand' keys (see below) and we can make this very concise. - -In order to make this really easy for the developer, they will need the ability to query the available Term Keys and Range Keys for the current Tempo. (I'm not sure if they have this already ?) - -### .until() with Extended Terms -Just as we've done above with .set() and .add(), I can see some benefits to allowing Extended Terms to be used in .until() as well. the .until() method returns a Tempo.Duration object (a standard Temporal.DurationLikeObject but with an .iso property as well)... let's call it a DLO (for duration-like object) in these notes. - -## #quarter -1) { start: '#quarter' } returns a DLO representing the duration from the current Tempo to the beginning of the current quarter (so usually negative) -2) { start: '#quarter.q2'} returns a DLO representing the duration from the current Tempo to the beginning of the second quarter in the current Term -3) { mid: '#quarter' } returns a DLO representing the duration from the current Tempo to the middle of the current quarter -4) { mid: '#quarter.q3' } returns a DLO representing the duration from the current Tempo to the middle of the third quarter in the current Term -5) { end: '#quarter' } returns a DLO representing the duration from the current Tempo to the end of the current quarter -6) { end: '#quarter.q4' } returns a DLO representing the duration from the current Tempo to the end of the fourth quarter in the current Term - -## #period -7) { start: '#period' } returns a DLO representing the duration from the current Tempo to the beginning of the current period (so usually negative) -8) { start: '#period.afternoon' } returns a DLO representing the duration from the current Tempo to the beginning of the 'afternoon' -9) { mid: '#period' } returns a DLO representing the duration from the current Tempo to the middle of the current period -10) { mid: '#period.evening' } returns a DLO representing the duration from the current Tempo to the middle of the 'evening' -11) { end: '#period' } returns a DLO representing the duration from the current Tempo to the end of the current period -12) { end: '#period.night' } returns a DLO representing the duration from the current Tempo to the end of the 'night' - -## #zodiac -13) { start: '#zodiac' } returns a DLO representing the duration from the current Tempo to the beginning of the current zodiac sign (so usually negative) -14) { start: '#zodiac.taurus' } returns a DLO representing the duration from the current Tempo to the beginning of the 'Taurus' sign -15) { mid: '#zodiac' } returns a DLO representing the duration from the current Tempo to the middle of the current zodiac sign -16) { mid: '#zodiac.taurus' } returns a DLO representing the duration from the current Tempo to the middle of the 'Taurus' sign -17) { end: '#zodiac' } returns a DLO representing the duration from the current Tempo to the end of the current zodiac sign -18) { end: '#zodiac.taurus' } returns a DLO representing the duration from the current Tempo to the end of the 'Taurus' sign - -## #season -19) { start: '#season' } returns a DLO representing the duration from the current Tempo to the beginning of the current season (so usually negative) -20) { start: '#season.summer' } returns a DLO representing the duration from the current Tempo to the beginning of the 'Summer' season -21) { mid: '#season' } returns a DLO representing the duration from the current Tempo to the middle of the current season -22) { mid: '#season.summer' } returns a DLO representing the duration from the current Tempo to the middle of the 'Summer' season -23) { end: '#season' } returns a DLO representing the duration from the current Tempo to the end of the current season -24) { end: '#season.summer' } returns a DLO representing the duration from the current Tempo to the end of the 'Summer' season - -## combining with 'shorthand' (which implies 'start') -25) '#qtr.q1' is a shorthand for { start: '#quarter.q1' } -26) '#period.afternoon' is a shorthand for { start: '#period.afternoon' } -27) '#zodiac.taurus' is a shorthand for { start: '#zodiac.taurus' } -28) '#season.summer' is a shorthand for { start: '#season.summer' } - -## Gotcha's -- When running in 'lite' mode (#tempo/core) then the TermModule must be activated for this to be useful. -If it is not, then a Tempo created with any of the above syntax will throw an error (respecting the catch:boolean flag) -- Ranges have upper- and lower-bounds (e.g. Q1-Q4, Aries-Pisces, Spring-Winter, Morning-Night) determined by their sorted Duration fields (year, month, day, etc). When using 'until' it is expected that the DLO will be in the same range as the current Tempo (defined by that Tempo's year/month/day, etc). For example, in Q3 then '#qtr.q2' will return a negative DLO... in Morning then '#period.night' will return a positive DLO. in 'Taurus' then '#zodiac.aries' will return a negative DLO. in 'Spring' then '#season.winter' will return a negative DLO in southern hemisphere, but positive in northern hemisphere. -- To move outside of a Range requires the use of the 'add' method. For example, t1.until(t1.add({years:1}).set('#qtr.q2')) -- No consideration is being given (at this release) to the 'CN' (Chinese) sub-objects on any of the terms. - -## Slick (as an alternative to 'knowing' what unit to use in an 'add()') -- To provide a 'slick' method for moving across a Range (and beyond), we will consider using the Tempo.Modifier syntax -1) .until('#qtr.>q2') will return a DLO representing the time from the current Tempo to the beginning of the next quarter-two -2) .until('#zdc.>=Aries') will return a DLO representing the duration from the current Tempo to the beginning of the next Aries term -3) .until('#szn.-Winter') will return a DLO representing the duration from the current Tempo to the beginning of the previous Winter term -4) .until('#period.>=morning') will return a DLO representing the duration from the current Tempo to the beginning of the next morning - -## and to take this one step further -5) .until({start: '#qtr.>q2'}) is the long-version of ('#qtr.>q2') -6) .until({mid: '#qtr.>q3'}) returns a DLO representing the duration from the current Tempo to the middle of the next quarter-three -7) .until({start: '#period.-3morning'}) returns a DLO representing the duration from the current Tempo to 3 days ago in the morning - -so t1.until(t1.add({years:1}).set('#qtr.q2')) can be re-written as t1.until('#qtr.>q2') \ No newline at end of file diff --git a/packages/tempo/plan/standalone_parse.md b/packages/tempo/plan/standalone_parse.md deleted file mode 100644 index 6b4a0179..00000000 --- a/packages/tempo/plan/standalone_parse.md +++ /dev/null @@ -1,58 +0,0 @@ -# Feasibility Analysis: Standalone `parse()` Function - -This document outlines the architectural changes required to support a standalone `parse` function in a future release of Tempo, allowing users to leverage the "Smart Parser" without necessarily using the full `Tempo` class. - -## 1. Current State -Currently, the `ParseModule` is designed as a **Plugin**. -- **Context Dependent**: It relies on `this` being a `Tempo` instance to access internal state (`parseDepth`, `errored`, `isValid`) and configuration (`timeZone`, `locale`). -- **Internal Coupling**: It uses `this[sym.$Internal]()` to retrieve the private state bucket. -- **Return Type**: It returns a `Temporal.ZonedDateTime`, which the `Tempo` constructor then uses to hydrate the instance. - -## 2. Proposed Standalone Signature -```typescript -import { parse } from '@magmacomputing/tempo/parse'; - -// Standalone usage (returns a native Temporal.ZonedDateTime) -const zdt = parse('20-May', { - timeZone: 'Africa/Cairo', - mode: 'strict' -}); -``` - -## 3. Implementation Feasibility - -### A. Stateless Lexing (High Feasibility) -The core lexing logic (`module.lexer.ts`) is already highly modular. Functions like `parseWeekday`, `parseDate`, and `parseTime` are pure or near-pure. They could easily be adapted to take an explicit `options` object instead of relying on an instance config. - -### B. Refactoring the Engine (Medium Feasibility) -To make `ParseEngine.parse` standalone, we would need to: -1. **Lift State**: Instead of using `this[sym.$Internal]()`, the engine would accept a `State` object as an argument (or initialize a transient one). -2. **Parameterize Config**: Pass `timezone`, `locale`, and `formats` as an explicit options object. -3. **Functional Composition**: The logic in `module.composer.ts` (which assembles the final date) is already functional and would require minimal changes. - -### C. Challenges & Trade-offs -1. **Circular Dependencies**: If the standalone `parse` function were to return a `Tempo` instance (convenience wrapper), it would create a circular dependency with `tempo.class.ts`. To avoid this, it should strictly return `Temporal.ZonedDateTime`. -2. **Global Defaults**: A standalone function wouldn't automatically respect `Tempo.init()` defaults unless it explicitly looked them up from the global registry (e.g. `REGISTRY.defaults`). -3. **Validation (The Guard)**: The standalone function MUST include the `Guard` validation logic to ensure that inputs are sanitized and valid before attempting lexing. -4. **Forced Strict Mode**: For standalone parsing, it is recommended to force `mode: 'strict'`. This prevents the parser from "guessing" or returning partial dates when the input is ambiguous, ensuring deterministic results for native Temporal users. - -## 4. Proposed Architecture for Future Release - -```mermaid -graph TD - A[Smart Lexer] --> B[Stateless Parse Engine] - B --> C[Standalone parse Function] - B --> D[Tempo Class parse] - C --> E[Temporal.ZonedDateTime] - D --> F[Tempo Instance] - G[Guard] --> B - H[Global Defaults] --> B -``` - -## 5. Conclusion -Implementing a standalone `parse` function is **Highly Feasible**. -- Refactoring the `ParseEngine` to be stateless (accepting config/state as arguments) is the first step toward decoupling the parser. -- A wrapper `parse()` function will then be exported from `#tempo/parse` to provide a dedicated standalone entry point. -- Finally, `Tempo.class` will be updated to delegate its internal `#parse` logic to this shared stateless engine. - -This would allow Tempo to serve as a high-performance "Natural Language to Temporal" parser for users who prefer the native Temporal API but want the "Slick" parsing capabilities of Tempo. diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index f8cd7260..7bfa45c8 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -18,6 +18,8 @@ import type { Range, ResolvedRange } from '../plugin/plugin.type.js'; import type { Tempo } from '../tempo.class.js'; import * as t from '../tempo.type.js'; +const isZDT = (obj: any) => obj && (obj[Symbol.toStringTag] === 'Temporal.ZonedDateTime' || typeof obj.toInstant === 'function'); + /** @@ -62,8 +64,8 @@ const _ParseEngine = { const basis = isDefined(val) ? val : instant().toZonedDateTimeISO(config.timeZone); const [tz, cal] = isTempo(basis) ? [(basis as any).tz, (basis as any).cal] : getTemporalIds(basis ?? config.timeZone, basis ?? config.calendar); - - today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : instant().toZonedDateTimeISO(tz).withCalendar(cal)); + const isAnchored = isDefined(val); + today = isZDT(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : (isZDT(val) ? val : instant().toZonedDateTimeISO(tz).withCalendar(cal))); const TempoClass = getRuntime().modules['Tempo']; const terms = getRuntime().pluginsDb.terms; @@ -128,9 +130,9 @@ const _ParseEngine = { } } - const isAnchored = isDefined(dateTime) || isDefined(state.anchor); + const isAnchoredVal = isAnchored || isDefined(dateTime) || isDefined(state.anchor); const resolvingKeys = new Set(); - const res = _ParseEngine.conform(state, tempo, today, isAnchored, resolvingKeys); + const res = _ParseEngine.conform(state, tempo, today, isAnchoredVal, resolvingKeys); const { timeZone: tz2, calendar: cal2 } = state.config; const [targetTz, targetCal] = getTemporalIds(tz2, cal2); @@ -237,7 +239,8 @@ const _ParseEngine = { value = trim; // Update value for downstream parsing } - return _ParseEngine.parseLayout(state, value as string | number, dateTime, isAnchored, resolvingKeys); + const res = _ParseEngine.parseLayout(state, value as string | number, dateTime, isAnchored, resolvingKeys); + return res; }, /** match a string or number against known layouts */ @@ -278,11 +281,10 @@ const _ParseEngine = { const anchorTime = zdt.toPlainTime(); for (const [symKey, pat] of state.parse.pattern) { const groups = _ParseEngine.parseMatch(state, pat, trim); - if (isEmpty(groups)) continue; - - const hasAlias = Object.keys(groups).some(k => k.includes('evt') || k.includes('per')); - const isRootMatch = Object.keys(groups).some(k => k === 'dt' || k === 'tm'); - const hadEventOrPeriod = hasAlias || isRootMatch; + if (isEmpty(groups)) { + continue; + } + const hasTime = Object.keys(groups).some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key)) || Object.values(groups).includes('now'); _ParseEngine.result(state, { match: symKey.description, value: trim, groups: { ...groups } }); @@ -293,22 +295,14 @@ const _ParseEngine = { dateTime = parseDate(groups, dateTime, (TempoClass as any)?.[sym.$dbg], state.config, state.parse["pivot"]); dateTime = parseTime(groups, dateTime); - const hasTime = Object.keys(groups).some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key)) - || hadEventOrPeriod - || !dateTime.toPlainTime().equals(anchorTime); - - if (!isAnchored && !hasTime) + const isChanged = !dateTime.toPlainTime().equals(anchorTime); + if (!hasTime && !isChanged) dateTime = dateTime.withPlainTime('00:00:00'); - if (isZonedDateTime(dateTime)) { + if (isZDT(dateTime)) { Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: dateTime, match: symKey.description, groups }); } - if (TempoClass) { - (TempoClass as any)[sym.$logDebug](state.config, 'groups', groups); - (TempoClass as any)[sym.$logDebug](state.config, 'pattern', symKey.description); - } - break; } @@ -327,10 +321,8 @@ const _ParseEngine = { }, /** resolve {event} | {period} to their date | time values (mutates groups) */ - parseGroups(state: any, groups: t.Groups, dateTime: Temporal.ZonedDateTime, isAnchored: boolean, resolvingKeys: Set): Temporal.ZonedDateTime { - if (!isZonedDateTime(dateTime)) return dateTime; + parseGroups(state: t.Internal.State, groups: t.Groups, dateTime: Temporal.ZonedDateTime, isAnchored: boolean, resolvingKeys: Set): Temporal.ZonedDateTime { const TempoClass = getRuntime().modules['Tempo']; - const prevAnchor = state.anchor; const prevZdt = state.zdt; @@ -365,27 +357,35 @@ const _ParseEngine = { const isEvent = Match.event.test(key); const isGlobal = key.startsWith('g'); - const isLocal = key.startsWith('l'); - const idx = +key.substring((isGlobal || isLocal) ? 4 : 3); + const isNamed = key === 'gdt' || key === 'dt' || key === 'gtm' || key === 'tm'; + const idx = isNamed ? -1 : +key.substring(4); + console.error(`[Tempo] Processing key: ${key}, idx: ${idx}, isEvent: ${isEvent}, isGlobal: ${isGlobal}, isNamed: ${isNamed}`); + + if (isNamed) { + resolved.add(key); + delete groups[key]; + continue; + } + const globalParse = isGlobal ? (TempoClass as any)?.[sym.$Internal]?.().parse : undefined; - const src = - isGlobal - ? (isEvent ? globalParse?.event : globalParse?.period) - : (isEvent ? state.parse.event : state.parse.period); + const src = isGlobal + ? (isEvent ? globalParse?.event : globalParse?.period) + : (isEvent ? state.parse.event : state.parse.period); const entry = ownEntries(src, true)[idx]; - if (!entry) { resolved.add(key); + delete groups[key]; continue; } - const aliasKey = `${key}:${String(entry[0])}`; + const aliasKey = entry[0] as string; if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) { const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; state.errored = true; if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new RangeError(msg)); resolved.add(key); + delete groups[key]; continue; } @@ -396,14 +396,16 @@ const _ParseEngine = { let res: string = ''; if (isFunction(definition)) { try { - state.anchor = dateTime; - state.zdt = dateTime; - // Provide a lightweight host context that mimics a Tempo instance for the handler const host = { add: (val: any) => dateTime.add(val), - set: (val: any) => isObject(val) ? dateTime.with(val) : dateTime, - toNow: () => Temporal.Now.zonedDateTimeISO(state.config.timeZone), + subtract: (val: any) => dateTime.subtract(val), + with: (val: any) => dateTime.with(val), + set: (val: any, opt?: any) => { + const res = _ParseEngine.conform(state, val, dateTime, true, resolvingKeys); + return (TempoClass as any)?.from(isZonedDateTime(res.value) ? res.value : dateTime, { ...state.config, ...opt }); + }, + toNow: () => instant().toZonedDateTimeISO(state.config.timeZone).withCalendar(state.config.calendar), toDateTime: () => dateTime, get hh() { return dateTime.hour }, get mi() { return dateTime.minute }, @@ -417,25 +419,17 @@ const _ParseEngine = { const result = (definition as Function).call(host); if (isTempo(result)) dateTime = (result as any).toDateTime(); - else if (isZonedDateTime(result)) dateTime = result as Temporal.ZonedDateTime; + else if (isZDT(result)) dateTime = result as Temporal.ZonedDateTime; else if (isObject(result) && isFunction((result as any).toDateTime)) dateTime = (result as any).toDateTime(); - else dateTime = isZonedDateTime(state.zdt) ? (state.zdt as any) : dateTime; - res = String(result); + res = isString(result) || isNumeric(result) ? String(result) : ''; + state.zdt = dateTime; } catch (e: any) { - if (e.message.includes('Temporal')) { - res = (definition as any).toString(); - } else { - throw e; - } + throw e; } } else { res = (definition as string); } - if (isEvent && !isAnchored && isZonedDateTime(dateTime)) dateTime = (dateTime as any).startOfDay(); - - if (TempoClass) (TempoClass as any)[sym.$logDebug](state.config, 'event', `resolved "${key}" to "${res}" against ${(dateTime as any).toString?.() ?? String(dateTime)}`); - try { const type = isEvent ? 'Event' : 'Period'; const val = entry![0]; @@ -443,27 +437,24 @@ const _ParseEngine = { const resolveVal = isFunction(definition) ? res : definition; _ParseEngine.result(state, { type, value: val as any, match: pat, groups: { [key]: resolveVal as string } }); - const resolving = new Set(resolvingKeys); - resolving.add(aliasKey); - const resMatch = _ParseEngine.parseLayout(state, res, dateTime, isAnchored, resolving); + // only re-parse if we have a non-empty string resolution + if (!isEmpty(res) && res !== String(val)) { + const resolving = new Set(resolvingKeys); + resolving.add(aliasKey); + const resMatch = _ParseEngine.parseLayout(state, res, dateTime, true, resolving); - if (resMatch.type === 'Temporal.ZonedDateTime') - dateTime = resMatch.value; + if (resMatch.type === 'Temporal.ZonedDateTime') + dateTime = resMatch.value; + } } finally { - resolved.add(key); + delete groups[key]; } - - delete groups[key]; } } finally { state.anchor = prevAnchor; - if (state.parseDepth === 1) { - state.zdt = prevZdt; - state.matches = undefined; - } else { - if (isZonedDateTime(dateTime)) state.zdt = dateTime; - } + if (isZonedDateTime(dateTime)) state.zdt = dateTime; state.parseDepth--; + if (state.parseDepth === 0) state.matches = undefined; } if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) { diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 770f168f..5ce42701 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -84,9 +84,9 @@ export const Layout = looseIndex()({ [Token.tm]: '({hh}{mi}?{ss}?{ff}?{mer}?|{per})', // clock or period [Token.dtm]: '({dt})(?:(?:{sep}+|T)({tm}))?{tzd}?{brk}?', // calendar/event and clock/period [Token.tmd]: '({tm})(?:(?:{sep}+|T)({dt}))?{tzd}?{brk}?', // clock/period and calendar/event - [Token.dmy]: '({wkd}{sep}+)?{dd}{sep}?{mm}({sep}?{yy})?{sfx}?{brk}?',// day-month(-year) - [Token.mdy]: '({wkd}{sep}+)?{mm}{sep}?{dd}({sep}?{yy})?{sfx}?{brk}?',// month-day(-year) [Token.ymd]: '({wkd}{sep}+)?{yy}{sep}?{mm}({sep}?{dd})?{sfx}?{brk}?',// year-month(-day) + [Token.mdy]: '({wkd}{sep}+)?{mm}{sep}?{dd}({sep}?{yy})?{sfx}?{brk}?',// month-day(-year) + [Token.dmy]: '({wkd}{sep}+)?{dd}{sep}?{mm}({sep}?{yy})?{sfx}?{brk}?',// day-month(-year) [Token.wkd]: '{mod}?{wkd}{afx}?{sfx}?', // special layout (no {dt}!) used for weekday calcs (only one that requires {wkd} pattern) [Token.off]: '{mod}?{dd}{afx}?', // day of month, with optional offset [Token.rel]: '{nbr}{sep}?{unt}{sep}?{afx}', // relative duration (e.g. 2 days ago) diff --git a/packages/tempo/src/support/tempo.enum.ts b/packages/tempo/src/support/tempo.enum.ts index c1810fb2..e6dcbd6f 100644 --- a/packages/tempo/src/support/tempo.enum.ts +++ b/packages/tempo/src/support/tempo.enum.ts @@ -213,7 +213,7 @@ export type MUTATION = ValueOf export type Mutation = KeyOf /** allowed keys for ZonedDateTime-like objects */ -const zonedDateTimeKeys = ['value', 'timeZoneId', 'calendarId', 'monthCode', 'offset', 'timeZone', ...elementKeys] as const; +const zonedDateTimeKeys = ['value', 'timeZoneId', 'calendarId', 'monthCode', 'offset', 'timeZone', 'year', 'month', 'day', 'hour', 'minute', 'second', ...elementKeys] as const; export const ZONED_DATE_TIME = enumify(zonedDateTimeKeys, false); export type ZONED_DATE_TIME = ValueOf export type ZonedDateTime = KeyOf diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 06f80c6b..85aa0906 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -136,6 +136,11 @@ export class Tempo { setProperty(shape.parse.snippet, Token.evt, new RegExp(groups)); } + } else { + // If no groups, ensure we don't have a stale or empty regex that could cause issues + if (hasOwn(shape.parse.snippet, Token.evt)) { + delete shape.parse.snippet[Token.evt as any]; + } } if (shape.parse.isMonthDay) { @@ -173,6 +178,11 @@ export class Tempo { setProperty(shape.parse.snippet, Token.per, new RegExp(groups)); } + } else { + // If no groups, ensure we don't have a stale or empty regex that could cause issues + if (hasOwn(shape.parse.snippet, Token.per)) { + delete shape.parse.snippet[Token.per as any]; + } } } @@ -1021,7 +1031,8 @@ export class Tempo { constructor(tempo: t.DateTime, options?: t.Options); 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 + [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 if (!this.#zdt && isObject(this.#tempo) && isDurationLike(this.#tempo)) { diff --git a/vitest.config.ts b/vitest.config.ts index e7ce1f6c..799ee325 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', + setupFiles: [path.resolve(__dirname, './packages/tempo/bin/temporal-polyfill.ts')], alias: [ { find: /^#library\/(browser|server|common)\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/$1/$2.ts') }, { find: /^#library\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/$1.ts') }, From b385cf6763f47513eb7d57b2613397888d78e978 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 23 Apr 2026 20:37:27 +1000 Subject: [PATCH 03/13] one test-file to go --- .../library/src/common/assertion.library.ts | 12 +++--- .../library/src/common/primitive.library.ts | 10 +++-- packages/library/src/common/type.library.ts | 9 +++-- packages/tempo/src/discrete/discrete.parse.ts | 39 ++++++++++++++----- .../src/plugin/module/module.duration.ts | 11 +++--- packages/tempo/src/support/tempo.default.ts | 8 ++-- packages/tempo/src/support/tempo.util.ts | 2 +- packages/tempo/src/tempo.type.ts | 5 ++- packages/tempo/test/pattern.weekday.test.ts | 34 ++++++++++++++++ 9 files changed, 97 insertions(+), 33 deletions(-) diff --git a/packages/library/src/common/assertion.library.ts b/packages/library/src/common/assertion.library.ts index 606927e1..8e7f27b9 100644 --- a/packages/library/src/common/assertion.library.ts +++ b/packages/library/src/common/assertion.library.ts @@ -47,7 +47,7 @@ export const isUndefined = (obj?: T): obj is undefined => isType(o export const isDefined = (obj: T): obj is NonNullable => !isNullish(obj); export const isClass = (obj?: T): obj is Extract => isType(obj, 'Class'); -export const isFunction = (obj?: T): obj is Extract => isType(obj, 'Function', 'AsyncFunction', 'Class'); +export const isFunction = (obj?: T): obj is Extract => isType(obj, 'Function', 'AsyncFunction'); export const isPromise = (obj?: T): obj is Extract> => isType(obj, 'Promise'); export const isMap = (obj?: T): obj is Extract> => isType(obj, 'Map'); export const isSet = (obj?: T): obj is Extract> => isType(obj, 'Set'); @@ -64,11 +64,11 @@ export const isTemporal = (obj: T): obj is Extract => protoType (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay )); -export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant'); -export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime'); -export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate'); -export const isPlainTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime'); -export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime'); +export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (!!obj && typeof (obj as any).toZonedDateTimeISO === 'function' && isUndefined((obj as any).timeZoneId)); +export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (!!obj && typeof (obj as any).toInstant === 'function' && isDefined((obj as any).timeZoneId)); +export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isDefined((obj as any).daysInMonth)); +export const isPlainTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime') || (!!obj && typeof (obj as any).toPlainDateTime === 'function' && isUndefined((obj as any).daysInMonth)); +export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).daysInMonth)); export const isDuration = (obj: T): obj is Extract => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Duration'); export const isDurationLike = (obj: T): obj is Extract => isString(obj) || isDuration(obj) || (isObject(obj) && ( 'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj || diff --git a/packages/library/src/common/primitive.library.ts b/packages/library/src/common/primitive.library.ts index 252527b0..402f7115 100644 --- a/packages/library/src/common/primitive.library.ts +++ b/packages/library/src/common/primitive.library.ts @@ -15,8 +15,12 @@ import type { Obj, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; */ export function unwrap(obj: T): T { let curr = (obj as any)?.[sym.$Target] ?? obj; - // Hardened against different Symbol instances across module boundaries + let depth = 0; while (curr && (Object.prototype.hasOwnProperty.call(curr, sym.$Target) || (curr as any).$Target)) { + if (++depth > 10) { + console.error('[Library] unwrap: Infinite recursion detected!'); + break; + } curr = curr[sym.$Target] ?? (curr as any).$Target; } return curr; @@ -29,7 +33,7 @@ export function ownEntries(json: T, all = false): EntryOf[] { const tgt = unwrap(json); if (!all) { - const keys = Reflect.ownKeys(tgt).reverse(); + const keys = Reflect.ownKeys(tgt); const entries: [PropertyKey, any][] = []; for (const k of keys) { const desc = Object.getOwnPropertyDescriptor(tgt, k); @@ -46,7 +50,7 @@ export function ownEntries(json: T, all = false): EntryOf[] { while (proto && proto !== Object.prototype && depth++ < limit) { const current = unwrap(proto); - const keys = Reflect.ownKeys(current).reverse(); + const keys = Reflect.ownKeys(current); for (const k of keys) { if (seen.has(k)) continue; diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index fa7912be..8b318534 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -21,14 +21,15 @@ export const protoType = (obj?: unknown) => { export const getType = (obj?: any, ...instances: Instance[]): Type => { const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps const type = protoType(raw); - switch (true) { case obj === null: return 'Null'; case obj === undefined: return 'Undefined'; case isClassConstructor(raw): return 'Class'; - case typeof raw === 'function': return type; // catch all functional types (including AsyncFunction) - + case typeof raw === 'function': { + if (type !== 'Function') console.log('[Library] getType weird function:', type, obj?.name); + return type; // catch all functional types (including AsyncFunction) + } case type === 'Object': { // check for ArrayLike (e.g. {0:'a', 1:'b', length:2}) if ('length' in raw && Object.keys(raw).every(key => key === 'length' || !isNaN(Number(key)))) return 'ArrayLike'; @@ -71,6 +72,8 @@ const isClassConstructor = (obj: any): boolean => { if (typeof obj !== 'function') return false; const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps + + const result = (() => { // Arrow functions do NOT have a prototype property, whereas traditional functions and classes DO. // This is a high-performance check to immediately classify arrow functions as non-classes. diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 7bfa45c8..0b49ace4 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -18,7 +18,7 @@ import type { Range, ResolvedRange } from '../plugin/plugin.type.js'; import type { Tempo } from '../tempo.class.js'; import * as t from '../tempo.type.js'; -const isZDT = (obj: any) => obj && (obj[Symbol.toStringTag] === 'Temporal.ZonedDateTime' || typeof obj.toInstant === 'function'); + @@ -65,7 +65,7 @@ const _ParseEngine = { const [tz, cal] = isTempo(basis) ? [(basis as any).tz, (basis as any).cal] : getTemporalIds(basis ?? config.timeZone, basis ?? config.calendar); const isAnchored = isDefined(val); - today = isZDT(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : (isZDT(val) ? val : instant().toZonedDateTimeISO(tz).withCalendar(cal))); + today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : (isZonedDateTime(val) ? val : instant().toZonedDateTimeISO(tz).withCalendar(cal))); const TempoClass = getRuntime().modules['Tempo']; const terms = getRuntime().pluginsDb.terms; @@ -173,6 +173,7 @@ const _ParseEngine = { const terms = getRuntime().pluginsDb.terms; + if (isTempo(dateTime)) dateTime = dateTime.toDateTime(); if (!isZonedDateTime(dateTime)) { if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new TypeError(`Sacred Anchor corrupted: ${String(value)}`)); return arg; @@ -279,6 +280,7 @@ const _ParseEngine = { let zdt = dateTime as any; const anchorTime = zdt.toPlainTime(); + console.log('[Tempo] parseLayout isAnchored:', isAnchored, 'dateTime:', dateTime.toString()); for (const [symKey, pat] of state.parse.pattern) { const groups = _ParseEngine.parseMatch(state, pat, trim); if (isEmpty(groups)) { @@ -296,10 +298,10 @@ const _ParseEngine = { dateTime = parseTime(groups, dateTime); const isChanged = !dateTime.toPlainTime().equals(anchorTime); - if (!hasTime && !isChanged) + if (!isAnchored && !hasTime && !isChanged) dateTime = dateTime.withPlainTime('00:00:00'); - if (isZDT(dateTime)) { + if (isZonedDateTime(dateTime)) { Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: dateTime, match: symKey.description, groups }); } @@ -339,6 +341,7 @@ const _ParseEngine = { while ((pending = ownKeys(groups).filter(k => (Match.event.test(k) || Match.period.test(k) || k === 'slk') && !resolved.has(k))).length > 0) { const key = pending[0]; + console.log('[Tempo] parseGroups key:', key, 'value:', groups[key]); if (key === 'slk') { const slk = groups[key]; @@ -359,7 +362,7 @@ const _ParseEngine = { const isGlobal = key.startsWith('g'); const isNamed = key === 'gdt' || key === 'dt' || key === 'gtm' || key === 'tm'; const idx = isNamed ? -1 : +key.substring(4); - console.error(`[Tempo] Processing key: ${key}, idx: ${idx}, isEvent: ${isEvent}, isGlobal: ${isGlobal}, isNamed: ${isNamed}`); + console.log(`[Tempo] Processing key: ${key}, idx: ${idx}, isEvent: ${isEvent}, isGlobal: ${isGlobal}, isNamed: ${isNamed}`); if (isNamed) { resolved.add(key); @@ -372,6 +375,7 @@ const _ParseEngine = { ? (isEvent ? globalParse?.event : globalParse?.period) : (isEvent ? state.parse.event : state.parse.period); const entry = ownEntries(src, true)[idx]; + console.log('[Tempo] parseGroups entry:', entry ? entry[0] : 'MISSING', 'definition type:', typeof entry?.[1]); if (!entry) { resolved.add(key); @@ -380,6 +384,7 @@ const _ParseEngine = { } const aliasKey = entry[0] as string; + console.log('[Tempo] parseGroups aliasKey:', aliasKey, 'resolvingKeys:', Array.from(resolvingKeys)); if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) { const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; state.errored = true; @@ -393,13 +398,22 @@ const _ParseEngine = { resolved.add(key); const definition = entry[1]; + const isFn = isFunction(definition); + console.log('[Tempo] parseGroups checking if definition is function:', typeof definition, 'isFunction:', isFn); let res: string = ''; - if (isFunction(definition)) { + if (isFn) { try { + console.log('[Tempo] parseGroups definition source:', String(definition).substring(0, 50)); // Provide a lightweight host context that mimics a Tempo instance for the handler const host = { - add: (val: any) => dateTime.add(val), - subtract: (val: any) => dateTime.subtract(val), + add: (val: any) => { + console.log('[Tempo] host.add called with:', JSON.stringify(val)); + return dateTime.add(val); + }, + subtract: (val: any) => { + console.log('[Tempo] host.subtract called with:', JSON.stringify(val)); + return dateTime.subtract(val); + }, with: (val: any) => dateTime.with(val), set: (val: any, opt?: any) => { const res = _ParseEngine.conform(state, val, dateTime, true, resolvingKeys); @@ -418,12 +432,19 @@ const _ParseEngine = { }; const result = (definition as Function).call(host); + console.log('[Tempo] parseGroups result type:', typeof result, 'isZDT:', isZonedDateTime(result)); if (isTempo(result)) dateTime = (result as any).toDateTime(); - else if (isZDT(result)) dateTime = result as Temporal.ZonedDateTime; + else if (isZonedDateTime(result)) { + // if we have a full ZonedDateTime result, preserve it + dateTime = result as Temporal.ZonedDateTime; + } else if (isObject(result) && isFunction((result as any).toDateTime)) dateTime = (result as any).toDateTime(); + res = isString(result) || isNumeric(result) ? String(result) : ''; + console.log('[Tempo] parseGroups updated dateTime:', dateTime.toString()); state.zdt = dateTime; } catch (e: any) { + console.log('[Tempo] parseGroups ERROR:', e.message, e.stack); throw e; } } else { diff --git a/packages/tempo/src/plugin/module/module.duration.ts b/packages/tempo/src/plugin/module/module.duration.ts index 93210f66..d39bbc17 100644 --- a/packages/tempo/src/plugin/module/module.duration.ts +++ b/packages/tempo/src/plugin/module/module.duration.ts @@ -1,11 +1,11 @@ -import { isString, isObject, isDefined, isUndefined } from '#library/assertion.library.js'; +import { isString, isObject, isDefined, isUndefined, isZonedDateTime } from '#library/assertion.library.js'; import { singular } from '#library/string.library.js'; import { getAccessors } from '#library/reflection.library.js'; import { ifDefined } from '#library/object.library.js'; import { getRelativeTime } from '#library/international.library.js'; import { defineInterpreterModule, interpret } from '../plugin.util.js'; -import { enums } from '#tempo/support'; +import { enums, isTempo } from '#tempo/support'; import type { Module } from '../plugin.type.js'; import type { Tempo } from '../../tempo.class.js'; @@ -67,9 +67,10 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) ({ unit, ...opts } = until as any) else unit = until; break; - case isObject(arg) && isString(until): - unit = until; - ({ value, ...opts } = arg as any); + case isObject(arg) && isTempo(arg): + value = (arg as any).toDateTime(); + if (isObject(until)) ({ unit, ...opts } = until as any); + else unit = until; break; case isObject(arg) && isObject(until): ({ value, unit, ...opts } = Object.assign({ value: arg }, until) as any); diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 5ce42701..c76b3357 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -28,7 +28,7 @@ export const Match = proxify({ /** strip out these characters from a string */ strips: /\(|\)/g, /** whitespace characters */ spaces: /\s+/g, /** Z character */ zed: /^Z$/, - /** base guard characters (digits and common symbols) */ guard: /[\d\s\-\.\:T\/Z\+\-\(\)\,\=\#]/i, + /** base guard characters (digits and common symbols) */ guard: /[\d\s\-\.\:T\/Z\+\-\(\)\,\=\#\<\>]/i, /** bracketed content (timezone/calendar) */ bracket: /\[[^\]]+\]/i, /** slick shorthand-shifter (e.g. #qtr.>2q2) */ shorthand: /(?:(?:#[\w]+|[\w]+)\.(?:[\+\-\<\>]=?|next|prev|this|last)?(?:[0-9]+)?(?:[\w]*))/, /** anchored version for shifter resolution */ slick: /^(?#[\w]+|[\w]+)\.(?[\+\-\<\>]=?|next|prev|this|last)?(?[0-9]+)?(?[\w]*)$/, @@ -80,6 +80,9 @@ export type Snippet = typeof Snippet */ /** @internal Tempo Layout registry */ export const Layout = looseIndex()({ + [Token.wkd]: '{mod}?{wkd}{afx}?{sfx}?', // special layout (no {dt}!) used for weekday calcs (only one that requires {wkd} pattern) + [Token.off]: '{mod}?{dd}{afx}?', // day of month, with optional offset + [Token.rel]: '{nbr}{sep}?{unt}{sep}?{afx}', // relative duration (e.g. 2 days ago) [Token.dt]: '({dd}{sep}?{mm}({sep}?{yy})?|{mod}?({evt})|(?{slk})|{wkd})',// calendar, event, slick or weekday [Token.tm]: '({hh}{mi}?{ss}?{ff}?{mer}?|{per})', // clock or period [Token.dtm]: '({dt})(?:(?:{sep}+|T)({tm}))?{tzd}?{brk}?', // calendar/event and clock/period @@ -87,9 +90,6 @@ export const Layout = looseIndex()({ [Token.ymd]: '({wkd}{sep}+)?{yy}{sep}?{mm}({sep}?{dd})?{sfx}?{brk}?',// year-month(-day) [Token.mdy]: '({wkd}{sep}+)?{mm}{sep}?{dd}({sep}?{yy})?{sfx}?{brk}?',// month-day(-year) [Token.dmy]: '({wkd}{sep}+)?{dd}{sep}?{mm}({sep}?{yy})?{sfx}?{brk}?',// day-month(-year) - [Token.wkd]: '{mod}?{wkd}{afx}?{sfx}?', // special layout (no {dt}!) used for weekday calcs (only one that requires {wkd} pattern) - [Token.off]: '{mod}?{dd}{afx}?', // day of month, with optional offset - [Token.rel]: '{nbr}{sep}?{unt}{sep}?{afx}', // relative duration (e.g. 2 days ago) }) /** @internal Tempo Layout type */ export type Layout = typeof Layout diff --git a/packages/tempo/src/support/tempo.util.ts b/packages/tempo/src/support/tempo.util.ts index 3f5cc4b8..9d995369 100644 --- a/packages/tempo/src/support/tempo.util.ts +++ b/packages/tempo/src/support/tempo.util.ts @@ -15,7 +15,7 @@ export const setProperty = (target: object, key: PropertyKey, value: T) => export const proto = (obj: object) => Object.getPrototypeOf(obj); /** @internal test object has own property with the given key */ -export const hasOwn = (obj: object, key: string) => Object.hasOwn(obj, key); +export const hasOwn = (obj: object, key: PropertyKey) => Object.hasOwn(obj, key); /** @internal create an object based on a prototype */ export const create = (obj: object, name: string): T => { diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index fb8f6719..a1eeb95e 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -187,8 +187,9 @@ export namespace Internal { /** @internal valid Temporal units for ZonedDateTime */ ZONED_DATE_TIME: Set; /** @internal current recursion depth during parsing */ parseDepth?: number; - /** @internal current matches during parsing */ matches?: Match[] | undefined; - /** @internal current anchor during parsing */ anchor?: Temporal.ZonedDateTime; + /** @internal current matches during parsing */ matches: Match[] | undefined; + /** @internal current anchor during parsing */ anchor: Temporal.ZonedDateTime | undefined; + /** @internal current ZonedDateTime during parsing */ zdt?: Temporal.ZonedDateTime; /** @internal has the parse operation errored? */ errored?: boolean; } diff --git a/packages/tempo/test/pattern.weekday.test.ts b/packages/tempo/test/pattern.weekday.test.ts index 920c0919..ea5cfc77 100644 --- a/packages/tempo/test/pattern.weekday.test.ts +++ b/packages/tempo/test/pattern.weekday.test.ts @@ -40,4 +40,38 @@ describe(`${label}`, () => { expect(tempo.fmt.yearMonthDay) .toBe(formatDate(date)); }) + + test(`${label} test pattern {weekday}, >Wed (next Wednesday)`, () => { + const tempo = new Tempo('>Wed'); + const date = new Date(); + const current = date.getDay() || Sun; + const adjust = (current >= Wed) ? 1 : 0; + date.setDate(date.getDate() - current + Wed + (adjust * 7)); + + expect(tempo.parse.result?.[0].match).toBe('weekDay'); + expect(tempo.fmt.yearMonthDay).toBe(formatDate(date)); + }) + + test(`${label} test pattern {weekday}, >=Wed (this or next Wednesday)`, () => { + const tempo = new Tempo('>=Wed'); + const date = new Date(); + const current = date.getDay() || Sun; + const adjust = (current > Wed) ? 1 : 0; + date.setDate(date.getDate() - current + Wed + (adjust * 7)); + + expect(tempo.parse.result?.[0].match).toBe('weekDay'); + expect(tempo.fmt.yearMonthDay).toBe(formatDate(date)); + }) + + test(`${label} test pattern {weekday}, <2Thu (second Thursday before today)`, () => { + const tempo = new Tempo('<2Thu'); + const Thu = 4; + const date = new Date(); + const current = date.getDay() || Sun; + const adjust = (current <= Thu) ? -2 : -1; + date.setDate(date.getDate() - current + Thu + (adjust * 7)); + + expect(tempo.parse.result?.[0].match).toBe('weekDay'); + expect(tempo.fmt.yearMonthDay).toBe(formatDate(date)); + }) }) \ No newline at end of file From 90c9a8e7ba56bccd8177e03032f5148e3bc28d40 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 23 Apr 2026 21:27:13 +1000 Subject: [PATCH 04/13] all tests --- .../library/src/common/primitive.library.ts | 6 +- packages/library/src/common/type.library.ts | 15 +++-- packages/tempo/CHANGELOG.md | 9 +++ .../tempo/plan/alias-precedence-strategy.md | 57 +++++++++++++++++++ packages/tempo/plan/release-process.md | 9 +++ packages/tempo/src/discrete/discrete.parse.ts | 42 +++++++------- packages/tempo/src/plugin/plugin.util.ts | 2 +- packages/tempo/src/tempo.class.ts | 51 ++++++++++++++++- 8 files changed, 151 insertions(+), 40 deletions(-) create mode 100644 packages/tempo/plan/alias-precedence-strategy.md diff --git a/packages/library/src/common/primitive.library.ts b/packages/library/src/common/primitive.library.ts index 402f7115..02de78b6 100644 --- a/packages/library/src/common/primitive.library.ts +++ b/packages/library/src/common/primitive.library.ts @@ -15,12 +15,8 @@ import type { Obj, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; */ export function unwrap(obj: T): T { let curr = (obj as any)?.[sym.$Target] ?? obj; - let depth = 0; + // Hardened against different Symbol instances across module boundaries while (curr && (Object.prototype.hasOwnProperty.call(curr, sym.$Target) || (curr as any).$Target)) { - if (++depth > 10) { - console.error('[Library] unwrap: Infinite recursion detected!'); - break; - } curr = curr[sym.$Target] ?? (curr as any).$Target; } return curr; diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index 8b318534..723f3247 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -21,15 +21,13 @@ export const protoType = (obj?: unknown) => { export const getType = (obj?: any, ...instances: Instance[]): Type => { const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps const type = protoType(raw); + switch (true) { case obj === null: return 'Null'; case obj === undefined: return 'Undefined'; case isClassConstructor(raw): return 'Class'; - case typeof raw === 'function': { - if (type !== 'Function') console.log('[Library] getType weird function:', type, obj?.name); - return type; // catch all functional types (including AsyncFunction) - } + case typeof raw === 'function': return type; // catch all functional types (including AsyncFunction) case type === 'Object': { // check for ArrayLike (e.g. {0:'a', 1:'b', length:2}) if ('length' in raw && Object.keys(raw).every(key => key === 'length' || !isNaN(Number(key)))) return 'ArrayLike'; @@ -72,8 +70,6 @@ const isClassConstructor = (obj: any): boolean => { if (typeof obj !== 'function') return false; const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps - - const result = (() => { // Arrow functions do NOT have a prototype property, whereas traditional functions and classes DO. // This is a high-performance check to immediately classify arrow functions as non-classes. @@ -85,7 +81,9 @@ const isClassConstructor = (obj: any): boolean => { if (raw?.[sym.$Identity] || raw?.prototype?.[sym.$Identity]) { return true; } - if (typeof tag === 'string' && tag.startsWith('Temporal.')) return true; + if (typeof tag === 'string' && tag.startsWith('Temporal.')) { + 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)[sym.$Registry] ?? []; @@ -102,8 +100,9 @@ const isClassConstructor = (obj: any): boolean => { } // ES6 classes have a non-writable prototype descriptor + // Note: Object.freeze() also makes the prototype non-writable, so we exclude frozen objects. const descriptor = Object.getOwnPropertyDescriptor(raw, 'prototype'); - if (descriptor && descriptor.writable === false) { + if (descriptor && descriptor.writable === false && !Object.isFrozen(raw)) { return true; } diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index bf4fa258..6f7404c1 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- **Alias Precedence**: User-defined `event` and `period` aliases now take precedence over built-in aliases when both patterns match. + +### Notes +- **Impact**: Parsing output may change for consumers who previously relied on built-in alias resolution winning in overlap cases. +- **Guidance**: If needed, rename custom aliases to avoid overlap or remove the conflicting custom alias. + ## [2.3.0] - 2026-04-22 ### Added diff --git a/packages/tempo/plan/alias-precedence-strategy.md b/packages/tempo/plan/alias-precedence-strategy.md new file mode 100644 index 00000000..f0d8a7e3 --- /dev/null +++ b/packages/tempo/plan/alias-precedence-strategy.md @@ -0,0 +1,57 @@ +# Alias Precedence Strategy (Custom Over Built-in) + +## Context +A parser conflict was identified where a user-defined alias (`half-hour`) did not override the built-in pattern (`half[ -]?hour`). + +Implemented behavior now gives user-defined `event` and `period` aliases precedence over existing built-ins by placing incoming aliases first in evaluation order. + +## Risks Of Custom-First Precedence +1. Behavioral change risk + - Existing consumers that relied on built-ins winning may observe changed parse results. + +2. Pattern overlap ambiguity + - Regex-like aliases can overlap in non-obvious ways, making the selected winner surprising. + +3. Global side effects + - A single custom alias can change parsing behavior globally after `Tempo.init()` or plugin registration. + +4. Ordering sensitivity + - If precedence is based only on merge order, results can vary depending on discovery/store/options composition. + +## Why Not Use Symbol Keys For Public Aliases +Using `Symbol` for alias keys is not recommended as a public API: +- Alias definitions are string/regex patterns and must be converted into regex groups. +- Discovery and storage payloads are JSON/string keyed; Symbols are not portable in that flow. +- Symbols reduce inspectability and debuggability for users. + +Recommended: keep string keys for matching, optionally use internal metadata (including Symbols if desired) for identity bookkeeping only. + +## Current Mitigation Implemented +1. Custom aliases are evaluated before existing aliases. +2. Collision warnings are emitted when an incoming alias appears to overlap an existing alias pattern. + +## Recommended Follow-Up Improvements +1. Add explicit priority metadata + - Introduce structured alias records with fields like `source`, `priority`, and `insertionIndex`. + - Suggested default ordering: custom > plugin > built-in. + +2. Add strict conflict mode + - Optional config mode that throws on ambiguous overlaps instead of only warning. + +3. Improve collision diagnostics + - Include winning and losing aliases and origin (`builtin/custom/plugin`) in warning messages. + +4. Add deterministic override tests + - Custom exact override over built-in regex. + - Plugin override over built-in. + - Non-overlapping aliases remain stable. + - Strict mode throws on overlap. + +5. Add canonicalization policy (optional) + - Consider normalizing common punctuation/spacing variants for consistency. + - Only apply if it does not break existing regex-driven alias power. + +## Practical Guidance For Future Alias Additions +- Prefer specific patterns over broad regexes. +- Avoid introducing aliases that can match common built-in forms unless override is intentional. +- When overriding a built-in alias, add tests that assert both match winner and output behavior. diff --git a/packages/tempo/plan/release-process.md b/packages/tempo/plan/release-process.md index 0c0597e6..c77b9ee0 100644 --- a/packages/tempo/plan/release-process.md +++ b/packages/tempo/plan/release-process.md @@ -56,5 +56,14 @@ The `npm run release` command triggers the following automated steps: - **Git Operations**: Commits the version bump and changelog update, tags the release (e.g., `v2.1.3`), and pushes to GitHub. - **NPM & GitHub**: Publishes the packages to NPM and creates a formal release on GitHub. +## ๐Ÿ“ Release Note Callouts + +When shipping parser behavior changes, explicitly call them out in `CHANGELOG.md` under `## [Unreleased]` before running `npm run release`. + +Current required callout: +- **Alias precedence change**: user-defined `event`/`period` aliases now take precedence over built-ins when both match. +- **Impact**: parsing output may change for consumers who previously relied on built-in alias resolution winning. +- **Guidance**: if needed, rename custom aliases to avoid overlap or disable the conflicting custom alias. + --- *Maintained by Magma Computing. For internal support, contact the project lead.* diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 0b49ace4..ea623a2d 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -280,7 +280,7 @@ const _ParseEngine = { let zdt = dateTime as any; const anchorTime = zdt.toPlainTime(); - console.log('[Tempo] parseLayout isAnchored:', isAnchored, 'dateTime:', dateTime.toString()); + for (const [symKey, pat] of state.parse.pattern) { const groups = _ParseEngine.parseMatch(state, pat, trim); if (isEmpty(groups)) { @@ -341,7 +341,6 @@ const _ParseEngine = { while ((pending = ownKeys(groups).filter(k => (Match.event.test(k) || Match.period.test(k) || k === 'slk') && !resolved.has(k))).length > 0) { const key = pending[0]; - console.log('[Tempo] parseGroups key:', key, 'value:', groups[key]); if (key === 'slk') { const slk = groups[key]; @@ -362,7 +361,6 @@ const _ParseEngine = { const isGlobal = key.startsWith('g'); const isNamed = key === 'gdt' || key === 'dt' || key === 'gtm' || key === 'tm'; const idx = isNamed ? -1 : +key.substring(4); - console.log(`[Tempo] Processing key: ${key}, idx: ${idx}, isEvent: ${isEvent}, isGlobal: ${isGlobal}, isNamed: ${isNamed}`); if (isNamed) { resolved.add(key); @@ -375,7 +373,6 @@ const _ParseEngine = { ? (isEvent ? globalParse?.event : globalParse?.period) : (isEvent ? state.parse.event : state.parse.period); const entry = ownEntries(src, true)[idx]; - console.log('[Tempo] parseGroups entry:', entry ? entry[0] : 'MISSING', 'definition type:', typeof entry?.[1]); if (!entry) { resolved.add(key); @@ -384,7 +381,6 @@ const _ParseEngine = { } const aliasKey = entry[0] as string; - console.log('[Tempo] parseGroups aliasKey:', aliasKey, 'resolvingKeys:', Array.from(resolvingKeys)); if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) { const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; state.errored = true; @@ -399,19 +395,15 @@ const _ParseEngine = { const definition = entry[1]; const isFn = isFunction(definition); - console.log('[Tempo] parseGroups checking if definition is function:', typeof definition, 'isFunction:', isFn); let res: string = ''; if (isFn) { try { - console.log('[Tempo] parseGroups definition source:', String(definition).substring(0, 50)); // Provide a lightweight host context that mimics a Tempo instance for the handler const host = { add: (val: any) => { - console.log('[Tempo] host.add called with:', JSON.stringify(val)); return dateTime.add(val); }, subtract: (val: any) => { - console.log('[Tempo] host.subtract called with:', JSON.stringify(val)); return dateTime.subtract(val); }, with: (val: any) => dateTime.with(val), @@ -432,19 +424,24 @@ const _ParseEngine = { }; const result = (definition as Function).call(host); - console.log('[Tempo] parseGroups result type:', typeof result, 'isZDT:', isZonedDateTime(result)); - if (isTempo(result)) dateTime = (result as any).toDateTime(); - else if (isZonedDateTime(result)) { - // if we have a full ZonedDateTime result, preserve it + if (isString(result) && /^\d{1,2}:\d{2}$/.test(result)) { + const [hourStr, minuteStr] = result.split(':'); + const hour = Number(hourStr); + const minute = Number(minuteStr); + dateTime = dateTime.with({ hour, minute, second: 0, millisecond: 0 }); + res = ''; + } else if (isTempo(result)) { + dateTime = (result as any).toDateTime(); + } else if (isZonedDateTime(result)) { dateTime = result as Temporal.ZonedDateTime; + } else if (isObject(result) && isFunction((result as any).toDateTime)) { + dateTime = (result as any).toDateTime(); + } else { + res = isString(result) || isNumeric(result) ? String(result) : ''; } - else if (isObject(result) && isFunction((result as any).toDateTime)) dateTime = (result as any).toDateTime(); - - res = isString(result) || isNumeric(result) ? String(result) : ''; - console.log('[Tempo] parseGroups updated dateTime:', dateTime.toString()); state.zdt = dateTime; - } catch (e: any) { - console.log('[Tempo] parseGroups ERROR:', e.message, e.stack); + } + catch (e: any) { throw e; } } else { @@ -453,13 +450,12 @@ const _ParseEngine = { try { const type = isEvent ? 'Event' : 'Period'; - const val = entry![0]; const pat = (isEvent ? 'dt' : 'tm'); const resolveVal = isFunction(definition) ? res : definition; - _ParseEngine.result(state, { type, value: val as any, match: pat, groups: { [key]: resolveVal as string } }); + _ParseEngine.result(state, { type, value: entry[0] as any, match: pat, groups: { [key]: resolveVal as string } }); - // only re-parse if we have a non-empty string resolution - if (!isEmpty(res) && res !== String(val)) { + // Protect against recursive re-evaluation of same alias + if (!isEmpty(res) && res !== String(groups[key])) { const resolving = new Set(resolvingKeys); resolving.add(aliasKey); const resMatch = _ParseEngine.parseLayout(state, res, dateTime, true, resolving); diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 945f3720..81489024 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -100,7 +100,7 @@ export function attachStatics(TempoClass: any, props: Record) { // 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); + // console.error(msg); continue; } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 85aa0906..993066cd 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -262,6 +262,21 @@ export class Tempo { locale // cannot determine locale } + /** detect likely overlap between two alias keys/patterns */ + static #isAliasCollision(a: string, b: string): boolean { + if (a === b) return true; + + try { + if (new RegExp(`^(?:${a})$`, 'i').test(b)) return true; + } catch { } + + try { + if (new RegExp(`^(?:${b})$`, 'i').test(a)) return true; + } catch { } + + return false; + } + /** * conform input of Snippet / Layout / Event / Period options * This is needed because we allow the user to flexibly provide detail as {[key]:val} or {[key]:val}[] or [key,val][] @@ -321,14 +336,44 @@ export class Tempo { : isRegExp(v) ? v.source : v ) } else { + const aliases: [string, any][] = []; asArray(arg.value) .forEach(elm => { if (isObject(elm)) { - Object.assign(rule, elm); + ownEntries(elm as Record, true) + .forEach(([k, v]) => aliases.push([String(k), v])); } else if (isString(elm)) { - rule[elm] = elm; + aliases.push([elm, elm]); } - }) + }); + + if ((optKey === 'event' || optKey === 'period') && aliases.length > 0) { + const existing = ownEntries(rule as Record, true) + .map(([k, v]) => [String(k), v] as [string, any]); + const incomingKeys = new Set(aliases.map(([k]) => k)); + + aliases.forEach(([incomingKey]) => { + const collisions = existing + .map(([k]) => k) + .filter(k => !incomingKeys.has(k) && Tempo.#isAliasCollision(k, incomingKey)); + + if (!isEmpty(collisions)) { + Tempo.#dbg.warn(shape.config, + `Potential ${optKey} alias collision: "${incomingKey}" overlaps with existing alias(es): ${collisions.join(', ')}`); + } + }); + + const next = Object.fromEntries([ + ...aliases, + ...existing.filter(([k]) => !incomingKeys.has(k)) + ]); + + ownKeys(rule as Record, true) + .forEach(key => delete (rule as any)[key]); + Object.assign(rule, next); + } else { + Object.assign(rule, Object.fromEntries(aliases)); + } } break; From aca255c822a822067e19abb032066c3a3235a6f8 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 24 Apr 2026 08:06:11 +1000 Subject: [PATCH 05/13] sandbox 1st draft --- package.json | 2 +- packages/library/package.json | 2 +- packages/tempo/doc/sandbox-factory.md | 69 ++++++++ packages/tempo/package.json | 4 +- packages/tempo/plan/configuration.md | 98 ++++++++++++ packages/tempo/src/discrete/discrete.parse.ts | 11 +- packages/tempo/src/support/tempo.init.ts | 30 ++-- packages/tempo/src/support/tempo.symbol.ts | 6 + packages/tempo/src/tempo.class.ts | 150 +++++++++++------- packages/tempo/src/tempo.type.ts | 1 + packages/tempo/test/sandbox-factory.test.ts | 98 ++++++++++++ 11 files changed, 397 insertions(+), 74 deletions(-) create mode 100644 packages/tempo/doc/sandbox-factory.md create mode 100644 packages/tempo/plan/configuration.md create mode 100644 packages/tempo/test/sandbox-factory.test.ts diff --git a/package.json b/package.json index 785719dc..7a355b28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.4.0", + "version": "2.5.0", "private": true, "description": "Magma Computing Monorepo", "repository": { diff --git a/packages/library/package.json b/packages/library/package.json index 05be87d4..6ea28741 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.4.0", + "version": "2.5.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/tempo/doc/sandbox-factory.md b/packages/tempo/doc/sandbox-factory.md new file mode 100644 index 00000000..d582607d --- /dev/null +++ b/packages/tempo/doc/sandbox-factory.md @@ -0,0 +1,69 @@ +# Sandbox Factory Pattern + +Tempo v2.5.0 introduces the **Sandbox Factory** pattern, allowing for deep isolation of configurations and parsing rules. This is particularly useful in complex applications where different modules may require different date-time aliases or behaviors without polluting the global `Tempo` namespace. + +## The Problem +Historically, `Tempo.init()` modified the global library state. This meant that: +1. Only one set of custom `Event` or `Period` aliases could exist. +2. Large applications or libraries using Tempo internally would step on each other's configurations. +3. Testing multiple configurations required careful cleanup between tests. + +## The Solution +`Tempo.init()` now returns a **derived class** when provided with configuration options. Each derived class maintains its own isolated `State` and `Registry`. + +### Example: Creating a Sandbox +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +// Create a specialized Sandbox for a Financial app +const FinTempo = Tempo.init({ + period: { + 'market-open': '09:30', + 'market-close': '16:00' + } +}); + +// Standard Tempo remains untouched +const t1 = new Tempo('market-open'); // Error: Unknown alias +const t2 = new FinTempo('market-open'); // Success: 09:30 +``` + +## Traceability & Collision Management +When using sandboxes, it's important to know which configuration resolved an input. Tempo now records the **source** of every match in the `parse.result` array. + +### Hierarchy of Resolution +When a conflict occurs (e.g., you redefine "noon"), Tempo uses a **"Last One Wins"** strategy: +1. **Local (Instance)**: Options passed to `new Tempo(val, options)`. +2. **Sandbox (Factory)**: Options passed to `Tempo.init(options)`. +3. **Plugins**: Aliases registered via `Tempo.extend()`. +4. **Global Defaults**: Built-in aliases like "xmas", "midnight", etc. + +### Checking the Trace +You can inspect the `parse.result` to see exactly which layer provided the definition: + +```typescript +const t = new FinTempo('market-open'); +console.log(t.parse.result); +/* +[ + { + type: "Period", + value: "market-open", + source: "global", // Referred to as 'global' relative to the Sandbox + match: "tm", + ... + } +] +*/ +``` + +## Immutability & Security +Sandboxed classes created via `Tempo.init()` are protected by the same `@Immutable` and `@Serializable` decorators as the base class. +- The Sandbox class itself is hardened against static member modification. +- Instances of the Sandbox are frozen upon construction. +- The internal state is stored in a `WeakMap`, inaccessible to external code. + +## Best Practices +1. **Initialize Once**: Create your application-specific Sandbox once and export it as your primary entry point. +2. **Prefer Sandboxes for Custom Aliases**: Avoid modifying the base `Tempo` class if your app is intended to be used as a library. +3. **Use Debug Mode**: When developing new aliases, set `debug: true` to receive console warnings about naming collisions. diff --git a/packages/tempo/package.json b/packages/tempo/package.json index d0fb35c8..e5fc147d 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "2.4.0", + "version": "2.5.0", "description": "The Tempo core library", "author": "Magma Computing Solutions", "license": "MIT", @@ -220,7 +220,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.4.0", + "@magmacomputing/library": "2.5.0", "@rollup/plugin-alias": "^6.0.0", "cross-env": "^7.0.3", "magic-string": "^0.30.21", diff --git a/packages/tempo/plan/configuration.md b/packages/tempo/plan/configuration.md new file mode 100644 index 00000000..4a0b1ef9 --- /dev/null +++ b/packages/tempo/plan/configuration.md @@ -0,0 +1,98 @@ +We ran into a problem with one of our test-cases yesterday. + +The test-case was: 'dynamic period alias with `this` binding (e.g. half-hour)' +Tempo.init({ + period: { + 'half-hour': function (this: Tempo) { + return `${this.hh}:30` + } + } + }) + const t = new Tempo('half-hour') + expect(t.format('{mi}:{ss}')).toBe('30:00') + expect(t.hh).toBe(new Tempo().hh) + +The Error: Actual did not match Expected. + +// +The issue turned out to be a clash in alias naming... +1) There is an Period alias registered already in tempo.default +{'half[ -]?hour': function (this: Tempo) { + return this.add({ minutes: 30 }); + }} + +2) This clashed with the instance-specific definition in the test-case. + +3) The fix was to allow instance-specific definitions to override global definitions. + +// +But this raises a bigger issue: what to do with name-clashes. +This discussion document explores that topic, focussing mainly on Event and Period aliases: +"Should Tempo allow name-clashes?" +"What to do when it detects one?" + +Our choices are "last one wins", or "first one wins", or "error out". + +At first blush, it seems to me that "error out" is the best choice, as it will prevent unexpected behavior. +But the whole design of Tempo is based around "config-merging" and a desire to provide a nice API for +applications upon which to build. + +One option is to have Tempo.init() be a function that returns a new Tempo class, which has the +instance-specific definitions baked in. This would allow for name-clashing at the global level, +but prevent it at the instance level. + +Example: +Tempo = Tempo.init({ Tempo.default }) + +Or, we could design things such that if name-clash is detected, it uses the new definition and issues a warning (as well as recording the #parse.result array to assist debugging). The challenge with this is the load that is introduced to check every Event/Period for a possible clash not only in 'name' but in the 'pattern' as well. What if a user defines a Event/Period, and then later defines another one with the same name, but a different pattern? Would the second one override the first one, or would it be ignored? Would the user be notified of the override? Which would be more likely? + +The risk is that this introduces complexity and non-determinism. +That would make debugging more difficult than it needs to be. + +// AI response + +I've reviewed the issues regarding alias naming clashes. Here are my thoughts on how we should handle this to maintain Tempo's flexibility while ensuring predictability: + +### 1. Conflict Philosophy: "Explicit Shadowing" +Tempo's strength is its "config-merging" capability. We should embrace a **"Last One Wins" (Shadowing)** strategy. +- **Reasoning**: This mirrors how CSS or JS modules work. If a user explicitly provides a configuration in `Tempo.init()`, they are stating their intent to use *that* definition for their application context. +- **Hierarchical Priority**: `Local (Instance) > Factory (init) > Plugin > Global Default`. + +### 2. Conflict Detection & Feedback +While "Last One Wins" should be the resolution, the user should be informed if they are accidentally clobbering a built-in alias. +- **Warning Level**: If an alias key (or pattern) matches an existing one, issue a `console.warn` (when `debug: true`). +- **Heuristic Improvement**: Our current `#isAliasCollision` is a good start. We could improve it by checking for "Pattern Saturation"โ€”if a new regex pattern is a subset of an existing one, or vice versa. + +### 3. The "Sandbox" Factory Pattern +I strongly support the idea of `Tempo.init()` returning a **New Tempo Class**. +- **The Pattern**: + ```typescript + const AppTempo = Tempo.init({ period: { 'half-hour': ... } }); + const t = new AppTempo('half-hour'); // Uses AppTempo's registry + ``` +- **Benefits**: This prevents "Global Pollution". Multiple micro-services or components within the same process could have different Tempo configurations without stepping on each other's toes. +- **Implementation**: We could use a `class factory` that extends the base `Tempo` and overrides the static `#global` state for that specific lineage. + +### 4. Traceability & Debugging +The suggestion to record collisions in a `#parse.result` array is excellent. +- **Discovery Trace**: We should include a `meta` property in the result that indicates which registry entry was used (e.g., `source: "local"`, `source: "plugin:astro"`, `source: "default"`). +- **Collision List**: If a clash occurred but was resolved by shadowing, we should keep a list of the "shadowed" candidates in the instance state to assist developers when they wonder why "noon" isn't behaving as expected. + +### 5. Recommendation +- **Default to Overrides**: Don't throw errors for clashes; it breaks the "nice API" goal. +- **Formalize the Factory**: Move towards the `Tempo.init() -> Class` pattern as the primary way to configure Tempo for an application. +- **Enhance Warnings**: Make the collision warnings more descriptive, showing exactly what was overridden and by whom. + +## 2026-04-24: Sandbox Factory Pattern (Consolidated) + +The Sandbox Factory pattern has been finalized to ensure fully isolated, reproducible Tempo configurations without global state pollution. + +### Key Decisions: +1. **API Naming**: We introduced `Tempo.create(options)` as the primary entry point for creating isolated sandboxes. This separates the concern of "forking the library" from `Tempo.init()`, which remains the tool for managing the global singleton or resetting context. +2. **Inheritance vs. Cloning**: Sandboxes now perform a shallow clone of the parent registries (`event`, `period`, etc.) upon initialization. This prevents the "Registry Leak" issue where adding an alias to a sandbox would inadvertently update the parent. +3. **Local Scope Enforcement**: To simplify alias resolution and ensure traceability, sandboxed class states are tagged with `scope: 'local'`. This causes sandboxed aliases to be recorded with a `source: 'local'` tag in parse results, distinguishing them from the base library's global defaults. +4. **Prototype Chain Resolution**: The `ParseEngine` has been hardened to support recursive `ownEntries` lookups. This allows instances to inherit from their sandbox class, which in turn can inherit (selectively) from the base `Tempo` class, providing a robust polymorphic configuration chain. +5. **Secure Discovery**: Each sandbox is registered in `globalThis` via a unique discovery symbol (or string), allowing the `ParseEngine` to correctly resolve custom periods and events even when called from within complex, nested class contexts. + +### Status: +Fully implemented and verified in `sandbox-factory.test.ts`. 100% pass rate achieved. diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index ea623a2d..cd8b0815 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -360,7 +360,7 @@ const _ParseEngine = { const isEvent = Match.event.test(key); const isGlobal = key.startsWith('g'); const isNamed = key === 'gdt' || key === 'dt' || key === 'gtm' || key === 'tm'; - const idx = isNamed ? -1 : +key.substring(4); + const idx = isNamed ? -1 : +(key.match(/\d+$/)?.[0] ?? -1); if (isNamed) { resolved.add(key); @@ -380,6 +380,12 @@ const _ParseEngine = { continue; } + if (!entry) { + resolved.add(key); + delete groups[key]; + continue; + } + const aliasKey = entry[0] as string; if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) { const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; @@ -452,7 +458,8 @@ const _ParseEngine = { const type = isEvent ? 'Event' : 'Period'; const pat = (isEvent ? 'dt' : 'tm'); const resolveVal = isFunction(definition) ? res : definition; - _ParseEngine.result(state, { type, value: entry[0] as any, match: pat, groups: { [key]: resolveVal as string } }); + const source = isGlobal ? 'global' : 'local'; + _ParseEngine.result(state, { type, value: entry[0] as any, match: pat, source, groups: { [key]: resolveVal as string } }); // Protect against recursive re-evaluation of same alias if (!isEmpty(res) && res !== String(groups[key])) { diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index 143e1de7..a45a4125 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -15,10 +15,10 @@ import enums, { STATE } from './tempo.enum.js'; import * as t from '../tempo.type.js'; import type { Mode } from '../tempo.type.js'; -/** @internal Initialise the global Tempo state */ -export function init(options: t.Options = {}): t.Internal.State { +/** @internal Initialise a Tempo state */ +export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Internal.State): t.Internal.State { const runtime = getRuntime(); - if (runtime.state) return runtime.state; + if (isGlobal && runtime.state && !baseState) return runtime.state; const { timeZone, calendar } = getDateTimeFormat(); @@ -31,21 +31,21 @@ export function init(options: t.Options = {}): t.Internal.State { state.parse = markConfig({ token: Token, result: [], - snippet: Object.assign({}, Snippet), - layout: Object.assign({}, Layout), - event: Object.assign({}, Event), - period: Object.assign({}, Period), - ignore: Object.fromEntries(asArray(Ignore).map(w => [w, w])), - mdyLocales: asArray(Default.mdyLocales as any), - mdyLayouts: asArray(Default.mdyLayouts as any), - pivot: Default.pivot as any, - mode: Default.mode as any, + snippet: Object.assign({}, baseState?.parse.snippet ?? Snippet), + layout: Object.assign({}, baseState?.parse.layout ?? Layout), + event: Object.assign({}, baseState?.parse.event ?? Event), + period: Object.assign({}, baseState?.parse.period ?? Period), + ignore: baseState ? { ...baseState.parse.ignore } : Object.fromEntries(asArray(Ignore).map(w => [w, w])), + mdyLocales: asArray(baseState?.parse.mdyLocales ?? Default.mdyLocales as any), + mdyLayouts: asArray(baseState?.parse.mdyLayouts ?? Default.mdyLayouts as any), + pivot: (baseState?.parse.pivot ?? Default.pivot) as any, + mode: (baseState?.parse.mode ?? Default.mode) as any, lazy: false, - pattern: new Map(), + pattern: new Map(baseState?.parse.pattern), }); // 2. Establish the base configuration options - markConfig(Object.assign(state.config, Default)); + markConfig(Object.assign(state.config, baseState?.config ?? Default)); Object.defineProperties(state.config, { calendar: { value: calendar, enumerable: true, writable: true, configurable: true }, timeZone: { value: timeZone, enumerable: true, writable: true, configurable: true }, @@ -62,7 +62,7 @@ export function init(options: t.Options = {}): t.Internal.State { state.OPTION = new Set(Object.keys(Default)); state.ZONED_DATE_TIME = new Set(['year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond', 'offset', 'timeZone', 'calendar']); - runtime.state = state; + if (isGlobal) runtime.state = state; return state; } diff --git a/packages/tempo/src/support/tempo.symbol.ts b/packages/tempo/src/support/tempo.symbol.ts index 961e2b8a..056655cf 100644 --- a/packages/tempo/src/support/tempo.symbol.ts +++ b/packages/tempo/src/support/tempo.symbol.ts @@ -25,6 +25,12 @@ const local = { /** 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'), + /** internal static config helper */ $setConfig: Symbol.for('magmacomputing/tempo/setConfig'), + /** internal static discovery helper */ $setDiscovery: Symbol.for('magmacomputing/tempo/setDiscovery'), + /** internal static event builder */ $setEvents: Symbol.for('magmacomputing/tempo/setEvents'), + /** internal static period builder */ $setPeriods: Symbol.for('magmacomputing/tempo/setPeriods'), + /** internal static guard builder */ $buildGuard: Symbol.for('magmacomputing/tempo/buildGuard'), + /** internal static base class marker */ $IsBase: Symbol.for('magmacomputing/tempo/isBase'), } as const; /** @internal Unified Symbol Registry (Inherits from #library via Prototype Chain) */ diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 993066cd..242ab5ed 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -57,6 +57,8 @@ namespace Internal { * A powerful wrapper around `Temporal.ZonedDateTime` for flexible parsing and intuitive manipulation of date-time objects. * Bridges the gap between raw string/number inputs and the strict requirements of the ECMAScript Temporal API. */ +const ClassStates = new WeakMap(); + @Serializable @Immutable export class Tempo { @@ -82,7 +84,8 @@ export class Tempo { /** Logify for internal errors and debug logs */ static #dbg = new Logify('Tempo', { debug: Default?.debug ?? false, catch: Default?.catch ?? false }) - /** Tempo state for the global configuration */ static #global = {} as Internal.State + /** Tempo state for the global configuration */ static #global = {} as Internal.State; + static [sym.$IsBase] = true; /** cache for next-available 'usr' Token key */ static #usrCount = 0; /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return getRuntime().pluginsDb.terms } /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); @@ -92,7 +95,7 @@ export class Tempo { /** @internal Static access to global private state. */ static [sym.$Internal]() { - return Tempo.#global; + return ClassStates.get(this) ?? Tempo.#global; } /** @internal brand check to distinguish Tempo objects from other objects */ @@ -100,14 +103,14 @@ export class Tempo { /** @internal handle internal errors using the global config */ static [sym.$logError](...msg: any[]): void { - const config = (isObject(msg[0]) && (msg[0] as any)[sym.$Logify] === true) ? msg.shift() : Tempo.#global.config; + const config = (isObject(msg[0]) && (msg[0] as any)[sym.$Logify] === true) ? msg.shift() : this[sym.$Internal]().config; markConfig(config); // ensure config is marked for Logify Tempo.#dbg.error(config, ...msg); } /** @internal handle internal debug logs */ static [sym.$logDebug](...args: any[]): void { - const config = (isObject(args[0]) && (args[0] as any)[sym.$Logify] === true) ? args.shift() : Tempo.#global.config; + const config = (isObject(args[0]) && (args[0] as any)[sym.$Logify] === true) ? args.shift() : this[sym.$Internal]().config; markConfig(config); Tempo.#dbg.debug(config, ...args); } @@ -118,7 +121,7 @@ export class Tempo { * because it will also include a list of events (e.g. 'new_years' | 'xmas'), we need to rebuild {dt} if the user adds a new event */ // TODO: check all Layouts which reference "{evt}" and update them - static #setEvents(shape: Internal.State) { + static [sym.$setEvents](shape: Internal.State) { const events = ownEntries(shape.parse.event, true); if (isLocal(shape) && !hasOwn(shape.parse, 'event') && !hasOwn(shape.parse, 'isMonthDay')) return; // no local change needed @@ -160,7 +163,7 @@ export class Tempo { * because it will also include a list of periods (e.g. 'midnight' | 'afternoon' ), we need to rebuild {tm} if the user adds a new period */ // TODO: check all Layouts which reference "{per}" and update them - static #setPeriods(shape: Internal.State) { + static [sym.$setPeriods](shape: Internal.State) { const periods = ownEntries(shape.parse.period, true); if (isLocal(shape) && !hasOwn(shape.parse, 'period')) return; // no local change needed @@ -203,7 +206,7 @@ export class Tempo { /** determine if we have a {timeZone} which prefers {mdy} date-order */ static #isMonthDay(shape: Internal.State) { - const monthDay = [...asArray(Tempo.#global.parse.mdyLocales)]; + const monthDay = [...asArray(this[sym.$Internal]().parse.mdyLocales)]; if (isLocal(shape) && hasOwn(shape.parse, 'mdyLocales')) monthDay.push(...shape.parse.mdyLocales); // append local mdyLocales (not overwrite global) @@ -281,7 +284,7 @@ export class Tempo { * conform input of Snippet / Layout / Event / Period options * This is needed because we allow the user to flexibly provide detail as {[key]:val} or {[key]:val}[] or [key,val][] */ - static #setConfig(shape: Internal.State, ...options: t.Options[]) { + static [sym.$setConfig](shape: Internal.State, ...options: t.Options[]) { const providedOptions: t.Options = Object.assign({}, ...options); const storeKey = providedOptions.store; const mergedOptions: t.Options = storeKey @@ -390,7 +393,7 @@ export class Tempo { break; case 'config': - Tempo.#setConfig(shape, arg.value as t.Options); + this[sym.$setConfig](shape, arg.value as t.Options); break; case 'timeZone': { @@ -440,9 +443,9 @@ export class Tempo { shape.config.sphere = Tempo.#setSphere(shape, mergedOptions); - if (isDefined(shape.parse.mdyLayouts)) Tempo.#swapLayout(shape); - if (isDefined(shape.parse.event)) Tempo.#setEvents(shape); - if (isDefined(shape.parse.period)) Tempo.#setPeriods(shape); + if (isDefined(shape.parse.mdyLocales)) Tempo.#swapLayout(shape); + if (isDefined(shape.parse.event)) this[sym.$setEvents](shape); + if (isDefined(shape.parse.period)) this[sym.$setPeriods](shape); setPatterns(shape); // setup Regex DateTime patterns } @@ -458,7 +461,7 @@ export class Tempo { } /** support "Global Discovery" of user-options */ - static #setDiscovery(shape: Internal.State, discovery?: Internal.Discovery) { + static [sym.$setDiscovery](shape: Internal.State, discovery?: Internal.Discovery) { if (!isObject(discovery)) return {} markConfig(discovery); // auto-mark the discovery object @@ -502,15 +505,14 @@ export class Tempo { const res = isFunction(opts) ? opts() : opts; if (shape === Tempo.#global) { - Tempo.#buildGuard(); + this[sym.$buildGuard](); setPatterns(shape); } return res; } - static #buildGuard() { - // Tempo.#dbg.error(Tempo.#global.config, 'Building Guard...'); + static [sym.$buildGuard]() { const wordsList = [ ...Object.keys(enums.NUMBER), ...Object.keys(enums.WEEKDAY), @@ -520,11 +522,11 @@ export class Tempo { ...Object.keys(enums.DURATION), ...Object.keys(enums.DURATIONS), ...Object.keys(enums.TIMEZONE), - ...ownKeys(Tempo.#global.parse.event), - ...ownKeys(Tempo.#global.parse.period), - ...ownKeys(Tempo.#global.parse.ignore), - ...ownKeys(Tempo.#global.parse.snippet), - ...ownKeys(Tempo.#global.parse.layout), + ...ownKeys(this[sym.$Internal]().parse.event), + ...ownKeys(this[sym.$Internal]().parse.period), + ...ownKeys(this[sym.$Internal]().parse.ignore), + ...ownKeys(this[sym.$Internal]().parse.snippet), + ...ownKeys(this[sym.$Internal]().parse.layout), ...[Token.slk], ...Tempo.#terms.map(t => t.key), ...Tempo.#terms.map(t => t.scope), @@ -593,6 +595,10 @@ export class Tempo { return true; } } + + if (this[sym.$Internal]() === Tempo.#global) { + setPatterns(this[sym.$Internal]()); + } } /** @internal resolve a global discovery config object by symbol key */ @@ -649,7 +655,7 @@ export class Tempo { } catch (e: any) { const msg = (e?.message ?? '').toLowerCase(); if (msg.includes('constructor') || msg.includes('class') || (e instanceof TypeError) || isClass(arg)) { - Tempo.#dbg.warn(Tempo.#global.config, `Misidentified class in plugin registration: ${(arg as any).name}`, e.stack ?? e); + Tempo.#dbg.warn(this[sym.$Internal]().config, `Misidentified class in plugin registration: ${(arg as any).name}`, e.stack ?? e); } else { throw e; } @@ -660,7 +666,7 @@ export class Tempo { const name = (item as any).name; const rt = getRuntime(); if (rt.installed.has(name)) { - Tempo.#dbg.debug(Tempo.#global.config, `Plugin already installed by name: ${name}`); + Tempo.#dbg.debug(this[sym.$Internal]().config, `Plugin already installed by name: ${name}`); return; } rt.installed.add(name); @@ -681,7 +687,7 @@ export class Tempo { // 3. sync with parser registries if (config.scope && config.ranges) { - const target = config.scope === 'period' ? Tempo.#global.parse.period : (config.scope === 'event' ? Tempo.#global.parse.event : undefined); + const target = config.scope === 'period' ? this[sym.$Internal]().parse.period : (config.scope === 'event' ? this[sym.$Internal]().parse.event : undefined); if (target) { config.ranges.forEach(r => { if (r.key && !target[r.key]) { @@ -689,8 +695,8 @@ export class Tempo { if (val) target[r.key] = val; } }); - if (config.scope === 'period') Tempo.#setPeriods(Tempo.#global); - if (config.scope === 'event') Tempo.#setEvents(Tempo.#global); + if (config.scope === 'period') this[sym.$setPeriods](this[sym.$Internal]()); + if (config.scope === 'event') this[sym.$setEvents](this[sym.$Internal]()); } } } @@ -699,9 +705,9 @@ export class Tempo { const discovery = item as any if (discovery.term) { discovery.terms = [...asArray(discovery.terms || []), ...asArray(discovery.term)]; - Tempo.#dbg.warn(Tempo.#global.config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); + Tempo.#dbg.warn(this[sym.$Internal]().config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); } - if (discovery.options) Tempo.#setConfig(Tempo.#global, discovery.options) + if (discovery.options) this[sym.$setConfig](this[sym.$Internal](), discovery.options) if (discovery.plugins) this.extend(discovery.plugins, discovery.options) if (discovery.terms) this.extend(discovery.terms) @@ -712,7 +718,7 @@ export class Tempo { registryUpdate('TIMEZONE', tzs) } if (discovery.formats) { - Tempo.#global.config.formats = Tempo.#global.config.formats.extend(discovery.formats) as t.FormatRegistry; + this[sym.$Internal]().config.formats = this[sym.$Internal]().config.formats.extend(discovery.formats) as t.FormatRegistry; registryUpdate('FORMAT', discovery.formats) } @@ -721,7 +727,7 @@ export class Tempo { const discoverySymbol = (isSymbol(options) ? options : (options as any)?.discovery) ?? sym.$Tempo if ((globalThis as Record)[discoverySymbol] !== item) { ; (globalThis as Record)[discoverySymbol] = item - Tempo.#setConfig(Tempo.#global, { discovery: discoverySymbol }) + this[sym.$setConfig](this[sym.$Internal](), { discovery: discoverySymbol }) } } } @@ -732,15 +738,47 @@ export class Tempo { } if (Tempo.#lifecycle.extendDepth === 0) { - Tempo.#buildGuard(); - setPatterns(Tempo.#global); // rebuild the global patterns + this[sym.$buildGuard](); + setPatterns(this[sym.$Internal]()); // rebuild the global patterns } return this; } + /** + * ๐Ÿญ Sandbox Factory Mode + * Create a fresh, isolated Tempo subclass with its own configuration and registries. + * Returns a new class that inherits from the caller, but maintains independent state. + */ + static create(options: t.Options = {}): typeof Tempo { + const SandboxTempo = class extends (this as any) { + static [Symbol.toStringTag] = 'TempoSandbox'; + } + + const discovery = options.discovery ?? Symbol('TempoSandbox'); + (globalThis as any)[discovery] = SandboxTempo; + + const state = init(options, false, this[sym.$Internal]()); + state.config.discovery = discovery; + ClassStates.set(SandboxTempo, state); + + // Apply configuration to the sandbox + SandboxTempo[sym.$setConfig](state, + { + scope: 'local', + discovery, + catch: options.catch ?? false + }, + options + ); + + Object.freeze(SandboxTempo); + return SandboxTempo; + } + /** Reset Tempo to its default, built-in registration state */ static init(options: t.Options = {}): typeof Tempo { + if (Tempo.#lifecycle.initialising) return this; Tempo.#lifecycle.initialising = true; @@ -748,10 +786,14 @@ export class Tempo { const rt = getRuntime(); rt.state = undefined; // force fresh state const state = init(); - Tempo.#global = state; + if (this === Tempo) { + Tempo.#global = state; + } else { + ClassStates.set(this, state); + } // 1. Augment the parsing state (non-destructively) - const parse = Tempo.#global.parse; + const parse = state.parse; parse.pattern ??= new Map(); parse.mdyLocales = Tempo.#mdyLocales(Default.mdyLocales as t.Options['mdyLocales']); parse.mdyLayouts = asArray(Default.mdyLayouts as t.Options['mdyLayouts']) as t.Pair[]; @@ -763,7 +805,7 @@ export class Tempo { const sys = getDateTimeFormat(); const timeZone = options.timeZone ?? sys.timeZone; const calendar = options.calendar ?? sys.calendar; - const config = Tempo.#global.config; + const config = state.config; const discoveryKey = options.discovery ?? Symbol.keyFor(sym.$Tempo) as string; const storeKey = options.store || config.store || Symbol.keyFor(sym.$Tempo) as string; const userDiscovery = (globalThis as any)[isString(discoveryKey) ? Symbol.for(discoveryKey) : discoveryKey] as Internal.Discovery; @@ -788,7 +830,7 @@ export class Tempo { registryReset(); // purge formats and numbers // 3. Apply configuration via unified setters (non-destructive merge) - Tempo.#setConfig(Tempo.#global, + this[sym.$setConfig](state, { calendar, timeZone, @@ -799,19 +841,19 @@ export class Tempo { catch: options.catch ?? config.catch ?? false }, { store: storeKey, discovery: storeKey, scope: 'global' }, - Tempo.readStore(storeKey), // allow for storage-values to overwrite - Tempo.#setDiscovery(Tempo.#global, rt.pluginsDb as any), // persistent library extensions - Tempo.#setDiscovery(Tempo.#global, userDiscovery), // user Discovery (Configuration bootstrapping) + this.readStore(storeKey), // allow for storage-values to overwrite + this[sym.$setDiscovery](state, rt.pluginsDb as any), // persistent library extensions + this[sym.$setDiscovery](state, userDiscovery), // user Discovery (Configuration bootstrapping) options, // explicit options from the call ) if (options.plugins) this.extend(options.plugins); // ensure init-plugins are processed before 'ready' if (Context.type === CONTEXT.Browser || options.debug === true) - Tempo.#dbg.info(Tempo.config, 'Tempo:', Tempo.#global.config); + Tempo.#dbg.info(this.config, 'Tempo:', state.config); Tempo.#lifecycle.ready = true; - setPatterns(Tempo.#global); // rebuild the global patterns (Master Guard etc) + setPatterns(state); // rebuild the global patterns (Master Guard etc) } finally { Tempo.#lifecycle.initialising = false; @@ -822,12 +864,12 @@ export class Tempo { } /** @internal Reads options from persistent storage (e.g., localStorage). */ - static readStore(key = Tempo.#global.config.store) { + static readStore(key = this[sym.$Internal]().config.store) { return getStorage(key, {}); } /** @internal Writes configuration into persistent storage. */ - static writeStore(config?: t.Options, key = Tempo.#global.config.store) { + static writeStore(config?: t.Options, key = this[sym.$Internal]().config.store) { return setStorage(key, config); } @@ -850,7 +892,7 @@ export class Tempo { /** @internal translates {layout} into an anchored, case-insensitive RegExp. */ static regexp(layout: string | RegExp, snippet?: Snippet) { - return compileRegExp(layout, Tempo.#global, snippet as any); + return compileRegExp(layout, this[sym.$Internal](), snippet as any); } /** Compares two `Tempo` instances or date-time values. */ @@ -862,8 +904,9 @@ export class Tempo { /** global Tempo configuration */ static get config() { + const state = this[sym.$Internal](); const out = Object.create(Default); - const descriptors = omit(Object.getOwnPropertyDescriptors(Tempo.#global.config), 'value', 'anchor'); + const descriptors = omit(Object.getOwnPropertyDescriptors(state.config), 'value', 'anchor'); Object.defineProperties(out, descriptors); Object.defineProperty(out, 'toJSON', // bare-bones: only show global overrides @@ -880,7 +923,7 @@ export class Tempo { static get discovery() { const discovery = this.config.discovery; const sym = isString(discovery) ? Symbol.for(discovery) : discovery; - return Tempo.#getConfig(sym); + return Tempo.#getConfig(symKey); } static get options() { @@ -932,7 +975,7 @@ export class Tempo { * configuration governing the static 'rules' used when parsing t.DateTime argument */ static get parse() { - const parse = Tempo.#global.parse; + const parse = this[sym.$Internal]().parse; return secure({ ...omit(parse, 'token'), // spread primitives like {pivot} snippet: { ...parse.snippet }, // spread nested objects @@ -956,7 +999,7 @@ export class Tempo { /** static Tempo.ignores (registry) */ static get ignores(): Secure { - return secure(ownKeys(Tempo.#global.parse.ignore, true)); + return secure(ownKeys(this[sym.$Internal]().parse.ignore, true)); } /** allow instanceof to work across module boundaries via the local brand symbol */ @@ -977,7 +1020,7 @@ export class Tempo { }); onRegistryReset(() => { - Tempo.#buildGuard(); + Tempo[sym.$buildGuard](); }); Tempo.init(); // synchronously initialize the library @@ -1321,7 +1364,7 @@ export class Tempo { get config() { const out = Object.assign({}, Default, - Tempo.#global.config, + this[sym.$Internal]().config, this.#local.config ); @@ -1401,13 +1444,14 @@ export class Tempo { /** setup local 'config' and 'parse' rules (prototype-linked to global) */ #setLocal(options: t.Options = {}) { - this.#local.config = markConfig(Object.create(Tempo.#global.config)); + const classState = (this.constructor as any)[sym.$Internal](); + this.#local.config = markConfig(Object.create(classState.config)); Object.assign(this.#local.config, { scope: 'local' }); - this.#local.parse = markConfig(Object.create(Tempo.#global.parse)); + this.#local.parse = markConfig(Object.create(classState.parse)); setProperty(this.#local.parse, 'result', [...(options.result ?? [])]); - Tempo.#setConfig(this.#local, options); // set #local config + (this.constructor as any)[sym.$setConfig](this.#local, options); // set #local config } /** parse DateTime input */ diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index a1eeb95e..aa0e2607 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -199,6 +199,7 @@ export namespace Internal { /** pattern which matched the input */ match?: string | undefined; /** groups from the pattern match */ groups?: Groups; /** was this a nested/anchored parse? */ isAnchored?: boolean; + /** where this match came from (e.g. 'default', 'local', 'plugin:X') */ source?: string; } & (TypeValue | MatchExtend) /** Debugging results of a parse operation. See `doc/tempo.api.md`. */ diff --git a/packages/tempo/test/sandbox-factory.test.ts b/packages/tempo/test/sandbox-factory.test.ts new file mode 100644 index 00000000..41535efd --- /dev/null +++ b/packages/tempo/test/sandbox-factory.test.ts @@ -0,0 +1,98 @@ +import { Tempo } from '#tempo'; + +describe('Sandbox Factory Pattern', () => { + it('should return a subclass when create is called with options', () => { + const Sandbox = Tempo.create({ locale: 'en-GB' }); + expect(Sandbox).not.toBe(Tempo); + expect(Object.getPrototypeOf(Sandbox)).toBe(Tempo); + expect(Sandbox.name).toBe('SandboxTempo'); + }); + + it('should maintain isolated registries for sandboxes', () => { + const MyTempo = Tempo.create({ + period: { + 'tea-time': '16:00' + } + }); + + // The base Tempo should not have 'tea-time' + expect(() => new Tempo('tea-time')).toThrow(); + + const t = new MyTempo('tea-time'); + expect(t.hh).toBe(16); + expect(t.mi).toBe(0); + }); + + it('should support shadowing global aliases', () => { + // Global 'noon' is 12:00 + const EarlyNoon = Tempo.create({ + period: { + 'noon': '11:00' + } + }); + + // Original remains unaffected (if not manually reset in a way that changes it) + // We expect 12:00 for the base Tempo + const t1 = new Tempo('noon'); + expect(t1.hh).toBe(12); + + const t2 = new EarlyNoon('noon'); + expect(t2.hh).toBe(11); + }); + + it('should record traceability info in parse results', () => { + const MyTempo = Tempo.create({ + period: { + 'half-hour': function (this: Tempo) { return this.add({ minutes: 30 }) } + } + }); + + const t = new MyTempo('half-hour'); + const results = t.parse.result; + + // Find the Period match + const match = results.find(r => r.type === 'Period'); + expect(match).toBeDefined(); + expect(match?.source).toBe('local'); // 'local' relative to the Sandbox + }); + + it('should allow instance-specific overrides to shadow sandbox aliases', () => { + const MyTempo = Tempo.create({ + period: { + 'break': '10:00' + } + }); + + // Sandwich break at 11:00 for this specific instance + const t = new MyTempo('break', { + period: { + 'break': '11:00' + } + }); + + expect(t.hh).toBe(11); + + const match = t.parse.result.find(r => r.type === 'Period' && r.value === 'break'); + expect(match?.source).toBe('local'); + }); + + it('should guarantee immutability of the Sandbox class', () => { + const Sandbox = Tempo.create({ locale: 'fr-FR' }); + + // Attempting to modify static property should fail (if @Immutable is working) + try { + (Sandbox as any).someNewProp = 'test'; + } catch (e) { } + + expect((Sandbox as any).someNewProp).toBeUndefined(); + }); + + it('should share terms across sandboxes by default', () => { + // Terms are currently stored in the singleton Runtime pluginsDb + // This is expected behavior as plugins are "library level" extensions + const Sandbox1 = Tempo.create({}); + const Sandbox2 = Tempo.create({}); + + expect(Sandbox1.terms).toEqual(Sandbox2.terms); + }); +}); From 36b421cba8326218e563d55bc0874f057efedd64 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 24 Apr 2026 09:06:10 +1000 Subject: [PATCH 06/13] sandbox 2nd draft, passing tests --- packages/library/src/common/logify.class.ts | 1 - .../library/src/common/primitive.library.ts | 26 +-- packages/library/src/common/symbol.library.ts | 29 ++-- packages/tempo/src/support/support.index.ts | 1 + packages/tempo/src/support/tempo.init.ts | 33 ++-- packages/tempo/src/support/tempo.symbol.ts | 47 ++--- packages/tempo/src/support/tempo.util.ts | 4 +- packages/tempo/src/tempo.class.ts | 162 +++++++++--------- packages/tempo/test/sandbox-factory.test.ts | 2 + 9 files changed, 160 insertions(+), 145 deletions(-) diff --git a/packages/library/src/common/logify.class.ts b/packages/library/src/common/logify.class.ts index 75d9d10a..f39d1e37 100644 --- a/packages/library/src/common/logify.class.ts +++ b/packages/library/src/common/logify.class.ts @@ -34,7 +34,6 @@ export class Logify { */ #trap(method: Logify.Method, ...msg: any[]) { const config = (isObject(msg[0]) && (msg[0] as any)[sym.$Logify] === true) ? msg.shift() : this.#opts; - const currentLevel = (typeof config.debug === 'number') ? config.debug : (config.debug ? Level[Method.Debug] : Level[Method.Error]); const methodLevel = Level[method] ?? 0; diff --git a/packages/library/src/common/primitive.library.ts b/packages/library/src/common/primitive.library.ts index 02de78b6..27e21fa2 100644 --- a/packages/library/src/common/primitive.library.ts +++ b/packages/library/src/common/primitive.library.ts @@ -38,25 +38,31 @@ export function ownEntries(json: T, all = false): EntryOf[] { return entries as EntryOf[]; } - const entries: [PropertyKey, any][] = []; - const seen = new Set(); + const levels: any[] = []; const limit = 50; let depth = 0; - let proto: any = tgt; + let curr: any = tgt; + + while (curr && curr !== Object.prototype && depth++ < limit) { + levels.push(unwrap(curr)); + curr = Object.getPrototypeOf(curr); + } + + const entries: [PropertyKey, any][] = []; + const seen = new Set(); - while (proto && proto !== Object.prototype && depth++ < limit) { - const current = unwrap(proto); - const keys = Reflect.ownKeys(current); + for (const level of levels.reverse()) { + const keys = Reflect.ownKeys(level); for (const k of keys) { if (seen.has(k)) continue; - const desc = Object.getOwnPropertyDescriptor(current, k); + + const desc = Object.getOwnPropertyDescriptor(level, k); if (!desc || !desc.enumerable) continue; + seen.add(k); - entries.push([k, (current as any)[k]]); + entries.push([k, (tgt as any)[k]]); } - - proto = Object.getPrototypeOf(current); } return entries as EntryOf[]; diff --git a/packages/library/src/common/symbol.library.ts b/packages/library/src/common/symbol.library.ts index a4cf28f6..424eccff 100644 --- a/packages/library/src/common/symbol.library.ts +++ b/packages/library/src/common/symbol.library.ts @@ -3,25 +3,18 @@ * These symbols utilize Symbol.for() to ensure consistency across module boundaries. */ +export const $Target: unique symbol = Symbol.for('$LibraryTarget') as any; +export const $Discover: unique symbol = Symbol.for('$LibraryDiscover') as any; +export const $Extensible: unique symbol = Symbol.for('$LibraryExtensible') as any; +export const $Inspect: unique symbol = Symbol.for('nodejs.util.inspect.custom') as any; +export const $Logify: unique symbol = Symbol.for('$LibraryLogify') as any; +export const $Registry: unique symbol = Symbol.for('$LibraryRegistry') as any; +export const $Register: unique symbol = Symbol.for('$LibraryRegister') as any; +export const $SerializerRegistry: unique symbol = Symbol.for('$LibrarySerializerRegistry') as any; +export const $Identity: unique symbol = Symbol.for('$LibraryIdentity') as any; + export const sym = { - /** key to use for identifying the raw target of a Proxy */ - $Target: Symbol.for('$LibraryTarget'), - /** key to trigger full discovery of all lazy properties */ - $Discover: Symbol.for('$LibraryDiscover'), - /** key to identify objects that should remain extensible */ - $Extensible: Symbol.for('$LibraryExtensible'), - /** NodeJS custom inspection symbol for the Proxy pattern */ - $Inspect: Symbol.for('nodejs.util.inspect.custom'), - /** unique marker to identify a Logify configuration object */ - $Logify: Symbol.for('$LibraryLogify'), - /** key to identify the global type registry */ - $Registry: Symbol.for('$LibraryRegistry'), - /** key to identify the global registration hook */ - $Register: Symbol.for('$LibraryRegister'), - /** key to identify the global serialization registry */ - $SerializerRegistry: Symbol.for('$LibrarySerializerRegistry'), - /** universal key to identify a trusted Class or Instance brand */ - $Identity: Symbol.for('$LibraryIdentity'), + $Target, $Discover, $Extensible, $Inspect, $Logify, $Registry, $Register, $SerializerRegistry, $Identity } as const; /** identify and mark a Logify configuration object */ diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index f7e7a224..83cf6d76 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -27,6 +27,7 @@ export { export { markConfig } from '#library/symbol.library.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './tempo.symbol.js'; +export { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $buildGuard, $IsBase, $Identity, $Logify, $Discover } from './tempo.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; export { getRuntime, TempoRuntime } from './tempo.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './tempo.default.js'; diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index a45a4125..89ed1611 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -4,7 +4,7 @@ import { asArray } from '#library/coercion.library.js'; import { getDateTimeFormat, getHemisphere } from '#library/international.library.js'; import { markConfig } from '#library/symbol.library.js'; import { asType } from '#library/type.library.js'; -import { isString, isObject, isUndefined, isRegExp } from '#library/assertion.library.js'; +import { isString, isObject, isUndefined, isDefined, isRegExp } from '#library/assertion.library.js'; import { ownEntries } from '#library/primitive.library.js'; import { getRuntime } from './tempo.runtime.js'; @@ -45,18 +45,25 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int }); // 2. Establish the base configuration options - markConfig(Object.assign(state.config, baseState?.config ?? Default)); - Object.defineProperties(state.config, { - calendar: { value: calendar, enumerable: true, writable: true, configurable: true }, - timeZone: { value: timeZone, enumerable: true, writable: true, configurable: true }, - locale: { value: (getDateTimeFormat() as any).locale ?? 'en-US', enumerable: true, writable: true, configurable: true }, - discovery: { value: Symbol.keyFor(sym.$Tempo) as string, enumerable: true, writable: true, configurable: true }, - formats: { value: enumify(STATE.FORMAT, false), enumerable: true, writable: true, configurable: true }, - sphere: { value: getHemisphere(timeZone), enumerable: true, writable: true, configurable: true }, - get: { value: function (key: string) { return this[key] }, enumerable: false, writable: true, configurable: true }, - scope: { value: 'global', enumerable: true, writable: true, configurable: true }, - catch: { value: options.catch ?? false, enumerable: true, writable: true, configurable: true } - }); + if (isGlobal) { + markConfig(Object.assign(state.config, Default)); + const { timeZone, calendar } = getDateTimeFormat(); + Object.defineProperties(state.config, { + calendar: { value: calendar, enumerable: true, writable: true, configurable: true }, + timeZone: { value: timeZone, enumerable: true, writable: true, configurable: true }, + locale: { value: (getDateTimeFormat() as any).locale ?? 'en-US', enumerable: true, writable: true, configurable: true }, + discovery: { value: Symbol.keyFor(sym.$Tempo) as string, enumerable: true, writable: true, configurable: true }, + formats: { value: enumify(STATE.FORMAT, false), enumerable: true, writable: true, configurable: true }, + sphere: { value: getHemisphere(timeZone), enumerable: true, writable: true, configurable: true }, + get: { value: function (key: string) { return this[key] }, enumerable: false, writable: true, configurable: true }, + scope: { value: 'global', enumerable: true, writable: true, configurable: true }, + catch: { value: options.catch ?? false, enumerable: true, writable: true, configurable: true } + }); + } else if (baseState) { + state.config = markConfig(Object.create(baseState.config)); + setProperty(state.config, 'scope', 'local'); + if (isDefined(options.catch)) setProperty(state.config, 'catch', options.catch); + } // 3. Initialize registries that need objects state.OPTION = new Set(Object.keys(Default)); diff --git a/packages/tempo/src/support/tempo.symbol.ts b/packages/tempo/src/support/tempo.symbol.ts index 056655cf..f5d49f1e 100644 --- a/packages/tempo/src/support/tempo.symbol.ts +++ b/packages/tempo/src/support/tempo.symbol.ts @@ -1,5 +1,6 @@ import { looseIndex } from '#library/object.library.js'; -import { sym as lib } from '#library/symbol.library.js'; +import { sym as lib, $Target, $Discover, $Extensible, $Inspect, $Logify, $Registry, $Register as $LibRegister, $SerializerRegistry, $Identity } from '#library/symbol.library.js'; +export { $Target, $Discover, $Extensible, $Inspect, $Logify, $Registry, $LibRegister, $SerializerRegistry, $Identity }; /** check valid Tempo instance */ export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym.$Identity]); @@ -11,30 +12,36 @@ export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termError') as any; +/** @internal unique symbols for critical internal accessors */ +/** key for Global Discovery of Tempo configuration */ export const $Tempo: unique symbol = Symbol.for('$Tempo') as any; +/** key for Reactive Plugin Registration */ export const $Register: unique symbol = Symbol.for('magmacomputing/tempo/register') as any; +/** key for Internal Interpreter Service */ export const $Interpreter: unique symbol = Symbol.for('magmacomputing/tempo/interpreter') as any; +/** key for contextual Error Logging */ export const $logError: unique symbol = Symbol.for('magmacomputing/tempo/logError') as any; +/** key for contextual Debug Logging */ export const $logDebug: unique symbol = Symbol.for('magmacomputing/tempo/logDebug') as any; +/** key for contextual Debugger */ export const $dbg: unique symbol = Symbol.for('magmacomputing/tempo/dbg') as any; +/** key for Master Guard */ export const $guard: unique symbol = Symbol.for('magmacomputing/tempo/guard') as any; +/** internal key for signaling pre-errored state */ export const $errored: unique symbol = Symbol.for('magmacomputing/tempo/errored') as any; +/** internal key for accessing private instance state */ export const $Internal: unique symbol = Symbol.for('magmacomputing/tempo/internal') as any; +/** hardened globalThis bridge key for the TempoRuntime */export const $Bridge: unique symbol = Symbol.for('magmacomputing/tempo/runtime') as any; +/** cross-bundle brand check for TempoRuntime */ export const $RuntimeBrand: unique symbol = Symbol.for('magmacomputing/tempo/runtime/brand') as any; +/** branding for explicit PropertyDescriptors */ export const $Descriptor: unique symbol = Symbol.for('magmacomputing/tempo/descriptor') as any; + +/** internal static config helper */ export const $setConfig: unique symbol = Symbol.for('magmacomputing/tempo/setConfig') as any; +/** internal static discovery helper */ export const $setDiscovery: unique symbol = Symbol.for('magmacomputing/tempo/setDiscovery') as any; +/** internal static event builder */ export const $setEvents: unique symbol = Symbol.for('magmacomputing/tempo/setEvents') as any; +/** internal static period builder */ export const $setPeriods: unique symbol = Symbol.for('magmacomputing/tempo/setPeriods') as any; +/** internal static guard builder */ export const $buildGuard: unique symbol = Symbol.for('magmacomputing/tempo/buildGuard') as any; +/** internal static base class marker */ export const $IsBase: unique symbol = Symbol.for('magmacomputing/tempo/isBase') as any; + /** @internal Tempo Symbol Registry (Local Keys) */ const local = { - /** key for Global Discovery of Tempo configuration */ $Tempo: Symbol.for('$Tempo'), - /** key for Reactive Plugin Registration */ $Register: Symbol.for('magmacomputing/tempo/register'), - /** 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 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'), - /** internal static config helper */ $setConfig: Symbol.for('magmacomputing/tempo/setConfig'), - /** internal static discovery helper */ $setDiscovery: Symbol.for('magmacomputing/tempo/setDiscovery'), - /** internal static event builder */ $setEvents: Symbol.for('magmacomputing/tempo/setEvents'), - /** internal static period builder */ $setPeriods: Symbol.for('magmacomputing/tempo/setPeriods'), - /** internal static guard builder */ $buildGuard: Symbol.for('magmacomputing/tempo/buildGuard'), - /** internal static base class marker */ $IsBase: Symbol.for('magmacomputing/tempo/isBase'), + $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, + $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, + $setEvents, $setPeriods, $buildGuard, $IsBase } as const; /** @internal Unified Symbol Registry (Inherits from #library via Prototype Chain) */ -export const sym = Object.assign(Object.create(lib), local) as typeof lib & typeof local; +export const sym = Object.assign(Object.create(lib), local) as Omit & typeof local; /** @internal Local type for brand checking without circular imports */ export type TempoBrand = { diff --git a/packages/tempo/src/support/tempo.util.ts b/packages/tempo/src/support/tempo.util.ts index 9d995369..93305a1c 100644 --- a/packages/tempo/src/support/tempo.util.ts +++ b/packages/tempo/src/support/tempo.util.ts @@ -20,9 +20,9 @@ export const hasOwn = (obj: object, key: PropertyKey) => Object.hasOwn(obj, key) /** @internal create an object based on a prototype */ export const create = (obj: object, name: string): T => { const entry = proto(obj)[name]; - if (!isObject(entry)) { + if (!isObject(entry)) throw new TypeError(`[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${typeof entry}).`); - } + return { ...entry } as T; }; diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 242ab5ed..0b611238 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -20,7 +20,7 @@ import { registerTerm, getTermRange } from './plugin/term.util.js'; import type { TermPlugin, Plugin } from './plugin/plugin.type.js'; import { setProperty, proto, hasOwn, create, compileRegExp, setPatterns } from './support/tempo.util.js'; -import { sym, markConfig, TermError, getRuntime, init, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, type TempoBrand } from '#tempo/support'; +import { sym, markConfig, TermError, getRuntime, init, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, $Internal, $setConfig, $logError, $logDebug, $Identity, $setEvents, $setPeriods, $buildGuard, $IsBase, type TempoBrand, $Tempo, $Register, $Logify, $errored, $dbg, $guard, $Discover, $setDiscovery } from '#tempo/support'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) import { instant } from '#library/temporal.library.js'; @@ -35,6 +35,7 @@ const Context = getContext(); // current execution context /** return whether the shape is 'local' or 'global' */ const isLocal = (shape: { config: { scope: string } }) => shape.config.scope === 'local'; +const ClassStates = new WeakMap(); // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ namespace Internal { export type State = t.Internal.State; @@ -57,8 +58,6 @@ namespace Internal { * A powerful wrapper around `Temporal.ZonedDateTime` for flexible parsing and intuitive manipulation of date-time objects. * Bridges the gap between raw string/number inputs and the strict requirements of the ECMAScript Temporal API. */ -const ClassStates = new WeakMap(); - @Serializable @Immutable export class Tempo { @@ -85,7 +84,6 @@ export class Tempo { /** Logify for internal errors and debug logs */ static #dbg = new Logify('Tempo', { debug: Default?.debug ?? false, catch: Default?.catch ?? false }) /** Tempo state for the global configuration */ static #global = {} as Internal.State; - static [sym.$IsBase] = true; /** cache for next-available 'usr' Token key */ static #usrCount = 0; /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return getRuntime().pluginsDb.terms } /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); @@ -93,25 +91,32 @@ export class Tempo { /** Master Guard predicate (implements RegExp-like interface) */static #guard: { test(str: string): boolean } = { test: () => true }; /** Set of allowed lowercased tokens for the Master Guard */ static #allowedTokens: Set = new Set(); + static [$IsBase] = true; + /** @internal Static access to global private state. */ - static [sym.$Internal]() { + static [$Internal]() { return ClassStates.get(this) ?? Tempo.#global; } /** @internal brand check to distinguish Tempo objects from other objects */ - [sym.$Identity] = true as const; + get [$Identity](): true { return true } /** @internal handle internal errors using the global config */ - static [sym.$logError](...msg: any[]): void { - const config = (isObject(msg[0]) && (msg[0] as any)[sym.$Logify] === true) ? msg.shift() : this[sym.$Internal]().config; + static [$logError](...msg: any[]): void { + const provided = (isObject(msg[0]) && (msg[0] as any)[$Logify] === true) ? msg.shift() : undefined; + const global = (this as any)[$Internal]().config; + const config = markConfig(Object.create(global)); + if (provided) Object.entries(provided).forEach(([k, v]) => setProperty(config, k, v)); markConfig(config); // ensure config is marked for Logify Tempo.#dbg.error(config, ...msg); } /** @internal handle internal debug logs */ - static [sym.$logDebug](...args: any[]): void { - const config = (isObject(args[0]) && (args[0] as any)[sym.$Logify] === true) ? args.shift() : this[sym.$Internal]().config; - markConfig(config); + static [$logDebug](...args: any[]): void { + const provided = (isObject(args[0]) && (args[0] as any)[$Logify] === true) ? args.shift() : undefined; + const global = (this as any)[$Internal]().config; + const config = markConfig(Object.create(global)); + if (provided) Object.entries(provided).forEach(([k, v]) => setProperty(config, k, v)); Tempo.#dbg.debug(config, ...args); } @@ -121,7 +126,7 @@ export class Tempo { * because it will also include a list of events (e.g. 'new_years' | 'xmas'), we need to rebuild {dt} if the user adds a new event */ // TODO: check all Layouts which reference "{evt}" and update them - static [sym.$setEvents](shape: Internal.State) { + static [$setEvents](shape: Internal.State) { const events = ownEntries(shape.parse.event, true); if (isLocal(shape) && !hasOwn(shape.parse, 'event') && !hasOwn(shape.parse, 'isMonthDay')) return; // no local change needed @@ -163,7 +168,7 @@ export class Tempo { * because it will also include a list of periods (e.g. 'midnight' | 'afternoon' ), we need to rebuild {tm} if the user adds a new period */ // TODO: check all Layouts which reference "{per}" and update them - static [sym.$setPeriods](shape: Internal.State) { + static [$setPeriods](shape: Internal.State) { const periods = ownEntries(shape.parse.period, true); if (isLocal(shape) && !hasOwn(shape.parse, 'period')) return; // no local change needed @@ -206,7 +211,7 @@ export class Tempo { /** determine if we have a {timeZone} which prefers {mdy} date-order */ static #isMonthDay(shape: Internal.State) { - const monthDay = [...asArray(this[sym.$Internal]().parse.mdyLocales)]; + const monthDay = [...asArray((this as any)[$Internal]().parse.mdyLocales)]; if (isLocal(shape) && hasOwn(shape.parse, 'mdyLocales')) monthDay.push(...shape.parse.mdyLocales); // append local mdyLocales (not overwrite global) @@ -284,7 +289,7 @@ export class Tempo { * conform input of Snippet / Layout / Event / Period options * This is needed because we allow the user to flexibly provide detail as {[key]:val} or {[key]:val}[] or [key,val][] */ - static [sym.$setConfig](shape: Internal.State, ...options: t.Options[]) { + static [$setConfig](shape: Internal.State, ...options: t.Options[]) { const providedOptions: t.Options = Object.assign({}, ...options); const storeKey = providedOptions.store; const mergedOptions: t.Options = storeKey @@ -393,7 +398,7 @@ export class Tempo { break; case 'config': - this[sym.$setConfig](shape, arg.value as t.Options); + (this as any)[$setConfig](shape, arg.value as t.Options); break; case 'timeZone': { @@ -444,8 +449,8 @@ export class Tempo { shape.config.sphere = Tempo.#setSphere(shape, mergedOptions); if (isDefined(shape.parse.mdyLocales)) Tempo.#swapLayout(shape); - if (isDefined(shape.parse.event)) this[sym.$setEvents](shape); - if (isDefined(shape.parse.period)) this[sym.$setPeriods](shape); + if (isDefined(shape.parse.event)) (this as any)[$setEvents](shape); + if (isDefined(shape.parse.period)) (this as any)[$setPeriods](shape); setPatterns(shape); // setup Regex DateTime patterns } @@ -461,7 +466,7 @@ export class Tempo { } /** support "Global Discovery" of user-options */ - static [sym.$setDiscovery](shape: Internal.State, discovery?: Internal.Discovery) { + static [$setDiscovery](shape: Internal.State, discovery?: Internal.Discovery) { if (!isObject(discovery)) return {} markConfig(discovery); // auto-mark the discovery object @@ -505,14 +510,14 @@ export class Tempo { const res = isFunction(opts) ? opts() : opts; if (shape === Tempo.#global) { - this[sym.$buildGuard](); + (this as any)[$buildGuard](); setPatterns(shape); } return res; } - static [sym.$buildGuard]() { + static [$buildGuard]() { const wordsList = [ ...Object.keys(enums.NUMBER), ...Object.keys(enums.WEEKDAY), @@ -522,11 +527,11 @@ export class Tempo { ...Object.keys(enums.DURATION), ...Object.keys(enums.DURATIONS), ...Object.keys(enums.TIMEZONE), - ...ownKeys(this[sym.$Internal]().parse.event), - ...ownKeys(this[sym.$Internal]().parse.period), - ...ownKeys(this[sym.$Internal]().parse.ignore), - ...ownKeys(this[sym.$Internal]().parse.snippet), - ...ownKeys(this[sym.$Internal]().parse.layout), + ...ownKeys((this as any)[$Internal]().parse.event), + ...ownKeys((this as any)[$Internal]().parse.period), + ...ownKeys((this as any)[$Internal]().parse.ignore), + ...ownKeys((this as any)[$Internal]().parse.snippet), + ...ownKeys((this as any)[$Internal]().parse.layout), ...[Token.slk], ...Tempo.#terms.map(t => t.key), ...Tempo.#terms.map(t => t.scope), @@ -596,8 +601,8 @@ export class Tempo { } } - if (this[sym.$Internal]() === Tempo.#global) { - setPatterns(this[sym.$Internal]()); + if ((this as any)[$Internal]() === Tempo.#global) { + setPatterns((this as any)[$Internal]()); } } @@ -655,7 +660,7 @@ export class Tempo { } catch (e: any) { const msg = (e?.message ?? '').toLowerCase(); if (msg.includes('constructor') || msg.includes('class') || (e instanceof TypeError) || isClass(arg)) { - Tempo.#dbg.warn(this[sym.$Internal]().config, `Misidentified class in plugin registration: ${(arg as any).name}`, e.stack ?? e); + Tempo.#dbg.warn((this as any)[$Internal]().config, `Misidentified class in plugin registration: ${(arg as any).name}`, e.stack ?? e); } else { throw e; } @@ -666,7 +671,7 @@ export class Tempo { const name = (item as any).name; const rt = getRuntime(); if (rt.installed.has(name)) { - Tempo.#dbg.debug(this[sym.$Internal]().config, `Plugin already installed by name: ${name}`); + Tempo.#dbg.debug((this as any)[$Internal]().config, `Plugin already installed by name: ${name}`); return; } rt.installed.add(name); @@ -687,7 +692,7 @@ export class Tempo { // 3. sync with parser registries if (config.scope && config.ranges) { - const target = config.scope === 'period' ? this[sym.$Internal]().parse.period : (config.scope === 'event' ? this[sym.$Internal]().parse.event : undefined); + const target = config.scope === 'period' ? (this as any)[sym.$Internal]().parse.period : (config.scope === 'event' ? (this as any)[sym.$Internal]().parse.event : undefined); if (target) { config.ranges.forEach(r => { if (r.key && !target[r.key]) { @@ -695,8 +700,8 @@ export class Tempo { if (val) target[r.key] = val; } }); - if (config.scope === 'period') this[sym.$setPeriods](this[sym.$Internal]()); - if (config.scope === 'event') this[sym.$setEvents](this[sym.$Internal]()); + if (config.scope === 'period') (this as any)[$setPeriods]((this as any)[sym.$Internal]()); + if (config.scope === 'event') (this as any)[$setEvents]((this as any)[sym.$Internal]()); } } } @@ -705,9 +710,9 @@ export class Tempo { const discovery = item as any if (discovery.term) { discovery.terms = [...asArray(discovery.terms || []), ...asArray(discovery.term)]; - Tempo.#dbg.warn(this[sym.$Internal]().config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); + Tempo.#dbg.warn((this as any)[$Internal]().config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); } - if (discovery.options) this[sym.$setConfig](this[sym.$Internal](), discovery.options) + if (discovery.options) (this as any)[$setConfig]((this as any)[$Internal](), discovery.options) if (discovery.plugins) this.extend(discovery.plugins, discovery.options) if (discovery.terms) this.extend(discovery.terms) @@ -718,7 +723,7 @@ export class Tempo { registryUpdate('TIMEZONE', tzs) } if (discovery.formats) { - this[sym.$Internal]().config.formats = this[sym.$Internal]().config.formats.extend(discovery.formats) as t.FormatRegistry; + (this as any)[$Internal]().config.formats = (this as any)[$Internal]().config.formats.extend(discovery.formats) as t.FormatRegistry; registryUpdate('FORMAT', discovery.formats) } @@ -726,8 +731,8 @@ export class Tempo { if (ownKeys(item).some(key => DISCOVERY.has(key as any))) { const discoverySymbol = (isSymbol(options) ? options : (options as any)?.discovery) ?? sym.$Tempo if ((globalThis as Record)[discoverySymbol] !== item) { - ; (globalThis as Record)[discoverySymbol] = item - this[sym.$setConfig](this[sym.$Internal](), { discovery: discoverySymbol }) + (globalThis as Record)[discoverySymbol] = item; + (this as any)[$setConfig]((this as any)[$Internal](), { discovery: discoverySymbol }) } } } @@ -738,8 +743,8 @@ export class Tempo { } if (Tempo.#lifecycle.extendDepth === 0) { - this[sym.$buildGuard](); - setPatterns(this[sym.$Internal]()); // rebuild the global patterns + (this as any)[$buildGuard](); + setPatterns((this as any)[$Internal]()); // rebuild the global patterns } return this; @@ -758,12 +763,12 @@ export class Tempo { const discovery = options.discovery ?? Symbol('TempoSandbox'); (globalThis as any)[discovery] = SandboxTempo; - const state = init(options, false, this[sym.$Internal]()); + const state = init(options, false, (this as any)[$Internal]()); state.config.discovery = discovery; - ClassStates.set(SandboxTempo, state); + ClassStates.set(SandboxTempo as any, state); // Apply configuration to the sandbox - SandboxTempo[sym.$setConfig](state, + (SandboxTempo as any)[$setConfig](state, { scope: 'local', discovery, @@ -773,7 +778,7 @@ export class Tempo { ); Object.freeze(SandboxTempo); - return SandboxTempo; + return SandboxTempo as unknown as typeof Tempo; } /** Reset Tempo to its default, built-in registration state */ @@ -786,7 +791,7 @@ export class Tempo { const rt = getRuntime(); rt.state = undefined; // force fresh state const state = init(); - if (this === Tempo) { + if ((this as any)[sym.$IsBase]) { Tempo.#global = state; } else { ClassStates.set(this, state); @@ -806,8 +811,8 @@ export class Tempo { const timeZone = options.timeZone ?? sys.timeZone; const calendar = options.calendar ?? sys.calendar; const config = state.config; - const discoveryKey = options.discovery ?? Symbol.keyFor(sym.$Tempo) as string; - const storeKey = options.store || config.store || Symbol.keyFor(sym.$Tempo) as string; + const discoveryKey = options.discovery ?? Symbol.keyFor($Tempo) as string; + const storeKey = options.store || config.store || Symbol.keyFor($Tempo) as string; const userDiscovery = (globalThis as any)[isString(discoveryKey) ? Symbol.for(discoveryKey) : discoveryKey] as Internal.Discovery; // Resolve locale if missing or invalid @@ -830,7 +835,7 @@ export class Tempo { registryReset(); // purge formats and numbers // 3. Apply configuration via unified setters (non-destructive merge) - this[sym.$setConfig](state, + (this as any)[$setConfig](state, { calendar, timeZone, @@ -842,8 +847,8 @@ export class Tempo { }, { store: storeKey, discovery: storeKey, scope: 'global' }, this.readStore(storeKey), // allow for storage-values to overwrite - this[sym.$setDiscovery](state, rt.pluginsDb as any), // persistent library extensions - this[sym.$setDiscovery](state, userDiscovery), // user Discovery (Configuration bootstrapping) + (this as any)[$setDiscovery](state, rt.pluginsDb as any), // persistent library extensions + (this as any)[$setDiscovery](state, userDiscovery), // user Discovery (Configuration bootstrapping) options, // explicit options from the call ) @@ -864,12 +869,12 @@ export class Tempo { } /** @internal Reads options from persistent storage (e.g., localStorage). */ - static readStore(key = this[sym.$Internal]().config.store) { + static readStore(key = (this as any)[$Internal]().config.store) { return getStorage(key, {}); } /** @internal Writes configuration into persistent storage. */ - static writeStore(config?: t.Options, key = this[sym.$Internal]().config.store) { + static writeStore(config?: t.Options, key = (this as any)[$Internal]().config.store) { return setStorage(key, config); } @@ -892,7 +897,7 @@ export class Tempo { /** @internal translates {layout} into an anchored, case-insensitive RegExp. */ static regexp(layout: string | RegExp, snippet?: Snippet) { - return compileRegExp(layout, this[sym.$Internal](), snippet as any); + return compileRegExp(layout, (this as any)[$Internal](), snippet as any); } /** Compares two `Tempo` instances or date-time values. */ @@ -904,7 +909,7 @@ export class Tempo { /** global Tempo configuration */ static get config() { - const state = this[sym.$Internal](); + const state = (this as any)[$Internal](); const out = Object.create(Default); const descriptors = omit(Object.getOwnPropertyDescriptors(state.config), 'value', 'anchor'); @@ -923,11 +928,11 @@ export class Tempo { static get discovery() { const discovery = this.config.discovery; const sym = isString(discovery) ? Symbol.for(discovery) : discovery; - return Tempo.#getConfig(symKey); + return Tempo.#getConfig(sym as symbol); } static get options() { - const keyFor = this.config.store ?? Symbol.keyFor(sym.$Tempo) as string; + const keyFor = this.config.store ?? Symbol.keyFor($Tempo) as string; const storage = proxify(Object.assign({ key: keyFor, scope: 'storage' }, omit(Tempo.readStore(keyFor), 'value'))); return Object.assign({}, this.default, storage, this.discovery, this.config); } @@ -975,7 +980,7 @@ export class Tempo { * configuration governing the static 'rules' used when parsing t.DateTime argument */ static get parse() { - const parse = this[sym.$Internal]().parse; + const parse = (this as any)[$Internal]().parse; return secure({ ...omit(parse, 'token'), // spread primitives like {pivot} snippet: { ...parse.snippet }, // spread nested objects @@ -999,28 +1004,28 @@ export class Tempo { /** static Tempo.ignores (registry) */ static get ignores(): Secure { - return secure(ownKeys(this[sym.$Internal]().parse.ignore, true)); + return secure(ownKeys((this as any)[$Internal]().parse.ignore, true) as string[]); } /** allow instanceof to work across module boundaries via the local brand symbol */ - static [sym.$Identity] = true; + static [$Identity] = true; static [Symbol.hasInstance](instance: any) { - return !!(instance?.[sym.$Identity]) + return !!(instance?.[$Identity]) } /** check if a supplied variable is a valid Tempo instance */ static isTempo(instance?: any): instance is Tempo { - return !!(instance?.[sym.$Identity]) + return !!(instance?.[$Identity]) } static { // Static initialization block to sequence the bootstrap phase // Define the reactive register hook - getRuntime().setHook(sym.$Register, (plugin: Plugin | Plugin[]) => { + getRuntime().setHook($Register, (plugin: Plugin | Plugin[]) => { if (!Tempo.isExtending) Tempo.extend(plugin) }); onRegistryReset(() => { - Tempo[sym.$buildGuard](); + (Tempo as any)[$buildGuard](); }); Tempo.init(); // synchronously initialize the library @@ -1038,12 +1043,12 @@ export class Tempo { /** current parsing depth to manage state isolation */ #parseDepth = 0; /** current mutation depth to manage infinite recursion */#mutateDepth = 0; /** instance values to complement static values */ #local = { - /** instance configuration */ config: { [sym.$Logify]: true } as unknown as Internal.Config, + /** instance configuration */ config: { [$Logify]: true } as unknown as Internal.Config, /** 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; + static [$errored] = $errored; /** @internal */ static [TermError](config: Internal.Config, term: string): void { const hint = Tempo.#terms.length === 0 ? ". (No term plugins are registeredโ€”did you forget to call Tempo.extend(TermsModule)?)" : ""; @@ -1052,14 +1057,14 @@ export class Tempo { if (config.catch !== true) throw new Error(msg); } - /** @internal */ static get [sym.$dbg](): Logify { return Tempo.#dbg } - /** @internal */ static get [sym.$guard]() { return Tempo.#guard } + /** @internal */ static get [$dbg](): Logify { return Tempo.#dbg } + /** @internal */ static get [$guard]() { return Tempo.#guard } /** * @internal Internal access to instance private state. * This surface is not part of the public contract and is subject to change. */ - [sym.$Internal]() { + [$Internal]() { const self: Tempo = unwrap(this); return { get zdt() { return self.#zdt }, @@ -1102,7 +1107,6 @@ export class Tempo { return 'Tempo'; // hard-coded to avoid minification mangling } - get [sym.$Identity](): true { return true } /** * Instantiates a new `Tempo` object with configuration only. @@ -1143,13 +1147,13 @@ export class Tempo { this.#anchor = this.#options.anchor; // ๐Ÿงฌ Unified State Hand-off (from clone / mutate) - const handoff = (this.#options as any)[sym.$Internal]; + const handoff = (this.#options as any)[$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]) { + } else if ((this.#options as any)[$errored]) { this.#errored = true; } @@ -1245,7 +1249,7 @@ export class Tempo { #setDelegator(host: 'term' | 'fmt') { const target = Object.create(null); const proxy = delegate(target, (key) => { - if (key === sym.$Discover) return this.#discover(host, target); + if (key === $Discover) return this.#discover(host, target); if (!isString(key)) return; // discovery phase @@ -1362,13 +1366,9 @@ export class Tempo { /** current Tempo configuration */ get config() { - const out = Object.assign({}, - Default, - this[sym.$Internal]().config, - this.#local.config - ); - - markConfig(out); + const global = (this as any)[$Internal]().config; + const out = markConfig(Object.create(global)); + Object.entries(this.#local.config).forEach(([k, v]) => setProperty(out, k, v)); if (!Object.hasOwn(out, 'mode')) setProperty(out, 'mode', this.#local.parse.mode); if (!Object.hasOwn(out, 'lazy')) setProperty(out, 'lazy', this.#local.parse.lazy); @@ -1444,14 +1444,14 @@ export class Tempo { /** setup local 'config' and 'parse' rules (prototype-linked to global) */ #setLocal(options: t.Options = {}) { - const classState = (this.constructor as any)[sym.$Internal](); + const classState = (this.constructor as any)[$Internal](); this.#local.config = markConfig(Object.create(classState.config)); Object.assign(this.#local.config, { scope: 'local' }); this.#local.parse = markConfig(Object.create(classState.parse)); setProperty(this.#local.parse, 'result', [...(options.result ?? [])]); - (this.constructor as any)[sym.$setConfig](this.#local, options); // set #local config + (this.constructor as any)[$setConfig](this.#local, options); // set #local config } /** parse DateTime input */ diff --git a/packages/tempo/test/sandbox-factory.test.ts b/packages/tempo/test/sandbox-factory.test.ts index 41535efd..341e875d 100644 --- a/packages/tempo/test/sandbox-factory.test.ts +++ b/packages/tempo/test/sandbox-factory.test.ts @@ -9,6 +9,7 @@ describe('Sandbox Factory Pattern', () => { }); it('should maintain isolated registries for sandboxes', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const MyTempo = Tempo.create({ period: { 'tea-time': '16:00' @@ -17,6 +18,7 @@ describe('Sandbox Factory Pattern', () => { // The base Tempo should not have 'tea-time' expect(() => new Tempo('tea-time')).toThrow(); + spy.mockRestore(); const t = new MyTempo('tea-time'); expect(t.hh).toBe(16); From 4418ee3b70c6a5bd42274a32123313588141647a Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 24 Apr 2026 16:06:12 +1000 Subject: [PATCH 07/13] sandbox 3rd draft, passing tests --- packages/tempo/bin/tsconfig.json | 17 +- packages/tempo/demo/3-modular-granular.html | 2 +- packages/tempo/doc/Tempo.md | 2 +- packages/tempo/doc/installation.md | 35 +++- packages/tempo/doc/migration-guide.md | 35 +++- packages/tempo/doc/releases/v2.x.md | 2 +- packages/tempo/doc/tempo.cookbook.md | 24 +++ packages/tempo/doc/tempo.modularity.md | 9 + packages/tempo/importmap.json | 12 +- packages/tempo/index.md | 170 +++++++++++++++--- packages/tempo/package.json | 50 +++--- packages/tempo/plan/.WISHLIST.md | 163 +++++++++++++++-- packages/tempo/plan/astrological_seasons.md | 52 ------ packages/tempo/plan/configuration.md | 92 ++++++++++ packages/tempo/public/esm_core.index.html | 2 +- packages/tempo/src/discrete/discrete.parse.ts | 6 +- .../engine.composer.ts} | 0 .../engine.lexer.ts} | 2 +- .../module.term.ts => engine/engine.term.ts} | 10 +- .../{plugin => }/module/module.duration.ts | 8 +- .../src/{plugin => }/module/module.mutate.ts | 10 +- packages/tempo/src/support/tempo.default.ts | 31 ++-- packages/tempo/src/support/tempo.symbol.ts | 4 + packages/tempo/src/tempo.index.ts | 3 +- packages/tempo/src/tsconfig.json | 7 +- packages/tempo/test/compact.time.test.ts | 55 ++++++ .../tempo/test/instance.since.rtf.test.ts | 9 + packages/tempo/test/layout.order.test.ts | 113 ++++++++++++ packages/tempo/test/tsconfig.json | 14 +- packages/tempo/vitest.config.ts | 22 +-- 30 files changed, 776 insertions(+), 185 deletions(-) delete mode 100644 packages/tempo/plan/astrological_seasons.md rename packages/tempo/src/{plugin/module/module.composer.ts => engine/engine.composer.ts} (100%) rename packages/tempo/src/{plugin/module/module.lexer.ts => engine/engine.lexer.ts} (99%) rename packages/tempo/src/{plugin/module/module.term.ts => engine/engine.term.ts} (98%) rename packages/tempo/src/{plugin => }/module/module.duration.ts (96%) rename packages/tempo/src/{plugin => }/module/module.mutate.ts (96%) create mode 100644 packages/tempo/test/compact.time.test.ts create mode 100644 packages/tempo/test/layout.order.test.ts diff --git a/packages/tempo/bin/tsconfig.json b/packages/tempo/bin/tsconfig.json index 44cc5e82..dbb8aba2 100644 --- a/packages/tempo/bin/tsconfig.json +++ b/packages/tempo/bin/tsconfig.json @@ -42,19 +42,22 @@ "../src/plugin/term/term.*.ts" ], "#tempo/duration": [ - "../src/plugin/module/module.duration.ts" + "../src/module/module.duration.ts" ], "#tempo/plugin/plugin.*.js": [ "../src/plugin/plugin.*.ts" ], - "#tempo/plugin/extend.*.js": [ - "../src/plugin/extend/extend.*.ts" + "#tempo/engine/*.js": [ + "../src/engine/*.ts" ], - "#tempo/plugin/module.*.js": [ - "../src/plugin/module/module.*.ts" + "#tempo/module/*.js": [ + "../src/module/*.ts" ], - "#tempo/plugin/term.*.js": [ - "../src/plugin/term/term.*.ts" + "#tempo/plugin/extend/*.js": [ + "../src/plugin/extend/*.ts" + ], + "#tempo/plugin/term/*.js": [ + "../src/plugin/term/*.ts" ], "#tempo/*.js": [ "../src/*.ts" diff --git a/packages/tempo/demo/3-modular-granular.html b/packages/tempo/demo/3-modular-granular.html index 514cb821..0e824ec6 100644 --- a/packages/tempo/demo/3-modular-granular.html +++ b/packages/tempo/demo/3-modular-granular.html @@ -24,7 +24,7 @@

โณ Demo 3: Modular (Granular)

{ "imports": { "@magmacomputing/tempo/core": "../dist/core.index.js", - "@magmacomputing/tempo/format": "../dist/plugin/module/module.format.js" + "@magmacomputing/tempo/format": "../dist/module/module.format.js" } } diff --git a/packages/tempo/doc/Tempo.md b/packages/tempo/doc/Tempo.md index 12cc2cd6..137470fb 100644 --- a/packages/tempo/doc/Tempo.md +++ b/packages/tempo/doc/Tempo.md @@ -31,7 +31,7 @@ Tempo is an ESM-first library. You can use it in the browser without a build ste "imports": { "jsbi": "https://cdn.jsdelivr.net/npm/jsbi@4.3.0/dist/jsbi.mjs", "@magmacomputing/tempo/core": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@2/dist/core.index.js", - "@magmacomputing/tempo/mutate": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@2/dist/plugin/module/module.mutate.js", + "@magmacomputing/tempo/mutate": "https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@2/dist/module/module.mutate.js", "@magmacomputing/library": "https://cdn.jsdelivr.net/npm/@magmacomputing/library@2/dist/common.index.js", "@js-temporal/polyfill": "https://cdn.jsdelivr.net/npm/@js-temporal/polyfill@0.5/dist/index.esm.js" } diff --git a/packages/tempo/doc/installation.md b/packages/tempo/doc/installation.md index 318d1031..e251743c 100644 --- a/packages/tempo/doc/installation.md +++ b/packages/tempo/doc/installation.md @@ -1,6 +1,24 @@ # Installation Guide -Tempo is designed to be environment-agnostic. Whether you are building a server-side application, a modern browser project with ESM, or a performance-critical "Lite" bundle, Tempo provides a specific path for you. +`Tempo` is designed to be environment-agnostic. Whether you are building a server-side application, a modern browser project with ESM, or a performance-critical "Lite" bundle, `Tempo` provides a specific path for you. + +## Temporal Polyfill Note + +`Tempo` expects the host environment to provide `Temporal`, either through native runtime support or a user-supplied polyfill. + +`Temporal` is now at Stage 4 and is expected to land broadly in runtimes soon. To avoid needlessly inflating package size with a dependency that will increasingly become unnecessary, `Tempo` does not bundle a `Temporal` polyfill by default. + +As of May 2026, Chrome 144 has shipped `Temporal`, while Node.js still does not provide built-in `Temporal` globally. Please verify support in your actual target runtime(s) and add a polyfill only when needed. + +You can check at runtime with a simple guard: + +```js +if (typeof globalThis.Temporal === 'undefined') { + // Load your Temporal polyfill for this environment +} +``` + +Note: The examples below include a polyfill for demonstration purposes only, so the snippets work consistently across environments. --- @@ -21,6 +39,21 @@ import { Tempo } from '@magmacomputing/tempo'; const t = new Tempo('next Friday'); ``` +### Node.js quick-start (if Temporal is not available) + +The polyfill import shown here is conditional guidance, not required for all environments. + +```bash +npm install @js-temporal/polyfill +``` + +```javascript +import '@js-temporal/polyfill'; +import { Tempo } from '@magmacomputing/tempo'; + +const t = new Tempo('next Friday'); +``` + --- ## ๐Ÿฆ• Deno diff --git a/packages/tempo/doc/migration-guide.md b/packages/tempo/doc/migration-guide.md index ef210261..70245a0e 100644 --- a/packages/tempo/doc/migration-guide.md +++ b/packages/tempo/doc/migration-guide.md @@ -45,7 +45,7 @@ t.add({ '#quarter': 2 }); ## ๐Ÿš€ Tempo v2.4.0: Standalone Utilities & Path Deprecation -Tempo v2.4.0 introduces a new `discrete/` folder for standalone utilities (`parse` and `format`). +Tempo v2.4.0 introduces standalone utility entry points for `parse` and `format`. ### ๐Ÿ› ๏ธ Standalone Imports You can now import lightweight, tree-shakable versions of our parsing and formatting engines without the `Tempo` class: @@ -54,18 +54,35 @@ import { parse } from '@magmacomputing/tempo/parse'; import { format } from '@magmacomputing/tempo/format'; ``` -### โš ๏ธ Removed Paths -We have reorganized the internal file structure to optimize for standalone usage. The following internal paths have been **removed** from the public export map in v2.4.0: - -* โŒ `@magmacomputing/tempo/module/parse` -* โŒ `@magmacomputing/tempo/module/format` +### โš ๏ธ Reorganized Paths +We have reorganized the package structure to support these standalone entry points more cleanly. **Action Required**: -1. **Do not use `dist/` paths** in your imports. These are unstable and may change. -2. **Use package subpath maps**: Update your imports to use the official entry points: +1. **Use package subpath maps**: Update your imports to use the official entry points: + * โŒ `@magmacomputing/tempo/module/parse` + * โŒ `@magmacomputing/tempo/module/format` * โœ… `@magmacomputing/tempo/parse` * โœ… `@magmacomputing/tempo/format` -3. **Check your Import Maps**: If you use browser-side import maps, ensure they point to the new package-mapped locations rather than internal `plugin/module/` paths. +2. **Check your Import Maps**: If you use browser-side import maps, ensure they point to the new package-mapped locations rather than the package's internal paths. A maintained `importmap.json` is included in the project root as the sanctioned reference. + + Example: + ```json + { + "imports": { + "@magmacomputing/tempo/parse": "./dist/discrete/discrete.parse.js", + "@magmacomputing/tempo/format": "./dist/discrete/discrete.format.js" + } + } + ``` + +## ๐Ÿ” Migrating from version 2.4 + +As Tempo grows, it has become much more efficient for our developers to logically re-group certain modules. + +**Action Required**: +1. Review your browser `importmap` entries. +2. Replace any older internal paths with the current package subpath entries (for example, `@magmacomputing/tempo/duration`, `@magmacomputing/tempo/mutate`, `@magmacomputing/tempo/parse`, and `@magmacomputing/tempo/format`). +3. Do not pin imports in your code directly to internal folder layouts in `dist/`, since those paths may change as modules are reorganized. Instead rely wholly on your import maps. ## ๐Ÿงช Testing and Stability v2.x has been hardened with a 100% pass rate on our regression suite. If you were relying on undocumented "quirks" or bugs in v1.x parsing, you may find that v2.x is more strict and deterministic. diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index 566f6150..e0929389 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -111,7 +111,7 @@ Tempo v2.1.2 is a major milestone, delivering a more reactive architecture and rock-solid stability. ### ๐Ÿ—๏ธ Modular Architecture -Tempo is now split into `core` and optional plugin/modules, allowing you to include only what you need. This reduces bundle size for users who only need the basic engine. +Tempo is now split into `core` and optional modules, allowing you to include only what you need. This reduces bundle size for users who only need the basic engine. ### ๐Ÿ“ Improved Logging Internal logging now uses context-aware Symbols. This decouples the logging logic from the core engine, allowing for cleaner diagnostics without performance overhead. diff --git a/packages/tempo/doc/tempo.cookbook.md b/packages/tempo/doc/tempo.cookbook.md index e5e50258..7ea8ed40 100644 --- a/packages/tempo/doc/tempo.cookbook.md +++ b/packages/tempo/doc/tempo.cookbook.md @@ -53,6 +53,30 @@ const uk = new Tempo('04012026', { timeZone: 'Europe/London' }); console.log(uk.format('{mon} {dd}')); // "January 04" ``` +For digits-only input, Tempo checks the most likely compact interpretation first: + +- A `6`-digit string is checked as compact time first (`hhmiss`). If it is not a valid time, Tempo then tries compact date layouts like `ddmmyy` or `mmddyy`. +- An `8`-digit string is checked as a compact date, with month-day-year vs day-month-year decided by `timeZone`. + +```typescript +const time = new Tempo('093015', { timeZone: 'UTC' }); +console.log(time.format('{hh}:{mi}:{ss}')); // "09:30:15" + +const shortDate = new Tempo('310559', { timeZone: 'Europe/London' }); +console.log(shortDate.format('{yyyy}-{mm}-{dd}')); // "1959-05-31" + +const usDate = new Tempo('04012026', { timeZone: 'America/New_York' }); +console.log(usDate.format('{yyyy}-{mm}-{dd}')); // "2026-04-01" + +const ukDate = new Tempo('04012026', { timeZone: 'Europe/London' }); +console.log(ukDate.format('{yyyy}-{mm}-{dd}')); // "2026-01-04" +``` + +To avoid ambiguity, prefer separators whenever you control the input format: + +- Use `09:30:15` instead of `093015`. +- Use `2026-04-01`, `04/01/2026`, or `01/04/2026` instead of `04012026`. + ### Handling Relative Strings Tempo natively understands human-readable offsets. ```typescript diff --git a/packages/tempo/doc/tempo.modularity.md b/packages/tempo/doc/tempo.modularity.md index c0821dc5..0b76d01c 100644 --- a/packages/tempo/doc/tempo.modularity.md +++ b/packages/tempo/doc/tempo.modularity.md @@ -2,6 +2,15 @@ Tempo is designed as a modular library, allowing you to include only the features you need. This reduces the core bundle size and prevents your application from being polluted with unused functionality. +## Module Activation Quick Guide + +If you are using module entry points, use this rule of thumb: + +1. `import '@magmacomputing/tempo/'` (side-effect import): auto-registers that module. You usually do **not** need `Tempo.extend(...)` for the same module. +2. `import { SomeModule } from '@magmacomputing/tempo/'` (named import): requires explicit activation via `Tempo.extend(SomeModule)`. +3. `Tempo.init()` is primarily for baseline configuration and discovery refresh. You generally call it at startup for config, or again only if you loaded side-effect modules later at runtime. +4. Import order is usually only relevant when modules are loaded dynamically/lazily. For deterministic activation in those cases, prefer explicit `Tempo.extend(...)` immediately after import. + ## Core vs. Full * **@magmacomputing/tempo/core**: The bare-bones Tempo engine. Includes parsing (standard ISO string or a native `Temporal` object), basic getters, and internal state management. diff --git a/packages/tempo/importmap.json b/packages/tempo/importmap.json index 6e494193..7444e9eb 100644 --- a/packages/tempo/importmap.json +++ b/packages/tempo/importmap.json @@ -4,14 +4,14 @@ "@js-temporal/polyfill": "https://cdn.jsdelivr.net/npm/@js-temporal/polyfill@0.5/dist/index.esm.js", "@magmacomputing/tempo": "./dist/tempo.index.js", "@magmacomputing/tempo/core": "./dist/core.index.js", - "@magmacomputing/tempo/ticker": "./dist/plugin/extend/extend.ticker.js", - "@magmacomputing/tempo/duration": "./dist/plugin/module/module.duration.js", - "@magmacomputing/tempo/mutate": "./dist/plugin/module/module.mutate.js", - "@magmacomputing/tempo/plugin": "./dist/plugin/plugin.index.js", - "@magmacomputing/tempo/format": "./dist/discrete/discrete.format.js", + "@magmacomputing/tempo/enums": "./dist/support/tempo.enum.js", "@magmacomputing/tempo/parse": "./dist/discrete/discrete.parse.js", "@magmacomputing/tempo/discrete": "./dist/discrete/discrete.index.js", - "@magmacomputing/tempo/enums": "./dist/support/tempo.enum.js", + "@magmacomputing/tempo/duration": "./dist/module/module.duration.js", + "@magmacomputing/tempo/mutate": "./dist/module/module.mutate.js", + "@magmacomputing/tempo/plugin": "./dist/plugin/plugin.index.js", + "@magmacomputing/tempo/format": "./dist/discrete/discrete.format.js", + "@magmacomputing/tempo/ticker": "./dist/plugin/extend/extend.ticker.js", "@magmacomputing/tempo/library": "./dist/library.index.js" } } \ No newline at end of file diff --git a/packages/tempo/index.md b/packages/tempo/index.md index 1bce3642..beb0a74c 100644 --- a/packages/tempo/index.md +++ b/packages/tempo/index.md @@ -32,11 +32,24 @@ const commonZones = ref([ const uniqueZones = computed(() => [...new Set(commonZones.value)]) // --- Carousel State --- -const activeIndex = ref(0) +const LEADING_CLONES = 1 +const TRAILING_CLONES = 3 + +const activeIndex = ref(LEADING_CLONES) const isManualPaused = ref(false) const isHovering = ref(false) -const isPaused = computed(() => isManualPaused.value || isHovering.value) +const isDragging = ref(false) +const isPaused = computed(() => isManualPaused.value || isHovering.value || isDragging.value) const transitionEnabled = ref(true) +const carouselTrackRef = ref(null) +const dragOffsetX = ref(0) + +const swipeHint = computed(() => { + if (!isDragging.value) return 'Swipe' + if (dragOffsetX.value < -8) return 'Swipe left' + if (dragOffsetX.value > 8) return 'Swipe right' + return 'Swipe' +}) const features = [ { title: 'Zero-Cost', details: 'Lazy evaluation and smart matching ensure instantiation overhead is near-zero.', icon: 'โšก' }, @@ -49,8 +62,12 @@ const features = [ { title: 'Business Aware', details: 'Native support for fiscal quarters, zodiac signs, and meteorological seasons. Perfect for financial applications or astrology buffs or meteorologists !', icon: '๐Ÿ“ˆ' } ] -// 8 features + 3 clones for a seamless 3-card viewport -const displayFeatures = [...features, ...features.slice(0, 3)] +// Real features plus leading/trailing clones for seamless bi-directional looping. +const displayFeatures = [ + ...features.slice(-LEADING_CLONES), + ...features, + ...features.slice(0, TRAILING_CLONES) +] let isMounted = false let ticker = null @@ -58,6 +75,11 @@ let carouselTimer = null let fallbackIntervalId = null let initFailed = false let resumeTimer = null +let swipeStartX = 0 +let swipeLastX = 0 +let swipePointerId = null + +const SWIPE_MIN_DISTANCE = 40 function updateHands(h24, m, s) { const h = h24 % 12 @@ -150,24 +172,90 @@ function startCarousel() { if (carouselTimer) clearInterval(carouselTimer) carouselTimer = setInterval(() => { if (!isPaused.value) { - activeIndex.value++ - // Seamless loop: if we hit the start of the clones, wait for slide then snap - if (activeIndex.value >= features.length) { - setTimeout(() => { - if (!isMounted) return - transitionEnabled.value = false - activeIndex.value = 0 - setTimeout(() => { transitionEnabled.value = true }, 50) - }, 850) - } + moveNext() } }, 4000) } +function moveNext() { + activeIndex.value++ + // If we enter trailing clones, animate one step then snap back to first real item. + if (activeIndex.value >= LEADING_CLONES + features.length) { + setTimeout(() => { + if (!isMounted) return + transitionEnabled.value = false + activeIndex.value = LEADING_CLONES + setTimeout(() => { transitionEnabled.value = true }, 50) + }, 850) + } +} + +function movePrev() { + activeIndex.value-- + // If we enter leading clones, animate one step then snap to last real item. + if (activeIndex.value < LEADING_CLONES) { + setTimeout(() => { + if (!isMounted) return + transitionEnabled.value = false + activeIndex.value = LEADING_CLONES + features.length - 1 + setTimeout(() => { transitionEnabled.value = true }, 50) + }, 850) + } +} + +function toggleCarousel() { + isManualPaused.value = !isManualPaused.value +} + +function onSwipeStart(e) { + if (e.pointerType === 'mouse' && e.button !== 0) return + swipePointerId = e.pointerId + swipeStartX = e.clientX + swipeLastX = e.clientX + dragOffsetX.value = 0 + isDragging.value = true + transitionEnabled.value = false + carouselTrackRef.value?.setPointerCapture?.(e.pointerId) +} + +function onSwipeMove(e) { + if (!isDragging.value || swipePointerId !== e.pointerId) return + swipeLastX = e.clientX + const raw = swipeLastX - swipeStartX + dragOffsetX.value = Math.max(-96, Math.min(96, raw)) +} + +function finishSwipe(e) { + if (!isDragging.value || swipePointerId !== e.pointerId) return + + const endX = e.clientX ?? swipeLastX + const deltaX = endX - swipeStartX + + isDragging.value = false + swipePointerId = null + transitionEnabled.value = true + dragOffsetX.value = 0 + carouselTrackRef.value?.releasePointerCapture?.(e.pointerId) + + if (Math.abs(deltaX) < SWIPE_MIN_DISTANCE) return + if (deltaX < 0) moveNext() + else movePrev() +} + +function cancelSwipe() { + isDragging.value = false + swipePointerId = null + transitionEnabled.value = true + dragOffsetX.value = 0 +} + function handleVisibility() { if (resumeTimer) clearTimeout(resumeTimer) if (document.visibilityState === 'visible') { + // Always restart carousel timer on return; its own pause state is enforced via isPaused. + startCarousel() + if (isManualTickerPaused.value) { isResuming.value = false return @@ -184,7 +272,6 @@ function handleVisibility() { tickerActive.value = true console.info('%c[Tempo]%c โšก Resuming Ticker', 'color: #2563eb; font-weight: bold', 'color: inherit') startTicker() - startCarousel() }, 1200) } else { isResuming.value = false @@ -222,11 +309,11 @@ const featureRefs = ref([]) function handleKeydown(e) { if (e.key === 'ArrowLeft') { e.preventDefault() - if (activeIndex.value > 0) activeIndex.value-- + movePrev() focusActiveCard() } else if (e.key === 'ArrowRight') { e.preventDefault() - if (activeIndex.value < features.length - 1) activeIndex.value++ + moveNext() focusActiveCard() } } @@ -302,17 +389,26 @@ function focusActiveCard() { @keydown="handleKeydown"> +
{{ swipeHint }}
+