From 609a893e6011d540dcd06d71631d07e402656351 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 5 Jul 2026 10:40:35 +1000 Subject: [PATCH 1/6] relocated request.library to common folder --- packages/library/src/common.index.ts | 1 + .../src/{server => common}/request.library.ts | 39 ++++-- packages/library/src/server.index.ts | 1 - .../test/common/request.library.test.ts | 129 ++++++++++++++++++ .../test/common/temporal_library.test.ts | 2 +- packages/tempo/src/library.index.ts | 1 + 6 files changed, 162 insertions(+), 11 deletions(-) rename packages/library/src/{server => common}/request.library.ts (59%) create mode 100644 packages/library/test/common/request.library.test.ts diff --git a/packages/library/src/common.index.ts b/packages/library/src/common.index.ts index 7d91b9cb..262b00d7 100644 --- a/packages/library/src/common.index.ts +++ b/packages/library/src/common.index.ts @@ -18,6 +18,7 @@ export * from './common/object.library.js'; export * from './common/pledge.class.js'; export * from './common/proxy.library.js'; export * from './common/reflection.library.js'; +export * from './common/request.library.js'; export * from './common/serialize.library.js'; export * from './common/storage.library.js'; export * from './common/string.library.js'; diff --git a/packages/library/src/server/request.library.ts b/packages/library/src/common/request.library.ts similarity index 59% rename from packages/library/src/server/request.library.ts rename to packages/library/src/common/request.library.ts index e4e3d7e2..686563b2 100644 --- a/packages/library/src/server/request.library.ts +++ b/packages/library/src/common/request.library.ts @@ -25,8 +25,19 @@ type Config = { /** response wrapper (eg. "alert({hello:'there'})" */ prefix?: string; } +export class HttpError extends Error { + constructor( + public status: number, + public statusText: string, + public body: any + ) { + super(`${status}: ${statusText}`); + this.name = 'HttpError'; + } +} + /** get data from a resource-url */ -export const httpRequest = (url: string | URL, init = {} as RequestInit, config = {} as Config) => { +export const fetchRequest = (url: string | URL, init = {} as RequestInit, config = {} as Config) => { const signallingInit = { ...init, signal: init.signal @@ -37,20 +48,30 @@ export const httpRequest = (url: string | URL, init = {} as RequestInit, conf return fetch(url, signallingInit) // caller will handle the 'catch' if error .then(async res => { if (res.ok) { + const contentType = res.headers.get('Content-Type') || ''; + const isJson = contentType.includes('application/json'); + if (config.prefix) { - const text = await res.text(); // read raw text first - const json = text.startsWith(config.prefix) // if it starts with the specified prefix - ? text.substring(config.prefix.length).replace(/\);?$/, '') // then strip the prefix AND any trailing closure - : text; + const rawPrefixText = await res.text(); // read raw text first + const json = rawPrefixText.startsWith(config.prefix) // if it starts with the specified prefix + ? rawPrefixText.substring(config.prefix.length).replace(/\);?$/, '') // then strip the prefix AND any trailing closure + : rawPrefixText; return JSON.parse(json) as T; // parse the unwrapped string } - const json = await res.json(); // default JSON parsing - return json as T; + return await (isJson + ? res.json() // default JSON parsing + : res.text()) as T; } - throw new Error(`${res.status}: ${res.statusText}`); // fetch not successful + let errorBody: any = null; + try { + const errorText = await res.text(); + try { errorBody = JSON.parse(errorText); } catch { errorBody = errorText; } + } catch { } + + throw new HttpError(res.status, res.statusText, errorBody); // fetch not successful }) } @@ -58,7 +79,7 @@ export const httpRequest = (url: string | URL, init = {} as RequestInit, conf * get Response headers only (no data). * useful for just checking that a URL exists */ -export const headRequest = (url: string | URL) => { +export const fetchHead = (url: string | URL) => { const signal = AbortSignal.timeout(TWO_SECONDS); const init = { method: METHOD.Head, signal } // only interested in verifying that url responds diff --git a/packages/library/src/server.index.ts b/packages/library/src/server.index.ts index a29ecb23..a5bdfa79 100644 --- a/packages/library/src/server.index.ts +++ b/packages/library/src/server.index.ts @@ -4,4 +4,3 @@ export * from './server/auth.library.js'; export * from './server/file.library.js'; -export * from './server/request.library.js'; diff --git a/packages/library/test/common/request.library.test.ts b/packages/library/test/common/request.library.test.ts new file mode 100644 index 00000000..81b198ce --- /dev/null +++ b/packages/library/test/common/request.library.test.ts @@ -0,0 +1,129 @@ +import { fetchRequest, fetchHead, HttpError } from '../../src/common/request.library.js'; + +describe('request.library', () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + globalThis.fetch = mockFetch; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('fetchRequest', () => { + it('should parse JSON when Content-Type is application/json', async () => { + const mockData = { message: 'hello' }; + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'Content-Type': 'application/json; charset=utf-8' }), + json: async () => mockData + } as unknown as Response); + + const result = await fetchRequest('https://example.com/api'); + expect(result).toEqual(mockData); + }); + + it('should return raw text when Content-Type is not JSON', async () => { + const mockText = 'hello world'; + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'Content-Type': 'text/plain' }), + text: async () => mockText + } as unknown as Response); + + const result = await fetchRequest('https://example.com/text'); + expect(result).toEqual(mockText); + }); + + it('should strip prefix if provided', async () => { + const prefix = ')]}\'\n'; + const rawText = `${prefix}{"a":1}`; + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'Content-Type': 'application/json' }), + text: async () => rawText + } as unknown as Response); + + const result = await fetchRequest('https://example.com/data', {}, { prefix }); + expect(result).toEqual({ a: 1 }); + }); + + it('should throw HttpError with JSON body on 400', async () => { + const errorBody = { error: 'Bad request' }; + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => JSON.stringify(errorBody) + } as unknown as Response); + + try { + await fetchRequest('https://example.com/api'); + expect.fail('Should have thrown HttpError'); + } catch (err: any) { + expect(err).toBeInstanceOf(HttpError); + expect(err.status).toBe(400); + expect(err.statusText).toBe('Bad Request'); + expect(err.body).toEqual(errorBody); + } + }); + + it('should throw HttpError with text body on 500 when JSON parsing fails', async () => { + const errorText = 'Internal Server Error Occurred'; + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + text: async () => errorText + } as unknown as Response); + + try { + await fetchRequest('https://example.com/api'); + expect.fail('Should have thrown HttpError'); + } catch (err: any) { + expect(err).toBeInstanceOf(HttpError); + expect(err.status).toBe(500); + expect(err.body).toBe(errorText); + } + }); + }); + + describe('fetchHead', () => { + it('should return status and headers on ok response', async () => { + const mockHeaders = new Headers({ 'Content-Length': '123' }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: mockHeaders + } as unknown as Response); + + const result = await fetchHead('https://example.com/api'); + expect(result.status).toBe(200); + expect(result.headers).toBe(mockHeaders); + }); + + it('should throw Error on non-ok response (except Forbidden)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found' + } as unknown as Response); + + await expect(fetchHead('https://example.com/api')).rejects.toThrow('404: Not Found'); + }); + + it('should return status and headers on Forbidden (403)', async () => { + const mockHeaders = new Headers(); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + headers: mockHeaders + } as unknown as Response); + + const result = await fetchHead('https://example.com/api'); + expect(result.status).toBe(403); + expect(result.headers).toBe(mockHeaders); + }); + }); +}); diff --git a/packages/library/test/common/temporal_library.test.ts b/packages/library/test/common/temporal_library.test.ts index 5d1dfb69..5b08f5ec 100644 --- a/packages/library/test/common/temporal_library.test.ts +++ b/packages/library/test/common/temporal_library.test.ts @@ -23,7 +23,7 @@ describe('Temporal Library Helpers', () => { it('should handle "Z" as a zone designator and pass it through (even if ZonedDateTime.from throws without a bracket)', () => { const bag = '2024-01-01T12:00:00Z'; - expect(() => toZonedDateTime(bag, 'Australia/Sydney')).toThrow(/requires a time zone ID in brackets/); + expect(() => toZonedDateTime(bag, 'Australia/Sydney')).toThrow(/time\s?zone/i); }); }); }); diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts index c950a71f..bb2dd7e8 100644 --- a/packages/tempo/src/library.index.ts +++ b/packages/tempo/src/library.index.ts @@ -11,6 +11,7 @@ export { enumify, type Enum } from '#library/enumerate.library.js'; export { proxify } from '#library/proxy.library.js'; export { stringify, objectify, cloneify } from '#library/serialize.library.js'; export { isObject, isFunction, isDefined, isUndefined, isEmpty, isNumber, isNumeric, isFiniteNumber } from '#library/assertion.library.js'; +export { fetchRequest, fetchHead } from '#library/request.library.js'; export { asArray } from '#library/coercion.library.js'; export { instant, normaliseFractionalDurations } from '#library/temporal.library.js'; From 20f7199afb11b81e26783b860b003e9321de5169 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 5 Jul 2026 12:46:16 +1000 Subject: [PATCH 2/6] add Tempo.epoch --- package-lock.json | 8 +++--- package.json | 2 +- packages/library/package.json | 2 +- packages/tempo/CHANGELOG.md | 9 +++++++ packages/tempo/package.json | 2 +- packages/tempo/src/tempo.class.ts | 38 ++++++++++++++++++++++------- packages/tempo/src/tempo.version.ts | 2 +- 7 files changed, 46 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14aae881..056d91c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.5.2", + "version": "3.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.5.2", + "version": "3.5.3", "workspaces": [ "packages/*" ], @@ -10149,7 +10149,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.5.2", + "version": "3.5.3", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -10160,7 +10160,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.5.2", + "version": "3.5.3", "license": "MIT", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index e4100e88..ecd55d9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.5.2", + "version": "3.5.3", "private": true, "engines": { "node": ">=20.0.0" diff --git a/packages/library/package.json b/packages/library/package.json index fa449bcd..d7aa59e5 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.5.2", + "version": "3.5.3", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index f13f884f..3d747985 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.5.3] - 2026-07-05 + +### Added +- **Flexible Epoch Getters**: Added a static `Tempo.epoch` getter that perfectly mirrors the instance `.epoch` property, enabling direct retrieval of current Unix timestamps (e.g. `Tempo.epoch.ss`). +- **Static Now Modifiers**: Extended the static `Tempo.now(unit)` method to accept optional string units (`'ns'`, `'us'`, `'ms'`, `'ss'`), defaulting to nanosecond precision for strict backwards compatibility. + +### Changed +- **DRY Refactoring**: Centralized instance and static `epoch` property calculations to a single internal static helper, ensuring absolute uniformity of mathematical transformations across the core library. + ## [3.5.2] - 2026-07-04 ### Added diff --git a/packages/tempo/package.json b/packages/tempo/package.json index f1f91bf2..57523a60 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.5.2", + "version": "3.5.3", "engines": { "node": ">=20.0.0" }, diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 16349a61..08be87d4 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -960,7 +960,33 @@ export class Tempo { static from(tempo: t.DateTime | undefined, options?: t.Options): Tempo; static from(tempo?: t.DateTime | t.Options, options?: t.Options) { return new this(tempo as NonNullable, options); } - static now() { return instant().epochNanoseconds; } + static now(unit: Exclude): number; + static now(unit?: Extract): bigint; + static now(unit?: Tempo.TimeStamp): number | bigint { + const inst = instant(); + switch (unit) { + case 'ss': return Math.trunc(inst.epochMilliseconds / 1_000); + case 'ms': return inst.epochMilliseconds; + case 'us': return Number(inst.epochNanoseconds / BigInt(1_000)); + case 'ns': + default: return inst.epochNanoseconds; + } + } + + static #getEpoch(inst: { epochMilliseconds: number, epochNanoseconds: bigint }) { + return secure({ + /** seconds since epoch */ ss: Math.trunc(inst.epochMilliseconds / 1_000), + /** milliseconds since epoch */ ms: inst.epochMilliseconds, + /** microseconds since epoch */ us: Number(inst.epochNanoseconds / BigInt(1_000)), + /** nanoseconds since epoch */ ns: inst.epochNanoseconds, + }); + } + + /** static units since Unix epoch */ + static get epoch() { + return this.#getEpoch(instant()); + } + /** get the current system Instant */ static get instant() { return Temporal.Instant.fromEpochNanoseconds(this.now()) } @@ -1517,14 +1543,7 @@ export class Tempo { /** Keyed results for all resolved terms */ get term(): TempoTermRegistry { return this.#term } /** Formatted results for all pre-defined format codes */ get fmt() { return this.#fmt } - /** units since epoch */ get epoch() { - return secure({ - /** seconds since epoch */ ss: Math.trunc(this.toDateTime().epochMilliseconds / 1_000), - /** milliseconds since epoch */ ms: this.toDateTime().epochMilliseconds, - /** microseconds since epoch */ us: Number(this.toDateTime().epochNanoseconds / BigInt(1_000)), - /** nanoseconds since epoch */ ns: this.toDateTime().epochNanoseconds, - }) - } + /** units since epoch for this date-time instance */ get epoch() { return Tempo.#getEpoch(this.toDateTime()); } /** * @Immutable class decorators wrap the class but leave internal lexical bindings pointing to the original, undecorated class. @@ -1721,6 +1740,7 @@ export namespace Tempo { /** @deprecated use `wy` */ export type ww = t.wy; + export type TimeStamp = t.Internal.TimeStamp; export type Duration = t.Duration; export type WEEKDAY = t.WEEKDAY; diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index ba19ea3d..a0116b56 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '3.5.2'; +export const TEMPO_VERSION = '3.5.3'; From e227a39fd854267c5d0c8549059f4423f2126345 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 6 Jul 2026 09:42:54 +1000 Subject: [PATCH 3/6] add shorthand to set, add, until, since --- package.json | 2 +- packages/library/package.json | 2 +- .../library/src/common/assertion.library.ts | 5 +- packages/tempo/CHANGELOG.md | 6 + packages/tempo/doc/releases/v3.x.md | 10 ++ packages/tempo/package.json | 2 +- packages/tempo/src/module/module.duration.ts | 33 ++++- packages/tempo/src/module/module.mutate.ts | 114 ++++++++++-------- packages/tempo/src/tempo.class.ts | 4 +- packages/tempo/src/tempo.type.ts | 21 +++- packages/tempo/src/tempo.version.ts | 2 +- .../tempo/test/instance/instance.add.test.ts | 8 ++ .../tempo/test/instance/instance.set.test.ts | 15 ++- .../test/instance/instance.since.test.ts | 8 ++ .../test/instance/instance.until.test.ts | 8 ++ 15 files changed, 171 insertions(+), 69 deletions(-) diff --git a/package.json b/package.json index ecd55d9a..3a726e0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.5.3", + "version": "3.6.0", "private": true, "engines": { "node": ">=20.0.0" diff --git a/packages/library/package.json b/packages/library/package.json index d7aa59e5..9fd9593d 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.5.3", + "version": "3.6.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/assertion.library.ts b/packages/library/src/common/assertion.library.ts index 3cd57750..c7132e59 100644 --- a/packages/library/src/common/assertion.library.ts +++ b/packages/library/src/common/assertion.library.ts @@ -74,7 +74,10 @@ export const isDuration = (obj: unknown): obj is Temporal.Duration => isType 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 + 'milliseconds' in obj || 'microseconds' in obj || 'nanoseconds' in obj || + 'yy' in obj || 'mm' in obj || 'ww' in obj || 'dd' in obj || + 'hh' in obj || 'mi' in obj || 'ss' in obj || + 'ms' in obj || 'us' in obj || 'ns' in obj )); export const isZonedDateTimeLike = (obj: unknown): obj is Temporal.ZonedDateTimeLike | string | Temporal.ZonedDateTime => isString(obj) || isZonedDateTime(obj) || (isObject(obj) && ( 'year' in obj || 'month' in obj || 'day' in obj || 'hour' in obj || 'minute' in obj || 'second' in obj || diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 3d747985..9f5065bc 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.6.0] - 2026-07-05 + +### Added +- **Shorthand Mutation Keys**: Added native support for Tempo's shorthand format tokens (e.g., `mi`, `ss`, `yy`, `ww`) across both `.add()` and `.set()` mutations, streamlining developer experience and aligning TypeScript definitions with the underlying runtime engine. +- **Shorthand Duration Keys**: Expanded shorthand token support directly into the `DurationModule`. You can now seamlessly use shorthand keys for duration instantiation (`Tempo.duration({ mi: 5 })`), comparisons (`t.until(other, 'mi')`), and strict balancing (`t.until(other).balance({ largestUnit: 'mi' })`), bringing total API consistency across the core library. + ## [3.5.3] - 2026-07-05 ### Added diff --git a/packages/tempo/doc/releases/v3.x.md b/packages/tempo/doc/releases/v3.x.md index 420f1cbc..7f42bb69 100644 --- a/packages/tempo/doc/releases/v3.x.md +++ b/packages/tempo/doc/releases/v3.x.md @@ -1,5 +1,15 @@ # 📜 Version 3.x History +## [v3.6.0] - 2026-07-05 + +### ✨ What's New — Shorthand Mutation Keys +Added native support for Tempo's shorthand format tokens (e.g., `mi`, `ss`, `yy`, `ww`) across both `.add()` and `.set()` mutations, streamlining developer experience and aligning TypeScript definitions with the underlying runtime engine. + +### ✨ What's New — Shorthand Duration Keys +Expanded shorthand token support directly into the `DurationModule`. You can now seamlessly use shorthand keys for duration instantiation (`Tempo.duration({ mi: 5 })`), comparisons (`t.until(other, 'mi')`), and strict balancing (`t.until(other).balance({ largestUnit: 'mi' })`), bringing total API consistency across the core library. + +--- + ## [v3.5.2] - 2026-07-04 ### ✨ What's New — Minified Global Bundles diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 57523a60..383f30d6 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.5.3", + "version": "3.6.0", "engines": { "node": ">=20.0.0" }, diff --git a/packages/tempo/src/module/module.duration.ts b/packages/tempo/src/module/module.duration.ts index 561f42b5..b0930c13 100644 --- a/packages/tempo/src/module/module.duration.ts +++ b/packages/tempo/src/module/module.duration.ts @@ -8,6 +8,7 @@ import { getRelativeTime, formatNumber, formatDuration, formatList } from '#libr import { defineInterpreterModule, interpret, type TempoModule } from '../plugin/plugin.util.js'; import { enums, isTempo, TempoError } from '#tempo/support'; import { Tempo } from '../tempo.class.js'; +import type * as t from '../tempo.type.js'; declare module '../tempo.class.js' { namespace Tempo { @@ -80,7 +81,11 @@ function toDuration(dur: Temporal.Duration, ctx: { relativeTo?: any, locale?: st if (!anchor) throw new TempoError("A relativeTo anchor is required for strict balancing. Pass an anchor or use { nominal: true } for mathematical balancing."); - const balanced = dur.round({ largestUnit, relativeTo: anchor }); + let temporalUnit = largestUnit; + if (temporalUnit === 'ww') temporalUnit = 'weeks'; + else if (temporalUnit in enums.ELEMENT) temporalUnit = `${enums.ELEMENT[temporalUnit as t.Element]}s`; + + const balanced = dur.round({ largestUnit: temporalUnit, relativeTo: anchor }); return toDuration(balanced, { ...ctx, relativeTo: anchor }); }, @@ -137,7 +142,7 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) let value, opts: any = {}, unit: any; switch (true) { - case isString(arg) && enums.ELEMENT.values().includes(singular(arg) as any): + case isString(arg) && (enums.ELEMENT.values().includes(singular(arg) as any) || arg in enums.ELEMENT || arg === 'ww'): unit = arg; ({ value, ...opts } = until || {}); break; @@ -177,11 +182,18 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) const [selfTz, selfCal] = getTemporalIds(selfZdt); const [offsetTz] = getTemporalIds(offsetZdt); + let temporalUnit = unit; + if (isDefined(temporalUnit)) { + if (temporalUnit === 'ww') temporalUnit = 'weeks'; + else if (temporalUnit in enums.ELEMENT) temporalUnit = `${enums.ELEMENT[temporalUnit as t.Element]}s`; + else temporalUnit = `${singular(temporalUnit)}s`; + } + const diffZone = selfTz !== offsetTz; - const dur = selfZdt.until(offsetZdt.withCalendar(selfCal), { largestUnit: diffZone ? 'hours' : (unit ?? 'years') }); + const dur = selfZdt.until(offsetZdt.withCalendar(selfCal), { largestUnit: diffZone ? 'hours' : (temporalUnit ?? 'years') }); if (isDefined(unit)) - unit = `${singular(unit)}s`; + unit = temporalUnit; if (isUndefined(unit) || since) { const locale = (this as any)?.config?.locale; @@ -246,7 +258,18 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) * DurationLikeObject -> EDO (with iso string) */ (duration as any).toDuration = (input: string | Temporal.DurationLikeObject, ctx?: any) => { - const dur = Temporal.Duration.from(input); + let mappedInput: any = input; + if (isObject(input)) { + mappedInput = { ...input }; + for (const [k, v] of Object.entries(input)) { + if (k === 'ww') { mappedInput['weeks'] = v; delete mappedInput[k]; } + else if (k in enums.ELEMENT) { + mappedInput[`${enums.ELEMENT[k as t.Element]}s`] = v; + delete mappedInput[k]; + } + } + } + const dur = Temporal.Duration.from(mappedInput); return toDuration(dur, ctx); } diff --git a/packages/tempo/src/module/module.mutate.ts b/packages/tempo/src/module/module.mutate.ts index 3b83ddbb..819258ec 100644 --- a/packages/tempo/src/module/module.mutate.ts +++ b/packages/tempo/src/module/module.mutate.ts @@ -79,68 +79,71 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options if (type === 'set' && SLICK_KEYS.includes(key as any)) { if (!isString(adjust)) { - logError(`Slick key '${key}' expects a string payload (e.g. '>1').`, this.config); - state.errored = true; - return currZdt; - } - - let matchSlickValue = Match.slickValue; - const backwardWords = new Set(); - if (state.config.registry?.modifiers) { - const symbols = ['+', '-', '<', '<=', '>', '>=', '=']; - const words = new Set(); - symbols.forEach(sym => { - const mapped = state.config.registry!.modifiers![sym]; - if (mapped) { - asArray(mapped).forEach(w => { - words.add(w); - if (sym === '<' || sym === '<=' || sym === '-') backwardWords.add(w); - }); + if (key === 'wkd') { + logError(`Slick key 'wkd' requires a weekday name (e.g. '>Fri').`, this.config); + state.errored = true; + return currZdt; + } + } else { + + let matchSlickValue = Match.slickValue; + const backwardWords = new Set(); + if (state.config.registry?.modifiers) { + const symbols = ['+', '-', '<', '<=', '>', '>=', '=']; + const words = new Set(); + symbols.forEach(sym => { + const mapped = state.config.registry!.modifiers![sym]; + if (mapped) { + asArray(mapped).forEach(w => { + words.add(w); + if (sym === '<' || sym === '<=' || sym === '-') backwardWords.add(w); + }); + } + }); + if (words.size > 0) { + const escapedWords = Array.from(words).map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + escapedWords.sort((a, b) => b.length - a.length); + const wordPattern = escapedWords.join('|'); + matchSlickValue = new RegExp(`^(?[\\+\\-\\<\\>\\=]=?|${wordPattern})?(?-?[0-9]+)?(?[\\w]*)$`); } - }); - if (words.size > 0) { - const escapedWords = Array.from(words).map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - escapedWords.sort((a, b) => b.length - a.length); - const wordPattern = escapedWords.join('|'); - matchSlickValue = new RegExp(`^(?[\\+\\-\\<\\>\\=]=?|${wordPattern})?(?-?[0-9]+)?(?[\\w]*)$`); } - } - const slick = adjust.match(matchSlickValue); - if (!slick || !slick.groups) { - logError(`Invalid slick syntax '${adjust}' for key '${key}'.`, this.config); - state.errored = true; - return currZdt; - } + const slick = adjust.match(matchSlickValue); + if (!slick || !slick.groups) { + logError(`Invalid slick syntax '${adjust}' for key '${key}'.`, this.config); + state.errored = true; + return currZdt; + } - let { sh_mod, sh_nbr, sh_unit } = slick.groups; + let { sh_mod, sh_nbr, sh_unit } = slick.groups; - if (key === 'wkd') { - if (!sh_unit) { - logError(`Slick key 'wkd' requires a weekday name (e.g. '>Fri').`, this.config); + if (key === 'wkd') { + if (!sh_unit) { + logError(`Slick key 'wkd' requires a weekday name (e.g. '>Fri').`, this.config); + state.errored = true; + return currZdt; + } + const offsetStr = (sh_mod || '>') + (sh_nbr || ''); + const res = resolveTermMutation((this.constructor as any), this, 'set', sh_unit, offsetStr, currZdt); + if (res === null) state.errored = true; + return res ?? currZdt; + } + + if (!sh_mod) { + logError(`Slick math requires a shift operator (e.g. '>', '<') for key '${key}'.`, this.config); state.errored = true; return currZdt; } - const offsetStr = (sh_mod || '>') + (sh_nbr || ''); - const res = resolveTermMutation((this.constructor as any), this, 'set', sh_unit, offsetStr, currZdt); - if (res === null) state.errored = true; - return res ?? currZdt; - } - if (!sh_mod) { - logError(`Slick math requires a shift operator (e.g. '>', '<') for key '${key}'.`, this.config); - state.errored = true; - return currZdt; - } - - let nbr = sh_nbr ? Number(sh_nbr) : 1; - if (sh_mod === '<' || sh_mod === '-' || sh_mod === 'prev' || sh_mod === 'last' || backwardWords.has(sh_mod)) nbr = -nbr; + let nbr = sh_nbr ? Number(sh_nbr) : 1; + if (sh_mod === '<' || sh_mod === '-' || sh_mod === 'prev' || sh_mod === 'last' || backwardWords.has(sh_mod)) nbr = -nbr; - const unitMap: Record = { - yy: 'years', mm: 'months', ww: 'weeks', dd: 'days', - hh: 'hours', mi: 'minutes', ss: 'seconds' - }; - return currZdt.add({ [unitMap[key]]: nbr }); + const unitMap: Record = { + yy: 'years', mm: 'months', ww: 'weeks', dd: 'days', + hh: 'hours', mi: 'minutes', ss: 'seconds' + }; + return currZdt.add({ [unitMap[key]]: nbr }); + } } const { mutate: op, offset, single, term } = ((key, adjust, type) => { @@ -201,6 +204,13 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options case 'add.millisecond': case 'add.microsecond': case 'add.nanosecond': return currZdt.add({ [`${single}s`]: offset }); + case 'add.yy': case 'add.mm': case 'add.dd': case 'add.hh': + case 'add.mi': case 'add.ss': case 'add.ms': case 'add.us': case 'add.ns': + case 'add.wy': case 'add.ww': { + const value = single === 'ww' ? 'week' : enums.ELEMENT[single as t.Element]; + return currZdt.add({ [`${value}s`]: offset }); + } + case 'set.period': case 'set.time': case 'set.date': case 'set.event': case 'set.dow': case 'set.wkd': { const res = parseInner(offset, currZdt); diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 08be87d4..a4e37b3e 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1452,8 +1452,8 @@ export class Tempo { /** Month number: Jan=1, Dec=12 */ get mm() { return this.toDateTime().month as t.mm } /** iso week number of the year */ get wy() { return getISOWeekOfYear(this.toDateTime()).weekOfYear as t.wy; } /** @deprecated use `wy` */ get ww() { return getISOWeekOfYear(this.toDateTime()).weekOfYear as t.wy; } - /** Day of the month (1-31) */ get dd() { return this.toDateTime().day } - /** Day of the month (alias for `dd`) */ get day() { return this.toDateTime().day } + /** Day of the month (1-31) */ get dd() { return this.toDateTime().day as t.dd } + /** Day of the month (alias for `dd`) */ get day() { return this.toDateTime().day as t.dd } /** Hour of the day (0-23) */ get hh() { return this.toDateTime().hour as t.hh } /** Minutes of the hour (0-59) */ get mi() { return this.toDateTime().minute as t.mi } /** Seconds of the minute (0-59) */ get ss() { return this.toDateTime().second as t.ss } diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 19f58295..f696667f 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -99,7 +99,7 @@ export interface Options extends Partial { /** Configuration to use for #until() and #since() argument */ export type DateTimeUnit = Temporal.DateUnit | Temporal.TimeUnit -export type Unit = DateTimeUnit | Plural +export type Unit = DateTimeUnit | Plural | Element | 'ww' export type Units = Plural; export type BaseDuration = Record; /** @@ -131,17 +131,32 @@ export type SetFields = { export type SlickKey = SLICK_KEYS[number]; export type SlickOffset = { [K in SlickKey]?: string }; -export type MutateSet = Prettify | DateTime export type AddUnits = { [K in Unit]?: number }; -export type MutateAdd = Prettify | DateTime +export type MutateAdd = Prettify | DateTime export type Modifier = '=' | '-' | '+' | '<' | '<=' | '-=' | '>' | '>=' | '+=' | 'this' | 'next' | 'prev' | 'last' | 'first' | undefined export type Relative = 'ago' | 'hence' | 'prior' | 'from now' export type mm = IntRange<0, 12> +export type dd = IntRange<1, 31> export type hh = IntRange<0, 24> export type mi = IntRange<0, 60> export type ss = IntRange<0, 60> diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index a0116b56..6de43431 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '3.5.3'; +export const TEMPO_VERSION = '3.6.0'; diff --git a/packages/tempo/test/instance/instance.add.test.ts b/packages/tempo/test/instance/instance.add.test.ts index 1890efae..2b04f44e 100644 --- a/packages/tempo/test/instance/instance.add.test.ts +++ b/packages/tempo/test/instance/instance.add.test.ts @@ -12,6 +12,14 @@ describe(`${label} add method`, () => { expect(t2.dd).toBe(20); }); + test('handles shorthand mutation keys (e.g. mi, ss)', () => { + const t = new Tempo('2024-01-01T12:00:00'); + const t2 = t.add({ mi: 15, ss: 30, ww: 1 }); + expect(t2.mi).toBe(15); + expect(t2.ss).toBe(30); + expect(t2.dd).toBe(8); // 1 week added + }); + test('adds months correctly', () => { const t = new Tempo('2024-05-20'); const t2 = t.add({ month: 3 }); diff --git a/packages/tempo/test/instance/instance.set.test.ts b/packages/tempo/test/instance/instance.set.test.ts index 71c7012f..0a92919e 100644 --- a/packages/tempo/test/instance/instance.set.test.ts +++ b/packages/tempo/test/instance/instance.set.test.ts @@ -17,6 +17,17 @@ describe(`${label} set method`, () => { expect(t2.dd).toBe(25); }); + test('sets atomic shorthand units correctly (e.g. mi, ss)', () => { + const t = new Tempo('2024-05-20T12:00:00'); + const t2 = t.set({ yy: 2025, mm: 12, dd: 25, hh: 15, mi: 30, ss: 45 }); + expect(t2.yy).toBe(2025); + expect(t2.mm).toBe(12); + expect(t2.dd).toBe(25); + expect(t2.hh).toBe(15); + expect(t2.mi).toBe(30); + expect(t2.ss).toBe(45); + }); + test('sets via parsing string (e.g. period)', () => { const t = new Tempo('2024-05-20 08:00'); const t2 = t.set({ event: 'afternoon' }); // afternoon -> 15:00 usually @@ -89,9 +100,9 @@ describe(`${label} set method`, () => { expect(() => t.set({ year: '<1' } as any)).toThrow('For relative Slick math, use the \'yy\' snippet key instead of \'year\''); }); - test('rejects non-string slick key payloads', () => { + test('rejects non-string wkd key payload', () => { const t = new Tempo('2024-05-20'); - expect(() => t.set({ mm: 5 } as any)).toThrow('Slick key \'mm\' expects a string payload'); + expect(() => t.set({ wkd: 5 } as any)).toThrow('Slick key \'wkd\' requires a weekday name'); }); test('rejects invalid slick math syntax without modifier', () => { diff --git a/packages/tempo/test/instance/instance.since.test.ts b/packages/tempo/test/instance/instance.since.test.ts index 8b014bce..7d4fb72c 100644 --- a/packages/tempo/test/instance/instance.since.test.ts +++ b/packages/tempo/test/instance/instance.since.test.ts @@ -14,6 +14,14 @@ describe(`${label} since method`, () => { expect(t2.since(t1, 'hours')).toMatch(/2h ago/i); }); + test('calculates time elapsed in shorthand units', () => { + const t1 = new Tempo('2024-01-01T12:00:00'); + const t2 = new Tempo('2024-01-01T14:30:00'); + + expect(t2.since(t1, 'hh')).toMatch(/2h ago/i); + expect(t2.since(t1, 'mi')).toMatch(/150m ago/i); + }); + test('calculates time elapsed from a string date', () => { const t = new Tempo('2024-01-02T00:00:00'); expect(t.since('2024-01-01T00:00:00', 'hours')).toMatch(/24h ago/i); diff --git a/packages/tempo/test/instance/instance.until.test.ts b/packages/tempo/test/instance/instance.until.test.ts index 0e3b98ef..0a084682 100644 --- a/packages/tempo/test/instance/instance.until.test.ts +++ b/packages/tempo/test/instance/instance.until.test.ts @@ -11,6 +11,14 @@ describe(`${label} until method`, () => { expect(t1.until(t2, 'minutes')).toBe(150); }); + test('calculates duration in shorthand units', () => { + const t1 = new Tempo('2024-01-01T12:00:00'); + const t2 = new Tempo('2024-01-01T14:30:00'); + + expect(t1.until(t2, 'hh')).toBe(2.5); + expect(t1.until(t2, 'mi')).toBe(150); + }); + test('calculates duration to a string date', () => { const t = new Tempo('2024-01-01T00:00:00'); expect(t.until('2024-01-02T00:00:00', 'hours')).toBe(24); From 11316f0eb8a7f7b7ac99146952a006926c93fdeb Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 6 Jul 2026 13:09:34 +1000 Subject: [PATCH 4/6] ready for review --- CHANGELOG.md | 2 +- packages/tempo/CHANGELOG.md | 4 +- packages/tempo/doc/ecosystem.md | 2 +- packages/tempo/doc/releases/v3.x.md | 2 +- packages/tempo/doc/tempo.extension.md | 47 +++---- packages/tempo/doc/tempo.plugin.md | 120 ++++++------------ packages/tempo/doc/tempo.term.md | 27 ++-- packages/tempo/src/plugin/plugin.type.ts | 1 + packages/tempo/src/plugin/plugin.util.ts | 11 +- .../test/plugins/plugin_registration.test.ts | 8 +- 10 files changed, 99 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73880850..394d628e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Ticker Plugin Licensing**: The `Ticker` plugin has transitioned to a licensed model. It remains completely free to use, but now requires validation via the Tempo Registry ecosystem. - **Robust License Validation**: Hardened runtime integrity for Tempo Pro plugins to enhance state security and prevent tampering. - **Edition Boundaries**: Added proactive runtime warnings for unlicensed attempts, establishing strict boundaries between the Community and Proprietary editions. -- **Extension Resolution**: Stabilized module resolution for plugins by migrating to `defineExtension` and correcting internal export paths. +- **Extension Resolution**: Stabilized module resolution for plugins by migrating to `definePlugin` and correcting internal export paths. - **Logging Subsystem Decoupling**: Fully decoupled the internal parsing and error boundaries from the legacy `Logify` architecture, enabling zero-cost trace instrumentation. - **Diagnostic Consistency**: Standardized fatal exceptions by introducing the `TempoError` class for internal invariant violations, and unified all console output through the centralized diagnostic bus (`logWarn`). - **Documentation**: Clarified API behavior for the Duration engine (especially `.since()` return types) and relative time math. Improved visibility and navigation for the Tempo License Registry and installation guides. diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 9f5065bc..c14dad99 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -39,7 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.5.0] - 2026-06-28 ### Added -- **Unified Plugin API Barrel**: Introduced `@magmacomputing/tempo/plugin-api` barrel export. This centralizes all plugin-authoring utilities (`defineModule`, `defineExtension`, `defineTerm`, etc.) and internal types into a single, highly discoverable endpoint. This architecture significantly cleans up application-level code and documentation by separating end-user imports from Plugin Developer imports. +- **Unified Plugin API Barrel**: Introduced `@magmacomputing/tempo/plugin-api` barrel export. This centralizes all plugin-authoring utilities (`defineModule`, `definePlugin`, `defineTerm`, etc.) and internal types into a single, highly discoverable endpoint. This architecture significantly cleans up application-level code and documentation by separating end-user imports from Plugin Developer imports. - **Strict Peer Dependency Validation**: Tempo Plugins now enforce strict `peerDependencies` on the new `plugin-api` barrel export, ensuring robust resolution for complex environments (like Smart CDNs or Vite) while preventing duplicate instances of internal utility functions. ### Changed @@ -480,7 +480,7 @@ _Version 2.4.0 was not released; the project merged new functionality from 2.4.0 ## [2.2.2] - 2026-04-18 ### Fixed -- **Plugin Infrastructure Preservation**: Refactored the Rollup configuration to treat all library files as public entry points. This prevents critical utilities (like `defineExtension`) from being tree-shaken during the build process, ensuring that modular plugins can register correctly. +- **Plugin Infrastructure Preservation**: Refactored the Rollup configuration to treat all library files as public entry points. This prevents critical utilities (like `definePlugin`) from being tree-shaken during the build process, ensuring that modular plugins can register correctly. - **API Surface Hardening**: Explicitly exported all registration and utility helpers (`defineModule`, `defineTerm`, etc.) from the main entry point to guarantee their availability for third-party extensions. - **Documentation Build Stability**: Updated the documentation configuration to utilize pre-compiled `dist/` assets. This resolves runtime `SyntaxError` issues in the browser caused by the presence of modern TC39 decorators in the raw TypeScript source files. - **Decorator Transpilation**: Refactored utility functions to ensure standard function declarations are used where appropriate, improving the reliability of the transpilation phase. diff --git a/packages/tempo/doc/ecosystem.md b/packages/tempo/doc/ecosystem.md index d83f90bb..69e574cc 100644 --- a/packages/tempo/doc/ecosystem.md +++ b/packages/tempo/doc/ecosystem.md @@ -13,5 +13,5 @@ Browse the live catalog below to discover extensions for scheduling, calendar ma ::: tip Submit Your Own Plugin! Have you built something amazing for Tempo? We want to see it! -You can have your own packages listed in this live catalog. Simply build your extension using `defineExtension()` or `defineTerm()` and publish it to NPM with the `"tempo-plugin"` keyword. Open an Issue on our GitHub repository with a link to your package, and we'll review it for inclusion in the global registry! +You can have your own packages listed in this live catalog. Simply build your extension using `definePlugin()` or `defineTerm()` and publish it to NPM with the `"tempo-plugin"` keyword. Open an Issue on our GitHub repository with a link to your package, and we'll review it for inclusion in the global registry! ::: diff --git a/packages/tempo/doc/releases/v3.x.md b/packages/tempo/doc/releases/v3.x.md index 7f42bb69..7f9c4448 100644 --- a/packages/tempo/doc/releases/v3.x.md +++ b/packages/tempo/doc/releases/v3.x.md @@ -38,7 +38,7 @@ The `{tz}` format token now natively supports a full suite of lowercase modifier Tempo v3.5.0 introduces a brand new barrel export for plugin developers: `@magmacomputing/tempo/plugin-api`. -This new endpoint centralizes all plugin-authoring utilities (`defineModule`, `defineExtension`, `defineTerm`) and internal types into a single location. This architecture significantly cleans up application-level code by formally separating end-user imports from Plugin Developer imports. +This new endpoint centralizes all plugin-authoring utilities (`defineModule`, `definePlugin`, `defineTerm`) and internal types into a single location. This architecture significantly cleans up application-level code by formally separating end-user imports from Plugin Developer imports. If you are developing a custom plugin, you simply update your imports: diff --git a/packages/tempo/doc/tempo.extension.md b/packages/tempo/doc/tempo.extension.md index bc79e1e0..108af94f 100644 --- a/packages/tempo/doc/tempo.extension.md +++ b/packages/tempo/doc/tempo.extension.md @@ -1,8 +1,8 @@ -# Creating an Extension Plugin +# Creating a Custom Plugin -While [Term Plugins](./tempo.term.md) are excellent for providing static, memoized data (like astrological signs or fiscal quarters), **Extension Plugins** allow you to fundamentally enhance the `Tempo` class with entirely new methods and behaviors. +While [Term Plugins](./tempo.term.md) are excellent for providing static, memoized data (like astrological signs or fiscal quarters), a general **Plugin** allows you to fundamentally enhance the `Tempo` class with entirely new methods and behaviors. -This guide will teach you the "Tempo-way" of authoring an Extension Plugin by building a classic, real-world example: **The Business Days Extension**. +This guide will teach you the "Tempo-way" of authoring a custom plugin by building a classic, real-world example: **The Business Days Plugin**. ## The Goal @@ -15,18 +15,17 @@ console.log(t.addBusinessDays(2).format('{www}')); // Output: 'Tue' --- -## 1. The `defineExtension` Factory +## 1. The `definePlugin` Factory -The safest and most efficient way to author a plugin is using the `defineExtension` factory. This handles the internal registration automatically. +The safest and most efficient way to author a plugin is using the `definePlugin` factory. This handles the internal registration automatically. ```typescript // src/index.ts -import { defineExtension } from '@magmacomputing/tempo/plugin'; -import type { Tempo } from '@magmacomputing/tempo/core'; +import { definePlugin, type TempoPlugin } from '@magmacomputing/tempo/plugin-api'; -export const BusinessDaysPlugin = defineExtension({ +export const BusinessDaysPlugin: TempoPlugin = definePlugin({ name: 'BusinessDaysPlugin', - install(TempoClass: any) { + install(TempoClass) { // Plugin implementation goes here! } }); @@ -42,9 +41,9 @@ To add an instance method, you extend the `TempoClass.prototype`. Let's implement our `.addBusinessDays()` logic: ```typescript -export const BusinessDaysPlugin = defineExtension({ +export const BusinessDaysPlugin: TempoPlugin = definePlugin({ name: 'BusinessDaysPlugin', - install(TempoClass: any) { + install(TempoClass) { TempoClass.prototype.addBusinessDays = function(days: number = 1) { let next = this; const direction = days >= 0 ? 1 : -1; @@ -56,7 +55,7 @@ export const BusinessDaysPlugin = defineExtension({ next = next.add({ days: direction }); // Only count Monday-Friday as a valid jump - if (next.toDateTime().dayOfWeek <= 5) { + if (next.dow <= TempoClass.WEEKDAY.Fri) { remaining--; } } @@ -68,7 +67,9 @@ export const BusinessDaysPlugin = defineExtension({ }); ``` -Notice how we drop into `.toDateTime()` to access the raw zone-aware `Temporal.ZonedDateTime` object? This is a common pattern in plugins when you need to access raw calendar properties (like `dayOfWeek`, `dayOfYear`, or `daysInMonth`) while preserving zone information without triggering unnecessary string formatting. +Notice how we used the `next.dow` getter to easily read the Day Of Week? Tempo instances have many lightweight getters (like `.yy`, `.mm`, `.dd`, `.hh`) built-in. + +However, if you ever need to calculate advanced calendar math (like `dayOfYear`, `daysInMonth`, or `weeksInYear`), you can drop into `this.toDateTime()` to access the raw, underlying `Temporal.ZonedDateTime` object. ## 3. TypeScript Module Augmentation @@ -78,7 +79,7 @@ You must declare this augmentation in the same file that exports your plugin: ```typescript // src/index.ts -import { defineExtension } from '@magmacomputing/tempo/plugin'; +import { definePlugin } from '@magmacomputing/tempo/plugin-api'; // ... (plugin implementation) ... @@ -92,17 +93,17 @@ declare module '@magmacomputing/tempo/core' { ## 4. Packing it as a Configurable Module -Sometimes, you want your plugin to accept options (e.g., passing in a custom array of public holidays to skip). To do this, wrap your `defineExtension` call in a standard factory function: +Sometimes, you want your plugin to accept options (e.g., passing in a custom array of public holidays to skip). To do this, wrap your `definePlugin` call in a standard factory function: ```typescript export type BusinessDayOptions = { skipHolidays?: boolean; }; -export const BusinessDaysModule = (pluginOptions: BusinessDayOptions = {}) => { - return defineExtension({ - name: 'BusinessDaysModule', - install(TempoClass: any) { +export const BusinessDaysPlugin = (pluginOptions: BusinessDayOptions = {}): TempoPlugin => { + return definePlugin({ + name: 'BusinessDaysPlugin', + install(TempoClass) { TempoClass.prototype.addBusinessDays = function(days: number = 1) { let next = this; const direction = days >= 0 ? 1 : -1; @@ -110,7 +111,7 @@ export const BusinessDaysModule = (pluginOptions: BusinessDayOptions = {}) => { while (remaining > 0) { next = next.add({ days: direction }); - if (next.toDateTime().dayOfWeek <= 5) { + if (next.dow <= TempoClass.WEEKDAY.Fri) { // We can now use 'pluginOptions' in our logic! if (!pluginOptions.skipHolidays /* || !isHoliday(next) */) { remaining--; @@ -127,13 +128,13 @@ export const BusinessDaysModule = (pluginOptions: BusinessDayOptions = {}) => { ## Consuming the Plugin -Your users can now import and register your extension elegantly: +Your users can now import and register your plugin elegantly: ```typescript import { Tempo } from '@magmacomputing/tempo/core'; -import { BusinessDaysModule } from 'my-business-days-plugin'; +import { BusinessDaysPlugin } from 'my-business-days-plugin'; -Tempo.extend(BusinessDaysModule({ skipHolidays: true })); +Tempo.extend(BusinessDaysPlugin({ skipHolidays: true })); const t = new Tempo(); const nextBiz = t.addBusinessDays(2); diff --git a/packages/tempo/doc/tempo.plugin.md b/packages/tempo/doc/tempo.plugin.md index a0c50837..bc378f46 100644 --- a/packages/tempo/doc/tempo.plugin.md +++ b/packages/tempo/doc/tempo.plugin.md @@ -2,52 +2,35 @@ Tempo is designed with a "lean core" philosophy. While it provides robust date-time manipulation and parsing out of the box, advanced functionality (like reactive Tickers or domain-specific business logic) is added through a flexible **Plugin System**. +In the Tempo ecosystem, a **Plugin** is the universal overarching term for any feature added to the core library. To make authoring plugins easy and consistent, Tempo provides two specialized factory functions: + +1. **`definePlugin`**: The standard factory for general-purpose features (e.g., adding prototype instance methods, static tools, or altering configuration). +2. **`defineTerm`**: A specialized factory exclusively for defining temporal vocabulary constraints (a "Term" is technically just a highly-opinionated "Plugin" focused on date ranges and schedules). + To manually register a plugin, use the static `extend` method. This is typically used for "opt-in" features or when you need to provide specific configuration to a plugin factory. ```typescript import { Tempo } from '@magmacomputing/tempo/core'; import { MyPlugin } from './my-plugin.js'; -import { HolidayModule } from './my-holiday-plugin.js'; +import { HolidayPlugin } from './my-holiday-plugin.js'; // Manual registration Tempo.extend(MyPlugin); // Registration with a Factory (providing options) -Tempo.extend(HolidayModule({ region: 'US-NY' })); +Tempo.extend(HolidayPlugin({ region: 'US-NY' })); ``` --- -The most efficient way to author a plugin is using the `defineExtension` factory. This helper automatically handles the internal registration logic, making your plugin available as soon as it is imported (via side effect). +## 1. Creating a Custom Plugin -## Example Plugin +Tempo provides a dedicated step-by-step guide for developers wishing to author their own general-purpose plugins. -```typescript -import { defineExtension } from '@magmacomputing/tempo/plugin-api'; - -export const MyPlugin = defineExtension({ - name: 'MyPlugin', - install(TempoClass: any) { - // 1. Add a static method - TempoClass.myStaticMethod = () => { /* ... */ }; - - // 2. Add an instance method (on the prototype) - TempoClass.prototype.toHoliday = function() { - return factory(this.add({ days: 1 })); - } -}); -``` - -## Manual Registration Pattern -If you prefer not to use the factory (e.g. for plugins that should *not* self-register), you can export a plain function with the `Tempo.Plugin` signature: - -```typescript -import type { Tempo } from '@magmacomputing/tempo/core'; - -export const ManualPlugin: Tempo.Plugin = (TempoClass, options, factory) => { - // ... implementation ... -} -``` +👉 **[Read the Custom Plugin Guide (tempo.extension.md)](./tempo.extension.md)** to learn how to: +- Use the `definePlugin` factory +- Safely extend the `TempoClass.prototype` +- Build a full `BusinessDaysPlugin` from scratch ## Type Safety (TypeScript) @@ -83,10 +66,10 @@ declare module '@magmacomputing/tempo/core' { --- -Modern Tempo plugins are designed to be "plug-and-play." By using the `defineExtension` factory, a plugin registers itself with the global Tempo registry as soon as it's imported. +Modern Tempo plugins are designed to be "plug-and-play." By using the `definePlugin` factory, a plugin registers itself with the global Tempo registry as soon as it's imported. ::: warning -**Premium Plugin Example**: The example below uses the `@magmacomputing/tempo-plugin-ticker` plugin, which is a commercial extension. You must provide a valid `license` key during initialization to activate it. +**Premium Plugin Example**: The example below uses the `@magmacomputing/tempo-plugin-ticker` plugin, which is a commercial plugin. You must provide a valid `license` key during initialization to activate it.
@@ -120,26 +103,12 @@ const pulse = Tempo.ticker(1); The core methods of Tempo (like `add`, `set`, `format`) are **protected**. The `extend()` system will prevent you from accidentally overwriting these essential behaviors. By standardizing plugins through the Tempo module system, the entire library remains small and fast, while offering unbounded domain-specific customization. ### 2. Immutability -When adding instance methods that "modify" the date, always follow the Tempo pattern of returning a **new instance**. Use the provided `factory` function to wrap the resulting `Temporal` object back into a `Tempo` instance. +When adding instance methods that "modify" the date, always follow the Tempo pattern of returning a **new instance**. Do not mutate `this`. Rely on the core methods (like `this.add()`) inside your plugin, as they automatically guarantee a fresh, cloned instance. ### 3. Namespace Respect -When adding many related methods, consider grouping them under a single property (e.g., `tempo.term.xyz` or `tempo.it.abc`) to keep the root `Tempo` interface clean and avoid collisions with future core updates. - -### 4. Extending Core Registries -As of **v2.0.1**, Tempo's core registries (`NUMBER`, `TIMEZONE`, `FORMAT`) are protected by a **Soft Freeze** layer. You cannot directly assign new values to them (e.g., `Tempo.TIMEZONE.myZone = '...'` will fail). - -Instead, use **`Tempo.extend()`** to add new data. This is the only supported way to add custom options, formats, or several timezone aliases at once. - -```typescript -Tempo.extend({ - timeZones: { 'UTC+13': 'Pacific/Auckland' }, - registry: { formats: { 'myCode': '{yy}{mm}{dd}' } } -}); -``` - -Using `Tempo.extend()` ensures that the library safely bypasses the "Soft Freeze" protection and that all internal caches (like the Master Guard) are correctly synchronized. +If your plugin provides many related methods, consider grouping them under a single namespace property on the instance (e.g., `tempo.holiday.isPublic()` rather than `tempo.isPublicHoliday()`). This keeps the root `Tempo` interface clean and minimizes the risk of naming collisions with other plugins or future core updates. -### 5. Error Handling & The Diagnostic Engine +### 4. Error Handling & The Diagnostic Engine When building plugins that perform complex parsing or logic, follow Tempo's **"Fail-fast by Default"** principle. - **Strict Mode (Default)**: If your plugin encounters a terminal error (e.g., invalid input that cannot be recovered), you should `throw` a descriptive error. @@ -160,14 +129,6 @@ if (errorCondition) { This pattern ensures that Tempo remains robust in production environments while providing strict validation during development. -### 6. Term Key/Scope Collisions -If your plugin registers a **Term** (`key` and optional `scope`), keep both identifiers globally unique. - -- Avoid reusing an existing Term `key` (e.g., another plugin already uses `qtr`). -- Avoid reusing an existing `scope` alias (e.g., another plugin already uses `quarter`). - -Current behavior is not ideal for collisions: duplicate Term keys are ignored, while scope alias resolution is order-dependent and can shadow another Term. Treat collisions as unsupported and choose unique names to ensure deterministic behavior. - ## Advanced Pattern: Stateful Classes & Callable Proxies For complex plugins (like the **Ticker**) that need to maintain internal state across multiple calls or provide both a class interface and a "shortcut" function, use the **Stateful Class + Proxy** pattern. @@ -195,6 +156,9 @@ Use a standard class to manage your state. This keeps your logic decoupled from class MyPluginInstance implements MyPluginTypes.Descriptor { #self!: MyPluginTypes.Instance; + // The developer writes this bootstrap method to save a reference to the Proxy. + // This is required so the class can return the Proxy (for method chaining) + // instead of returning 'this' (which would be the un-proxied, non-callable instance). bootstrap(proxy: MyPluginTypes.Instance) { this.#self = proxy; return this.#self; @@ -204,26 +168,26 @@ class MyPluginInstance implements MyPluginTypes.Descriptor { ``` ### 3. Wrap with a Proxy in the Factory -Use a `Proxy` in your `defineExtension` factory to handle the callability trap. This allows your plugin to act as a function (the shortcut) and an object (the stateful class) simultaneously. +Use a `Proxy` in your `definePlugin` factory to handle the callability trap. This allows your plugin to act as a function (the shortcut) and an object (the stateful class) simultaneously. ```typescript -export const MyPlugin = defineExtension({ +export const MyPlugin: TempoPlugin = definePlugin({ name: 'MyPlugin', - install(TempoClass: any) { + install(TempoClass) { TempoClass.myTool = function(arg1: any): MyPluginTypes.Instance { const instance = new MyPluginInstance(arg1); - const proxy = new Proxy((() => instance.doSomething()) as any, { - get: (_, prop) => { - // Map proxy properties to instance methods - if (prop in instance) return (instance as any)[prop].bind(instance); - return (instance as any)[prop]; - }, - apply: (target) => target() - }) as unknown as MyPluginTypes.Instance; - - return instance.bootstrap(proxy); - }; + const proxy = new Proxy((() => instance.doSomething()) as any, { + get: (_, prop) => { + // Map proxy properties to instance methods + if (prop in instance) return (instance as any)[prop].bind(instance); + return (instance as any)[prop]; + }, + apply: (target) => target() + }) as unknown as MyPluginTypes.Instance; + + return instance.bootstrap(proxy); + }; }); ``` @@ -240,7 +204,7 @@ If your plugin requires its own configuration, export a **factory function** tha // tempo-plugin-holiday/index.ts import { defineModule } from '@magmacomputing/tempo/plugin-api'; -export const HolidayModule = (pluginOptions = {}) => { +export const HolidayPlugin = (pluginOptions = {}) => { return defineModule((TempoClass, tempoOptions, factory) => { // ... use pluginOptions here ... }); @@ -248,7 +212,7 @@ export const HolidayModule = (pluginOptions = {}) => { ``` ### The Module Aggregator Pattern -If your plugin provides multiple related components (like the `TermsModule`), wrap them in an aggregator module to provide a uniform activation experience for the user. +If your plugin provides multiple related components, wrap them in an aggregator module to provide a uniform activation experience for the user. ```typescript // index.ts @@ -256,14 +220,14 @@ import { defineModule } from '@magmacomputing/tempo/plugin-api'; import { PluginA } from './plugin.a.js'; import { PluginB } from './plugin.b.js'; -export const MyFeatureModule = defineModule((TempoClass, options) => { +export const MyFeaturePlugin = defineModule((TempoClass, options) => { TempoClass.extend([PluginA, PluginB]); }); ``` ### Commercial & Premium Plugins -If you have built a powerful plugin and wish to distribute it commercially, you do not need to implement your own licensing engine. Build your plugin using the standard `defineModule` or `defineExtension` wrappers. +If you have built a powerful plugin and wish to distribute it commercially, you do not need to implement your own licensing engine. Build your plugin using the standard `definePlugin` wrappers. Once your plugin is ready for the marketplace, **[Contact Magma Computing Solutions](https://github.com/magmacomputing)**. We can inject our proprietary licensing and cryptographic verification engine directly into your build pipeline, ensuring your plugin is securely gated and protected from unauthorized use. @@ -296,7 +260,7 @@ async function boot() { ## Consuming a Plugin -For developers using your extension, the process should be as simple as a single import and one call to `extend()`. +For developers using your plugin, the process should be as simple as a single import and one call to `extend()`. ```typescript import { Tempo } from '@magmacomputing/tempo'; @@ -327,9 +291,9 @@ Tempo.extend( ## 🤝 Need Help Writing a Plugin? -If you have a complex business requirement or need a high-performance plugin built to professional standards, we can help. Our team can design, implement, and verify custom Tempo extensions tailored to your specific domain. +If you have a complex business requirement or need a high-performance plugin built to professional standards, we can help. Our team can design, implement, and verify custom Tempo plugins tailored to your specific domain. **[Contact Magma Computing Solutions](https://github.com/magmacomputing)** to discuss your requirements. -- [Extension Plugin Guide](./tempo.extension.md): Learn the "Tempo-way" to write a prototype extension (like Business Days). +- [General Plugin Guide](./tempo.extension.md): Learn the "Tempo-way" to write a prototype plugin (like Business Days). - [Tempo Terms Guide](./tempo.term.md): Documentation on the "Memoized Lookup" pattern for business logic. diff --git a/packages/tempo/doc/tempo.term.md b/packages/tempo/doc/tempo.term.md index 5f19d9fc..e7711c4e 100644 --- a/packages/tempo/doc/tempo.term.md +++ b/packages/tempo/doc/tempo.term.md @@ -158,7 +158,7 @@ Tempo.extend(QuarterTerm); A Term plugin is ideally created using the **`defineTerm`** factory function provided by the library. This ensures correct type-inference and automatically handles registration during the discovery phase. -If you are developing a commercial plugin and require license enforcement, simply build your logic using the standard `defineTerm` factory. Once ready for the marketplace, contact Magma Computing Solutions to have our proprietary licensing and cryptographic verification engine wrapped around your plugin prior to distribution. +If you are developing a commercial plugin and require license enforcement, simply build your logic using the standard `defineTerm` factory. Once ready for the marketplace, **[contact Magma Computing Solutions](https://github.com/magmacomputing)** to have our proprietary licensing and cryptographic verification engine wrapped around your plugin prior to distribution. ### Plugin Definition @@ -189,10 +189,10 @@ function resolve(t: Tempo, anchor?: any): any[] { } /** 3. The Plugin Object */ -export const MySeasonTerm = defineTerm({ - key: 'szn', - scope: 'season', - description: 'Custom seasonal range', +export const MyRetailSeasonTerm = defineTerm({ + key: 'rsn', + scope: 'retailSeason', + description: 'Custom retail seasonal range', ranges, /** Optional: Used for multi-cycle discovery & traversals */ @@ -245,10 +245,10 @@ Use the static **`Tempo.extend()`** method. This allows you to add Terms dynamic ```ts import { Tempo } from '@magmacomputing/tempo/core'; -import { MySeasonTerm } from './term.myseason.js'; +import { MyRetailSeasonTerm } from './term.myretailseason.js'; // Register the term plugin -Tempo.extend(MySeasonTerm); +Tempo.extend(MyRetailSeasonTerm); ``` Every `Tempo` instance created after that point will have the custom Term available. @@ -311,6 +311,13 @@ return keyOnly ? 'MyTerm' : { }; ``` +### Multi-Cycle Discovery & Traversals (The `resolve` method) +The `define` method only returns a *single* boundary (the one the instance currently falls into). But what happens if a user wants to traverse forward by three boundaries (`t.add({ '#rsn': 3 })`)? Or what if the `Ticker` engine needs to discover virtual deadlines across multiple years? + +To enable this, your plugin can optionally provide a **`resolve(anchor)`** method. This method should return the *complete array of boundaries* for a given cycle (e.g., all the retail seasons in a given year). + +When the core engine needs to mathematically "step" through boundaries across multiple years, it repeatedly calls your `resolve` method with different `anchor` dates (e.g., shifting the year forward) to discover future/past boundaries seamlessly! + ## 🛠️ Developer Guide: Best Practices To ensure a custom `Term` plugin integrates fully with Tempo, follow these guidelines: @@ -320,7 +327,7 @@ To ensure a custom `Term` plugin integrates fully with Tempo, follow these guide 3. **Memoization Safety**: Keep the `define` function pure. It will only be called once per instance access. 4. **Math Readiness**: Always use `getTermRange` or provide boundaries. Without them, users cannot use your Term in `add()`, `set()`, or `ticker()`. 5. **Key consistency**: It is valid to remap the returned scope `key` (for example, `cfy` -> `FY2024`) when that is the semantic value your Term represents. Be intentional and keep it consistent with your `ranges` lookup and consumer expectations. -6. **Unique Names**: Keep `key` and `scope` globally unique across all registered Terms. Collisions are unsupported and may produce order-dependent lookups. +6. **Unique Names & Collisions**: Keep `key` and `scope` globally unique across all registered Terms. Avoid reusing an existing Term `key` (e.g., another plugin already uses `qtr`) or `scope` alias. Current behavior is not ideal for collisions: duplicate Term keys are ignored, while scope alias resolution is order-dependent and can shadow another Term. Treat collisions as unsupported and choose unique names to ensure deterministic behavior. 7. **Semantic Casing**: To provide a predictable developer experience, adhere to the following casing standards for your `Term` and `Range` fields: * **Lower-case**: Internal identifiers (Term `key` and `scope`) and marker fields (e.g., `group`, `sphere`, `id`). * **CapInit / TitleCase**: Presentational identifiers (e.g., Range `key` or `symbol`, like `'Q1'` or `'Spring'`). This ensures `t.term.season.key` returns a properly capitalized string. @@ -329,8 +336,8 @@ To ensure a custom `Term` plugin integrates fully with Tempo, follow these guide ```ts declare module '@magmacomputing/tempo/core' { interface TempoTermRegistry { - szn: 'Spring' | 'Summer' | 'Autumn' | 'Winter'; - season: { + rsn: 'Spring' | 'Summer' | 'Autumn' | 'Winter'; + retailSeason: { key: 'Spring' | 'Summer' | 'Autumn' | 'Winter'; symbol: string; // ... diff --git a/packages/tempo/src/plugin/plugin.type.ts b/packages/tempo/src/plugin/plugin.type.ts index aa1c3ccf..58ad99ec 100644 --- a/packages/tempo/src/plugin/plugin.type.ts +++ b/packages/tempo/src/plugin/plugin.type.ts @@ -18,6 +18,7 @@ export interface Module extends Plugin { /** * ## Extension * Type for Extension plugins. + * @deprecated Use `Plugin` instead. */ export interface Extension extends Plugin { [key: string]: any; diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 170946e5..65d3190d 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -11,6 +11,7 @@ import type { Plugin, Module, Extension } from './plugin.type.js'; export type TempoType = typeof Tempo; export type TempoPlugin = Plugin; export type TempoModule = Module; +/** @deprecated Use `TempoPlugin` instead. */ export type TempoExtension = Extension; export function getHost(t: any): any { @@ -171,12 +172,12 @@ export function defineInterpreterModule(name: string, logic: any, statics?: Reco } /** - * ## defineExtension - * Used to register a class-augmenting extension. + * ## definePlugin + * Used to register a plugin. */ -export function defineExtension>(extension: T): T { - registerPlugin(extension); - return extension; +export function definePlugin>(plugin: T): T { + registerPlugin(plugin); + return plugin; } /** diff --git a/packages/tempo/test/plugins/plugin_registration.test.ts b/packages/tempo/test/plugins/plugin_registration.test.ts index 70d47391..15018dd6 100644 --- a/packages/tempo/test/plugins/plugin_registration.test.ts +++ b/packages/tempo/test/plugins/plugin_registration.test.ts @@ -1,8 +1,8 @@ import { Tempo } from '#tempo'; -import { defineExtension } from '#tempo/plugin/plugin.util.js'; +import { definePlugin } from '#tempo/plugin/plugin.util.js'; -const DummyModule = defineExtension({ - name: 'DummyModule', +const DummyPlugin = definePlugin({ + name: 'DummyPlugin', install(TempoClass: any) { TempoClass.dummy = true; } @@ -11,7 +11,7 @@ const DummyModule = defineExtension({ describe('Plugin Registration / Initialization', () => { test('Plugins should survive Tempo.init() reset', () => { // 1. Verify installed - Tempo.extend(DummyModule); + Tempo.extend(DummyPlugin); Tempo.init(); expect((Tempo as any).dummy).toBe(true); From 06522a24fbab03a00850272b51dca1a68c39e479 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 6 Jul 2026 17:11:12 +1000 Subject: [PATCH 5/6] PR 1st review --- .../library/src/common/request.library.ts | 4 +- packages/library/src/common/type.library.ts | 6 +-- packages/tempo/CHANGELOG.md | 6 +++ packages/tempo/src/engine/engine.lexer.ts | 10 ++--- packages/tempo/src/engine/engine.pattern.ts | 2 +- packages/tempo/src/library.index.ts | 2 +- packages/tempo/src/module/module.duration.ts | 32 +++++++------- packages/tempo/src/module/module.mutate.ts | 2 +- packages/tempo/src/plugin/plugin.util.ts | 7 ++++ packages/tempo/src/support/support.enum.ts | 3 +- packages/tempo/src/tempo.class.ts | 3 ++ packages/tempo/src/tempo.type.ts | 42 ++++++++++--------- 12 files changed, 71 insertions(+), 48 deletions(-) diff --git a/packages/library/src/common/request.library.ts b/packages/library/src/common/request.library.ts index 686563b2..85cb119a 100644 --- a/packages/library/src/common/request.library.ts +++ b/packages/library/src/common/request.library.ts @@ -1,6 +1,6 @@ import type { ValueOf } from '#library/type.library.js'; -const TWO_SECONDS = 2000; // default time-out for requests, in milliseconds +const TWO_SECONDS = 2_000; // default time-out for requests, in milliseconds export const HTTP = { Ok: 200, @@ -88,6 +88,6 @@ export const fetchHead = (url: string | URL) => { if (ok || status === HTTP.Forbidden) // forbidden, but at least we know url responds return { status, headers } - throw new Error(`${status}: ${statusText}`); // fetch not successful + throw new HttpError(status, statusText, null); // fetch not successful }) } diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index fdf2045a..8d40139b 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -177,6 +177,7 @@ export type OneKey = { [Q in keyof O]: O[Q] } : never }[K] +/** @deprecated natively supported by modern IDEs via hover verbosity. Slated for removal in v4.0.0 */ export type Prettify = { [K in keyof T]: T[K]; } & {} export type ParseInt = T extends `${infer N extends number}` ? N : never export type Plural = `${T}s`; @@ -402,15 +403,14 @@ export type Secure = T extends Primitive | Function | Date | RegExp | Error | ? SecureObject : T export interface SecureArray extends ReadonlyArray> { } -export type SecureObject = { readonly [K in keyof T]: Secure }; +export type SecureObject = { readonly [K in keyof T]: Secure } type LooseString = (string & {}) -type LooseSymbol = (symbol & {}) type LooseProperty = (PropertyKey & {}) // https://www.youtube.com/watch?v=lraHlXpuhKs&t=43s /** Loose union */ -export type LooseUnion = T | LooseString +export type LooseUnion = T | LooseString /** Loose property key */ export type LooseKey = K | LooseProperty // /** Loose auto-complete */ diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index c14dad99..02d32ee5 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Shorthand Mutation Keys**: Added native support for Tempo's shorthand format tokens (e.g., `mi`, `ss`, `yy`, `ww`) across both `.add()` and `.set()` mutations, streamlining developer experience and aligning TypeScript definitions with the underlying runtime engine. - **Shorthand Duration Keys**: Expanded shorthand token support directly into the `DurationModule`. You can now seamlessly use shorthand keys for duration instantiation (`Tempo.duration({ mi: 5 })`), comparisons (`t.until(other, 'mi')`), and strict balancing (`t.until(other).balance({ largestUnit: 'mi' })`), bringing total API consistency across the core library. +### Changed +- **Enum Performance Optimization**: Upgraded internal dictionary traversals across the parsing engine to utilize native, memoized `.keys()` methods provided by the `enumify` registry, eliminating the overhead of standard `Object.keys()` iterations. + +### Fixed +- **Prototype Bleed Vulnerability**: Replaced `in` operator checks with secure `.has()` methods across the core engine and duration parser, ensuring strict enum member validation and completely eliminating the risk of arbitrary property or method-based prototype leakage. + ## [3.5.3] - 2026-07-05 ### Added diff --git a/packages/tempo/src/engine/engine.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts index 169e59bd..b5938224 100644 --- a/packages/tempo/src/engine/engine.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -30,14 +30,14 @@ function num(groups: Record) { } const num = resolveNumber(val); - if (num in enums.NUMBER) { + if (enums.NUMBER.has(num)) { acc[key] = enums.NUMBER[num as t.Number]; return acc; } const cal = prefix(val); // get the three-character prefix for a Weekday/Month - if (cal in enums.WEEKDAY) acc[key] = enums.WEEKDAY[cal as t.WEEKDAY]; - else if (cal in enums.MONTH) acc[key] = enums.MONTH[cal as t.MONTH]; + if (enums.WEEKDAY.has(cal)) acc[key] = enums.WEEKDAY[cal as t.WEEKDAY]; + else if (enums.MONTH.has(cal)) acc[key] = enums.MONTH[cal as t.MONTH]; return acc; }, {} as Record); @@ -47,7 +47,7 @@ function num(groups: Record) { export function resolveNumber(str: any): t.Number | any { if (!isString(str)) return str; const low = str.trim().toLowerCase(); - return Object.keys(enums.NUMBER).find(key => key.startsWith(low)) ?? str; + return enums.NUMBER.keys().find(key => key.startsWith(low)) ?? str; } /** conform weekday names (3-characters) using prefix matching */ @@ -65,7 +65,7 @@ export function prefix(str: any): any { if (low === 'all' || low === 'eve') return 'All'; // handle special case of "all" / "every" for (const table of [enums.WEEKDAY, enums.MONTH]) { - const match = Object.keys(table).find(key => { + const match = table.keys().find(key => { const normalized = key.toLowerCase(); return normalized.startsWith(low); }); diff --git a/packages/tempo/src/engine/engine.pattern.ts b/packages/tempo/src/engine/engine.pattern.ts index 81964bb9..04f541ea 100644 --- a/packages/tempo/src/engine/engine.pattern.ts +++ b/packages/tempo/src/engine/engine.pattern.ts @@ -116,7 +116,7 @@ export class PatternCompiler { // 1. ensure numeric snippets are current if (enums?.NUMBER) { - const keys = Object.keys(enums.NUMBER).map(w => Match.escape(w)); + const keys = enums.NUMBER.keys().map(w => Match.escape(w)); const nbr = new RegExp(`(?[0-9]+|${keys.sort((a, b) => b.length - a.length).join('|')})`); snippet[Token.nbr] = nbr; diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts index bb2dd7e8..58656121 100644 --- a/packages/tempo/src/library.index.ts +++ b/packages/tempo/src/library.index.ts @@ -11,7 +11,7 @@ export { enumify, type Enum } from '#library/enumerate.library.js'; export { proxify } from '#library/proxy.library.js'; export { stringify, objectify, cloneify } from '#library/serialize.library.js'; export { isObject, isFunction, isDefined, isUndefined, isEmpty, isNumber, isNumeric, isFiniteNumber } from '#library/assertion.library.js'; -export { fetchRequest, fetchHead } from '#library/request.library.js'; +export { fetchRequest, fetchHead, HttpError } from '#library/request.library.js'; export { asArray } from '#library/coercion.library.js'; export { instant, normaliseFractionalDurations } from '#library/temporal.library.js'; diff --git a/packages/tempo/src/module/module.duration.ts b/packages/tempo/src/module/module.duration.ts index b0930c13..94947b9b 100644 --- a/packages/tempo/src/module/module.duration.ts +++ b/packages/tempo/src/module/module.duration.ts @@ -35,6 +35,15 @@ declare module '#library/type.library.js' { } } +/** + * Maps shorthand units (via enums.ELEMENT) to standard plural unit names. + */ +function resolveElementUnit(unit: string): string | undefined { + return (enums.ELEMENT.has(unit)) + ? `${enums.ELEMENT[unit as t.Element]}s` + : undefined; +} + /** * Convert a Temporal.Duration to a full Tempo.Duration object (EDO). */ @@ -81,9 +90,7 @@ function toDuration(dur: Temporal.Duration, ctx: { relativeTo?: any, locale?: st if (!anchor) throw new TempoError("A relativeTo anchor is required for strict balancing. Pass an anchor or use { nominal: true } for mathematical balancing."); - let temporalUnit = largestUnit; - if (temporalUnit === 'ww') temporalUnit = 'weeks'; - else if (temporalUnit in enums.ELEMENT) temporalUnit = `${enums.ELEMENT[temporalUnit as t.Element]}s`; + const temporalUnit = resolveElementUnit(largestUnit as string) ?? largestUnit; const balanced = dur.round({ largestUnit: temporalUnit, relativeTo: anchor }); @@ -135,14 +142,13 @@ function toDuration(dur: Temporal.Duration, ctx: { relativeTo?: any, locale?: st /** * Internal implementation of Tempo.until and Tempo.since - * (moved out of tempo.class.ts to reduce core bundle size) */ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) { const since = type === 'since'; let value, opts: any = {}, unit: any; switch (true) { - case isString(arg) && (enums.ELEMENT.values().includes(singular(arg) as any) || arg in enums.ELEMENT || arg === 'ww'): + case isString(arg) && (enums.ELEMENT.values().includes(singular(arg) as any) || enums.ELEMENT.has(arg)): unit = arg; ({ value, ...opts } = until || {}); break; @@ -184,9 +190,7 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) let temporalUnit = unit; if (isDefined(temporalUnit)) { - if (temporalUnit === 'ww') temporalUnit = 'weeks'; - else if (temporalUnit in enums.ELEMENT) temporalUnit = `${enums.ELEMENT[temporalUnit as t.Element]}s`; - else temporalUnit = `${singular(temporalUnit)}s`; + temporalUnit = resolveElementUnit(temporalUnit as string) ?? `${singular(temporalUnit)}s`; } const diffZone = selfTz !== offsetTz; @@ -218,17 +222,17 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) || (isFunction(rtConfig) ? rtConfig : rtConfig?.format) || opts['rtfFormat'] || (this as any).config['rtfFormat']; - const getOpt = (key: string, legacy: string, def: string) => + const getOpt = (key: string, legacy: string, def: string) => rtOptions?.[key] || rtConfig?.[key] || opts[legacy] || (this as any).config[legacy] || def; const getFormatted = (val: number, u: any) => { const su = singular(u); if (isFunction(rtf)) return rtf(val, su); if (rtf instanceof Intl.RelativeTimeFormat) return rtf.format(val, su); - + const style = getOpt('style', 'rtfStyle', 'narrow'); const numeric = getOpt('numeric', 'rtfNumeric', 'always'); - + return getRelativeTime(val, su as Intl.RelativeTimeFormatUnit, locale, style, numeric); } @@ -262,9 +266,9 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) if (isObject(input)) { mappedInput = { ...input }; for (const [k, v] of Object.entries(input)) { - if (k === 'ww') { mappedInput['weeks'] = v; delete mappedInput[k]; } - else if (k in enums.ELEMENT) { - mappedInput[`${enums.ELEMENT[k as t.Element]}s`] = v; + const resolved = resolveElementUnit(k); + if (resolved) { + mappedInput[resolved] = v; delete mappedInput[k]; } } diff --git a/packages/tempo/src/module/module.mutate.ts b/packages/tempo/src/module/module.mutate.ts index 819258ec..de24e055 100644 --- a/packages/tempo/src/module/module.mutate.ts +++ b/packages/tempo/src/module/module.mutate.ts @@ -207,7 +207,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options case 'add.yy': case 'add.mm': case 'add.dd': case 'add.hh': case 'add.mi': case 'add.ss': case 'add.ms': case 'add.us': case 'add.ns': case 'add.wy': case 'add.ww': { - const value = single === 'ww' ? 'week' : enums.ELEMENT[single as t.Element]; + const value = enums.ELEMENT[single as t.Element]; return currZdt.add({ [`${value}s`]: offset }); } diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 65d3190d..64adf52d 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -180,6 +180,13 @@ export function definePlugin>(plugin: T): T { return plugin; } +/** + * @deprecated Use `definePlugin` instead. Retained for backwards compatibility. + */ +export function defineExtension>(plugin: T): T { + return definePlugin(plugin); +} + /** * ## registerPlugin * Registration hook for general plugins. diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index 76eedfc1..9d644cb8 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -214,11 +214,12 @@ export const MONTH_DAY = proxify(STATE.MONTH_DAY, true, false); export const LOCALE = proxify(STATE.LOCALE, true, true); /** date-time element tokens */ -const elementKeys = ['yy', 'mm', 'wy', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns'] as const; +const elementKeys = ['yy', 'mm', 'wy', 'ww', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns'] as const; export const ELEMENT = enumify({ yy: 'year', mm: 'month', wy: 'week', + ww: 'week', dd: 'day', hh: 'hour', mi: 'minute', diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index a4e37b3e..459c194b 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -449,6 +449,9 @@ export class Tempo { static [$buildGuard](targetState?: Internal.State) { const state = targetState ?? this[$Internal](); + // Note: We MUST use Object.keys() here instead of enums.XXX.keys() because this static guard + // executes during module initialization. Circular dependencies mean the enumify methods + // (like .keys()) may not be fully attached to the prototype yet! const wordsList = [ ...Object.keys(enums.NUMBER), ...Object.keys(enums.WEEKDAY), diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index f696667f..4bab7797 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -9,7 +9,7 @@ import type { Pledge } from '#library/pledge.class.js'; import type { DebugLevel } from '#library/logger.class.js'; import type { ScopedSet } from '#library/scopedset.class.js'; -import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue, RegistryOption, Branded } from '#library/type.library.js'; +import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue, RegistryOption, Branded, LooseUnion } from '#library/type.library.js'; import { sym, type TempoBrand } from '#tempo/support/support.symbol.js'; import * as enums from '#tempo/support/support.enum.js'; @@ -99,7 +99,7 @@ export interface Options extends Partial { /** Configuration to use for #until() and #since() argument */ export type DateTimeUnit = Temporal.DateUnit | Temporal.TimeUnit -export type Unit = DateTimeUnit | Plural | Element | 'ww' +export type Unit = DateTimeUnit | Plural | Element export type Units = Plural; export type BaseDuration = Record; /** @@ -132,30 +132,30 @@ export type SlickKey = SLICK_KEYS[number]; export type SlickOffset = { [K in SlickKey]?: string }; export type MutateShorthand = { - yy?: string | number; - mm?: string | number; - ww?: string; - dd?: string | number; - hh?: string | number; - mi?: string | number; - ss?: string | number; - ms?: number; - us?: number; - ns?: number; - wkd?: string; -}; - -export type MutateSet = Prettify; + mm?: LooseUnion; + ww?: LooseUnion; + dd?: LooseUnion
; + hh?: LooseUnion; + mi?: LooseUnion; + ss?: LooseUnion; + ms?: LooseUnion; + us?: LooseUnion; + ns?: LooseUnion; + wkd?: LooseUnion; +} + +export type MutateSet = SetFields & MutateShorthand & { timeZone?: Temporal.TimeZoneLike; calendar?: Temporal.CalendarLike; -} & TermOffset> | DateTime +} & TermOffset | DateTime export type AddUnits = { [K in Unit]?: number }; -export type MutateAdd = Prettify | DateTime +export type MutateAdd = AddUnits & { [K in Element]?: number } & TermOffset | DateTime export type Modifier = '=' | '-' | '+' | '<' | '<=' | '-=' | '>' | '>=' | '+=' | 'this' | 'next' | 'prev' | 'last' | 'first' | undefined export type Relative = 'ago' | 'hence' | 'prior' | 'from now' -export type mm = IntRange<0, 12> +export type mm = IntRange<1, 12> export type dd = IntRange<1, 31> export type hh = IntRange<0, 24> export type mi = IntRange<0, 60> @@ -163,9 +163,11 @@ export type ss = IntRange<0, 60> export type ms = IntRange<0, 999> export type us = IntRange<0, 999> export type ns = IntRange<0, 999> +/** ISO 8601 week number (1-53) */ export type wy = IntRange<1, 53> -/** @deprecated use `wy` */ +/** alias for `wy` */ export type ww = IntRange<1, 53> +export type wkd = IntRange<1, 7> export type Duration = NonOptional & Record<"iso", string> & Record<"sign", number> & Record<"blank", boolean> & Record<"unit", string | undefined> & { balance(opts?: { nominal?: boolean; relativeTo?: any; largestUnit?: Unit | string }): Duration; From 877d1764391df1a0d7bc79a285dd68fa997e3d39 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 6 Jul 2026 17:35:55 +1000 Subject: [PATCH 6/6] tighten hh|mi|ss upper boundaries --- packages/tempo/src/tempo.type.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 4bab7797..0fc9552b 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -134,6 +134,7 @@ export type SlickOffset = { [K in SlickKey]?: string }; export type MutateShorthand = { yy?: LooseUnion; mm?: LooseUnion; + wy?: LooseUnion; ww?: LooseUnion; dd?: LooseUnion
; hh?: LooseUnion; @@ -157,9 +158,9 @@ export type Relative = 'ago' | 'hence' | 'prior' | 'from now' export type mm = IntRange<1, 12> export type dd = IntRange<1, 31> -export type hh = IntRange<0, 24> -export type mi = IntRange<0, 60> -export type ss = IntRange<0, 60> +export type hh = IntRange<0, 23> +export type mi = IntRange<0, 59> +export type ss = IntRange<0, 59> export type ms = IntRange<0, 999> export type us = IntRange<0, 999> export type ns = IntRange<0, 999>