From 79c6c54c0465ba504c7bb420b742bb016b1d79f4 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 7 Sep 2026 17:34:17 +1000 Subject: [PATCH 01/11] initial commit --- package-lock.json | 4 ++-- packages/plugins/.setup/catalog.json | 2 +- packages/plugins/geo/CHANGELOG.md | 7 +++++++ packages/plugins/geo/package.json | 4 ++-- packages/tempo/.vitepress/theme/data/catalog.json | 2 +- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 59846ea6..240e4153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10030,13 +10030,13 @@ }, "packages/plugins/geo": { "name": "@magmacomputing/tempo-plugin-geo", - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@js-temporal/polyfill": "^0.5.1" }, "peerDependencies": { - "@magmacomputing/tempo": "^4.1.1" + "@magmacomputing/tempo": "^4.1.0" } }, "packages/plugins/snap": { diff --git a/packages/plugins/.setup/catalog.json b/packages/plugins/.setup/catalog.json index 4d557044..df3c659d 100644 --- a/packages/plugins/.setup/catalog.json +++ b/packages/plugins/.setup/catalog.json @@ -78,7 +78,7 @@ "packageName": "@magmacomputing/tempo-plugin-geo", "plan": "community", "status": "active", - "version": "0.1.0" + "version": "1.0.0" }, { "id": "_std", diff --git a/packages/plugins/geo/CHANGELOG.md b/packages/plugins/geo/CHANGELOG.md index c94a8541..85d1d870 100644 --- a/packages/plugins/geo/CHANGELOG.md +++ b/packages/plugins/geo/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the `@magmacomputing/tempo-plugin-geo` project will be do The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] - 2026-09-07 + +### Added +- **Stable Community Release**: + - Official 1.0.0 release of `@magmacomputing/tempo-plugin-geo`. + - Configured npm Trusted Publisher automation for CI/CD publishing. + ## [0.1.0] - 2026-09-07 ### Added diff --git a/packages/plugins/geo/package.json b/packages/plugins/geo/package.json index 1f10ac15..4bed3b2f 100644 --- a/packages/plugins/geo/package.json +++ b/packages/plugins/geo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-geo", - "version": "0.1.0", + "version": "1.0.0", "description": "Tempo community plugin for IP geolocation lookup, browser hardware location services, and coordinate resolution.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -53,4 +53,4 @@ "default": "./dist/index.js" } } -} \ No newline at end of file +} diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index 4d557044..df3c659d 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -78,7 +78,7 @@ "packageName": "@magmacomputing/tempo-plugin-geo", "plan": "community", "status": "active", - "version": "0.1.0" + "version": "1.0.0" }, { "id": "_std", From 7bdc525f149992a8bc1f4548b2fbf354964f5dc5 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 8 Sep 2026 11:11:38 +1000 Subject: [PATCH 02/11] update tempo-plugin-batch --- packages/plugins/.setup/catalog.json | 4 +-- packages/plugins/ai/CHANGELOG.md | 12 ++++--- packages/plugins/ai/package.json | 2 +- packages/plugins/batch/CHANGELOG.md | 9 ++++++ packages/plugins/batch/package.json | 2 +- .../plugins/batch/src/BatchOrchestrator.ts | 20 ++++++++---- packages/plugins/batch/src/worker.ts | 31 ++++++++++++++++--- 7 files changed, 60 insertions(+), 20 deletions(-) diff --git a/packages/plugins/.setup/catalog.json b/packages/plugins/.setup/catalog.json index df3c659d..5a5cab59 100644 --- a/packages/plugins/.setup/catalog.json +++ b/packages/plugins/.setup/catalog.json @@ -24,7 +24,7 @@ "packageName": "@magmacomputing/tempo-plugin-batch", "plan": "community", "status": "active", - "version": "1.0.2" + "version": "1.1.0" }, { "id": "finance", @@ -60,7 +60,7 @@ "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", "status": "active", - "version": "1.2.1" + "version": "2.0.0" }, { "id": "ticker", diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 5c0a6101..75504373 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -5,13 +5,15 @@ All notable changes to the `@magmacomputing/tempo-plugin-ai` project will be doc The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.2.1] - 2026-09-07 +## [2.0.0] - 2026-09-08 -### Security & Reliability +### Breaking Changes & Major Release +- **Standardized on Tempo v4.1.0+ Community Core**: + - Requires `@magmacomputing/tempo` `^4.1.0`. + - Migrated configuration discovery to the dedicated `pluginOptions.ai` configuration slot introduced in Tempo v4.1.0, deprecating legacy `plugins.ai` dictionary passing. - **Self-Contained Network Transport (`fetch.ts`)**: - - Decoupled network request utilities from `@magmacomputing/tempo/library` into a self-contained local transport helper. - - Added chunk-by-chunk stream consumption via `res.body.getReader()` with proactive byte accounting and immediate reader cancellation (`await reader.cancel()`). - - Enforced upfront `Content-Length` checks against `maxBytes` and strictly bounded stream reads to prevent unbounded memory allocation and OS thread starvation. + - Decoupled network transport entirely from `@magmacomputing/tempo/library` (removing reliance on core internal `HttpError` and `fetchRequest`). + - Implemented proactive stream consumption via `res.body.getReader()` with strict byte accounting and upfront `Content-Length` enforcement against `maxBytes` to prevent memory exhaustion. ## [1.2.0] - 2026-09-06 diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index 3b78cccb..7497adb2 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-ai", - "version": "1.2.1", + "version": "2.0.0", "description": "Tempo community plugin for LLM-powered natural language parsing.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/plugins/batch/CHANGELOG.md b/packages/plugins/batch/CHANGELOG.md index e0ac1e6f..7cc4112c 100644 --- a/packages/plugins/batch/CHANGELOG.md +++ b/packages/plugins/batch/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to the `@magmacomputing/tempo-plugin-batch` project will be documented in this file. +## [1.1.0] - 2026-09-08 + +### Added & Enhanced +- **Worker Thread Runtime Flag Inheritance (`execArgv`)**: + - Configured `BatchOrchestrator` to forward `process.execArgv` to spawned worker threads. + - Workers automatically inherit native runtime flags (such as `--harmony-temporal`) or custom loader configurations (`--import`) without bundling or depending on a polyfill, strictly preserving Tempo's zero-dependency, native-first Temporal philosophy. +- **Shorthand Mutation Parsing**: + - Added support for relative duration shorthand strings (e.g. `+1d`, `+1w`, `+1h`, `+1m`) alongside `{ Term: Value }` mutation objects. + ## [1.0.2] - 2026-08-20 ### Fixed diff --git a/packages/plugins/batch/package.json b/packages/plugins/batch/package.json index 6a912d81..4937489c 100644 --- a/packages/plugins/batch/package.json +++ b/packages/plugins/batch/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-batch", - "version": "1.0.2", + "version": "1.1.0", "description": "Tempo community plugin bringing C-level parallelization to massive date arrays via SharedArrayBuffer and Worker Threads.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/plugins/batch/src/BatchOrchestrator.ts b/packages/plugins/batch/src/BatchOrchestrator.ts index c9447175..78bb8c22 100644 --- a/packages/plugins/batch/src/BatchOrchestrator.ts +++ b/packages/plugins/batch/src/BatchOrchestrator.ts @@ -36,9 +36,8 @@ export class BatchOrchestrator { if (epochs.length === 0) return []; if (options.threads !== undefined) { - if (!Number.isInteger(options.threads) || options.threads <= 0) { + if (!Number.isInteger(options.threads) || options.threads <= 0) throw new Error("options.threads must be a positive integer"); - } } const threadCount = options.threads ?? os.cpus().length; @@ -82,6 +81,10 @@ export class BatchOrchestrator { const workers: Promise[] = []; const actualThreads = Math.min(threadCount, Math.ceil(epochs.length / chunkSize)); + const execArgv = (process.execArgv || []).filter( + arg => !arg.startsWith('--input-type') && !arg.startsWith('--eval') && !arg.startsWith('-e') && !arg.startsWith('--print') && !arg.startsWith('-p') + ); + for (let i = 0; i < actualThreads; i++) { const startIdx = i * chunkSize; const endIdx = Math.min((i + 1) * chunkSize, epochs.length); @@ -94,8 +97,9 @@ export class BatchOrchestrator { outputBuffer, startIdx, endIdx, - operation - } + operation, + }, + execArgv, }); worker.on('message', (msg: any) => { if (msg.status === 'done') resolve(); @@ -135,6 +139,9 @@ export class BatchOrchestrator { private static async _transformWithPostMessage(epochs: number[], operation: string, threadCount: number, chunkSize: number, options: BatchOptions): Promise { const workers: Promise[] = []; const actualThreads = Math.min(threadCount, Math.ceil(epochs.length / chunkSize)); + const execArgv = (process.execArgv || []).filter( + arg => !arg.startsWith('--input-type') && !arg.startsWith('--eval') && !arg.startsWith('-e') && !arg.startsWith('--print') && !arg.startsWith('-p') + ); for (let i = 0; i < actualThreads; i++) { const startIdx = i * chunkSize; @@ -146,8 +153,9 @@ export class BatchOrchestrator { workerData: { mode: 'postMessage', chunk, - operation - } + operation, + }, + execArgv, }); worker.on('message', (msg: any) => { if (msg.status === 'done') resolve(msg.result); diff --git a/packages/plugins/batch/src/worker.ts b/packages/plugins/batch/src/worker.ts index 457d2070..4ad3362d 100644 --- a/packages/plugins/batch/src/worker.ts +++ b/packages/plugins/batch/src/worker.ts @@ -1,6 +1,30 @@ import { workerData, parentPort } from 'node:worker_threads'; import { Tempo } from '@magmacomputing/tempo'; +function applyMutation(t: any, op: any) { + if (typeof op === 'string') { + const match = op.trim().match(/^([+-]?\d+)\s*([a-zA-Z]+)$/); + if (match) { + const count = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + const unitMap: Record = { + d: 'days', day: 'days', days: 'days', + w: 'weeks', week: 'weeks', weeks: 'weeks', + m: 'minutes', min: 'minutes', mins: 'minutes', minute: 'minutes', minutes: 'minutes', + h: 'hours', hr: 'hours', hrs: 'hours', hour: 'hours', hours: 'hours', + s: 'seconds', sec: 'seconds', secs: 'seconds', second: 'seconds', seconds: 'seconds', + mo: 'months', month: 'months', months: 'months', + y: 'years', yr: 'years', yrs: 'years', year: 'years', years: 'years' + }; + const mappedUnit = unitMap[unit]; + if (mappedUnit) { + return t.add({ [mappedUnit]: count }); + } + } + } + return t.add(op); +} + async function run() { if (!parentPort) return; @@ -14,11 +38,8 @@ async function run() { for (let i = startIdx; i < endIdx; i++) { const epoch = inputView[i]; - // Using Tempo to mutate. In a full implementation, we'd have robust parsing of the 'operation' string. - // For this prototype, we assume the operation is an add operation (e.g. "+1w"). - // We get the mutated epoch number and put it back into the buffer. const t = new Tempo(epoch); - const resultT = t.add(operation); + const resultT = applyMutation(t, operation); outputView[i] = resultT.epoch.ms; } parentPort.postMessage({ status: 'done' }); @@ -30,7 +51,7 @@ async function run() { for (let i = 0; i < chunk.length; i++) { const epoch = chunk[i]; const t = new Tempo(epoch); - const resultT = t.add(operation); + const resultT = applyMutation(t, operation); result[i] = resultT.epoch.ms; } From 71be40a354ef47994121bef3eae09f1d39995237 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 8 Sep 2026 12:43:08 +1000 Subject: [PATCH 03/11] PR 1st review --- .../src/common/runtime/mapper.library.ts | 37 ++++++++- .../test/common/runtime/mapper.common.test.ts | 54 ++++++++++++- .../plugins/batch/src/BatchOrchestrator.ts | 69 +++++++++++++++-- packages/plugins/batch/src/index.ts | 1 + packages/plugins/batch/test/batch.test.ts | 77 ++++++++++++++++++- 5 files changed, 225 insertions(+), 13 deletions(-) diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts index 8e40bd9a..46dbf660 100644 --- a/packages/library/src/common/runtime/mapper.library.ts +++ b/packages/library/src/common/runtime/mapper.library.ts @@ -1,5 +1,6 @@ import { CONTEXT, getContext } from '#library/utility.library.js'; import { isNullish, isNumber, isString, isSafeKey } from '#library/assertion.library.js'; +import { getStorage } from '#library/storage.library.js'; export interface GeoLookupResult { lat?: number; @@ -120,9 +121,39 @@ export const getStashedGeo = (): GeoConfig | undefined => { const raw = localStorage.getItem('_map_'); if (raw) { const parsed = JSON.parse(raw); - const coords = parsed?.geolocation?.coords; - if (isNumber(coords?.latitude) && isNumber(coords?.longitude)) { - return { latitude: coords.latitude, longitude: coords.longitude }; + const coords = parsed?.geolocation?.coords ?? parsed?.coords ?? parsed; + const lat = coords?.latitude ?? coords?.lat; + const lng = coords?.longitude ?? coords?.lng ?? coords?.lon ?? coords?.long; + if (isNumber(lat) && isNumber(lng)) { + return { latitude: lat, longitude: lng }; + } + } + } + } catch { + // ignore storage access errors + } + } else if (type === CONTEXT.NodeJS || type === CONTEXT.Deno) { + try { + const raw = getStorage('_map_') ?? getStorage('TEMPO_GEO'); + if (raw) { + if (typeof raw === 'string' && raw.includes(',')) { + const parts = raw.split(',').map(s => parseFloat(s.trim())); + if (parts.length >= 2 && isNumber(parts[0]) && isNumber(parts[1])) { + return { latitude: parts[0], longitude: parts[1] }; + } + } + if (typeof raw === 'object') { + const coords = raw.geolocation?.coords ?? raw.coords ?? raw; + const lat = coords?.latitude ?? coords?.lat; + const lng = coords?.longitude ?? coords?.lng ?? coords?.lon ?? coords?.long; + if (isNumber(lat) && isNumber(lng)) { + const result: GeoConfig = { latitude: lat, longitude: lng }; + const elevation = raw.elevation ?? coords.elevation; + if (isNumber(elevation)) result.elevation = elevation; + if (raw.sphere === 'north' || raw.sphere === 'south') result.sphere = raw.sphere; + if (isString(raw.country)) result.country = raw.country; + if (isString(raw.city)) result.city = raw.city; + return result; } } } diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts index 76798a1e..f1f54fe6 100644 --- a/packages/library/test/common/runtime/mapper.common.test.ts +++ b/packages/library/test/common/runtime/mapper.common.test.ts @@ -1,4 +1,5 @@ -import { coerceGeo, geoLookup, resolveGeoCoordinates } from '../../../src/common/runtime/mapper.library.js'; +import { coerceGeo, geoLookup, resolveGeoCoordinates, getStashedGeo } from '../../../src/common/runtime/mapper.library.js'; +import { setStorage } from '#library/storage.library.js'; describe('common/runtime/mapper.library', () => { afterEach(() => { @@ -61,8 +62,59 @@ describe('common/runtime/mapper.library', () => { expect(result.lat).toBe(-33.8688); expect(result.lng).toBe(151.2093); expect(result.city).toBe('Sydney'); + expect(mockFetch).toHaveBeenCalledTimes(1); } finally { vi.unstubAllGlobals(); } }); + + it('getStashedGeo in Node.js returns undefined by default without developer configuration', () => { + expect(getStashedGeo()).toBeUndefined(); + }); + + it('getStashedGeo in Node.js discovers coordinates set explicitly via setStorage', () => { + try { + setStorage('_map_', { + geolocation: { coords: { latitude: -33.8688, longitude: 151.2093 } }, + city: 'Sydney', + }); + + const stashed = getStashedGeo(); + expect(stashed).toBeDefined(); + expect(stashed?.latitude).toBe(-33.8688); + expect(stashed?.longitude).toBe(151.2093); + expect(stashed?.city).toBe('Sydney'); + } finally { + setStorage('_map_', undefined); + } + }); + + it('getStashedGeo in Node.js discovers coordinates pre-seeded in process.env.TEMPO_GEO', () => { + try { + setStorage('_map_', undefined); + process.env.TEMPO_GEO = '{"latitude": 37.7749, "longitude": -122.4194, "city": "San Francisco"}'; + + const stashed = getStashedGeo(); + expect(stashed).toBeDefined(); + expect(stashed?.latitude).toBe(37.7749); + expect(stashed?.longitude).toBe(-122.4194); + expect(stashed?.city).toBe('San Francisco'); + } finally { + delete process.env.TEMPO_GEO; + } + }); + + it('getStashedGeo in Node.js supports comma-separated string coordinates in TEMPO_GEO', () => { + try { + setStorage('_map_', undefined); + process.env.TEMPO_GEO = '51.5074, -0.1278'; + + const stashed = getStashedGeo(); + expect(stashed).toBeDefined(); + expect(stashed?.latitude).toBe(51.5074); + expect(stashed?.longitude).toBe(-0.1278); + } finally { + delete process.env.TEMPO_GEO; + } + }); }); diff --git a/packages/plugins/batch/src/BatchOrchestrator.ts b/packages/plugins/batch/src/BatchOrchestrator.ts index 78bb8c22..75fc01ff 100644 --- a/packages/plugins/batch/src/BatchOrchestrator.ts +++ b/packages/plugins/batch/src/BatchOrchestrator.ts @@ -21,10 +21,65 @@ export interface BatchOptions { rehydrate?: boolean; } +const ALLOWED_FLAGS_WITH_VALUE = new Set([ + '--import', + '--loader', + '--experimental-loader', + '-r', + '--require', +]); + +const ALLOWED_STANDALONE_FLAGS = new Set([ + '--experimental-vm-modules', + '--experimental-temporal', + '--experimental-specifier-resolution', + '--inspect', + '--inspect-brk', + '--trace-warnings', + '--no-warnings', + '--trace-deprecation', + '--no-deprecation', +]); + /** * Orchestrates the parallel execution of a mutation or formatting operation across an array of epochs. */ export class BatchOrchestrator { + /** + * Sanitizes process.execArgv using a positive AllowList of worker-safe options (loaders, polyfills, inspection). + * Any V8 memory/optimization flags or process-level flags are safely dropped. + * + * @param argv - Array of Node CLI options (defaults to process.execArgv) + * @returns Sanitized array of CLI options safe for worker_threads + * @internal + */ + static sanitizeExecArgv(argv: string[] = process.execArgv || []): string[] { + const result: string[] = []; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const eqIdx = arg.indexOf('='); + const hasEq = eqIdx !== -1; + const rawFlag = hasEq ? arg.slice(0, eqIdx) : arg; + const flag = rawFlag.replace(/_/g, '-'); + + if (ALLOWED_FLAGS_WITH_VALUE.has(flag)) { + if (hasEq) { + result.push(arg); + } else if (i + 1 < argv.length) { + result.push(arg, argv[++i]); + } + continue; + } + + if (ALLOWED_STANDALONE_FLAGS.has(flag)) { + result.push(arg); + continue; + } + } + + return result; + } /** * Transforms an array of epochs using a worker pool. * @param epochs Array of raw millisecond epoch numbers. @@ -81,9 +136,8 @@ export class BatchOrchestrator { const workers: Promise[] = []; const actualThreads = Math.min(threadCount, Math.ceil(epochs.length / chunkSize)); - const execArgv = (process.execArgv || []).filter( - arg => !arg.startsWith('--input-type') && !arg.startsWith('--eval') && !arg.startsWith('-e') && !arg.startsWith('--print') && !arg.startsWith('-p') - ); + const sanitized = this.sanitizeExecArgv(); + const execArgv = sanitized.length > 0 ? sanitized : undefined; for (let i = 0; i < actualThreads; i++) { const startIdx = i * chunkSize; @@ -99,7 +153,7 @@ export class BatchOrchestrator { endIdx, operation, }, - execArgv, + ...(execArgv ? { execArgv } : {}), }); worker.on('message', (msg: any) => { if (msg.status === 'done') resolve(); @@ -139,9 +193,8 @@ export class BatchOrchestrator { private static async _transformWithPostMessage(epochs: number[], operation: string, threadCount: number, chunkSize: number, options: BatchOptions): Promise { const workers: Promise[] = []; const actualThreads = Math.min(threadCount, Math.ceil(epochs.length / chunkSize)); - const execArgv = (process.execArgv || []).filter( - arg => !arg.startsWith('--input-type') && !arg.startsWith('--eval') && !arg.startsWith('-e') && !arg.startsWith('--print') && !arg.startsWith('-p') - ); + const sanitized = this.sanitizeExecArgv(); + const execArgv = sanitized.length > 0 ? sanitized : undefined; for (let i = 0; i < actualThreads; i++) { const startIdx = i * chunkSize; @@ -155,7 +208,7 @@ export class BatchOrchestrator { chunk, operation, }, - execArgv, + ...(execArgv ? { execArgv } : {}), }); worker.on('message', (msg: any) => { if (msg.status === 'done') resolve(msg.result); diff --git a/packages/plugins/batch/src/index.ts b/packages/plugins/batch/src/index.ts index 5d773b52..14309cfc 100644 --- a/packages/plugins/batch/src/index.ts +++ b/packages/plugins/batch/src/index.ts @@ -1,6 +1,7 @@ import { BatchOrchestrator, type BatchOptions } from './BatchOrchestrator.js'; import { definePlugin, type TempoPlugin } from '@magmacomputing/tempo/plugin/sdk'; +export { BatchOrchestrator }; export type { BatchOptions }; declare module '@magmacomputing/tempo' { diff --git a/packages/plugins/batch/test/batch.test.ts b/packages/plugins/batch/test/batch.test.ts index a7ad4b25..72537944 100644 --- a/packages/plugins/batch/test/batch.test.ts +++ b/packages/plugins/batch/test/batch.test.ts @@ -1,5 +1,5 @@ import { Tempo } from '@magmacomputing/tempo'; -import { BatchPlugin } from '../src/index.js'; +import { BatchPlugin, BatchOrchestrator } from '../src/index.js'; describe('Batch Plugin', () => { it('should successfully register the Tempo.batch namespace', () => { @@ -7,6 +7,81 @@ describe('Batch Plugin', () => { expect((Tempo as any).batch).toBeTypeOf('function'); }); + describe('sanitizeExecArgv', () => { + it('should only preserve allowlisted flags with separate and inline values', () => { + const input = [ + '--max-old-space-size=4096', + '--title', 'worker-title', + '--import', 'tsx', + '-r', 'ts-node/register', + '--require', 'source-map-support/register', + '--experimental-temporal', + '--eval=console.log(1)', + '--inspect=9229', + ]; + + const sanitized = BatchOrchestrator.sanitizeExecArgv(input); + expect(sanitized).toEqual([ + '--import', 'tsx', + '-r', 'ts-node/register', + '--require', 'source-map-support/register', + '--experimental-temporal', + '--inspect=9229', + ]); + }); + + it('should strip unsupported flags with separate following values', () => { + const input = [ + '--trace-warnings', + '-e', 'console.log("hi")', + '--title', 'worker-title', + '--max-old-space-size', '2048', + '--input-type', 'module', + '-p', 'test()', + '--inspect', + ]; + + const sanitized = BatchOrchestrator.sanitizeExecArgv(input); + expect(sanitized).toEqual(['--trace-warnings', '--inspect']); + }); + + it('should strip unsupported flags with inline = values', () => { + const input = [ + '--max-old-space-size=4096', + '--max_old_space_size=2048', + '--title=my-service', + '--eval=1+1', + '--print=hello', + '--input-type=commonjs', + '--experimental-vm-modules', + ]; + + const sanitized = BatchOrchestrator.sanitizeExecArgv(input); + expect(sanitized).toEqual(['--experimental-vm-modules']); + }); + + it('should strip unsupported boolean flags', () => { + const input = [ + '--check', + '-c', + '-i', + '--interactive', + '--expose-gc', + '--prof-process', + '--loader=ts-node/esm', + ]; + + const sanitized = BatchOrchestrator.sanitizeExecArgv(input); + expect(sanitized).toEqual(['--loader=ts-node/esm']); + }); + + it('should handle trailing flags without value and empty arrays', () => { + expect(BatchOrchestrator.sanitizeExecArgv(['--title'])).toEqual([]); + expect(BatchOrchestrator.sanitizeExecArgv(['--max-old-space-size'])).toEqual([]); + expect(BatchOrchestrator.sanitizeExecArgv([])).toEqual([]); + }); + }); + it.todo('should successfully orchestrate a batch mutation (Requires Node 26+ native Temporal)', async () => { Tempo.use(BatchPlugin); const startEpoch = new Tempo().epoch.ms; From 5c45d6f055941f7010b90097e947ffe5cc958b77 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:23:25 +0000 Subject: [PATCH 04/11] Document batch worker mutation and payload handlers --- packages/plugins/batch/src/worker.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/plugins/batch/src/worker.ts b/packages/plugins/batch/src/worker.ts index 4ad3362d..b3361fed 100644 --- a/packages/plugins/batch/src/worker.ts +++ b/packages/plugins/batch/src/worker.ts @@ -1,6 +1,13 @@ import { workerData, parentPort } from 'node:worker_threads'; import { Tempo } from '@magmacomputing/tempo'; +/** + * Applies a duration mutation, including supported shorthand units, to a Tempo instance. + * + * @param t - Tempo instance to mutate + * @param op - Mutation value or shorthand duration string + * @returns The mutated Tempo instance + */ function applyMutation(t: any, op: any) { if (typeof op === 'string') { const match = op.trim().match(/^([+-]?\d+)\s*([a-zA-Z]+)$/); @@ -25,6 +32,9 @@ function applyMutation(t: any, op: any) { return t.add(op); } +/** + * Processes the current worker payload and posts either its result or an error to the parent thread. + */ async function run() { if (!parentPort) return; From ef8d36c5ff1b807b0b1167325c76d1a400fa080b Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 8 Sep 2026 15:32:41 +1000 Subject: [PATCH 05/11] PR 2nd review --- package-lock.json | 15 +- package.json | 2 +- packages/library/CHANGELOG.md | 18 + packages/library/package.json | 6 +- .../library/src/common/runtime/cache.class.ts | 309 ++++++++++++++++++ packages/library/src/common/runtime/index.ts | 1 + .../src/common/runtime/mapper.library.ts | 205 ++++++++---- .../src/common/runtime/storage.library.ts | 50 ++- .../test/common/runtime/cache.class.test.ts | 159 +++++++++ .../test/common/runtime/mapper.common.test.ts | 104 +++--- .../common/runtime/storage.library.test.ts | 154 ++++++++- .../.setup/community-plugin-template.md | 20 +- packages/plugins/ai/package.json | 3 +- packages/plugins/ai/test/parse.test.ts | 2 +- packages/plugins/geo/README.md | 128 +++++++- packages/plugins/geo/doc/index.md | 150 +++++++-- packages/plugins/geo/src/index.ts | 63 +++- packages/plugins/geo/test/geo.test.ts | 122 ++++++- packages/tempo-cli/commands/version-sync.js | 19 +- packages/tempo/package.json | 2 +- packages/tempo/public/esm_sh.index.html | 2 +- packages/tempo/public/llms.txt | 4 +- packages/tempo/src/support/support.cache.ts | 287 ---------------- packages/tempo/src/support/support.index.ts | 2 +- packages/tempo/src/support/support.init.ts | 2 +- packages/tempo/src/tempo.type.ts | 2 +- packages/tempo/src/tempo.version.ts | 2 +- packages/tempo/test/support/cache.test.ts | 2 +- packages/tempo/vitest.config.ts | 5 +- vitest.config.ts | 25 +- 30 files changed, 1356 insertions(+), 509 deletions(-) create mode 100644 packages/library/src/common/runtime/cache.class.ts create mode 100644 packages/library/test/common/runtime/cache.class.test.ts diff --git a/package-lock.json b/package-lock.json index 240e4153..0af07d41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "4.1.1", + "version": "4.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "4.1.1", + "version": "4.1.2", "workspaces": [ "packages/*", "packages/plugins/*", @@ -9961,7 +9961,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "4.1.1", + "version": "4.1.2", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -9979,10 +9979,11 @@ }, "packages/plugins/ai": { "name": "@magmacomputing/tempo-plugin-ai", - "version": "1.2.1", + "version": "2.0.0", "license": "MIT", "devDependencies": { - "@js-temporal/polyfill": "^0.5.1" + "@js-temporal/polyfill": "^0.5.1", + "@magmacomputing/library": "*" }, "peerDependencies": { "@magmacomputing/tempo": "^4.1.0" @@ -9999,7 +10000,7 @@ }, "packages/plugins/batch": { "name": "@magmacomputing/tempo-plugin-batch", - "version": "1.0.2", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@js-temporal/polyfill": "^0.5.1" @@ -10067,7 +10068,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "4.1.1", + "version": "4.1.2", "license": "MIT", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index 1b24ab30..13862ef2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "4.1.1", + "version": "4.1.2", "private": true, "engines": { "node": ">=20.0.0" diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index 136ef756..e253e6f9 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -5,6 +5,24 @@ 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). +## [4.1.2] - 2026-09-08 + +### Added +- **Generic Bounded LRU & TTL Cache Engine (`BoundedCache`)**: + - Implemented high-performance `BoundedCache` in `#library/common/runtime/cache.class.js` supporting configurable capacity constraints (`maxSize`, default 100) and time-to-live expiration (`ttl`, default `Infinity`). + - Added per-entry TTL override support in `set(key, val, ttl?)` with precomputed absolute expiration deadlines (`expiresAt = Date.now() + ttl`). + - Implemented $O(1)$ fast-path expiration checks: bypasses `#expires` map checks and eliminates clock reads (`Date.now()`) when `ttl === Infinity` or `#expires.size === 0`. + - Added bulk clear (`clear()`), lazy eviction (`evictExpired()`), iteration (`keys()`, `values()`, `entries()`, `forEach()`, `[Symbol.iterator]()`), and size inspection (`size`). +- **Bounded In-Memory Server Storage (`storage.library`)**: + - Replaced unbounded `Map` backing `nodeStorage` with `BoundedCache(1000, Infinity)`. + - Added `ServerStorageOptions` interface and updated `setStorage(key, value, options?)` overload to support optional custom `ttl`. + - Added `clearStorage()` utility to purge in-memory storage entries across test environments and lifecycle boundaries while preserving tombstone deletion semantics (`undefined` value). +- **Geolocation Caching & Multi-Tenant Partitioning (`mapper.library`)**: + - Wired `geoLookup()` to automatically cache resolved geographic coordinates in ambient storage with a 24-hour TTL (`86,400,000 ms`). + - Added `{ refresh: true }` option to `geoLookup()` to bypass cached results and force fresh network resolution. + - Added `stashGeo(coords, ttl?, keyOrOpts?)`, `clearStashedGeo(keyOrOpts?)`, and `getStashedGeo(keyOrOpts?)` helpers. + - Implemented multi-tenant and IP cache key partitioning (`resolveCacheKey` scoping to `_map_:` or `_map_:`), preventing tenants from trampling shared geolocation coordinates. + ## [4.1.0] - 2026-09-06 ### Added diff --git a/packages/library/package.json b/packages/library/package.json index aaf2bc78..bf0b8b45 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "4.1.1", + "version": "4.1.2", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", @@ -185,6 +185,10 @@ "development": "./src/common/scheduling/schedule.library.ts", "default": "./dist/common/scheduling/schedule.library.js" }, + "#library/cache.class.js": { + "development": "./src/common/runtime/cache.class.ts", + "default": "./dist/common/runtime/cache.class.js" + }, "#library/decorator.library.js": { "development": "./src/common/runtime/decorator.library.ts", "default": "./dist/common/runtime/decorator.library.js" diff --git a/packages/library/src/common/runtime/cache.class.ts b/packages/library/src/common/runtime/cache.class.ts new file mode 100644 index 00000000..6d4753a7 --- /dev/null +++ b/packages/library/src/common/runtime/cache.class.ts @@ -0,0 +1,309 @@ +import { isDefined, isString, isUndefined } from '#library/assertion.library.js'; +import { StringTag } from '#library/decorator.library.js'; + +/** + * ## BoundedCache + * High-performance Map implementation enforcing maximum capacity (LRU) and TTL eviction. + * Supports static keys (immortal glossary definitions) and date-salt staleness purging. + */ +@StringTag('BoundedCache') +export class BoundedCache extends Map { + #expires = new Map(); + #staticKeys = new Set(); + #ttls = new Map(); + maxSize: number; + ttl: number; + + constructor(maxSize = 1000, ttl = 24 * 60 * 60 * 1000) { + super(); + this.maxSize = maxSize; + this.ttl = ttl; + } + + /** + * Returns true to identify this instance as a BoundedCache. + */ + get isBoundedCache(): boolean { + return true; + } + + #isExpired(key: K): boolean { + const deadline = this.#expires.get(key); + if (deadline === undefined) return false; + return Date.now() > deadline; + } + + /** + * Purges TTL-expired entries. + */ + evictExpired(): void { + if (this.#expires.size === 0) return; + const now = Date.now(); + for (const [key, deadline] of this.#expires.entries()) { + if (now > deadline) { + this.delete(key); + } + } + } + + /** + * Register an immortal static key (e.g. business glossary term). + * Static keys bypass TTL expiration and LRU capacity eviction. + */ + setStatic(key: K, value: V): this { + if (super.has(key)) super.delete(key); + super.set(key, value); + this.#staticKeys.add(key); + this.#expires.delete(key); + this.#ttls.delete(key); + return this; + } + + /** + * Check if a key is a static key. + */ + isStatic(key: K): boolean { + return this.#staticKeys.has(key); + } + + /** + * Retrieves a value from the cache by key. Evaluates TTL expiration via `#expires` + * specifically for the requested key, and updates LRU ordering by deleting and re-inserting + * the key in the underlying Map. Does not sweep or evict other expired entries. + * + * @param key - The cache key to retrieve + * @returns The cached value, or undefined if not found or expired + */ + override get(key: K): V | undefined { + if (this.#isExpired(key)) { + this.delete(key); + return undefined; + } + if (!super.has(key)) return undefined; + const val = super.get(key)!; + super.delete(key); + super.set(key, val); + + const deadline = this.#expires.get(key); + if (deadline !== undefined) { + const effectiveTtl = this.#ttls.get(key) ?? this.ttl; + if (effectiveTtl !== Infinity) { + this.#expires.set(key, Date.now() + effectiveTtl); + } + } + return val; + } + + /** + * Checks if a key exists in the cache and is not expired. + * Automatically evicts expired entries on access. + * + * @param key - The cache key to check + * @returns True if the key exists and is not expired, false otherwise + */ + override has(key: K): boolean { + if (this.#isExpired(key)) { + this.delete(key); + return false; + } + return super.has(key); + } + + /** + * Returns the number of entries in the cache after evicting expired entries. + */ + override get size(): number { + this.evictExpired(); + return super.size; + } + + /** + * Executes a callback for each cache entry after evicting expired entries. + * + * @param callbackfn - Function to execute for each entry + * @param thisArg - Optional value to use as `this` when executing the callback + */ + override forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: any): void { + this.evictExpired(); + super.forEach(callbackfn, thisArg); + } + + /** + * Sets a key-value pair in the cache, updating its expiration deadline and enforcing LRU eviction. + * If the cache exceeds maxSize, the oldest non-static entry is removed. + * + * @param key - The cache key to set + * @param value - The value to store + * @param ttl - Optional custom time-to-live in milliseconds for this specific entry + * @returns This cache instance for chaining + */ + override set(key: K, value: V, ttl?: number): this { + this.evictExpired(); + + if (super.has(key)) super.delete(key); + + super.set(key, value); + this.#staticKeys.delete(key); + + const effectiveTtl = ttl ?? this.ttl; + if (effectiveTtl !== Infinity) { + this.#expires.set(key, Date.now() + effectiveTtl); + if (isDefined(ttl)) { + this.#ttls.set(key, ttl); + } else { + this.#ttls.delete(key); + } + } else { + this.#expires.delete(key); + this.#ttls.delete(key); + } + + while (this.size > this.maxSize) { + let evicted = false; + const keysIter = super.keys(); + let res = keysIter.next(); + while (!res.done) { + const k = res.value; + if (!this.#staticKeys.has(k)) { + this.delete(k); + evicted = true; + break; + } + res = keysIter.next(); + } + if (!evicted) break; + } + + return this; + } + + /** + * Deletes a key from the cache, removing its expiration and static key markers. + * + * @param key - The cache key to delete + * @returns True if the key was deleted, false if it did not exist + */ + override delete(key: K): boolean { + this.#staticKeys.delete(key); + this.#expires.delete(key); + this.#ttls.delete(key); + return super.delete(key); + } + + /** + * Deletes all cache entries whose keys start with the specified prefix. + * The prefix comparison is case-insensitive. + * + * @param prefix - The prefix string to match against cache keys + * @returns The number of entries deleted + */ + deletePrefix(prefix: string): number { + const normalizedPrefix = String(prefix).trim().toLowerCase(); + const toDelete: K[] = []; + for (const key of super.keys()) { + if (isString(key) && key.toLowerCase().startsWith(normalizedPrefix)) + toDelete.push(key); + } + + for (const k of toDelete) + this.delete(k); + + return toDelete.length; + } + + /** + * Purges cache entries. + * If `count` is specified, evicts up to `count` oldest non-static entries. + * If omitted, clears all entries, including static entries. + * + * @param count - Optional number of entries to evict. If omitted, clears all entries. + */ + override clear(count?: number): void { + if (count === undefined) { + this.#staticKeys.clear(); + this.#expires.clear(); + this.#ttls.clear(); + super.clear(); + return; + } + + let evicted = 0; + for (const key of super.keys()) { + if (evicted >= count) break; + if (!this.#staticKeys.has(key)) { + this.delete(key); + evicted++; + } + } + } + + /** + * Returns an iterator of cache keys after evicting expired entries. + * + * @returns Iterator of cache keys + */ + override keys(): MapIterator { + this.evictExpired(); + return super.keys(); + } + + /** + * Returns an iterator of cache values after evicting expired entries. + * + * @returns Iterator of cache values + */ + override values(): MapIterator { + this.evictExpired(); + return super.values(); + } + + /** + * Returns an iterator of cache entries after evicting expired entries. + * + * @returns Iterator of [key, value] pairs + */ + override entries(): MapIterator<[K, V]> { + this.evictExpired(); + return super.entries(); + } + + /** + * Returns an iterator of cache entries after evicting expired entries, making the cache iterable. + * + * @returns Iterator of [key, value] pairs + */ + override[Symbol.iterator](): MapIterator<[K, V]> { + this.evictExpired(); + return super[Symbol.iterator](); + } + + /** + * Returns a plain key-value object of all active non-expired cache entries. + * Filters out non-string keys to prevent lossy key conversions or collisions (e.g. numeric 1 vs string "1"). + */ + toJSON(): Record { + this.evictExpired(); + const stringEntries: [string, V][] = []; + for (const [k, v] of super.entries()) { + if (typeof k === 'string') + stringEntries.push([k, v]); + } + return Object.fromEntries(stringEntries) as Record; + } + + /** + * Creates a BoundedCache from an iterable of key-value pairs. + * + * @param entries - Iterable of [key, value] pairs to populate the cache + * @param maxSize - Maximum number of entries (default: 1000) + * @param ttl - Time-to-live in milliseconds (default: 24 hours) + * @returns A new BoundedCache instance + */ + static fromEntries(entries: Iterable, maxSize = 1000, ttl = 24 * 60 * 60 * 1000): BoundedCache { + const cache = new BoundedCache(maxSize, ttl); + for (const [k, v] of entries) { + cache.set(k, v); + } + return cache; + } +} diff --git a/packages/library/src/common/runtime/index.ts b/packages/library/src/common/runtime/index.ts index 89f2dde9..fa7b3889 100644 --- a/packages/library/src/common/runtime/index.ts +++ b/packages/library/src/common/runtime/index.ts @@ -1,4 +1,5 @@ export * from './boundary.library.js'; +export * from './cache.class.js'; export * from './decorator.library.js'; export * from './enumerate.library.js'; export * from './evaluation.library.js'; diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts index 46dbf660..03f9eef6 100644 --- a/packages/library/src/common/runtime/mapper.library.ts +++ b/packages/library/src/common/runtime/mapper.library.ts @@ -1,13 +1,13 @@ import { CONTEXT, getContext } from '#library/utility.library.js'; import { isNullish, isNumber, isString, isSafeKey } from '#library/assertion.library.js'; -import { getStorage } from '#library/storage.library.js'; +import { getStorage, setStorage } from '#library/storage.library.js'; export interface GeoLookupResult { - lat?: number; - lng?: number; - latitude?: number; - longitude?: number; - error?: string; + lat?: number | undefined; + lng?: number | undefined; + latitude?: number | undefined; + longitude?: number | undefined; + error?: string | undefined; [key: string]: any; } @@ -68,7 +68,7 @@ export interface CoordinateInput { } /** - * Extracts and coerces latitude and longitude from input object (Tempo options, config, or instance) + * Extracts and coerces latitude and longitude from input object (options, config, or instance) * into a canonical `{ latitude, longitude, ... }` GeoConfig object. * * @param input - Optional object containing coordinate or geo properties @@ -110,102 +110,179 @@ export const coerceGeo = (input?: any): GeoConfig | undefined => { return Object.keys(result).length > 0 ? result : undefined; }; +/** + * Helper to resolve storage cache keys for single-tenant default or multi-tenant scoped lookups. + * @internal + */ +const resolveCacheKey = (keyOrOpts?: string | Record): string => { + if (typeof keyOrOpts === 'string' && keyOrOpts.trim().length > 0) + return keyOrOpts.startsWith('_map_') ? keyOrOpts : `_map_:${keyOrOpts.trim()}`; + if (keyOrOpts && typeof keyOrOpts === 'object') { + const k = keyOrOpts.key ?? keyOrOpts.ip ?? keyOrOpts.query; + if (typeof k === 'string' && k.trim().length > 0) + return `_map_:${k.trim()}`; + } + return '_map_'; +}; + /** * Synchronously retrieves stashed geolocation from storage or memory cache if present. + * Supports optional tenant key or options object for multi-tenant isolation. + * + * @param keyOrOpts - Optional tenant key or lookup options containing key/ip + * @returns Cached GeoConfig or undefined */ -export const getStashedGeo = (): GeoConfig | undefined => { - const { type } = getContext(); - if (type === CONTEXT.Browser || type === CONTEXT.WebWorker) { - try { - if (typeof localStorage !== 'undefined') { - const raw = localStorage.getItem('_map_'); - if (raw) { - const parsed = JSON.parse(raw); - const coords = parsed?.geolocation?.coords ?? parsed?.coords ?? parsed; - const lat = coords?.latitude ?? coords?.lat; - const lng = coords?.longitude ?? coords?.lng ?? coords?.lon ?? coords?.long; - if (isNumber(lat) && isNumber(lng)) { - return { latitude: lat, longitude: lng }; - } - } +export const getStashedGeo = (keyOrOpts?: string | Record): GeoConfig | undefined => { + const cacheKey = resolveCacheKey(keyOrOpts); + try { + const raw = getStorage(cacheKey) ?? (typeof localStorage !== 'undefined' ? localStorage.getItem(cacheKey) : undefined); + if (!raw) return undefined; + + const parsed = typeof raw === 'string' && (raw.startsWith('{') || raw.startsWith('[')) + ? JSON.parse(raw) + : raw; + + if (typeof parsed === 'string' && parsed.includes(',')) { + const parts = parsed.split(',').map(s => parseFloat(s.trim())); + if (parts.length >= 2 && isNumber(parts[0]) && isNumber(parts[1])) { + return { latitude: parts[0], longitude: parts[1] }; } - } catch { - // ignore storage access errors } - } else if (type === CONTEXT.NodeJS || type === CONTEXT.Deno) { - try { - const raw = getStorage('_map_') ?? getStorage('TEMPO_GEO'); - if (raw) { - if (typeof raw === 'string' && raw.includes(',')) { - const parts = raw.split(',').map(s => parseFloat(s.trim())); - if (parts.length >= 2 && isNumber(parts[0]) && isNumber(parts[1])) { - return { latitude: parts[0], longitude: parts[1] }; - } - } - if (typeof raw === 'object') { - const coords = raw.geolocation?.coords ?? raw.coords ?? raw; - const lat = coords?.latitude ?? coords?.lat; - const lng = coords?.longitude ?? coords?.lng ?? coords?.lon ?? coords?.long; - if (isNumber(lat) && isNumber(lng)) { - const result: GeoConfig = { latitude: lat, longitude: lng }; - const elevation = raw.elevation ?? coords.elevation; - if (isNumber(elevation)) result.elevation = elevation; - if (raw.sphere === 'north' || raw.sphere === 'south') result.sphere = raw.sphere; - if (isString(raw.country)) result.country = raw.country; - if (isString(raw.city)) result.city = raw.city; - return result; - } - } + + if (typeof parsed === 'object' && parsed !== null) { + const coords = parsed.geolocation?.coords ?? parsed.coords ?? parsed; + const lat = coords?.latitude ?? coords?.lat; + const lng = coords?.longitude ?? coords?.lng ?? coords?.lon ?? coords?.long; + if (isNumber(lat) && isNumber(lng)) { + const result: GeoConfig = { latitude: lat, longitude: lng }; + const elevation = parsed.elevation ?? coords.elevation; + if (isNumber(elevation)) result.elevation = elevation; + if (parsed.sphere === 'north' || parsed.sphere === 'south') result.sphere = parsed.sphere; + if (isString(parsed.country)) result.country = parsed.country; + if (isString(parsed.city)) result.city = parsed.city; + return result; } - } catch { - // ignore storage access errors } + } catch { + // ignore storage access errors } return undefined; }; +/** + * Explicitly stashes geolocation coordinates in storage with an optional TTL (default: 24 hours). + * Supports optional tenant key or options object for multi-tenant isolation. + * + * @param coords - Geolocation coordinates and metadata to stash + * @param ttl - Time-to-live in milliseconds (default: 24 hours) + * @param keyOrOpts - Optional tenant key or lookup options containing key/ip + */ +export const stashGeo = ( + coords: GeoConfig, + ttl = 24 * 60 * 60 * 1000, + keyOrOpts?: string | Record +): void => { + const cacheKey = resolveCacheKey(keyOrOpts); + setStorage(cacheKey, coords, { ttl }); +}; + +/** + * Clears stashed geolocation coordinates from storage. + * Supports optional tenant key or options object for multi-tenant isolation. + * + * @param keyOrOpts - Optional tenant key or lookup options containing key/ip + */ +export const clearStashedGeo = (keyOrOpts?: string | Record): void => { + const cacheKey = resolveCacheKey(keyOrOpts); + setStorage(cacheKey, undefined); +}; + /** * Universal geolocation lookup dispatcher. * Automatically delegates to browser `geoLocation()` or server `serverGeoLocation()` based on runtime context. + * When coordinates are resolved, stashes the result in storage with a 24-hour TTL for fast cached lookups. * - * @param opts - Lookup options passed down to environment handler + * @param opts - Lookup options passed down to environment handler (e.g. `{ refresh: true, key: 'tenant-1' }`) */ export const geoLookup = async (opts: Record = {}): Promise => { + const useCache = opts.refresh !== true; + + if (useCache) { + const stashed = getStashedGeo(opts); + if (stashed && isNumber(stashed.latitude) && isNumber(stashed.longitude)) { + const { latitude, longitude, ...rest } = stashed; + return { + ...rest, + status: 'cached', + lat: latitude, + lng: longitude, + latitude, + longitude, + }; + } + } + const { type } = getContext(); + let res: GeoLookupResult; switch (type) { case CONTEXT.Browser: { const { geoLocation } = await import('#browser/mapper.library.js'); - const res = await geoLocation(opts as any); - if (res.error) - return { error: res.error }; + const browserRes = await geoLocation(opts as any); + if (browserRes.error) + return { error: browserRes.error }; - const lat = res.coords?.latitude; - const lng = res.coords?.longitude; - return { lat, lng, latitude: lat, longitude: lng, ...res }; + const lat = browserRes.coords?.latitude; + const lng = browserRes.coords?.longitude; + res = { lat, lng, latitude: lat, longitude: lng, ...browserRes }; + break; } case CONTEXT.WebWorker: { - const stashed = getStashedGeo(); + const stashed = getStashedGeo(opts); if (stashed && isNumber(stashed.latitude) && isNumber(stashed.longitude)) return { lat: stashed.latitude, lng: stashed.longitude, latitude: stashed.latitude, longitude: stashed.longitude }; const { serverGeoLocation } = await import('#server/mapper.library.js'); - return serverGeoLocation(opts as any); + res = await serverGeoLocation(opts as any); + break; } case CONTEXT.NodeJS: case CONTEXT.Deno: default: { const { serverGeoLocation } = await import('#server/mapper.library.js'); - return serverGeoLocation(opts as any); + res = await serverGeoLocation(opts as any); + break; } } -} + + // Stash successfully resolved coordinates with 24-hour TTL + const lat = res.latitude ?? res.lat; + const lng = res.longitude ?? res.lng; + if (isNullish(res.error) && isNumber(lat) && isNumber(lng)) { + const stashPayload: GeoConfig = { + latitude: lat, + longitude: lng, + ...(isString(res.city) ? { city: res.city } : {}), + ...(isString(res.country) ? { country: res.country } : {}), + ...(isNumber(res.elevation) ? { elevation: res.elevation } : {}), + ...(res.sphere === 'north' || res.sphere === 'south' ? { sphere: res.sphere } : {}), + }; + try { + const ttl = isNumber(opts.ttl) ? opts.ttl : 24 * 60 * 60 * 1000; + stashGeo(stashPayload, ttl, opts); + } catch { + // ignore storage errors + } + } + + return res; +}; /** * Universal coordinate resolver. - * Extracts latitude and longitude from input object (Tempo instance, config, options), + * Extracts latitude and longitude from input object (instance, config, options), * or triggers `geoLookup()` if coordinates are omitted. * * @param input - Optional object containing coordinate properties @@ -219,7 +296,7 @@ export const resolveGeoCoordinates = async ( if (coerced && isNumber(coerced.latitude) && isNumber(coerced.longitude)) return { lat: coerced.latitude, lng: coerced.longitude }; - const stashed = getStashedGeo(); + const stashed = getStashedGeo(opts); if (stashed && isNumber(stashed.latitude) && isNumber(stashed.longitude)) return { lat: stashed.latitude, lng: stashed.longitude }; @@ -228,5 +305,5 @@ export const resolveGeoCoordinates = async ( return { lat: lookup.lat, lng: lookup.lng }; return null; -} +}; diff --git a/packages/library/src/common/runtime/storage.library.ts b/packages/library/src/common/runtime/storage.library.ts index e7cd647e..5213100c 100644 --- a/packages/library/src/common/runtime/storage.library.ts +++ b/packages/library/src/common/runtime/storage.library.ts @@ -1,12 +1,13 @@ import { objectify, stringify } from '#library/serialize.library.js'; import { CONTEXT, getContext } from '#library/utility.library.js'; import { isDefined, isUndefined, isString } from '#library/assertion.library.js'; +import { BoundedCache } from '#library/cache.class.js'; const context = getContext(); /** Creates an in-memory Storage object fallback for environments without native Storage support */ -export const createMemoryStorage = (): Storage => { - const map = new Map(); +export const createMemoryStorage = (maxSize = 1000, ttl = Infinity): Storage => { + const map = new BoundedCache(maxSize, ttl); return { get length() { return map.size; }, clear: () => map.clear(), @@ -14,7 +15,7 @@ export const createMemoryStorage = (): Storage => { key: (index: number) => Array.from(map.keys())[index] ?? null, removeItem: (key: string) => { map.delete(key); }, setItem: (key: string, value: string) => { map.set(key, String(value)); }, - }; + } } const memoryStores = new Map(); @@ -57,7 +58,7 @@ let storage = context.type === CONTEXT.Browser ? getSafeStorage() : createMemoryStorage(); -const nodeStorage = new Map(); +export const nodeStorage = new BoundedCache(1000, Infinity); /** * Selects the active browser storage mechanism (localStorage or sessionStorage). @@ -142,19 +143,31 @@ export function getStorage(key?: string, dflt?: T): T | undefined { : dflt; } +/** Options for fine-grained cache control in server storage contexts */ +export interface ServerStorageOptions { + /** Custom time-to-live in milliseconds for this specific entry */ + ttl?: number; + /** If true, registers as an immortal static key immune to LRU capacity eviction and TTL */ + static?: boolean; +} + /** * Sets or deletes a value in the active storage mechanism across any runtime environment. * Automatically serializes objects for safe storage. * * @param key - The storage key to set * @param val - The value to store (if undefined, the key is deleted) + * @param options - Optional cache control options (server context only) * @example * ```ts * setStorage('user', { name: 'Alice' }); * setStorage('user', undefined); // deletes 'user' + * setStorage('coords', { lat: 10, lng: 20 }, { ttl: 86400000 }); // expires after 24h * ``` */ -export function setStorage(key: string, val?: T) { +export function setStorage(key: string, val?: T): void; +export function setStorage(key: string, val: T | undefined, options: ServerStorageOptions): void; +export function setStorage(key: string, val?: T, options?: ServerStorageOptions): void { const stash = isDefined(val) ? stringify(val) : undefined; const set = isDefined(stash); @@ -170,7 +183,13 @@ export function setStorage(key: string, val?: T) { break; case CONTEXT.NodeJS: - nodeStorage.set(key, stash); + if (!set) { + nodeStorage.set(key, undefined, options?.ttl); + } else if (options?.static) { + nodeStorage.setStatic(key, stash); + } else { + nodeStorage.set(key, stash, options?.ttl); + } break; case CONTEXT.Deno: @@ -194,6 +213,25 @@ export function setStorage(key: string, val?: T) { } } +/** + * Clears in-memory storage entries across environments (NodeJS nodeStorage, Browser memory storage). + */ +export function clearStorage(): void { + switch (context.type) { + case CONTEXT.Browser: + try { + storage.clear(); + } catch { + // ignore + } + break; + + case CONTEXT.NodeJS: + nodeStorage.clear(); + break; + } +} + /** * Safely determines if the current execution context is in a unit-testing environment (e.g. Vitest, Jest, or CI). * Uses universal getStorage API to avoid direct environment variable AST inspection. diff --git a/packages/library/test/common/runtime/cache.class.test.ts b/packages/library/test/common/runtime/cache.class.test.ts new file mode 100644 index 00000000..2a27861e --- /dev/null +++ b/packages/library/test/common/runtime/cache.class.test.ts @@ -0,0 +1,159 @@ +import { BoundedCache } from '#library/cache.class.js'; + +describe('common/runtime/cache.class', () => { + it('identifies as a BoundedCache and provides basic Map semantics', () => { + const cache = new BoundedCache(10, 60000); + expect(cache.isBoundedCache).toBe(true); + expect(Object.prototype.toString.call(cache)).toBe('[object BoundedCache]'); + + cache.set('foo', 'bar'); + expect(cache.has('foo')).toBe(true); + expect(cache.get('foo')).toBe('bar'); + expect(cache.size).toBe(1); + + expect(cache.delete('foo')).toBe(true); + expect(cache.has('foo')).toBe(false); + expect(cache.delete('foo')).toBe(false); + }); + + it('evicts LRU items when maxSize is exceeded', () => { + const cache = new BoundedCache(2, 60000); + cache.set('a', '1'); + cache.set('b', '2'); + cache.set('c', '3'); // 'a' should be evicted + + expect(cache.has('a')).toBe(false); + expect(cache.get('b')).toBe('2'); + expect(cache.get('c')).toBe('3'); + expect(cache.size).toBe(2); + }); + + it('updates recency when calling get() so accessed items avoid eviction', () => { + const cache = new BoundedCache(2, 60000); + cache.set('a', '1'); + cache.set('b', '2'); + + // Read 'a' to make it most recently used + expect(cache.get('a')).toBe('1'); + + // Insert 'c'. 'b' should be evicted because 'a' was refreshed by get() + cache.set('c', '3'); + + expect(cache.has('b')).toBe(false); + expect(cache.get('a')).toBe('1'); + expect(cache.get('c')).toBe('3'); + expect(cache.size).toBe(2); + }); + + it('protects static keys from LRU capacity eviction and TTL expiration', () => { + const cache = new BoundedCache(2, 10); + cache.setStatic('static_term', 'IMMORTAL'); + expect(cache.isStatic('static_term')).toBe(true); + expect(cache.isStatic('other')).toBe(false); + + cache.set('a', '1'); + cache.set('b', '2'); // 'a' should be evicted, static_term must remain + + expect(cache.has('static_term')).toBe(true); + expect(cache.get('static_term')).toBe('IMMORTAL'); + expect(cache.has('a')).toBe(false); + expect(cache.get('b')).toBe('2'); + }); + + it('evicts expired entries after TTL has elapsed', async () => { + const cache = new BoundedCache(10, 20); // 20ms TTL + cache.set('fast', 'expiring'); + expect(cache.has('fast')).toBe(true); + + // Wait for TTL expiration + await new Promise(resolve => setTimeout(resolve, 35)); + + expect(cache.has('fast')).toBe(false); + expect(cache.get('fast')).toBeUndefined(); + expect(cache.size).toBe(0); + }); + + it('deletes entries by prefix with case-insensitivity', () => { + const cache = new BoundedCache(10, 60000); + cache.set('geo:sydney', '-33,151'); + cache.set('geo:melbourne', '-37,144'); + cache.set('user:alice', 'token1'); + + const deleted = cache.deletePrefix('GEO:'); + expect(deleted).toBe(2); + expect(cache.has('geo:sydney')).toBe(false); + expect(cache.has('geo:melbourne')).toBe(false); + expect(cache.has('user:alice')).toBe(true); + }); + + it('clears all entries or only up to count oldest non-static entries', () => { + const cache = new BoundedCache(10, 60000); + cache.setStatic('immortal', 'safe'); + cache.set('k1', 'v1'); + cache.set('k2', 'v2'); + cache.set('k3', 'v3'); + + cache.clear(2); // Evict 2 oldest non-static entries (k1, k2) + expect(cache.has('immortal')).toBe(true); + expect(cache.has('k1')).toBe(false); + expect(cache.has('k2')).toBe(false); + expect(cache.has('k3')).toBe(true); + + cache.clear(); // Complete wipe + expect(cache.size).toBe(0); + expect(cache.has('immortal')).toBe(false); + }); + + it('provides iterable methods and iterates correctly', () => { + const cache = new BoundedCache(10, 60000); + cache.set('k1', 'v1'); + cache.set('k2', 'v2'); + + expect([...cache.keys()]).toEqual(['k1', 'k2']); + expect([...cache.values()]).toEqual(['v1', 'v2']); + expect([...cache.entries()]).toEqual([['k1', 'v1'], ['k2', 'v2']]); + expect([...cache]).toEqual([['k1', 'v1'], ['k2', 'v2']]); + + const collector: string[] = []; + cache.forEach((v, k) => collector.push(`${k}=${v}`)); + expect(collector).toEqual(['k1=v1', 'k2=v2']); + }); + + it('serializes to plain JSON object and rehydrates via fromEntries()', () => { + const cache = new BoundedCache(10, 60000); + cache.set('k1', 'v1'); + cache.set('k2', 'v2'); + + expect(cache.toJSON()).toEqual({ k1: 'v1', k2: 'v2' }); + expect(JSON.stringify(cache)).toBe('{"k1":"v1","k2":"v2"}'); + + const rehydrated = BoundedCache.fromEntries(cache.entries(), 5, 30000); + expect(rehydrated.get('k1')).toBe('v1'); + expect(rehydrated.get('k2')).toBe('v2'); + expect(rehydrated.maxSize).toBe(5); + expect(rehydrated.ttl).toBe(30000); + }); + + it('filters out non-string keys in toJSON to avoid key collision', () => { + const cache = new BoundedCache(10, 60000); + cache.set(123, 'number_key'); + cache.set('123', 'string_key'); + + expect(cache.toJSON()).toEqual({ '123': 'string_key' }); + }); + + it('supports per-entry custom TTL overrides when default ttl is Infinity', async () => { + const cache = new BoundedCache(10, Infinity); + cache.set('permanent', 'stays'); + cache.set('short_lived', 'expires_soon', 25); // 25ms TTL + + expect(cache.get('permanent')).toBe('stays'); + expect(cache.get('short_lived')).toBe('expires_soon'); + + await new Promise(resolve => setTimeout(resolve, 40)); + + expect(cache.get('permanent')).toBe('stays'); + expect(cache.has('short_lived')).toBe(false); + expect(cache.get('short_lived')).toBeUndefined(); + }); +}); diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts index f1f54fe6..d4e99f03 100644 --- a/packages/library/test/common/runtime/mapper.common.test.ts +++ b/packages/library/test/common/runtime/mapper.common.test.ts @@ -1,9 +1,14 @@ -import { coerceGeo, geoLookup, resolveGeoCoordinates, getStashedGeo } from '../../../src/common/runtime/mapper.library.js'; -import { setStorage } from '#library/storage.library.js'; +import { coerceGeo, geoLookup, resolveGeoCoordinates, getStashedGeo, stashGeo, clearStashedGeo } from '../../../src/common/runtime/mapper.library.js'; +import { setStorage, clearStorage } from '#library/storage.library.js'; describe('common/runtime/mapper.library', () => { + beforeEach(() => { + clearStorage(); + }); + afterEach(() => { vi.unstubAllGlobals(); + clearStorage(); }); it('resolveGeoCoordinates extracts coordinates synchronously if present', async () => { @@ -44,7 +49,7 @@ describe('common/runtime/mapper.library', () => { } }); - it('geoLookup dispatches to server environment handler in Node.js', async () => { + it('geoLookup dispatches to server environment handler in Node.js and caches with 24h TTL', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ @@ -58,63 +63,80 @@ describe('common/runtime/mapper.library', () => { vi.stubGlobal('fetch', mockFetch); try { + // First call queries the network and stashes coordinates const result = await geoLookup(); expect(result.lat).toBe(-33.8688); expect(result.lng).toBe(151.2093); expect(result.city).toBe('Sydney'); expect(mockFetch).toHaveBeenCalledTimes(1); + + // Second call returns cached coordinates without hitting fetch + const cached = await geoLookup(); + expect(cached.lat).toBe(-33.8688); + expect(cached.city).toBe('Sydney'); + expect(cached.status).toBe('cached'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // refresh: true forces a fresh network call + const refreshed = await geoLookup({ refresh: true }); + expect(refreshed.lat).toBe(-33.8688); + expect(mockFetch).toHaveBeenCalledTimes(2); } finally { vi.unstubAllGlobals(); } }); - it('getStashedGeo in Node.js returns undefined by default without developer configuration', () => { + it('supports multi-tenant key partitioning so tenants do not trample each other', async () => { + stashGeo({ latitude: 51.5074, longitude: -0.1278, city: 'London' }, 86400000, { key: 'tenant-uk' }); + stashGeo({ latitude: 35.6762, longitude: 139.6503, city: 'Tokyo' }, 86400000, { key: 'tenant-jp' }); + + const uk = getStashedGeo({ key: 'tenant-uk' }); + const jp = getStashedGeo({ key: 'tenant-jp' }); + const ambient = getStashedGeo(); + + expect(uk?.city).toBe('London'); + expect(jp?.city).toBe('Tokyo'); + expect(ambient).toBeUndefined(); // Ambient server default remains untouched + + // Clear only tenant-uk + clearStashedGeo({ key: 'tenant-uk' }); + expect(getStashedGeo({ key: 'tenant-uk' })).toBeUndefined(); + expect(getStashedGeo({ key: 'tenant-jp' })?.city).toBe('Tokyo'); + }); + + it('getStashedGeo in Node.js returns undefined by default without storage configuration', () => { expect(getStashedGeo()).toBeUndefined(); }); it('getStashedGeo in Node.js discovers coordinates set explicitly via setStorage', () => { - try { - setStorage('_map_', { - geolocation: { coords: { latitude: -33.8688, longitude: 151.2093 } }, - city: 'Sydney', - }); + setStorage('_map_', { + geolocation: { coords: { latitude: -33.8688, longitude: 151.2093 } }, + city: 'Sydney', + }); - const stashed = getStashedGeo(); - expect(stashed).toBeDefined(); - expect(stashed?.latitude).toBe(-33.8688); - expect(stashed?.longitude).toBe(151.2093); - expect(stashed?.city).toBe('Sydney'); - } finally { - setStorage('_map_', undefined); - } + const stashed = getStashedGeo(); + expect(stashed).toBeDefined(); + expect(stashed?.latitude).toBe(-33.8688); + expect(stashed?.longitude).toBe(151.2093); + expect(stashed?.city).toBe('Sydney'); }); - it('getStashedGeo in Node.js discovers coordinates pre-seeded in process.env.TEMPO_GEO', () => { - try { - setStorage('_map_', undefined); - process.env.TEMPO_GEO = '{"latitude": 37.7749, "longitude": -122.4194, "city": "San Francisco"}'; - - const stashed = getStashedGeo(); - expect(stashed).toBeDefined(); - expect(stashed?.latitude).toBe(37.7749); - expect(stashed?.longitude).toBe(-122.4194); - expect(stashed?.city).toBe('San Francisco'); - } finally { - delete process.env.TEMPO_GEO; - } + it('getStashedGeo in Node.js discovers coordinates set as JSON string in setStorage', () => { + setStorage('_map_', '{"latitude": 37.7749, "longitude": -122.4194, "city": "San Francisco"}'); + + const stashed = getStashedGeo(); + expect(stashed).toBeDefined(); + expect(stashed?.latitude).toBe(37.7749); + expect(stashed?.longitude).toBe(-122.4194); + expect(stashed?.city).toBe('San Francisco'); }); - it('getStashedGeo in Node.js supports comma-separated string coordinates in TEMPO_GEO', () => { - try { - setStorage('_map_', undefined); - process.env.TEMPO_GEO = '51.5074, -0.1278'; + it('getStashedGeo in Node.js supports comma-separated string coordinates in storage', () => { + setStorage('_map_', '51.5074, -0.1278'); - const stashed = getStashedGeo(); - expect(stashed).toBeDefined(); - expect(stashed?.latitude).toBe(51.5074); - expect(stashed?.longitude).toBe(-0.1278); - } finally { - delete process.env.TEMPO_GEO; - } + const stashed = getStashedGeo(); + expect(stashed).toBeDefined(); + expect(stashed?.latitude).toBe(51.5074); + expect(stashed?.longitude).toBe(-0.1278); }); }); diff --git a/packages/library/test/common/runtime/storage.library.test.ts b/packages/library/test/common/runtime/storage.library.test.ts index 2efca189..7f887338 100644 --- a/packages/library/test/common/runtime/storage.library.test.ts +++ b/packages/library/test/common/runtime/storage.library.test.ts @@ -1,6 +1,6 @@ -import { getStorage, setStorage } from '#library/storage.library.js'; +import { getStorage, setStorage, clearStorage, nodeStorage, createMemoryStorage } from '#library/storage.library.js'; -describe('Storage Tombstone Behavior (NodeJS)', () => { +describe('Storage Tombstone & BoundedCache Integration (NodeJS)', () => { const envKey = 'TEST_ENV_FALLBACK_KEY'; const tombstoneKey = 'TEST_TOMBSTONE_KEY'; @@ -13,27 +13,151 @@ describe('Storage Tombstone Behavior (NodeJS)', () => { }; beforeEach(() => { + clearStorage(); + nodeStorage.maxSize = 1000; setEnv(envKey, undefined); setEnv(tombstoneKey, undefined); }); - it('should fall back to process.env for untouched keys', () => { - setEnv(envKey, 'env_value'); - expect(getStorage(envKey)).toBe('env_value'); + afterEach(() => { + nodeStorage.maxSize = 1000; + clearStorage(); + setEnv(envKey, undefined); + setEnv(tombstoneKey, undefined); + }); + + describe('Tombstone Behavior', () => { + it('should fall back to process.env for untouched keys', () => { + setEnv(envKey, 'env_value'); + expect(getStorage(envKey)).toBe('env_value'); + }); + + it('should honor deletion tombstone and prevent process.env fallback for cleared keys', () => { + setEnv(tombstoneKey, 'env_fallback_value'); + + // Store value initially + setStorage(tombstoneKey, 'initial_value'); + expect(getStorage(tombstoneKey)).toBe('initial_value'); + + // Explicitly delete key (records tombstone) + setStorage(tombstoneKey, undefined); + + // Reading cleared key must NOT fall back to process.env + expect(getStorage(tombstoneKey)).toBeUndefined(); + expect(getStorage(tombstoneKey, 'dflt')).toBe('dflt'); + }); + }); + + describe('BoundedCache LRU Eviction & Recency', () => { + it('should evict oldest unaccessed keys when nodeStorage maxSize is exceeded', () => { + nodeStorage.maxSize = 3; + + setStorage('k1', 'v1'); + setStorage('k2', 'v2'); + setStorage('k3', 'v3'); + expect(getStorage('k1')).toBe('v1'); + expect(getStorage('k2')).toBe('v2'); + expect(getStorage('k3')).toBe('v3'); + + // Adding 4th item evicts the oldest (k1) + setStorage('k4', 'v4'); + expect(getStorage('k1')).toBeUndefined(); + expect(getStorage('k2')).toBe('v2'); + expect(getStorage('k3')).toBe('v3'); + expect(getStorage('k4')).toBe('v4'); + }); + + it('should update recency when calling getStorage() so accessed items avoid eviction', () => { + nodeStorage.maxSize = 3; + + setStorage('k1', 'v1'); + setStorage('k2', 'v2'); + setStorage('k3', 'v3'); + + // Access k1 to make it most recently used; k2 is now the oldest + expect(getStorage('k1')).toBe('v1'); + + // Insert k4 -> should evict k2, retaining k1, k3, k4 + setStorage('k4', 'v4'); + expect(getStorage('k1')).toBe('v1'); + expect(getStorage('k2')).toBeUndefined(); + expect(getStorage('k3')).toBe('v3'); + expect(getStorage('k4')).toBe('v4'); + }); + + it('should evict tombstones under LRU pressure like normal entries', () => { + nodeStorage.maxSize = 2; + + setStorage('tombstone', undefined); + setStorage('active1', 'val1'); + + // Both keys are present (one as a tombstone) + expect(nodeStorage.has('tombstone')).toBe(true); + + // Insert active2 -> evicts the oldest entry ('tombstone') + setStorage('active2', 'val2'); + expect(nodeStorage.has('tombstone')).toBe(false); + expect(getStorage('active1')).toBe('val1'); + expect(getStorage('active2')).toBe('val2'); + }); }); - it('should honor deletion tombstone and prevent process.env fallback for cleared keys', () => { - setEnv(tombstoneKey, 'env_fallback_value'); + describe('ServerStorageOptions (TTL & Static)', () => { + it('should support per-entry custom TTL in server context', async () => { + setStorage('permanent', 'stays'); + setStorage('temporary', 'expires_soon', { ttl: 30 }); // 30ms TTL + + expect(getStorage('permanent')).toBe('stays'); + expect(getStorage('temporary')).toBe('expires_soon'); + + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(getStorage('permanent')).toBe('stays'); + expect(getStorage('temporary')).toBeUndefined(); + }); + + it('should protect static keys from LRU eviction', () => { + nodeStorage.maxSize = 2; + + setStorage('glossary_term', 'immutable_definition', { static: true }); + setStorage('d1', 'dyn1'); + setStorage('d2', 'dyn2'); + + // glossary_term is static so it must NOT be evicted even though capacity=2 is exceeded by dynamic keys + expect(getStorage('glossary_term')).toBe('immutable_definition'); + expect(getStorage('d1')).toBeUndefined(); // d1 evicted + expect(getStorage('d2')).toBe('dyn2'); + }); + }); + + describe('clearStorage Utility', () => { + it('should clear all nodeStorage entries', () => { + setStorage('k1', 'v1'); + setStorage('k2', 'v2', { static: true }); + expect(nodeStorage.size).toBe(2); + + clearStorage(); + expect(nodeStorage.size).toBe(0); + expect(getStorage('k1')).toBeUndefined(); + expect(getStorage('k2')).toBeUndefined(); + }); + }); - // Store value initially - setStorage(tombstoneKey, 'initial_value'); - expect(getStorage(tombstoneKey)).toBe('initial_value'); + describe('createMemoryStorage Bounded Limits', () => { + it('should respect maxSize in createMemoryStorage fallback', () => { + const memStore = createMemoryStorage(2); - // Explicitly delete key (records tombstone) - setStorage(tombstoneKey, undefined); + memStore.setItem('a', 'valA'); + memStore.setItem('b', 'valB'); + expect(memStore.length).toBe(2); + expect(memStore.getItem('a')).toBe('valA'); - // Reading cleared key must NOT fall back to process.env - expect(getStorage(tombstoneKey)).toBeUndefined(); - expect(getStorage(tombstoneKey, 'dflt')).toBe('dflt'); + // Adding 3rd item evicts 'b' (since 'a' was accessed) + memStore.setItem('c', 'valC'); + expect(memStore.length).toBe(2); + expect(memStore.getItem('b')).toBeNull(); + expect(memStore.getItem('a')).toBe('valA'); + expect(memStore.getItem('c')).toBe('valC'); + }); }); }); diff --git a/packages/plugins/.setup/community-plugin-template.md b/packages/plugins/.setup/community-plugin-template.md index aa787879..423dd316 100644 --- a/packages/plugins/.setup/community-plugin-template.md +++ b/packages/plugins/.setup/community-plugin-template.md @@ -118,9 +118,27 @@ Community plugins must follow a uniform documentation standard. ## 5. Source Code (`src/index.ts`) -- Rely strictly on open core extensions (`definePlugin`, `defineTerm`). +- Rely strictly on open core extensions (`definePlugin`, `defineTerm`, `defineNamespace`). - While optional, it is highly recommended to provide a short `description` when using `defineTerm` (e.g., `description: 'My custom term'`) so it appears in the `Tempo.terms` registry. +> [!IMPORTANT] +> **Guideline for Future Agents & Developers: Base Class Lockdown & Immutability** +> +> 1. **Zero Flat Pollution on `Tempo`**: Never dump multiple loose methods or properties directly onto the `Tempo` root class. Group related static utilities under a dedicated, cohesive namespace (e.g., `Tempo.geo.*`, `Tempo.sync.*`). +> 2. **Static Namespace Immutability**: Any static object or namespace attached to the `Tempo` base class **must be strictly immutable/frozen**: +> - The namespace object itself must be recursively frozen via `Object.freeze()`. +> - The property must be mounted onto `TempoClass` using `Object.defineProperty` with `writable: false`, `configurable: false`, and `enumerable: false` (or via the SDK's `attachStatics` utility). +> ```typescript +> Object.defineProperty(TempoClass, 'myNamespace', { +> value: Object.freeze(myNamespaceObject), +> writable: false, +> configurable: false, +> enumerable: false, +> }); +> ``` +> *Rationale*: This protects the host class from monkey-patching, accidental mutation, and tampering, while ensuring compatibility with Tempo's internal `@Immutable` and `@Securable` engines. +> 3. **Fluent Immutable Instance Methods**: Instance methods attached to `Tempo.prototype` must adhere to Tempo's immutable design principles. Methods should return a **new** enriched or transformed `Tempo` instance (e.g., `return new TempoClass(this, { ... })`) rather than mutating `this` in place. + ## 6. TypeScript Documentation (TSDoc) All exported components (functions, interfaces, classes, and types) must be properly documented using the standard Magma TSDoc format. This ensures rich intellisense tooltips for developers utilizing the plugin. diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index 7497adb2..af95ee47 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -33,7 +33,8 @@ "@magmacomputing/tempo": "^4.1.0" }, "devDependencies": { - "@js-temporal/polyfill": "^0.5.1" + "@js-temporal/polyfill": "^0.5.1", + "@magmacomputing/library": "*" }, "keywords": [ "tempo", diff --git a/packages/plugins/ai/test/parse.test.ts b/packages/plugins/ai/test/parse.test.ts index 296b1ddb..879caacf 100644 --- a/packages/plugins/ai/test/parse.test.ts +++ b/packages/plugins/ai/test/parse.test.ts @@ -1,5 +1,5 @@ import { parseAI, initAI, resetAI, aiCache, getAiRateLimits, getAiConfig, TempoAiError, AiMode, DEFAULT_PROVIDERS } from '../src/index.js'; -import { BoundedCache } from '@magmacomputing/tempo/support'; +import { BoundedCache } from '@magmacomputing/library'; import { Tempo } from '@magmacomputing/tempo'; describe('AI Parsing Plugin (parseAI)', () => { diff --git a/packages/plugins/geo/README.md b/packages/plugins/geo/README.md index 9df33348..6ed653a6 100644 --- a/packages/plugins/geo/README.md +++ b/packages/plugins/geo/README.md @@ -6,50 +6,144 @@ npm version npm peer dependency version License TypeScript Ready Documentation

-A Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that provides IP geolocation lookup, browser hardware location services, and coordinate resolution. +A Community plugin for the [Tempo](https://github.com/magmacomputing/magma) ecosystem that provides IP geolocation lookup, browser hardware location services, coordinate normalization, and 24-hour cached coordinate stashing. + +By keeping geolocation logic in this plugin, core `@magmacomputing/tempo` remains zero-network and purely deterministic. 👉 **[View the full documentation on our GitHub Pages](https://magmacomputing.github.io/magma/doc/9-plugins/geo.index.html)** +--- + ## Installation ```bash npm install @magmacomputing/tempo-plugin-geo ``` +--- + ## Usage -### Functional Paradigm (Tree-Shakeable) +### 1. Fluent OOP with Namespaced `Tempo.geo` + +Installing `GeoPlugin` mounts an immutable, locked-down **`Tempo.geo`** namespace onto the `Tempo` class: ```typescript import { Tempo } from '@magmacomputing/tempo'; -import { geoLookup, resolveGeoCoordinates } from '@magmacomputing/tempo-plugin-geo'; +import { GeoPlugin } from '@magmacomputing/tempo-plugin-geo'; + +Tempo.use(GeoPlugin); + +// 1. Universal Geolocation Lookup (cached for 24h) +const lookupResult = await Tempo.geo.lookup(); +console.log(lookupResult.lat, lookupResult.lng, lookupResult.city); + +// 2. Inspect Current Ambient / Global Coordinates +console.log(Tempo.geo.current); // { latitude: ..., longitude: ..., city: ... } -// Automatically resolves coordinates via browser hardware GPS or server IP lookup -const geo = await geoLookup(); -const t = new Tempo({ geo }); +// 3. Force Fresh Network Lookup (bypassing 24h cache) +const fresh = await Tempo.geo.lookup({ refresh: true }); -console.log(t.geo?.latitude, t.geo?.longitude); +// 4. Enrich a Tempo Instance Asynchronously +const t = new Tempo(); +const localTime = await t.geoLocate(); +console.log(localTime.geo?.latitude, localTime.geo?.longitude); ``` -### Fluent OOP Paradigm +### 2. Functional Tree-Shakeable APIs + +All underlying utilities can be imported as standalone pure functions without augmenting `Tempo`: ```typescript import { Tempo } from '@magmacomputing/tempo'; -import { GeoPlugin } from '@magmacomputing/tempo-plugin-geo'; +import { + geoLookup, + resolveGeoCoordinates, + stashGeo, + clearStashedGeo, + getStashedGeo, +} from '@magmacomputing/tempo-plugin-geo'; + +// Standalone lookup & instance creation +const coords = await geoLookup(); +const t = new Tempo('2026-06-21', { geo: coords }); +``` -Tempo.use(GeoPlugin); +--- -const t = new Tempo(); +## The `Tempo.geo` API Surface -// Resolve coordinates asynchronously and return a new enriched Tempo instance -const localTime = await t.geoLocate(); -console.log(localTime.geo?.latitude, localTime.geo?.longitude); -``` +| Method / Property | Description | +| :--- | :--- | +| `Tempo.geo.lookup(opts?)` | Universal geolocation lookup (browser hardware GPS or server IP lookup) cached for 24h. Supports `{ refresh: true }`. | +| `Tempo.geo.resolve(input, opts?)` | Asynchronously resolves coordinates from an instance, configuration, or ambient storage cache. | +| `Tempo.geo.coerce(input)` | Pure function normalizing various coordinate formats (`lat/lng`, `latitude/longitude`, etc.) into a canonical `GeoConfig`. | +| `Tempo.geo.stash(coords, ttl?, keyOrOpts?)` | Stashes coordinates in storage with an optional custom TTL (default: 24h) and multi-tenant partitioning. | +| `Tempo.geo.clear(keyOrOpts?)` | Purges stashed coordinates from storage. | +| `Tempo.geo.get(keyOrOpts?)` | Reads stashed coordinates for the specified tenant/IP or ambient default. | +| `Tempo.geo.current` | **Read-only getter** returning the active global/ambient coordinates snapshot (`getStashedGeo() ?? Tempo.config.geo`). | +| `Tempo.geo.server(opts?)` | Low-level server-side IP geolocation handler. | +| `Tempo.geo.browser(opts?)` | Low-level browser Geolocation API handler. | + +--- + +## ⚠️ Critical Operational Warnings + +### 1. Server Context vs. Client Context + +> [!WARNING] +> **Ambient IP lookup on a server resolves the SERVER's location, NOT the user's location.** + +- In a server environment (Node.js, Deno, Bun, Edge runtimes), calling `Tempo.geo.lookup()` without options will query the **datacenter's public outbound IP address**. +- If your server runs in AWS `us-east-1` (Virginia) and an Australian user hits your API, calling ambient `Tempo.geo.lookup()` will resolve to Virginia! +- **Best Practice for Backends**: + - Always extract the client IP from trusted reverse proxy headers (e.g., `X-Forwarded-For`, `CF-Connecting-IP`) and pass it explicitly: + ```typescript + const userCoords = await Tempo.geo.lookup({ ip: clientIp }); + const userTime = new Tempo(date, { geo: userCoords }); + ``` + - Or receive explicit GPS/browser coordinates from the frontend client request payload. + +--- -## Documentation +### 2. Multi-Tenant Key Isolation -For full API reference and configuration options, please visit the official **[Geo Plugin Documentation ↗](https://magmacomputing.github.io/magma/doc/9-plugins/geo.index.html)**. +> [!CAUTION] +> **Unpartitioned ambient storage is shared. In multi-tenant environments, always use unique keys or instance-level options.** + +- Ambient storage stores coordinates under `_map_` by default. +- In a shared process or server handling requests for multiple tenants or distinct users, calling `stash()` or ambient `lookup()` without a key will cause tenants to **overwrite each other's cached coordinates**! +- **Solution A: Multi-Tenant Key Scoping**: + Pass a tenant identifier or user ID as the key: + ```typescript + // Stash coordinates partitioned for tenant A: + Tempo.geo.stash(tenantACoords, undefined, 'tenant-alpha'); + + // Lookup / retrieve for a specific tenant: + const coords = Tempo.geo.get('tenant-alpha'); + Tempo.geo.clear('tenant-alpha'); + ``` + The cache automatically partitions keys under `_map_:`, guaranteeing strict isolation. + +- **Solution B: Instance-Level Configuration (Recommended)**: + Avoid ambient storage altogether by binding coordinates directly to `Tempo` instances: + ```typescript + const tenantTime = new Tempo(date, { geo: tenantCoords }); + ``` + Instance-level coordinates are completely local, immutable, and never touch shared memory or ambient caches. + +--- + +## Security & Immutability + +Is `Tempo.geo` locked down? **Yes.** +- The `Tempo.geo` object is frozen via `Object.freeze()`. +- The `geo` property on `Tempo` is defined with `writable: false`, `configurable: false`, and `enumerable: false`. +- Any attempt to reassign `Tempo.geo = ...` or mutate `Tempo.geo.lookup = ...` will throw a `TypeError` in strict mode. + +--- ## Licensing This is a **Community** plugin. It is completely free and open-source for personal and commercial use under the MIT license. + diff --git a/packages/plugins/geo/doc/index.md b/packages/plugins/geo/doc/index.md index a108f178..5b69e26e 100644 --- a/packages/plugins/geo/doc/index.md +++ b/packages/plugins/geo/doc/index.md @@ -6,9 +6,11 @@ npm version npm peer dependency version License TypeScript Ready

-A Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that provides IP geolocation lookup, browser hardware location services, and coordinate resolution. +A Community plugin for the [Tempo](https://github.com/magmacomputing/magma) ecosystem providing IP geolocation lookup, browser hardware location services, coordinate normalization, and 24-hour cached coordinate stashing. -By decoupling network-based geolocation lookup into a dedicated plugin, `@magmacomputing/tempo` remains zero-network and pure, while applications that need automatic location detection can easily install this plugin. +By decoupling network-based geolocation lookup into a dedicated plugin, `@magmacomputing/tempo` remains zero-network and purely deterministic. + +--- ## Installation @@ -16,36 +18,140 @@ By decoupling network-based geolocation lookup into a dedicated plugin, `@magmac npm install @magmacomputing/tempo-plugin-geo ``` -## Features +--- -- **Cross-Environment Geolocation**: - - **Browser**: Uses hardware Geolocation API (`navigator.geolocation`) with local storage stashing. - - **Server (Node.js/Deno)**: Queries bounded IP geolocation lookup via secure HTTPS endpoints with automatic timeout and size limits. -- **Universal Coordinate Resolution**: - - Extracts coordinates from existing objects or instances, falling back to lookup only when needed. -- **Tree-Shakeable Functional APIs**: - - `geoLookup()`, `resolveGeoCoordinates()`, `serverGeoLocation()`, `geoLocation()`, `coerceGeo()`. -- **Fluent OOP Integration**: - - `t.geoLocate()`, `t.geoLookup()`, `Tempo.geoLookup()`. +## Architecture & Namespacing -## Usage +Installing `GeoPlugin` mounts an immutable, locked-down **`Tempo.geo`** namespace onto the `Tempo` class. This avoids polluting the root class while providing a single landing pad for all coordinate and lookup operations. ```typescript import { Tempo } from '@magmacomputing/tempo'; -import { geoLookup, GeoPlugin } from '@magmacomputing/tempo-plugin-geo'; +import { GeoPlugin } from '@magmacomputing/tempo-plugin-geo'; + +Tempo.use(GeoPlugin); +``` + +### The `Tempo.geo` API Surface -// Functional: -const geo = await geoLookup(); -const t1 = new Tempo({ geo }); -console.log(t1.geo); +| Method / Property | Description | +| :--- | :--- | +| `Tempo.geo.lookup(opts?)` | Universal geolocation lookup (browser hardware GPS or server IP lookup) cached for 24h. Supports `{ refresh: true }`. | +| `Tempo.geo.resolve(input, opts?)` | Asynchronously resolves coordinates from an instance, configuration, or ambient storage cache. | +| `Tempo.geo.coerce(input)` | Pure function normalizing various coordinate formats (`lat/lng`, `latitude/longitude`, etc.) into a canonical `GeoConfig`. | +| `Tempo.geo.stash(coords, ttl?, keyOrOpts?)` | Stashes coordinates in storage with an optional custom TTL (default: 24h) and multi-tenant partitioning. | +| `Tempo.geo.clear(keyOrOpts?)` | Purges stashed coordinates from storage. | +| `Tempo.geo.get(keyOrOpts?)` | Reads stashed coordinates for the specified tenant/IP or ambient default. | +| `Tempo.geo.current` | **Read-only getter** returning the active global/ambient coordinates snapshot (`getStashedGeo() ?? Tempo.config.geo`). | +| `Tempo.geo.server(opts?)` | Low-level server-side IP geolocation handler. | +| `Tempo.geo.browser(opts?)` | Low-level browser Geolocation API handler. | + +--- + +## Usage Examples + +### 1. Fluent OOP with `Tempo.geo` + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { GeoPlugin } from '@magmacomputing/tempo-plugin-geo'; -// Fluent OOP: Tempo.use(GeoPlugin); -const t2 = new Tempo(); -const localTime = await t2.geoLocate(); -console.log(localTime.geo); + +// Universal Geolocation Lookup (cached for 24h) +const lookup = await Tempo.geo.lookup(); +console.log(lookup.lat, lookup.lng, lookup.city); + +// Inspect live ambient coordinate snapshot +console.log(Tempo.geo.current); + +// Force fresh network lookup (bypassing 24h cache) +const fresh = await Tempo.geo.lookup({ refresh: true }); + +// Enrich a Tempo instance asynchronously +const t = new Tempo(); +const localTime = await t.geoLocate(); +console.log(localTime.geo?.latitude, localTime.geo?.longitude); +``` + +### 2. Functional Tree-Shakeable APIs + +All underlying utilities can be imported as standalone pure functions: + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { + geoLookup, + resolveGeoCoordinates, + stashGeo, + clearStashedGeo, + getStashedGeo, +} from '@magmacomputing/tempo-plugin-geo'; + +const coords = await geoLookup(); +const t = new Tempo('2026-06-21', { geo: coords }); ``` +--- + +## ⚠️ Critical Operational Warnings + +### 1. Server Context vs. Client Context + +::: warning Server IP vs. User Location +**Ambient IP lookup on a server resolves the SERVER's location, NOT the user's location.** + +- In a server environment (Node.js, Deno, Bun, Edge runtimes), calling `Tempo.geo.lookup()` without options queries the **server datacenter's public outbound IP address**. +- If your server runs in AWS `us-east-1` (Virginia) and an Australian user hits your API, calling ambient `Tempo.geo.lookup()` will resolve to Virginia! +- **Best Practice for Backends**: + - Always extract the client IP from trusted reverse proxy headers (e.g., `X-Forwarded-For`, `CF-Connecting-IP`) and pass it explicitly: + ```typescript + const userCoords = await Tempo.geo.lookup({ ip: clientIp }); + const userTime = new Tempo(date, { geo: userCoords }); + ``` + - Or receive explicit GPS/browser coordinates from the frontend client request payload. +::: + +--- + +### 2. Multi-Tenant Key Isolation + +::: danger Shared Ambient Storage +**Unpartitioned ambient storage is shared. In multi-tenant environments, always use unique keys or instance-level options.** + +- Ambient storage stores coordinates under `_map_` by default. +- In a shared process handling requests for multiple tenants or distinct users, calling `stash()` or ambient `lookup()` without a key will cause tenants to **overwrite each other's cached coordinates**! +- **Solution A: Multi-Tenant Key Scoping**: + Pass a tenant identifier or user ID as the key: + ```typescript + // Stash coordinates partitioned for tenant A: + Tempo.geo.stash(tenantACoords, undefined, 'tenant-alpha'); + + // Lookup / retrieve for a specific tenant: + const coords = Tempo.geo.get('tenant-alpha'); + Tempo.geo.clear('tenant-alpha'); + ``` + The cache automatically partitions keys under `_map_:`, guaranteeing strict isolation. + +- **Solution B: Instance-Level Configuration (Recommended)**: + Avoid ambient storage altogether by binding coordinates directly to `Tempo` instances: + ```typescript + const tenantTime = new Tempo(date, { geo: tenantCoords }); + ``` + Instance-level coordinates are completely local, immutable, and never touch shared memory or ambient caches. +::: + +--- + +## Security & Immutability + +`Tempo.geo` is completely locked down: +- The `Tempo.geo` namespace object is frozen with `Object.freeze()`. +- The `geo` property on `TempoClass` is defined with `writable: false`, `configurable: false`, and `enumerable: false`. +- Any attempt to reassign `Tempo.geo = ...` or mutate `Tempo.geo.lookup = ...` will throw a `TypeError` in strict mode. + +--- + ## Licensing This is a **Community** plugin. It is completely free and open-source for personal and commercial use under the MIT license. + diff --git a/packages/plugins/geo/src/index.ts b/packages/plugins/geo/src/index.ts index 21ec0c20..33527efc 100644 --- a/packages/plugins/geo/src/index.ts +++ b/packages/plugins/geo/src/index.ts @@ -5,6 +5,8 @@ import { resolveGeoCoordinates, coerceGeo, getStashedGeo, + stashGeo, + clearStashedGeo, type GeoLookupResult, type GeoConfig, type CoordinateInput, @@ -25,6 +27,8 @@ export { resolveGeoCoordinates, coerceGeo, getStashedGeo, + stashGeo, + clearStashedGeo, serverGeoLocation, serverGeoCoords, serverMapHemisphere, @@ -39,21 +43,56 @@ export type { ServerGeolocationResult, }; -type GeoLookupFn = typeof geoLookup; -type ResolveGeoCoordinatesFn = typeof resolveGeoCoordinates; -type ServerGeoLocationFn = typeof serverGeoLocation; -type GeoLocationFn = typeof geoLocation; +/** + * Cohesive static namespace for geolocation operations on Tempo. + */ +export interface TempoGeoNamespace { + /** Asynchronous universal geolocation lookup (browser hardware or server IP) with 24h caching */ + readonly lookup: typeof geoLookup; + /** Asynchronously resolves coordinates from an instance, config, or ambient storage */ + readonly resolve: typeof resolveGeoCoordinates; + /** Coerces coordinates and configurations into a canonical GeoConfig object */ + readonly coerce: typeof coerceGeo; + /** Explicitly stashes coordinates into storage with optional TTL (default 24h) and multi-tenant partitioning */ + readonly stash: typeof stashGeo; + /** Clears stashed coordinates from storage */ + readonly clear: typeof clearStashedGeo; + /** Retrieves stashed coordinates from storage */ + readonly get: typeof getStashedGeo; + /** Low-level server-side IP geolocation handler */ + readonly server: typeof serverGeoLocation; + /** Low-level browser geolocation API handler */ + readonly browser: typeof geoLocation; + /** Current ambient or global coordinates snapshot (reads getStashedGeo() ?? Tempo.config.geo) */ + readonly current: GeoConfig | undefined; +} /** - * GeoPlugin installs geolocation lookup and coordinate resolution helpers onto Tempo. + * GeoPlugin installs geolocation lookup and coordinate resolution helpers onto Tempo under the `Tempo.geo` namespace. */ export const GeoPlugin: TempoPlugin = definePlugin({ name: 'geo', install(TempoClass: any) { - TempoClass.geoLookup = geoLookup; - TempoClass.resolveGeoCoordinates = resolveGeoCoordinates; - TempoClass.serverGeoLocation = serverGeoLocation; - TempoClass.geoLocation = geoLocation; + const geoNamespace: TempoGeoNamespace = { + lookup: geoLookup, + resolve: resolveGeoCoordinates, + coerce: coerceGeo, + stash: stashGeo, + clear: clearStashedGeo, + get: getStashedGeo, + server: serverGeoLocation, + browser: geoLocation, + get current(): GeoConfig | undefined { + return getStashedGeo() ?? TempoClass.config?.geo; + }, + } + + Object.defineProperty(TempoClass, 'geo', { + value: Object.freeze(geoNamespace), + writable: false, + configurable: false, + enumerable: false, + }); /** * Asynchronously resolves coordinates for the current instance (or uses existing coordinates), @@ -98,10 +137,8 @@ declare module '@magmacomputing/tempo' { */ geoLookup(opts?: Record): Promise<{ lat: number; lng: number } | null>; } + namespace Tempo { - let geoLookup: GeoLookupFn; - let resolveGeoCoordinates: ResolveGeoCoordinatesFn; - let serverGeoLocation: ServerGeoLocationFn; - let geoLocation: GeoLocationFn; + let geo: TempoGeoNamespace; } } diff --git a/packages/plugins/geo/test/geo.test.ts b/packages/plugins/geo/test/geo.test.ts index d71a819e..589ecf09 100644 --- a/packages/plugins/geo/test/geo.test.ts +++ b/packages/plugins/geo/test/geo.test.ts @@ -5,6 +5,9 @@ import { resolveGeoCoordinates, coerceGeo, serverGeoLocation, + stashGeo, + clearStashedGeo, + getStashedGeo, } from '../src/index.js'; describe('Tempo Plugin: Geo', () => { @@ -12,8 +15,13 @@ describe('Tempo Plugin: Geo', () => { Tempo.use(GeoPlugin); }); + beforeEach(() => { + clearStashedGeo(); + }); + afterEach(() => { vi.restoreAllMocks(); + clearStashedGeo(); }); describe('Pure functional coordinate utilities', () => { @@ -88,10 +96,27 @@ describe('Tempo Plugin: Geo', () => { }); describe('Tempo OOP Integration via GeoPlugin', () => { - it('should attach static methods onto Tempo class', () => { - expect(typeof Tempo.geoLookup).toBe('function'); - expect(typeof Tempo.resolveGeoCoordinates).toBe('function'); - expect(typeof Tempo.serverGeoLocation).toBe('function'); + it('should attach Tempo.geo namespace with utilities', () => { + expect(typeof Tempo.geo).toBe('object'); + expect(typeof Tempo.geo.lookup).toBe('function'); + expect(typeof Tempo.geo.resolve).toBe('function'); + expect(typeof Tempo.geo.coerce).toBe('function'); + expect(typeof Tempo.geo.stash).toBe('function'); + expect(typeof Tempo.geo.clear).toBe('function'); + expect(typeof Tempo.geo.get).toBe('function'); + expect(typeof Tempo.geo.server).toBe('function'); + expect(typeof Tempo.geo.browser).toBe('function'); + expect(Tempo.geo.current).toBeUndefined(); + }); + + it('should ensure Tempo.geo is a locked-down, frozen, non-writable object', () => { + expect(Object.isFrozen(Tempo.geo)).toBe(true); + expect(() => { + (Tempo as any).geo = {}; + }).toThrow(); + expect(() => { + (Tempo.geo as any).lookup = () => {}; + }).toThrow(); }); it('should allow instance method .geoLookup() to resolve coordinates', async () => { @@ -158,4 +183,93 @@ describe('Tempo Plugin: Geo', () => { expect((located.geo as any)?.customKey).toBe('customValue'); }); }); + + describe('Geo Stashing and Caching with BoundedCache', () => { + it('should provide stash, clear, get, and current on Tempo.geo', () => { + expect(typeof Tempo.geo.stash).toBe('function'); + expect(typeof Tempo.geo.clear).toBe('function'); + expect(typeof Tempo.geo.get).toBe('function'); + Tempo.geo.stash({ latitude: -33.8688, longitude: 151.2093, city: 'Sydney' }); + expect(Tempo.geo.current).toEqual({ + latitude: -33.8688, + longitude: 151.2093, + city: 'Sydney', + }); + Tempo.geo.clear(); + expect(Tempo.geo.current).toBeUndefined(); + }); + + it('should cache geoLookup results and bypass with { refresh: true }', async () => { + const mockFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + ip: '1.2.3.4', + success: true, + lat: 34.0522, + lon: -118.2437, + city: 'Los Angeles', + country: 'United States', + }), + { status: 200 } + ) + ) + ); + + const first = await geoLookup(); + expect(first.lat).toBe(34.0522); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Second lookup should be served from BoundedCache (zero network calls) + const cached = await geoLookup(); + expect(cached.lat).toBe(34.0522); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Calling with { refresh: true } should bypass cache + const refreshed = await geoLookup({ refresh: true }); + expect(refreshed.lat).toBe(34.0522); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should allow manual stashGeo and clearStashedGeo with multi-tenant isolation', () => { + stashGeo({ latitude: 51.5074, longitude: -0.1278, city: 'London' }, undefined, 'tenant-uk'); + stashGeo({ latitude: 48.8566, longitude: 2.3522, city: 'Paris' }, undefined, 'tenant-fr'); + + expect(getStashedGeo('tenant-uk')).toEqual({ + latitude: 51.5074, + longitude: -0.1278, + city: 'London', + }); + expect(getStashedGeo('tenant-fr')).toEqual({ + latitude: 48.8566, + longitude: 2.3522, + city: 'Paris', + }); + + clearStashedGeo('tenant-uk'); + expect(getStashedGeo('tenant-uk')).toBeUndefined(); + expect(getStashedGeo('tenant-fr')).toBeDefined(); + }); + + it('should perform lookup via Tempo.geo.lookup() and update Tempo.geo.current', async () => { + const mockPayload = { + ip: '9.9.9.9', + success: true, + lat: -37.8136, + lon: 144.9631, + city: 'Melbourne', + country: 'Australia', + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockPayload), { status: 200 }) + ); + + const result = await Tempo.geo.lookup(); + expect(result.lat).toBe(-37.8136); + expect(result.lng).toBe(144.9631); + expect(result.city).toBe('Melbourne'); + expect(Tempo.geo.current?.city).toBe('Melbourne'); + }); + }); }); diff --git a/packages/tempo-cli/commands/version-sync.js b/packages/tempo-cli/commands/version-sync.js index cc617c71..e9991231 100644 --- a/packages/tempo-cli/commands/version-sync.js +++ b/packages/tempo-cli/commands/version-sync.js @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process'; import path from 'node:path'; -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); @@ -29,9 +29,9 @@ export async function versionSync(_args) { let syncedCount = 0; let alreadySyncedCount = 0; for (const ws of workspaces) { + const wsRelPath = ws.replace('@magmacomputing/', ''); + const wsPkgPath = path.resolve(ROOT_DIR, 'packages', wsRelPath, 'package.json'); try { - const wsRelPath = ws.replace('@magmacomputing/', ''); - const wsPkgPath = path.resolve(ROOT_DIR, 'packages', wsRelPath, 'package.json'); if (existsSync(wsPkgPath)) { const wsPkg = JSON.parse(readFileSync(wsPkgPath, 'utf8')); if (wsPkg.version === version) { @@ -45,6 +45,19 @@ export async function versionSync(_args) { console.log(`✅ Synced ${ws} to ${version}`); syncedCount++; } catch (error) { + // Fallback to direct package.json modification if npm CLI workspace versioning fails + if (existsSync(wsPkgPath)) { + try { + const rawContent = readFileSync(wsPkgPath, 'utf8'); + const updatedContent = rawContent.replace(/"version":\s*"[^"]+"/, `"version": "${version}"`); + writeFileSync(wsPkgPath, updatedContent, 'utf8'); + console.log(`✅ Synced ${ws} to ${version} (file fallback)`); + syncedCount++; + continue; + } catch (writeErr) { + console.warn(`⚠️ File write fallback failed for ${ws}:`, writeErr?.message || writeErr); + } + } console.warn(`⚠️ Bypassed ${ws} (error details):`, error?.message || error); } } diff --git a/packages/tempo/package.json b/packages/tempo/package.json index d0c4f8b2..92d6fc42 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "4.1.1", + "version": "4.1.2", "engines": { "node": ">=20.0.0" }, diff --git a/packages/tempo/public/esm_sh.index.html b/packages/tempo/public/esm_sh.index.html index c65e79b9..48a0f6af 100644 --- a/packages/tempo/public/esm_sh.index.html +++ b/packages/tempo/public/esm_sh.index.html @@ -270,7 +270,7 @@

Tempo

{ "imports": { "@js-temporal/polyfill": "https://esm.sh/@js-temporal/polyfill@0.5.1", - "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.1.1" + "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.1.2" } } diff --git a/packages/tempo/public/llms.txt b/packages/tempo/public/llms.txt index f305002e..d519347f 100644 --- a/packages/tempo/public/llms.txt +++ b/packages/tempo/public/llms.txt @@ -1,6 +1,6 @@ -# Tempo: Immutable Date-Time Engine & AI Syntax Rules (v4.1.1) +# Tempo: Immutable Date-Time Engine & AI Syntax Rules (v4.1.2) -> Tempo (v4.1.1) is an immutable TypeScript date-time engine built around the ECMAScript Temporal API. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments. +> Tempo (v4.1.2) is an immutable TypeScript date-time engine built around the ECMAScript Temporal API. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments. ## Core Architectural Rules & Philosophy - **Temporal Engine**: Tempo uses native `Temporal` in modern runtimes or `@js-temporal/polyfill`. Never instantiate legacy JavaScript `Date`. diff --git a/packages/tempo/src/support/support.cache.ts b/packages/tempo/src/support/support.cache.ts index 8205a021..11dc909d 100644 --- a/packages/tempo/src/support/support.cache.ts +++ b/packages/tempo/src/support/support.cache.ts @@ -1,292 +1,6 @@ -import { isString, isUndefined } from '#library/assertion.library.js'; import { secure } from '#library/proxy.library.js'; import type * as t from '../tempo.type.js'; -/** - * ## BoundedCache - * High-performance Map implementation enforcing maximum capacity (LRU) and TTL eviction. - * Supports static keys (immortal glossary definitions) and date-salt staleness purging. - */ -export class BoundedCache extends Map { - #timestamps = new Map(); - #staticKeys = new Set(); - maxSize: number; - ttl: number; - - constructor(maxSize = 1000, ttl = 24 * 60 * 60 * 1000) { - super(); - this.maxSize = maxSize; - this.ttl = ttl; - } - - /** - * Returns true to identify this instance as a BoundedCache. - */ - get isBoundedCache(): boolean { - return true; - } - - #isExpired(key: K): boolean { - if (this.#staticKeys.has(key)) return false; - const time = this.#timestamps.get(key); - if (isUndefined(time)) return false; - return Date.now() - time > this.ttl; - } - - /** - * Purges TTL-expired entries. - */ - evictExpired(): void { - const now = Date.now(); - for (const key of super.keys()) { - if (this.#staticKeys.has(key)) continue; - const time = this.#timestamps.get(key); - if (time !== undefined && (now - time > this.ttl)) { - this.delete(key); - } - } - } - - /** - * Register an immortal static key (e.g. business glossary term). - * Static keys bypass TTL expiration and LRU capacity eviction. - */ - setStatic(key: K, value: V): this { - if (super.has(key)) super.delete(key); - super.set(key, value); - this.#timestamps.delete(key); - this.#staticKeys.add(key); - return this; - } - - /** - * Check if a key is a static key. - */ - isStatic(key: K): boolean { - return this.#staticKeys.has(key); - } - - /** - * Retrieves a value from the cache by key. Evaluates TTL expiration via `#timestamps` - * specifically for the requested key, and updates LRU ordering by deleting and re-inserting - * the key in the underlying Map. Does not sweep or evict other expired entries. - * - * @param key - The cache key to retrieve - * @returns The cached value, or undefined if not found or expired - */ - override get(key: K): V | undefined { - if (this.#isExpired(key)) { - this.delete(key); - return undefined; - } - if (!super.has(key)) return undefined; - const val = super.get(key)!; - super.delete(key); - super.set(key, val); - if (!this.#staticKeys.has(key)) - this.#timestamps.set(key, Date.now()); - return val; - } - - /** - * Checks if a key exists in the cache and is not expired. - * Automatically evicts expired entries on access. - * - * @param key - The cache key to check - * @returns True if the key exists and is not expired, false otherwise - */ - override has(key: K): boolean { - if (this.#isExpired(key)) { - this.delete(key); - return false; - } - return super.has(key); - } - - /** - * Returns the number of entries in the cache after evicting expired entries. - */ - override get size(): number { - this.evictExpired(); - return super.size; - } - - /** - * Executes a callback for each cache entry after evicting expired entries. - * - * @param callbackfn - Function to execute for each entry - * @param thisArg - Optional value to use as `this` when executing the callback - */ - override forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: any): void { - this.evictExpired(); - super.forEach(callbackfn, thisArg); - } - - /** - * Sets a key-value pair in the cache, updating its timestamp and enforcing LRU eviction. - * If the cache exceeds maxSize, the oldest non-static entry is removed. - * - * @param key - The cache key to set - * @param value - The value to store - * @returns This cache instance for chaining - */ - override set(key: K, value: V): this { - this.evictExpired(); - - if (super.has(key)) super.delete(key); - - super.set(key, value); - this.#staticKeys.delete(key); - this.#timestamps.set(key, Date.now()); - - while (this.size > this.maxSize) { - let evicted = false; - const keysIter = super.keys(); - let res = keysIter.next(); - while (!res.done) { - const k = res.value; - if (!this.#staticKeys.has(k)) { - this.delete(k); - evicted = true; - break; - } - res = keysIter.next(); - } - if (!evicted) break; - } - - return this; - } - - /** - * Deletes a key from the cache, removing its timestamp and static key markers. - * - * @param key - The cache key to delete - * @returns True if the key was deleted, false if it did not exist - */ - override delete(key: K): boolean { - this.#timestamps.delete(key); - this.#staticKeys.delete(key); - return super.delete(key); - } - - /** - * Deletes all cache entries whose keys start with the specified prefix. - * The prefix comparison is case-insensitive. - * - * @param prefix - The prefix string to match against cache keys - * @returns The number of entries deleted - */ - deletePrefix(prefix: string): number { - const normalizedPrefix = String(prefix).trim().toLowerCase(); - const toDelete: K[] = []; - for (const key of super.keys()) { - if (isString(key) && key.toLowerCase().startsWith(normalizedPrefix)) - toDelete.push(key); - } - - for (const k of toDelete) - this.delete(k); - - return toDelete.length; - } - - /** - * Purges cache entries. - * If `count` is specified, evicts up to `count` oldest non-static entries. - * If omitted, clears all entries, including static entries. - * - * @param count - Optional number of entries to evict. If omitted, clears all entries. - */ - override clear(count?: number): void { - if (count === undefined) { - this.#timestamps.clear(); - this.#staticKeys.clear(); - super.clear(); - return; - } - - let evicted = 0; - for (const key of super.keys()) { - if (evicted >= count) break; - if (!this.#staticKeys.has(key)) { - this.delete(key); - evicted++; - } - } - } - - /** - * Returns an iterator of cache keys after evicting expired entries. - * - * @returns Iterator of cache keys - */ - override keys(): MapIterator { - this.evictExpired(); - return super.keys(); - } - - /** - * Returns an iterator of cache values after evicting expired entries. - * - * @returns Iterator of cache values - */ - override values(): MapIterator { - this.evictExpired(); - return super.values(); - } - - /** - * Returns an iterator of cache entries after evicting expired entries. - * - * @returns Iterator of [key, value] pairs - */ - override entries(): MapIterator<[K, V]> { - this.evictExpired(); - return super.entries(); - } - - /** - * Returns an iterator of cache entries after evicting expired entries, making the cache iterable. - * - * @returns Iterator of [key, value] pairs - */ - override[Symbol.iterator](): MapIterator<[K, V]> { - this.evictExpired(); - return super[Symbol.iterator](); - } - - /** - * Returns a plain key-value object of all active non-expired cache entries. - * Filters out non-string keys to prevent lossy key conversions or collisions (e.g. numeric 1 vs string "1"). - */ - toJSON(): Record { - this.evictExpired(); - const stringEntries: [string, V][] = []; - for (const [k, v] of super.entries()) { - if (typeof k === 'string') - stringEntries.push([k, v]); - } - return Object.fromEntries(stringEntries) as Record; - } - - /** - * Creates a BoundedCache from an iterable of key-value pairs. - * - * @param entries - Iterable of [key, value] pairs to populate the cache - * @param maxSize - Maximum number of entries (default: 1000) - * @param ttl - Time-to-live in milliseconds (default: 24 hours) - * @returns A new BoundedCache instance - */ - static fromEntries(entries: Iterable, maxSize = 1000, ttl = 24 * 60 * 60 * 1000): BoundedCache { - const cache = new BoundedCache(maxSize, ttl); - for (const [k, v] of entries) { - cache.set(k, v); - } - return cache; - } -} - /** * Creates a normalized cache facade exposing safe operations over the active state's BoundedCache. */ @@ -346,4 +60,3 @@ export function createCacheFacade(getState: () => t.Internal.State) { } }); } - diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index c1ae5dbb..ba35fcf3 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -42,4 +42,4 @@ export { SCHEMA, getLargestUnit, logError, logWarn, logDebug, logTrace, setLogLe export { setPatterns } from '../engine/engine.pattern.js'; export { init, extendState } from './support.init.js'; export { TempoError } from './support.error.js'; -export { BoundedCache, createCacheFacade } from './support.cache.js'; \ No newline at end of file +export { createCacheFacade } from './support.cache.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index b1443d68..69706352 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -12,6 +12,7 @@ import { ownEntries } from '#library/primitive.library.js'; import { parseLogLevel } from '#library/logger.class.js'; import { evaluate } from '#library/evaluation.library.js'; import { coerceGeo } from '#library/mapper.library.js'; +import { BoundedCache } from '#library/cache.class.js'; import { getRuntime } from './support.runtime.js'; import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError, generateLocalizedSnippets } from './support.util.js'; @@ -20,7 +21,6 @@ import { Match, Snippet, Layout, Event, Period, Ignore, Default } from './suppor import { STATE } from './support.enum.js'; import enums from './support.enum.js'; -import { BoundedCache } from './support.cache.js'; import * as t from '../tempo.type.js'; import { registryUpdate } from './support.register.js'; diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index d422fb2d..2eee79b2 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -10,12 +10,12 @@ import type { DebugLevel } from '#library/logger.class.js'; import type { ScopedSet } from '#library/scopedset.class.js'; import type { IntRange, NonOptional, Property, Plural, TemporalObject, TypeValue, RegistryOption, Branded, LooseUnion, Evaluable } from '#library/type.library.js'; import type { GeoOptions, GeoConfig } from '#library/mapper.library.js'; +import type { BoundedCache } from '#library/cache.class.js'; export type { GeoOptions, GeoConfig }; import { sym, type TempoBrand } from '#tempo/support/support.symbol.js'; import * as enums from '#tempo/support/support.enum.js'; -import { BoundedCache } from '#tempo/support/support.cache.js'; import { SLICK_KEYS, type Snippet, type Layout, type Event, type Period, type Ignore } from '#tempo/support/support.default.js'; import type { Token } from '#tempo/support/support.symbol.js'; diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 5a81149f..59fe4e59 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 = '4.1.1'; +export const TEMPO_VERSION = '4.1.2'; diff --git a/packages/tempo/test/support/cache.test.ts b/packages/tempo/test/support/cache.test.ts index 35ff232c..be140f9f 100644 --- a/packages/tempo/test/support/cache.test.ts +++ b/packages/tempo/test/support/cache.test.ts @@ -1,5 +1,5 @@ import { Tempo } from '#tempo'; -import { BoundedCache } from '../../src/support/support.cache.js'; +import { BoundedCache } from '#library/cache.class.js'; describe('Tempo Core Caching Architecture', () => { beforeEach(() => { diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 57fa9412..6246499a 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -54,11 +54,11 @@ export default defineConfig({ { find: /^#tempo$/, replacement: resolve(__dirname, './dist/tempo.index.js') }, { find: /^#library\/(primitives|temporal|security|scheduling|runtime)\/(.*)\.js$/, replacement: resolve(__dirname, '../library/dist/common/$1/$2.js') }, { find: /^#library\/(array|assertion|coercion|number|object|primitive|string|symbol|type)\.library\.js$/, replacement: resolve(__dirname, '../library/dist/common/primitives/$1.library.js') }, - { find: /^#library\/(boundary|decorator|enumerate|evaluation|function|international|json|logger|mapper|pledge|proxy|reflection|request|scopedset|serialize|storage|utility)\.(library|class)\.js$/, replacement: resolve(__dirname, '../library/dist/common/runtime/$1.$2.js') }, { find: /^#library\/(cron|rrule|schedule)\.library\.js$/, replacement: resolve(__dirname, '../library/dist/common/scheduling/$1.library.js') }, { find: /^#library\/(buffer|cipher|webtoken)\.library\.js$/, replacement: resolve(__dirname, '../library/dist/common/security/$1.library.js') }, { find: /^#library\/(calendar|temporal)\.library\.js$/, replacement: resolve(__dirname, '../library/dist/common/temporal/$1.library.js') }, { find: /^#library\/temporal\.polyfill\.js$/, replacement: resolve(__dirname, '../library/dist/common/temporal/temporal.polyfill.js') }, + { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, '../library/dist/common/runtime/$1.js') }, { find: /^#library$/, replacement: resolve(__dirname, '../library/dist/common.index.js') }, { find: /^@magmacomputing\/tempo\/plugin$/, replacement: resolve(__dirname, './dist/plugin/plugin.index.js') }, { find: /^@magmacomputing\/tempo\/plugin\/sdk$/, replacement: resolve(__dirname, './dist/plugin/plugin.sdk.js') }, @@ -70,7 +70,6 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo\/library$/, replacement: resolve(__dirname, './dist/library.index.js') }, { find: /^@magmacomputing\/tempo$/, replacement: resolve(__dirname, './dist/tempo.index.js') }, ] : [ - { find: /^@magmacomputing\/tempo\/plugin-api$/, replacement: resolve(__dirname, './src/plugin/plugin.sdk.ts') }, { find: /^@magmacomputing\/tempo\/plugin$/, replacement: resolve(__dirname, './src/plugin/plugin.index.ts') }, { find: /^@magmacomputing\/tempo\/plugin\/sdk$/, replacement: resolve(__dirname, './src/plugin/plugin.sdk.ts') }, { find: /^@magmacomputing\/tempo\/plugin\/(.*)$/, replacement: resolve(__dirname, './src/plugin/$1.ts') }, @@ -105,11 +104,11 @@ export default defineConfig({ { find: /^@magmacomputing\/library\/(.*)$/, replacement: resolve(__dirname, '../library/src/$1.ts') }, { find: /^#library\/(primitives|temporal|security|scheduling|runtime)\/(.*)\.js$/, replacement: resolve(__dirname, '../library/src/common/$1/$2.ts') }, { find: /^#library\/(array|assertion|coercion|number|object|primitive|string|symbol|type)\.library\.js$/, replacement: resolve(__dirname, '../library/src/common/primitives/$1.library.ts') }, - { find: /^#library\/(boundary|decorator|enumerate|evaluation|function|international|json|logger|mapper|pledge|proxy|reflection|request|scopedset|serialize|storage|utility)\.(library|class)\.js$/, replacement: resolve(__dirname, '../library/src/common/runtime/$1.$2.ts') }, { find: /^#library\/(cron|rrule|schedule)\.library\.js$/, replacement: resolve(__dirname, '../library/src/common/scheduling/$1.library.ts') }, { find: /^#library\/(buffer|cipher|webtoken)\.library\.js$/, replacement: resolve(__dirname, '../library/src/common/security/$1.library.ts') }, { find: /^#library\/(calendar|temporal)\.library\.js$/, replacement: resolve(__dirname, '../library/src/common/temporal/$1.library.ts') }, { find: /^#library\/temporal\.polyfill\.js$/, replacement: resolve(__dirname, '../library/src/common/temporal/temporal.polyfill.ts') }, + { find: /^#library\/(.*)\.js$/, replacement: resolve(__dirname, '../library/src/common/runtime/$1.ts') }, { find: /^#library$/, replacement: resolve(__dirname, '../library/src/common.index.ts') }, ] } diff --git a/vitest.config.ts b/vitest.config.ts index 6e1b59b5..904a2d9d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs' import path, { dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' @@ -19,7 +18,6 @@ export default defineConfig({ ], resolve: { alias: [ - { find: /^@magmacomputing\/tempo\/plugin-api$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/plugin.sdk.ts') }, { find: /^@magmacomputing\/tempo\/plugin$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/plugin.index.ts') }, { find: /^@magmacomputing\/tempo\/plugin\/sdk$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/plugin.sdk.ts') }, { find: /^@magmacomputing\/tempo\/plugin\/(.*)$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/$1.ts') }, @@ -38,28 +36,29 @@ export default defineConfig({ { find: /^#library\/(primitives|temporal|security|scheduling|runtime)\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/$1/$2.ts') }, { find: /^#library\/(browser|server)\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/$1/$2.ts') }, { find: /^#library\/(array|assertion|coercion|number|object|primitive|string|symbol|type)\.library\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/primitives/$1.library.ts') }, - { find: /^#library\/(boundary|decorator|enumerate|evaluation|function|international|json|logger|pledge|proxy|reflection|request|scopedset|serialize|storage|utility)\.(library|class)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/runtime/$1.$2.ts') }, { find: /^#library\/(cron|rrule|schedule)\.library\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/scheduling/$1.library.ts') }, { find: /^#library\/(buffer|cipher|webtoken)\.library\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/security/$1.library.ts') }, { find: /^#library\/(calendar|temporal)\.library\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/temporal/$1.library.ts') }, { find: /^#library\/temporal\.polyfill\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/temporal/temporal.polyfill.ts') }, + { find: /^#library\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/runtime/$1.ts') }, { find: /^#library$/, replacement: path.resolve(__dirname, './packages/library/src/common.index.ts') }, - { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/term/$1.ts') }, - { find: /^#tempo\/plugin\.(util|type)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/plugin.$1.ts') }, - { find: /^#tempo\/plugin\.(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/extend/plugin.$1.ts') }, - { find: /^#tempo\/term\/quarter$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/term/term.quarter.ts') }, - { find: /^#tempo\/term$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/term/term.index.ts') }, - { find: /^#tempo\/term\/(.*)$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/term/$1') }, - { find: /^#tempo\/std$/, replacement: path.resolve(__dirname, './packages/plugins/.std/src/index.ts') }, { find: /^#tempo\/core$/, replacement: path.resolve(__dirname, './packages/tempo/src/core.index.ts') }, { find: /^#tempo\/config\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/config/$1.ts') }, { find: /^#tempo\/config$/, replacement: path.resolve(__dirname, './packages/tempo/src/config/config.index.ts') }, + { find: /^#tempo\/term$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/term/term.index.ts') }, + { find: /^#tempo\/term\/(.*)$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/term/$1') }, { find: /^#tempo\/(parse|format|mutate|duration)$/, replacement: path.resolve(__dirname, './packages/tempo/src/module/module.$1.ts') }, - { find: /^#tempo\/support$/, replacement: path.resolve(__dirname, './packages/tempo/src/support/support.index.ts') }, { find: /^#tempo\/module$/, replacement: path.resolve(__dirname, './packages/tempo/src/module/module.index.ts') }, - { find: /^#tempo\/tempo\.class\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/tempo.index.ts') }, + { find: /^#tempo\/support$/, replacement: path.resolve(__dirname, './packages/tempo/src/support/support.index.ts') }, + { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/scripts/$1.ts') }, + { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/plugin.$1.ts') }, + { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/extend/$1.ts') }, + { find: /^#tempo\/engine\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/engine/$1.ts') }, + { find: /^#tempo\/module\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/module/$1.ts') }, + { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/term/$1.ts') }, { find: /^#tempo\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/$1.ts') }, - { find: /^#tempo\/(.*)$/, replacement: path.resolve(__dirname, './packages/tempo/src/$1.ts') } + { find: /^#tempo\/std$/, replacement: path.resolve(__dirname, './packages/plugins/.std/src/index.ts') }, + { find: /^#tempo$/, replacement: path.resolve(__dirname, './packages/tempo/src/tempo.index.ts') } ] }, test: { From 2daf90ce7bff8f93df73eab0d64b9c5a9d1f067e Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:13:02 +0000 Subject: [PATCH 06/11] Document bounded cache configuration and expiration checks --- packages/library/src/common/runtime/cache.class.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/library/src/common/runtime/cache.class.ts b/packages/library/src/common/runtime/cache.class.ts index 6d4753a7..eb9059f0 100644 --- a/packages/library/src/common/runtime/cache.class.ts +++ b/packages/library/src/common/runtime/cache.class.ts @@ -14,6 +14,12 @@ export class BoundedCache extends Map { maxSize: number; ttl: number; + /** + * Creates a bounded cache with configurable capacity and entry lifetime. + * + * @param maxSize - Maximum number of non-static entries retained + * @param ttl - Default time-to-live for entries in milliseconds + */ constructor(maxSize = 1000, ttl = 24 * 60 * 60 * 1000) { super(); this.maxSize = maxSize; @@ -27,6 +33,12 @@ export class BoundedCache extends Map { return true; } + /** + * Determines whether a cache entry has passed its expiration deadline. + * + * @param key - Cache key to inspect + * @returns True when the entry has expired, otherwise false + */ #isExpired(key: K): boolean { const deadline = this.#expires.get(key); if (deadline === undefined) return false; From 7fc3f75486752dabc507b6e6db888d3f021ae701 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 9 Sep 2026 09:26:45 +1000 Subject: [PATCH 07/11] PR 3rd review --- packages/library/CHANGELOG.md | 4 +- .../library/src/browser/mapper.library.ts | 6 +- .../library/src/common/runtime/cache.class.ts | 30 +-- .../src/common/runtime/mapper.library.ts | 73 +++++-- .../src/common/runtime/storage.library.ts | 3 +- packages/library/src/server/mapper.library.ts | 17 +- .../test/common/runtime/cache.class.test.ts | 21 ++ .../test/common/runtime/mapper.common.test.ts | 52 ++++- .../library/test/server/mapper.server.test.ts | 28 +++ .../.setup/community-plugin-template.md | 6 +- packages/plugins/geo/CHANGELOG.md | 18 +- packages/plugins/geo/README.md | 13 +- packages/plugins/geo/doc/index.md | 14 +- packages/plugins/geo/plan/README.md | 204 ++++++++++++++++++ packages/plugins/geo/src/index.ts | 4 +- .../tempo/.vitepress/theme/data/catalog.json | 4 +- packages/tempo/README.md | 1 + packages/tempo/doc/7-ecosystem/vision.md | 21 +- packages/tempo/index.md | 2 +- packages/tempo/src/library.index.ts | 2 +- packages/tempo/src/support/support.index.ts | 1 + 21 files changed, 447 insertions(+), 77 deletions(-) create mode 100644 packages/plugins/geo/plan/README.md diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index e253e6f9..c585e3cd 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Generic Bounded LRU & TTL Cache Engine (`BoundedCache`)**: - - Implemented high-performance `BoundedCache` in `#library/common/runtime/cache.class.js` supporting configurable capacity constraints (`maxSize`, default 100) and time-to-live expiration (`ttl`, default `Infinity`). + - Implemented high-performance `BoundedCache` in `#library/common/runtime/cache.class.js` supporting configurable capacity constraints (`maxSize`, default 1000) and time-to-live expiration (`ttl`, default `Infinity`). - Added per-entry TTL override support in `set(key, val, ttl?)` with precomputed absolute expiration deadlines (`expiresAt = Date.now() + ttl`). - Implemented $O(1)$ fast-path expiration checks: bypasses `#expires` map checks and eliminates clock reads (`Date.now()`) when `ttl === Infinity` or `#expires.size === 0`. - Added bulk clear (`clear()`), lazy eviction (`evictExpired()`), iteration (`keys()`, `values()`, `entries()`, `forEach()`, `[Symbol.iterator]()`), and size inspection (`size`). @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wired `geoLookup()` to automatically cache resolved geographic coordinates in ambient storage with a 24-hour TTL (`86,400,000 ms`). - Added `{ refresh: true }` option to `geoLookup()` to bypass cached results and force fresh network resolution. - Added `stashGeo(coords, ttl?, keyOrOpts?)`, `clearStashedGeo(keyOrOpts?)`, and `getStashedGeo(keyOrOpts?)` helpers. - - Implemented multi-tenant and IP cache key partitioning (`resolveCacheKey` scoping to `_map_:` or `_map_:`), preventing tenants from trampling shared geolocation coordinates. + - Implemented multi-tenant and IP cache key partitioning (`resolveCacheKey` scoping to `_magma_geo_:` or `_magma_geo_:`), preventing tenants from trampling shared geolocation coordinates. ## [4.1.0] - 2026-09-06 diff --git a/packages/library/src/browser/mapper.library.ts b/packages/library/src/browser/mapper.library.ts index 863204fb..2ecb45e6 100644 --- a/packages/library/src/browser/mapper.library.ts +++ b/packages/library/src/browser/mapper.library.ts @@ -27,7 +27,7 @@ interface MapStore { // a localStorage object const defaults = { catch: true, debug: 0 } as MapOpts; // default Options const context = getContext(); // browser / nodejs / google-apps const mapStore = {} as MapStore; // static object to hold last position -const MAP_KEY = '_map_'; // localStorage key +const MAP_KEY = '_magma_geo_'; // localStorage key const log = new Logger('[Mapper]'); let storePromise: Promise | null = null; @@ -38,7 +38,7 @@ const getStore = () => { import('#browser/webstore.class.js') .then(({ WebStore }) => { const local = new WebStore('local'); - Object.assign(mapStore, local.get(MAP_KEY, {}));// fetch the previous MAP_KEY coordinates + Object.assign(mapStore, local.get(MAP_KEY, {}));// fetch previous coordinates resolve(local); // localStorage wrapper }) .catch(reject); @@ -146,7 +146,7 @@ export const mapQuery = (coords?: google.maps.GeocoderRequest, opts = {} as MapO geoCoords(coords) // get a Location object .then((loc) => { switch (true) { - case (!(typeof window !== 'undefined' && 'google' in window && 'maps' in window['google'])): + case (context.type !== CONTEXT.Browser || !window['google']?.maps): throw new Error('Google Maps API not configured'); case isNullish(loc): // unsuccessful geoLocation diff --git a/packages/library/src/common/runtime/cache.class.ts b/packages/library/src/common/runtime/cache.class.ts index 6d4753a7..722ffc84 100644 --- a/packages/library/src/common/runtime/cache.class.ts +++ b/packages/library/src/common/runtime/cache.class.ts @@ -69,7 +69,7 @@ export class BoundedCache extends Map { /** * Retrieves a value from the cache by key. Evaluates TTL expiration via `#expires` * specifically for the requested key, and updates LRU ordering by deleting and re-inserting - * the key in the underlying Map. Does not sweep or evict other expired entries. + * the key in the underlying Map. Leaves the expiration deadline unchanged. * * @param key - The cache key to retrieve * @returns The cached value, or undefined if not found or expired @@ -83,14 +83,6 @@ export class BoundedCache extends Map { const val = super.get(key)!; super.delete(key); super.set(key, val); - - const deadline = this.#expires.get(key); - if (deadline !== undefined) { - const effectiveTtl = this.#ttls.get(key) ?? this.ttl; - if (effectiveTtl !== Infinity) { - this.#expires.set(key, Date.now() + effectiveTtl); - } - } return val; } @@ -129,7 +121,9 @@ export class BoundedCache extends Map { } /** - * Sets a key-value pair in the cache, updating its expiration deadline and enforcing LRU eviction. + * Sets a key-value pair in the cache, enforcing LRU eviction. + * If finite-TTL is configured, entries retain their original absolute expiration deadline + * unless an explicit `ttl` override is passed. * If the cache exceeds maxSize, the oldest non-static entry is removed. * * @param key - The cache key to set @@ -140,19 +134,27 @@ export class BoundedCache extends Map { override set(key: K, value: V, ttl?: number): this { this.evictExpired(); + const existingDeadline = this.#expires.get(key); + if (super.has(key)) super.delete(key); super.set(key, value); this.#staticKeys.delete(key); - const effectiveTtl = ttl ?? this.ttl; - if (effectiveTtl !== Infinity) { - this.#expires.set(key, Date.now() + effectiveTtl); - if (isDefined(ttl)) { + if (isDefined(ttl)) { + if (ttl !== Infinity) { + this.#expires.set(key, Date.now() + ttl); this.#ttls.set(key, ttl); } else { + this.#expires.delete(key); this.#ttls.delete(key); } + } else if (existingDeadline !== undefined) { + // Retain existing absolute expiration deadline during cache updates + this.#expires.set(key, existingDeadline); + } else if (this.ttl !== Infinity) { + this.#expires.set(key, Date.now() + this.ttl); + this.#ttls.delete(key); } else { this.#expires.delete(key); this.#ttls.delete(key); diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts index 03f9eef6..0fdd2e94 100644 --- a/packages/library/src/common/runtime/mapper.library.ts +++ b/packages/library/src/common/runtime/mapper.library.ts @@ -1,5 +1,5 @@ import { CONTEXT, getContext } from '#library/utility.library.js'; -import { isNullish, isNumber, isString, isSafeKey } from '#library/assertion.library.js'; +import { isNullish, isNumber, isString, isSafeKey, isObject, isEmpty } from '#library/assertion.library.js'; import { getStorage, setStorage } from '#library/storage.library.js'; export interface GeoLookupResult { @@ -67,6 +67,22 @@ export interface CoordinateInput { [key: string]: any; } +const MAP_KEY = '_magma_geo_'; +const DEFAULT_GEO_TTL = 24 * 60 * 60 * 1000; // 24 hours + +const GEO_PROPERTIES = [ + 'latitude', + 'lat', + 'longitude', + 'lng', + 'lon', + 'long', + 'elevation', + 'sphere', + 'country', + 'city', +] as const; + /** * Extracts and coerces latitude and longitude from input object (options, config, or instance) * into a canonical `{ latitude, longitude, ... }` GeoConfig object. @@ -74,7 +90,7 @@ export interface CoordinateInput { * @param input - Optional object containing coordinate or geo properties */ export const coerceGeo = (input?: any): GeoConfig | undefined => { - if (isNullish(input) || typeof input !== 'object') return undefined; + if (isNullish(input) || !isObject(input)) return undefined; const geoObj = input.geo ?? input; const cfgGeo = input.config?.geo ?? input.config; @@ -100,30 +116,34 @@ export const coerceGeo = (input?: any): GeoConfig | undefined => { if (isString(country)) result.country = country; if (isString(city)) result.city = city; - if (geoObj && typeof geoObj === 'object') { + if (isObject(geoObj)) { for (const key of Object.keys(geoObj)) { - if (isSafeKey(key) && !['latitude', 'lat', 'longitude', 'lng', 'lon', 'long', 'elevation', 'sphere', 'country', 'city'].includes(key)) + if (isSafeKey(key) && !GEO_PROPERTIES.includes(key as any)) (result as any)[key] = geoObj[key]; } } return Object.keys(result).length > 0 ? result : undefined; -}; +} /** * Helper to resolve storage cache keys for single-tenant default or multi-tenant scoped lookups. * @internal */ const resolveCacheKey = (keyOrOpts?: string | Record): string => { - if (typeof keyOrOpts === 'string' && keyOrOpts.trim().length > 0) - return keyOrOpts.startsWith('_map_') ? keyOrOpts : `_map_:${keyOrOpts.trim()}`; - if (keyOrOpts && typeof keyOrOpts === 'object') { + if (isString(keyOrOpts) && !isEmpty(keyOrOpts)) { + const trimmed = keyOrOpts.trim(); + return (trimmed.startsWith(MAP_KEY)) + ? trimmed + : `${MAP_KEY}:${trimmed}`; + } + if (keyOrOpts && isObject(keyOrOpts)) { const k = keyOrOpts.key ?? keyOrOpts.ip ?? keyOrOpts.query; - if (typeof k === 'string' && k.trim().length > 0) - return `_map_:${k.trim()}`; + if (isString(k) && !isEmpty(k)) + return `${MAP_KEY}:${k.trim()}`; } - return '_map_'; -}; + return MAP_KEY; +} /** * Synchronously retrieves stashed geolocation from storage or memory cache if present. @@ -135,21 +155,29 @@ const resolveCacheKey = (keyOrOpts?: string | Record): string => { export const getStashedGeo = (keyOrOpts?: string | Record): GeoConfig | undefined => { const cacheKey = resolveCacheKey(keyOrOpts); try { - const raw = getStorage(cacheKey) ?? (typeof localStorage !== 'undefined' ? localStorage.getItem(cacheKey) : undefined); + const raw = getStorage(cacheKey) ?? + (typeof localStorage !== 'undefined' ? localStorage.getItem(cacheKey) : undefined); if (!raw) return undefined; - const parsed = typeof raw === 'string' && (raw.startsWith('{') || raw.startsWith('[')) + const parsed = isString(raw) && (raw.startsWith('{') || raw.startsWith('[')) ? JSON.parse(raw) : raw; - if (typeof parsed === 'string' && parsed.includes(',')) { + if (isObject(parsed)) { + if (isNumber(parsed._expires) && Date.now() > parsed._expires) { + clearStashedGeo(keyOrOpts); + return undefined; + } + } + + if (isString(parsed) && parsed.includes(',')) { const parts = parsed.split(',').map(s => parseFloat(s.trim())); if (parts.length >= 2 && isNumber(parts[0]) && isNumber(parts[1])) { return { latitude: parts[0], longitude: parts[1] }; } } - if (typeof parsed === 'object' && parsed !== null) { + if (isObject(parsed)) { const coords = parsed.geolocation?.coords ?? parsed.coords ?? parsed; const lat = coords?.latitude ?? coords?.lat; const lng = coords?.longitude ?? coords?.lng ?? coords?.lon ?? coords?.long; @@ -167,7 +195,7 @@ export const getStashedGeo = (keyOrOpts?: string | Record): GeoConf // ignore storage access errors } return undefined; -}; +} /** * Explicitly stashes geolocation coordinates in storage with an optional TTL (default: 24 hours). @@ -179,12 +207,15 @@ export const getStashedGeo = (keyOrOpts?: string | Record): GeoConf */ export const stashGeo = ( coords: GeoConfig, - ttl = 24 * 60 * 60 * 1000, + ttl = DEFAULT_GEO_TTL, keyOrOpts?: string | Record ): void => { const cacheKey = resolveCacheKey(keyOrOpts); - setStorage(cacheKey, coords, { ttl }); -}; + const payload = (isNumber(ttl) && ttl > 0) + ? { ...coords, _expires: Date.now() + ttl } + : coords; + setStorage(cacheKey, payload, { ttl }); +} /** * Clears stashed geolocation coordinates from storage. @@ -270,7 +301,7 @@ export const geoLookup = async (opts: Record = {}): Promise>( diff --git a/packages/library/test/common/runtime/cache.class.test.ts b/packages/library/test/common/runtime/cache.class.test.ts index 2a27861e..25e1e09b 100644 --- a/packages/library/test/common/runtime/cache.class.test.ts +++ b/packages/library/test/common/runtime/cache.class.test.ts @@ -156,4 +156,25 @@ describe('common/runtime/cache.class', () => { expect(cache.has('short_lived')).toBe(false); expect(cache.get('short_lived')).toBeUndefined(); }); + + it('preserves absolute expiration deadline without renewal on get() or set() updates', async () => { + const cache = new BoundedCache(10, 40); // 40ms TTL + cache.set('item', 'v1'); + + // Access item at 20ms + await new Promise(resolve => setTimeout(resolve, 20)); + expect(cache.get('item')).toBe('v1'); + + // Update item value at 25ms without specifying a new TTL + cache.set('item', 'v2'); + expect(cache.get('item')).toBe('v2'); + + // Wait past original 40ms deadline (e.g. at 55ms total) + await new Promise(resolve => setTimeout(resolve, 35)); + + // Must be expired because original absolute deadline was not renewed + expect(cache.has('item')).toBe(false); + expect(cache.get('item')).toBeUndefined(); + }); }); + diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts index d4e99f03..f4aaa3f1 100644 --- a/packages/library/test/common/runtime/mapper.common.test.ts +++ b/packages/library/test/common/runtime/mapper.common.test.ts @@ -109,7 +109,7 @@ describe('common/runtime/mapper.library', () => { }); it('getStashedGeo in Node.js discovers coordinates set explicitly via setStorage', () => { - setStorage('_map_', { + setStorage('_magma_geo_', { geolocation: { coords: { latitude: -33.8688, longitude: 151.2093 } }, city: 'Sydney', }); @@ -122,7 +122,7 @@ describe('common/runtime/mapper.library', () => { }); it('getStashedGeo in Node.js discovers coordinates set as JSON string in setStorage', () => { - setStorage('_map_', '{"latitude": 37.7749, "longitude": -122.4194, "city": "San Francisco"}'); + setStorage('_magma_geo_', '{"latitude": 37.7749, "longitude": -122.4194, "city": "San Francisco"}'); const stashed = getStashedGeo(); expect(stashed).toBeDefined(); @@ -132,11 +132,57 @@ describe('common/runtime/mapper.library', () => { }); it('getStashedGeo in Node.js supports comma-separated string coordinates in storage', () => { - setStorage('_map_', '51.5074, -0.1278'); + setStorage('_magma_geo_', '51.5074, -0.1278'); const stashed = getStashedGeo(); expect(stashed).toBeDefined(); expect(stashed?.latitude).toBe(51.5074); expect(stashed?.longitude).toBe(-0.1278); }); + + it('getStashedGeo does not read legacy _map_ storage key', () => { + setStorage('_map_', { latitude: 40.7128, longitude: -74.006, city: 'New York' }); + + const stashed = getStashedGeo(); + expect(stashed).toBeUndefined(); + }); + + it('getStashedGeo removes and ignores expired entries with _expires timestamp', async () => { + // Stash entry with 30ms TTL + stashGeo({ latitude: 10, longitude: 20, city: 'ExpiringCity' }, 30, { key: 'short' }); + + expect(getStashedGeo({ key: 'short' })?.city).toBe('ExpiringCity'); + + // Wait past 30ms TTL + await new Promise(resolve => setTimeout(resolve, 45)); + + expect(getStashedGeo({ key: 'short' })).toBeUndefined(); + }); + + it('geoLookup with explicit ip queries provider with ip in path', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'success', + lat: 37.751, + lon: -122.522, + country: 'United States', + city: 'San Francisco', + query: '8.8.8.8', + }), + }); + + vi.stubGlobal('fetch', mockFetch); + + const result = await geoLookup({ ip: '8.8.8.8', refresh: true }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://ipwho.is/8.8.8.8', + expect.anything() + ); + expect(result.query).toBe('8.8.8.8'); + expect(result.city).toBe('San Francisco'); + + vi.unstubAllGlobals(); + }); }); + diff --git a/packages/library/test/server/mapper.server.test.ts b/packages/library/test/server/mapper.server.test.ts index 8aa26c49..2d0c94e2 100644 --- a/packages/library/test/server/mapper.server.test.ts +++ b/packages/library/test/server/mapper.server.test.ts @@ -54,4 +54,32 @@ describe('server/mapper.library', () => { vi.unstubAllGlobals(); }); + + it('serverGeoLocation queries provider URL with explicit IP path when ip option is supplied', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'success', + lat: 37.751, + lon: -122.522, + country: 'United States', + city: 'San Francisco', + query: '8.8.8.8', + }), + }); + + vi.stubGlobal('fetch', mockFetch); + + const geo = await serverGeoLocation({ ip: '8.8.8.8' }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://ipwho.is/8.8.8.8', + expect.anything() + ); + expect(geo.status).toBe('success'); + expect(geo.query).toBe('8.8.8.8'); + expect(geo.city).toBe('San Francisco'); + + vi.unstubAllGlobals(); + }); }); + diff --git a/packages/plugins/.setup/community-plugin-template.md b/packages/plugins/.setup/community-plugin-template.md index 423dd316..94aa6ed1 100644 --- a/packages/plugins/.setup/community-plugin-template.md +++ b/packages/plugins/.setup/community-plugin-template.md @@ -126,11 +126,13 @@ Community plugins must follow a uniform documentation standard. > > 1. **Zero Flat Pollution on `Tempo`**: Never dump multiple loose methods or properties directly onto the `Tempo` root class. Group related static utilities under a dedicated, cohesive namespace (e.g., `Tempo.geo.*`, `Tempo.sync.*`). > 2. **Static Namespace Immutability**: Any static object or namespace attached to the `Tempo` base class **must be strictly immutable/frozen**: -> - The namespace object itself must be recursively frozen via `Object.freeze()`. +> - The namespace object itself (including any nested objects or child namespaces) must be recursively frozen using `deepFreeze()` (available directly from `@magmacomputing/tempo/plugin/sdk` or `#library/utility.library.js`). > - The property must be mounted onto `TempoClass` using `Object.defineProperty` with `writable: false`, `configurable: false`, and `enumerable: false` (or via the SDK's `attachStatics` utility). > ```typescript +> import { deepFreeze } from '@magmacomputing/tempo/plugin/sdk'; +> > Object.defineProperty(TempoClass, 'myNamespace', { -> value: Object.freeze(myNamespaceObject), +> value: deepFreeze(myNamespaceObject), > writable: false, > configurable: false, > enumerable: false, diff --git a/packages/plugins/geo/CHANGELOG.md b/packages/plugins/geo/CHANGELOG.md index 85d1d870..4d5fc1fe 100644 --- a/packages/plugins/geo/CHANGELOG.md +++ b/packages/plugins/geo/CHANGELOG.md @@ -5,12 +5,26 @@ All notable changes to the `@magmacomputing/tempo-plugin-geo` project will be do The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] - 2026-09-07 +## [1.0.0] - 2026-09-08 + +### Changed (Breaking Changes) +- **Consolidated `Tempo.geo` Namespace**: + - Removed loose static methods mounted directly on the `Tempo` root class (`Tempo.geoLookup`, `Tempo.resolveGeoCoordinates`, `Tempo.serverGeoLocation`, `Tempo.geoLocation`) to prevent flat base class pollution. + - All static geolocation utilities are now organized under the dedicated, immutable **`Tempo.geo`** namespace (`Tempo.geo.lookup`, `Tempo.geo.resolve`, `Tempo.geo.server`, `Tempo.geo.browser`, `Tempo.geo.stash`, `Tempo.geo.clear`, `Tempo.geo.get`, and `Tempo.geo.current`). + - Standalone pure functions (`geoLookup`, `resolveGeoCoordinates`, `serverGeoLocation`, etc.) remain available as direct named exports from the package for tree-shaking. +- **Storage Key Scoping**: + - Standardized ambient coordinate storage on `_magma_geo_`. ### Added +- **24-Hour TTL Caching & Multi-Tenant Partitioning**: + - Added automatic 24-hour TTL caching for `Tempo.geo.lookup()` with `{ refresh: true }` cache bypass support. + - Added multi-tenant partitioning in `Tempo.geo.stash()`, `Tempo.geo.get()`, and `Tempo.geo.clear()`, scoping storage under `_magma_geo_:`. + - Added `Tempo.geo.current` read-only getter to inspect active ambient coordinates snapshot. +- **Immutability Hardening**: + - The `Tempo.geo` namespace and attached utilities are recursively frozen via `deepFreeze()`. - **Stable Community Release**: - Official 1.0.0 release of `@magmacomputing/tempo-plugin-geo`. - - Configured npm Trusted Publisher automation for CI/CD publishing. + - Configured npm Trusted Publisher automation for CI/CD publishing with Sigstore provenance. ## [0.1.0] - 2026-09-07 diff --git a/packages/plugins/geo/README.md b/packages/plugins/geo/README.md index 6ed653a6..0dd96f49 100644 --- a/packages/plugins/geo/README.md +++ b/packages/plugins/geo/README.md @@ -111,7 +111,7 @@ const t = new Tempo('2026-06-21', { geo: coords }); > [!CAUTION] > **Unpartitioned ambient storage is shared. In multi-tenant environments, always use unique keys or instance-level options.** -- Ambient storage stores coordinates under `_map_` by default. +- Ambient storage stores coordinates under `_magma_geo_` by default. - In a shared process or server handling requests for multiple tenants or distinct users, calling `stash()` or ambient `lookup()` without a key will cause tenants to **overwrite each other's cached coordinates**! - **Solution A: Multi-Tenant Key Scoping**: Pass a tenant identifier or user ID as the key: @@ -123,7 +123,7 @@ const t = new Tempo('2026-06-21', { geo: coords }); const coords = Tempo.geo.get('tenant-alpha'); Tempo.geo.clear('tenant-alpha'); ``` - The cache automatically partitions keys under `_map_:`, guaranteeing strict isolation. + The cache automatically partitions keys under `_magma_geo_:`, guaranteeing strict isolation. - **Solution B: Instance-Level Configuration (Recommended)**: Avoid ambient storage altogether by binding coordinates directly to `Tempo` instances: @@ -136,10 +136,11 @@ const t = new Tempo('2026-06-21', { geo: coords }); ## Security & Immutability -Is `Tempo.geo` locked down? **Yes.** -- The `Tempo.geo` object is frozen via `Object.freeze()`. -- The `geo` property on `Tempo` is defined with `writable: false`, `configurable: false`, and `enumerable: false`. -- Any attempt to reassign `Tempo.geo = ...` or mutate `Tempo.geo.lookup = ...` will throw a `TypeError` in strict mode. +In keeping with Tempo's strict immutability principles, the `Tempo.geo` namespace is fully locked down: + +- **Deeply Frozen**: The entire `Tempo.geo` namespace and its attached utilities are recursively frozen. +- **Tamper-Proof**: Protected against modification, deletion, or monkey-patching. Any attempt to reassign `Tempo.geo` or mutate its methods (e.g. `Tempo.geo.lookup = ...`) will throw a `TypeError` in strict mode. +- **Pure Instance Operations**: Instance methods like `t.geoLocate()` always return a new, enriched `Tempo` instance, preserving the immutability of the original instance. --- diff --git a/packages/plugins/geo/doc/index.md b/packages/plugins/geo/doc/index.md index 5b69e26e..9c3baeac 100644 --- a/packages/plugins/geo/doc/index.md +++ b/packages/plugins/geo/doc/index.md @@ -118,7 +118,7 @@ const t = new Tempo('2026-06-21', { geo: coords }); ::: danger Shared Ambient Storage **Unpartitioned ambient storage is shared. In multi-tenant environments, always use unique keys or instance-level options.** -- Ambient storage stores coordinates under `_map_` by default. +- Ambient storage stores coordinates under `_magma_geo_` by default. - In a shared process handling requests for multiple tenants or distinct users, calling `stash()` or ambient `lookup()` without a key will cause tenants to **overwrite each other's cached coordinates**! - **Solution A: Multi-Tenant Key Scoping**: Pass a tenant identifier or user ID as the key: @@ -130,7 +130,7 @@ const t = new Tempo('2026-06-21', { geo: coords }); const coords = Tempo.geo.get('tenant-alpha'); Tempo.geo.clear('tenant-alpha'); ``` - The cache automatically partitions keys under `_map_:`, guaranteeing strict isolation. + The cache automatically partitions keys under `_magma_geo_:`, guaranteeing strict isolation. - **Solution B: Instance-Level Configuration (Recommended)**: Avoid ambient storage altogether by binding coordinates directly to `Tempo` instances: @@ -144,14 +144,14 @@ const t = new Tempo('2026-06-21', { geo: coords }); ## Security & Immutability -`Tempo.geo` is completely locked down: -- The `Tempo.geo` namespace object is frozen with `Object.freeze()`. -- The `geo` property on `TempoClass` is defined with `writable: false`, `configurable: false`, and `enumerable: false`. -- Any attempt to reassign `Tempo.geo = ...` or mutate `Tempo.geo.lookup = ...` will throw a `TypeError` in strict mode. +In keeping with Tempo's strict immutability principles, the `Tempo.geo` namespace is fully locked down: + +- **Deeply Frozen**: The entire `Tempo.geo` namespace and its attached utilities are recursively frozen. +- **Tamper-Proof**: Protected against modification, deletion, or monkey-patching. Any attempt to reassign `Tempo.geo` or mutate its methods (e.g. `Tempo.geo.lookup = ...`) will throw a `TypeError` in strict mode. +- **Pure Instance Operations**: Instance methods like `t.geoLocate()` always return a new, enriched `Tempo` instance, preserving the immutability of the original instance. --- ## Licensing This is a **Community** plugin. It is completely free and open-source for personal and commercial use under the MIT license. - diff --git a/packages/plugins/geo/plan/README.md b/packages/plugins/geo/plan/README.md new file mode 100644 index 00000000..354cfb04 --- /dev/null +++ b/packages/plugins/geo/plan/README.md @@ -0,0 +1,204 @@ +# Tempo Plugin Geo: Gap Analysis & Feature Roadmap + +> **Status:** Planning / Future Backlog +> **Target Release:** `v1.0.0` (Pre-publish review) & `v1.1.0+` +> **Related Source:** [`packages/library/src/browser/mapper.library.ts`](file:///home/michael/Project/magma/packages/library/src/browser/mapper.library.ts), [`packages/library/src/server/mapper.library.ts`](file:///home/michael/Project/magma/packages/library/src/server/mapper.library.ts) + +--- + +## 1. Executive Summary & Guiding Philosophy + +### The Mission: "Humanizing Temporal" +The standard JavaScript `Temporal` API is mathematically rigorous, but it is notoriously low-level and clumsy. Answering simple human questions—such as *"What time is it where the client is right now?"*, *"Is it daylight or business hours at their location?"*, or *"What season is it there?"*—requires stitching together multiple disjoint APIs, external IP fetches, timezone resolvers, and coordinate conversions. + +Tempo's primary mission is to **humanize Temporal**: making date-times intuitive, expressive, and effortless to work with. + +Geolocation is fundamental to this mission. **Time and space are inseparable**: civil time only has meaning relative to a physical location on Earth. By anchoring Tempo's deterministic, immutable API to spatial awareness—**while strictly adhering to ISO 8601 standards**—`tempo-plugin-geo` elevates Tempo from an abstract clock calculator into a deeply context-aware temporal tool. + +--- + +## 2. Gap Analysis & Humanizing Opportunities + +### 2.1 Auto-Syncing Instance Timezone (`t.tz`) via Resolved Geolocation +* **Current State in `mapper.library.ts`:** + * Server IP queries (`https://ipwho.is/`) return IANA timezone identifiers (e.g. `"timezone": "America/New_York"` or `"timezone": "Australia/Sydney"`). + * Google Maps Geocoder responses can be coupled with Google TimeZone API or reverse lookup tables. +* **Current State in `tempo-plugin-geo`:** + * `t.geoLocate()` only assigns `this.config.geo = { latitude, longitude }`. + * The instance timezone (`this.tz`) remains unchanged (typically the ambient system local timezone or UTC). +* **Opportunity & Value:** + * Allow `t.geoLocate({ setTimezone: true })` (or configurable default). + * When geolocating across timezones or client IPs, the instance's wall-clock time automatically aligns to the local civil time of that geographic coordinate: + ```ts + const event = tempo('2026-09-08T12:00:00Z'); + const clientEvent = await event.geoLocate({ ip: req.clientIp, setTimezone: true }); + // clientEvent.tz === 'Australia/Sydney' + // clientEvent.format('h:mm a z') -> '10:00 pm AEST' + ``` + +--- + +### 2.2 Stationary Drift Filtering & GPS Noise Protection +* **Current State in `browser/mapper.library.ts`:** + * Implements coordinate delta testing before refreshing or invalidating cached geocoder results: + ```ts + const prevLat = coords?.latitude?.toFixed(3); + const prevLng = coords?.longitude?.toFixed(3); + const test1 = value.coords.latitude.toFixed(3) !== prevLat; + const test2 = value.coords.longitude.toFixed(3) !== prevLng; + const test3 = prevTime < (instant().epochMilliseconds - 3_600_000); // 1 hour TTL + ``` +* **Current State in `tempo-plugin-geo`:** + * Coordinates are cached and looked up as exact floating-point numbers. + * In mobile/browser environments, raw GPS jitter in the 4th–6th decimal places (~10m to 10cm) creates arbitrary cache misses. +* **Opportunity & Value:** + * Introduce stationary drift suppression in `Tempo.geo.coerce` and cache key generation. + * Rounding to 3 decimal places (~111 meters at equator) or providing a configurable threshold (`driftToleranceMeters: 100`) prevents cache thrashing, rate-limit exhaustion, and unnecessary battery drain. + +--- + +### 2.3 Hemisphere Awareness & Astronomical Seasons +* **Current State in `browser/mapper.library.ts`:** + * `mapHemisphere(coords)` returns `'north' | 'south' | null`. + * Evaluates `lat >= 0 ? 'north' : 'south'`, falling back to `getHemisphere()` based on timezone offset. +* **Current State in `tempo-plugin-geo`:** + * Latitude is stored, but no hemisphere helpers or properties exist on `Tempo` or `Tempo.geo`. +* **Opportunity & Value:** + * Seasons are flipped between Northern and Southern hemispheres: + * Solstices & Equinoxes: June is Summer Solstice in the North, Winter Solstice in the South. + * Meteorological & Astronomical Quarters: Q1 is Winter in North, Summer in South. + * Direct synergy with sibling plugins: + * `tempo-plugin-astro`: Zodiac signs, solar declination, equinox offsets. + * `tempo-plugin-celestial`: Sunrise, sunset, twilight calculations (which are inverted by hemisphere). + * **Proposed API:** + ```ts + Tempo.geo.hemisphere(coords: CoordinateInput): 'north' | 'south' | 'equator' | null; + tempoInstance.hemisphere; // getter -> 'north' | 'south' + ``` + +--- + +### 2.4 Explicit Anti-Goal: Strict ISO 8601 Week Invariant (Out of Scope) +* **Architectural Invariant:** + * Tempo is intentionally and strictly opinionated: it is architected directly around **ISO 8601** standards (Monday is day 1, standard ISO week numbering 1–53). +* **Boundary Rule for `tempo-plugin-geo`:** + * While reverse geocoding provides country codes (`US`, `AU`, `GB`, `AE`), **`tempo-plugin-geo` must NEVER alter Tempo's week alignments, day indexing, or calendar math.** + * Altering week starts (e.g. attempting Sunday-start or Saturday-start weeks) compromises Tempo's deterministic ISO-8601 contract and creates conflicting calendar state across instances. +* **Acceptable Scope (Informational Only):** + * Country codes should remain purely informational metadata (e.g. `t.geo.countryCode`), enabling external consumer logic (such as looking up regional public holiday sets or fiscal quarter offsets) without modifying Tempo's internal calendar rules. + +--- + +### 2.5 Great-Circle / Haversine Distance & Transit Duration +* **Current State in `mapper.library.ts`:** + * Raw coordinates are retrieved, but distance math is not provided. +* **Opportunity & Value:** + * A lightweight, zero-dependency Haversine formula calculation directly within `Tempo.geo`: + ```ts + Tempo.geo.distance(t1: Tempo | CoordinateInput, t2: Tempo | CoordinateInput, opts?: { unit?: 'km' | 'mi' | 'nm' }): number; + ``` + * **Applications for Date-Time Workflows:** + * **Transit Velocity:** Calculate average travel speed between two timestamped events: + $$\text{speed} = \frac{\text{Tempo.geo.distance}(t_1, t_2)}{\text{t2.diff}(t1, 'hours')}$$ + * **Impossible Travel Detection:** Flag security anomalies (e.g., login from London at 10:00 and Tokyo at 11:00). + * **Flight / Travel Time Validation:** Validate schedule consistency across time zones and physical locations. + +--- + +### 2.6 Rich Spatial Metadata Retention on `Tempo` Instances +* **Current State in `browser/mapper.library.ts` & `server/mapper.library.ts`:** + * Server responses include `country`, `city`, `timezone`, `query` (IP). + * Google Maps responses include `formatted_address`, `locality`, `administrative_area_level_1`, `postal_code`. +* **Current State in `tempo-plugin-geo`:** + * `t.geo` is strictly `{ latitude: number, longitude: number }`. +* **Opportunity & Value:** + * Extend `GeoConfig` to preserve rich metadata when available: + ```ts + export interface GeoConfig { + latitude: number; + longitude: number; + elevation?: number; + sphere?: 'north' | 'south'; + country?: string; + countryCode?: string; + city?: string; + region?: string; + postalCode?: string; + timezone?: string; + formattedAddress?: string; + } + ``` + * Enables format tokens and metadata logging: + ```ts + t.format('[Event at] {city}, {country} ({lat}, {lng})'); + ``` + +--- + +### 2.7 Elevation & Solar Horizon Adjustments +* **Current State in `browser/mapper.library.ts`:** + * Google Maps Geocoding and Elevation APIs supply elevation in meters. +* **Opportunity & Value:** + * Elevation alters apparent sunrise, sunset, and solar noon times due to horizon dip: + $$\Delta\theta \approx 0.0347^\circ \times \sqrt{h_{\text{meters}}}$$ + * Locations at significant altitude (e.g., Denver at 1,600m, Mexico City at 2,240m) see sunrises several minutes earlier and sunsets several minutes later than sea level. + * Preserving `elevation` in `t.geo.elevation` allows downstream plugins (`tempo-plugin-astro`, `tempo-plugin-celestial`) to produce high-precision solar calculations. + +--- + +### 2.8 Unified Browser-Server Storage & Stash Semantics +* **Current State:** + * Browser uses `WebStore('local')` with key `_magma_geo_`. + * Server uses `BoundedCache` with key `_magma_geo_:` or `_magma_geo_:`. +* **Opportunity & Value:** + * Standardize storage abstraction so that cache keys, TTL expiration (24h default), and multi-tenant partitioning operate identically across Node.js, Deno, Bun, and browser environments. + +### 2.9 Human-Centric "Business Hours" Checker (`t.isBusinessHours()`) +* **The "Humanizing" Need:** + * Distributed teams and automated workflows constantly ask: *"Can I notify this user right now, or is it 3:00 AM there?"* / *"Is the client in their local business hours?"* + * Raw Temporal requires manual zone conversions, extracting `dayOfWeek`, checking bounds, and formatting. +* **ISO 8601 Compliant Design:** + * Default definition strictly adheres to ISO 8601: **Monday through Friday (days 1–5)**, 09:00 to 17:00 local time. + * Simple, expressive API: + ```ts + const clientNow = await tempo().geoLocate({ ip: req.clientIp }); + if (clientNow.isBusinessHours()) { + // Send real-time notification + } + ``` + +--- + +### 2.10 Natural Solar Time vs. Civil Clock Time (`Tempo.geo.solarOffset`) +* **The "Humanizing" Need:** + * Civil timezones are political artifacts. In many regions, the clock reads 12:00 PM, but the sun is nowhere near its zenith (e.g. western Spain, western China, or borders of time zones where solar noon differs by 1–2 hours from clock noon). + * For humans tracking circadian health, natural light exposure, or outdoor activities, understanding natural solar time humanizes the relationship between the clock and nature. +* **ISO 8601 Compliant Design:** + * Does not alter ISO timestamps or civil date math. + * Calculates solar time offset based on longitude ($\approx 4\text{ minutes per } 1^\circ \text{ offset from standard meridian}$): + ```ts + const offsetMinutes = Tempo.geo.solarOffset(coords); // e.g. -42 minutes + ``` + +--- + +## 3. Prioritized Implementation Roadmap + +| Priority | Feature | Effort | Target | Humanizing Impact | Description | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **P1** | **Timezone Auto-Sync** (`setTimezone: true`) | Low | `v1.0.0` / `v1.0.1` | **High** | Syncs `t.tz` to local civil time at resolved location. | +| **P1** | **Stationary Drift Filtering** | Low | `v1.0.0` / `v1.0.1` | Medium | Prevents GPS noise cache thrashing (`toFixed(3)`). | +| **P2** | **Hemisphere Awareness** (`Tempo.geo.hemisphere`) | Low | `v1.1.0` | **High** | Flipped seasonal awareness (`t.season`) for southern hemisphere humans. | +| **P2** | **Rich Metadata Preservation** | Medium | `v1.1.0` | **High** | Retains `city`, `country`, `formattedAddress` for template formatting. | +| **P3** | **Business Hours Helper** (`t.isBusinessHours()`) | Low | `v1.1.0` | **High** | Instant check if local time is 9–5 Mon–Fri (ISO 8601). | +| **P3** | **Haversine Distance Utility** | Low | `v1.1.0` | Medium | `Tempo.geo.distance(t1, t2)` for transit speed and impossible travel. | +| **P4** | **Elevation Horizon Correction** | Medium | `v1.2.0` | Medium | Horizon dip correction for high-precision sunrise/sunset. | +| **P4** | **Natural Solar Offset** | Low | `v1.2.0` | Medium | Quantifies delta between civil clock time and actual solar zenith. | + +--- + +## 4. Architectural Invariants & Non-Goals + +1. **Strict ISO 8601 Week Invariant:** Tempo is strictly opinionated. Monday is ALWAYS day 1, and ISO week numbering rules are absolute. `tempo-plugin-geo` must never modify, reconfigure, or override week start or weekend alignments. +2. **Immutable Tempo Attachment:** As established in `packages/plugins/.setup/community-plugin-template.md`, the `Tempo.geo` namespace must remain locked-down (`deepFreeze()` via `@magmacomputing/tempo/plugin/sdk` + `writable: false`, `configurable: false`), and all instance methods (`geoLocate`) must return new Tempo instances. +3. **Partitioned Isolation:** All stashing and caching must preserve multi-tenant cache keys (`_magma_geo_:`). diff --git a/packages/plugins/geo/src/index.ts b/packages/plugins/geo/src/index.ts index 33527efc..fdc1d755 100644 --- a/packages/plugins/geo/src/index.ts +++ b/packages/plugins/geo/src/index.ts @@ -1,5 +1,5 @@ import { Tempo } from '@magmacomputing/tempo'; -import { definePlugin, type TempoPlugin } from '@magmacomputing/tempo/plugin/sdk'; +import { definePlugin, deepFreeze, type TempoPlugin } from '@magmacomputing/tempo/plugin/sdk'; import { geoLookup, resolveGeoCoordinates, @@ -88,7 +88,7 @@ export const GeoPlugin: TempoPlugin = definePlugin({ } Object.defineProperty(TempoClass, 'geo', { - value: Object.freeze(geoNamespace), + value: deepFreeze(geoNamespace), writable: false, configurable: false, enumerable: false, diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index df3c659d..5a5cab59 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -24,7 +24,7 @@ "packageName": "@magmacomputing/tempo-plugin-batch", "plan": "community", "status": "active", - "version": "1.0.2" + "version": "1.1.0" }, { "id": "finance", @@ -60,7 +60,7 @@ "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", "status": "active", - "version": "1.2.1" + "version": "2.0.0" }, { "id": "ticker", diff --git a/packages/tempo/README.md b/packages/tempo/README.md index ccbce593..795aeb3a 100644 --- a/packages/tempo/README.md +++ b/packages/tempo/README.md @@ -15,6 +15,7 @@ **Tempo** is a premium, high-performance wrapper for the ECMAScript `Temporal` API. Designed for professionals, it combines **immutable** state-management with a **fluent**, natural-language engine. It is the modern, type-safe successor to legacy libraries like Moment.js and Luxon. +> **Our Mission: Humanizing Temporal.** Bringing developer intuition and expressive natural-language ergonomics to JavaScript dates—anchored in strict ISO 8601 standards. [Read our vision & architecture →](https://magmacomputing.github.io/magma/doc/7-ecosystem/vision.html)

npm version Socket Security Temporal Documentation Open in StackBlitz diff --git a/packages/tempo/doc/7-ecosystem/vision.md b/packages/tempo/doc/7-ecosystem/vision.md index 93e3f592..e59cd4e5 100644 --- a/packages/tempo/doc/7-ecosystem/vision.md +++ b/packages/tempo/doc/7-ecosystem/vision.md @@ -1,21 +1,26 @@ # The Vision for Tempo -## Introduction -While the `Temporal` API provides a mathematically sound foundation for dates in JavaScript, it is intentionally low-level and strict. **Tempo** acts as a developer-friendly wrapper that bridges the "onboarding gap" between raw inputs and Temporal's precision. +## Introduction: Humanizing Temporal + +The ECMAScript `Temporal` specification represents a monumental leap forward for JavaScript dates, providing mathematical rigor, timezone correctness, and nanosecond precision. However, as an engine-level foundation, native `Temporal` is intentionally low-level, verbose, and rigid. Answering everyday human questions—such as *"What time is it where our client is right now?"*, *"Is it daylight or business hours at their location?"*, or *"How many business days until the next fiscal quarter?"*—can feel mechanically clunky, requiring developers to stitch together multiple disjoint types, offset normalizers, calendar converters, and external APIs. + +Tempo's primary mission is to **humanize Temporal**: transforming low-level precision into an intuitive, expressive, and effortless developer experience. + +We believe that developer ergonomics should never compromise mathematical correctness. Tempo achieves this balance through an unyielding commitment to **strict ISO 8601 standards**—ensuring deterministic calendar arithmetic, unambiguous week calculations (where Monday is always day 1), and complete immutability—while providing a warm, fluent API that understands human intent. ## Core Value Proposition ### 1. Closing the Onboarding Gap -Modern developers expect a "warm" API for common tasks. Native Temporal requires explicit types and ISO formats for almost every operation. Tempo provides a familiar, intuitive entry point (similar to the ease of Day.js) while maintaining the rock-solid reliability of Temporal under the hood. +Modern developers expect a "warm" API for common tasks. Native Temporal requires explicit types and strict ISO formats for almost every operation. Tempo provides a familiar, intuitive entry point (similar to the ease of Day.js) while maintaining the rock-solid reliability of Temporal under the hood. ### 2. Human-Centric Parsing -Data in the real world is messy. Tempo's **Layout** and **Snippet** engine allows developers to interpret human-readable strings, aliases, and custom formats without writing complex, custom utility functions. It turns "today" or "Christmas" into machine-exact time points effortlessly. +Data in the real world is messy. Tempo's **Layout** and **Snippet** engine allows developers to interpret human-readable strings, aliases, and custom formats without writing complex custom utility functions. It turns "today", "next Friday 3pm", or "Christmas" into machine-exact time points effortlessly. -### 3. Business Intelligence via Plugin -Tempo extends beyond simple date arithmetic through its **Terms** system. By providing declarative access to complex calculations—such as fiscal quarters, meteorological seasons, and zodiac signs—Tempo moves domain-specific logic out of the application code and into a reusable, extensible plugin architecture. +### 3. Contextual & Business Intelligence via Plugins +Time and physical reality are inseparable: civil time only has meaning relative to a location, a business calendar, or natural cycles. Through its modular plugin architecture, Tempo bridges time with real-world context—such as IP and GPS geolocation (`@magmacomputing/tempo-plugin-geo`), fiscal quarters, meteorological seasons, and astronomical cycles—moving domain-specific complexity out of application code and into reusable, extensible plugins. -### 4. Lean but Robust -Tempo is designed to be a thin, highly capable layer. It prioritizes a lightweight API surface for the developer while maintaining robust internal logic to handle the complexities of timezones, calendars, and durations. +### 4. Lean, Deterministic, and Hardened +Tempo is designed to be a thin, highly capable layer. It prioritizes a lightweight, ergonomic API surface for the developer while maintaining robust internal logic to handle the complexities of timezones, calendars, and durations with zero mutable side effects. ## Conclusion Tempo is not intended to replace Temporal, but to humanize it. It is the tool for developers who want the future of JavaScript dates today, without the overhead of building their own high-level utility library from scratch. diff --git a/packages/tempo/index.md b/packages/tempo/index.md index 817d7f13..9c3bd9e1 100644 --- a/packages/tempo/index.md +++ b/packages/tempo/index.md @@ -57,7 +57,7 @@ const features = [ { title: 'Tempo AI Engine', details: 'Natural language date parsing, multi-provider dispatch (OpenAI, Gemini, Groq, Mistral), and slot resolution.', icon: '🤖' }, { title: 'Tempo.ticker()', details: 'Community Plugin: State-of-the-art timing engine with AsyncGenerator support and native Daylight Saving Time resolution.', icon: '⏱️' }, { title: 'Modular Plugins', details: 'Extend Tempo with specialized plugins for Snapping, High-Throughput Batching, and AI dispatch.', icon: '🔌' }, - { title: 'Temporal Inside', details: 'Built on the ECMAScript Temporal API. Inherit the reliability of the modern standard.', icon: '🏗️' }, + { title: 'Humanizing Temporal', details: 'Transforming low-level Temporal complexity into intuitive, expressive APIs—anchored in strict ISO 8601 standards.', icon: '🧭' }, { title: 'Cycle Persistence', details: 'Shift by semantic terms while preserving your relative day-of-period offset.', icon: '🔄' }, { title: 'Business & Financial', details: 'Native support for fiscal quarters, zodiac cycles, and meteorological seasons for finance and analytics.', icon: '📈' } ] diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts index 9a097f5b..5d16dd75 100644 --- a/packages/tempo/src/library.index.ts +++ b/packages/tempo/src/library.index.ts @@ -11,7 +11,7 @@ export * as webToken from '#library/webtoken.library.js'; export { enumify, type Enum } from '#library/enumerate.library.js'; export { stringify, objectify, cloneify } from '#library/serialize.library.js'; export { parseJSONC, stripJSONC, cleanify, isJSON, rawJSON, isRawJSON } from '#library/json.library.js'; -export { getContext, CONTEXT } from '#library/utility.library.js'; +export { getContext, CONTEXT, deepFreeze } from '#library/utility.library.js'; export { Interval } from '#library/scheduling/interval.class.js'; export * from '#library/proxy.library.js'; diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index ba35fcf3..fe3fff6e 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -33,6 +33,7 @@ export { } from './support.enum.js'; export { markConfig } from '#library/symbol.library.js'; +export { BoundedCache } from '#library/cache.class.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './support.symbol.js'; export { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $LogConfig, $Discover } from './support.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './support.register.js'; From 3d92d30df4261d9b5721031e166fab1b6fd05ae4 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 9 Sep 2026 14:13:05 +1000 Subject: [PATCH 08/11] PR 4th review, geo plugin features --- packages/functions/src/celestial/index.ts | 44 +- packages/functions/test/celestial.test.ts | 21 + packages/library/package.json | 2 +- .../library/src/browser/mapper.library.ts | 2 +- .../src/common/runtime/mapper.library.ts | 397 +++++++++++++++--- .../test/common/runtime/mapper.common.test.ts | 157 ++++++- packages/plugins/celestial/src/index.ts | 48 ++- .../plugins/celestial/test/celestial.test.ts | 10 + packages/plugins/geo/plan/README.md | 251 +++++------ packages/plugins/geo/src/index.ts | 112 ++++- packages/plugins/geo/test/geo.test.ts | 238 +++++++++-- packages/tempo/CHANGELOG.md | 18 + .../tempo/doc/2-core-concepts/tempo.config.md | 33 ++ .../tempo/doc/2-core-concepts/tempo.format.md | 51 +++ .../tempo/doc/2-core-concepts/tempo.parse.md | 28 ++ packages/tempo/package.json | 2 +- packages/tempo/public/esm_sh.index.html | 2 +- packages/tempo/public/llms.txt | 4 +- packages/tempo/src/module/module.format.ts | 38 +- packages/tempo/src/support/support.enum.ts | 7 +- packages/tempo/src/tempo.class.ts | 17 +- packages/tempo/src/tempo.type.ts | 3 +- packages/tempo/src/tempo.version.ts | 2 +- .../tempo/test/core/constructor.core.test.ts | 14 +- .../test/instance/instance.format.test.ts | 59 +++ 25 files changed, 1237 insertions(+), 323 deletions(-) diff --git a/packages/functions/src/celestial/index.ts b/packages/functions/src/celestial/index.ts index e3032d52..6058fd17 100644 --- a/packages/functions/src/celestial/index.ts +++ b/packages/functions/src/celestial/index.ts @@ -427,6 +427,13 @@ export interface SolarOptions { long?: number; lng?: number; lon?: number; + elevation?: number; + geo?: { + latitude?: number; + longitude?: number; + elevation?: number; + [key: string]: any; + }; } export interface SolarTwilightWindow { @@ -437,6 +444,7 @@ export interface SolarTwilightWindow { export interface SunriseSunsetResult { latitude: number; longitude: number; + elevation?: number; sunriseMs: number; sunsetMs: number; solarNoonMs: number; @@ -451,39 +459,27 @@ export interface SunriseSunsetResult { } /** - * Resolves latitude and longitude from positional arguments or a coordinate options object. + * Resolves latitude, longitude, and elevation from positional arguments or a coordinate options object. * * @param latOrOptions - A latitude value or options containing coordinate fields * @param lngInput - The longitude used when `latOrOptions` is a numeric latitude - * @returns An object containing the resolved `lat` and `lng` values + * @returns An object containing the resolved `lat`, `lng`, and `elevation` values */ -/** - * Resolves latitude and longitude from positional arguments or a coordinate options object. - * - * @param latOrOptions - A latitude value or options containing coordinate fields - * @param lngInput - The longitude used when `latOrOptions` is a numeric latitude - * @returns An object containing the resolved `lat` and `lng` values - */ -function resolveCoordinates(latOrOptions: number | SolarOptions = 0, lngInput = 0): { lat: number; lng: number } { +function resolveCoordinates(latOrOptions: number | SolarOptions = 0, lngInput = 0): { lat: number; lng: number; elevation: number } { if (typeof latOrOptions === 'number') - return { lat: latOrOptions, lng: lngInput }; + return { lat: latOrOptions, lng: lngInput, elevation: 0 }; if (latOrOptions && typeof latOrOptions === 'object') { const geo = (latOrOptions as any).geo ?? latOrOptions; const lat = geo.latitude ?? geo.lat ?? (latOrOptions as any).latitude ?? (latOrOptions as any).lat ?? 0; const lng = geo.longitude ?? geo.lng ?? geo.lon ?? geo.long ?? (latOrOptions as any).longitude ?? (latOrOptions as any).lng ?? (latOrOptions as any).lon ?? (latOrOptions as any).long ?? 0; - return { lat, lng }; + const rawElevation = geo.elevation ?? (latOrOptions as any).elevation; + const elevation = typeof rawElevation === 'number' && !isNaN(rawElevation) ? rawElevation : 0; + return { lat, lng, elevation }; } - return { lat: 0, lng: 0 }; + return { lat: 0, lng: 0, elevation: 0 }; } -/** - * Determines the UTC start of the calendar day at a specified longitude. - * - * @param epochMs - The input timestamp in milliseconds since the Unix epoch - * @param lng - The longitude in degrees used to determine the local date - * @returns The UTC start timestamp, local date, and longitude-adjusted timestamp - */ /** * Determines the UTC start of the calendar day at a specified longitude. * @@ -517,10 +513,11 @@ export function getSunriseSunset( ? new Date(dateInput).getTime() : dateInput.getTime(); - const { lat, lng } = resolveCoordinates(latOrOptions, lonInput); + const { lat, lng, elevation } = resolveCoordinates(latOrOptions, lonInput); const { startOfDayMs, localDate, localMs } = getStartOfLocalDayMs(epochMs, lng); - // Solar calculations using standard zenith (90.833°) + // Solar calculations using standard zenith (90.833°) adjusted for atmospheric horizon dip + const dipDeg = elevation > 0 ? 0.0347 * Math.sqrt(elevation) : 0; const dayOfYear = Math.floor((localMs - Date.UTC(localDate.getUTCFullYear(), 0, 0)) / 86400000); const gamma = (2 * Math.PI / 365) * (dayOfYear - 1); @@ -543,7 +540,7 @@ export function getSunriseSunset( return Math.acos(cosHA) * (180 / Math.PI); }; - const haDeg = calcHaDeg(90.833); + const haDeg = calcHaDeg(90.833 + dipDeg); const haMin = haDeg * 4; const sunriseMs = startOfDayMs + ((solarNoonMin - haMin) * 60000); const sunsetMs = startOfDayMs + ((solarNoonMin + haMin) * 60000); @@ -589,6 +586,7 @@ export function getSunriseSunset( return { latitude: lat, longitude: lng, + ...(elevation > 0 ? { elevation } : {}), sunriseMs, sunsetMs, solarNoonMs, diff --git a/packages/functions/test/celestial.test.ts b/packages/functions/test/celestial.test.ts index 90393572..cc502b8f 100644 --- a/packages/functions/test/celestial.test.ts +++ b/packages/functions/test/celestial.test.ts @@ -82,6 +82,27 @@ describe('Astro Pure Functions (tempo-fns)', () => { expect(nightRes.index).toBe(1); // 1-based (1 = night) }); + it('factors elevation into apparent sunrise/sunset and daylight duration via horizon dip', () => { + const date = new Date('2026-06-21T12:00:00Z'); + // Sea level in Denver (lat 39.7392, lng -104.9903, elevation 0m) + const seaLevel = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: 0 }); + // Actual Mile High City elevation (1600m above sea level) + const highAltitude = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: 1600 }); + + // Higher elevation causes horizon dip: sunrise is earlier, sunset is later + expect(highAltitude.sunriseMs).toBeLessThan(seaLevel.sunriseMs); + expect(highAltitude.sunsetMs).toBeGreaterThan(seaLevel.sunsetMs); + expect(highAltitude.daylightDurationMs).toBeGreaterThan(seaLevel.daylightDurationMs); + // Solar noon remains unchanged by elevation + expect(highAltitude.solarNoonMs).toBe(seaLevel.solarNoonMs); + + // Difference in sunrise/sunset is approx 8.5 minutes for 1600m at latitude ~40°N + const diffMin = (seaLevel.sunriseMs - highAltitude.sunriseMs) / 60000; + expect(diffMin).toBeGreaterThan(7); + expect(diffMin).toBeLessThan(10); + expect(highAltitude.elevation).toBe(1600); + }); + it('calculates Western Tropical Zodiac sign', () => { expect(getZodiacSign('2026-03-25')).toBe('Aries'); expect(getZodiacSign('2026-07-25')).toBe('Leo'); diff --git a/packages/library/package.json b/packages/library/package.json index bf0b8b45..eaca4101 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "4.1.2", + "version": "4.2.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/browser/mapper.library.ts b/packages/library/src/browser/mapper.library.ts index 2ecb45e6..70a3cf5a 100644 --- a/packages/library/src/browser/mapper.library.ts +++ b/packages/library/src/browser/mapper.library.ts @@ -27,7 +27,7 @@ interface MapStore { // a localStorage object const defaults = { catch: true, debug: 0 } as MapOpts; // default Options const context = getContext(); // browser / nodejs / google-apps const mapStore = {} as MapStore; // static object to hold last position -const MAP_KEY = '_magma_geo_'; // localStorage key +const MAP_KEY = '_magma_browser_map_'; // distinct browser MapStore localStorage key const log = new Logger('[Mapper]'); let storePromise: Promise | null = null; diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts index 0fdd2e94..7b00aad7 100644 --- a/packages/library/src/common/runtime/mapper.library.ts +++ b/packages/library/src/common/runtime/mapper.library.ts @@ -1,16 +1,60 @@ import { CONTEXT, getContext } from '#library/utility.library.js'; -import { isNullish, isNumber, isString, isSafeKey, isObject, isEmpty } from '#library/assertion.library.js'; +import { isNullish, isNumber, isString, isSafeKey, isObject, isEmpty, isReference, isPrimitive } from '#library/assertion.library.js'; import { getStorage, setStorage } from '#library/storage.library.js'; +import { evaluate } from '#library/evaluation.library.js'; + +/** + * Supported hemisphere zones including the equatorial band. + */ +export type GeoSphere = 'north' | 'south' | 'equator'; + +/** + * Supported units for geographic distance calculation. + */ +export type DistanceUnit = 'km' | 'miles' | 'm'; + +/** + * Supported units for solar offset calculation. + */ +export type SolarOffsetUnit = 'minutes' | 'seconds' | 'hours'; + +/** + * Configuration options for natural solar time offset calculation. + */ +export interface SolarOffsetOptions { + /** Unit of return value (default: 'minutes') */ + unit?: SolarOffsetUnit; + /** Number of decimal places to round (default: 2) */ + precision?: number; + /** Whether to compute apparent solar time incorporating Equation of Time (default: false for mean solar time) */ + apparent?: boolean; + /** Explicit IANA timezone override (defaults to coords.timezone or ambient timezone) */ + timeZone?: string; + /** Reference date/time for DST offset and Equation of Time calculations (default: Date.now()) */ + date?: Date | number | any; +} export interface GeoLookupResult { lat?: number | undefined; lng?: number | undefined; latitude?: number | undefined; longitude?: number | undefined; + elevation?: number | undefined; + sphere?: GeoSphere | undefined; + country?: string | undefined; + city?: string | undefined; + timezone?: string | undefined; + status?: string | undefined; error?: string | undefined; [key: string]: any; } +export interface ResolvedCoordinates extends GeoConfig { + lat: number; + lng: number; + [key: string]: any; +} + export interface GeoOptions { /** Latitude coordinate in degrees */ latitude?: number | undefined; @@ -26,10 +70,16 @@ export interface GeoOptions { long?: number | undefined; /** Altitude / Elevation in meters above sea level */ elevation?: number | undefined; + /** Inferred or explicit hemisphere ('north' | 'south' | 'equator') */ + sphere?: GeoSphere | undefined; /** ISO country code (e.g. 'US', 'AU') */ country?: string | undefined; /** City or locality name */ city?: string | undefined; + /** IANA Time Zone ID (e.g. 'Australia/Sydney') */ + timezone?: string | undefined; + /** Time zone alias @internal */ + tz?: string | undefined; /** Custom or future string/number key property */ [key: string]: any; /** Custom symbol key property */ @@ -37,18 +87,20 @@ export interface GeoOptions { } export interface GeoConfig { - /** Latitude coordinate in degrees */ + /** Latitude coordinate in degrees (-90 to 90) */ latitude?: number | undefined; - /** Longitude coordinate in degrees */ + /** Longitude coordinate in degrees (-180 to 180) */ longitude?: number | undefined; /** Altitude / Elevation in meters above sea level */ elevation?: number | undefined; - /** Inferred or explicit hemisphere ('north' | 'south') */ - sphere?: 'north' | 'south' | undefined; + /** Inferred or explicit hemisphere ('north' | 'south' | 'equator') */ + sphere?: GeoSphere | undefined; /** ISO country code (e.g. 'US', 'AU') */ country?: string | undefined; /** City or locality name */ city?: string | undefined; + /** IANA Time Zone ID (e.g. 'Australia/Sydney') */ + timezone?: string | undefined; /** Custom or future string/number key property */ [key: string]: any; /** Custom symbol key property */ @@ -70,7 +122,11 @@ export interface CoordinateInput { const MAP_KEY = '_magma_geo_'; const DEFAULT_GEO_TTL = 24 * 60 * 60 * 1000; // 24 hours -const GEO_PROPERTIES = [ +/** + * Canonical geographic property keys used for property segregation and custom key preservation. + * @internal + */ +export const GEO_PROPERTIES = [ 'latitude', 'lat', 'longitude', @@ -81,50 +137,124 @@ const GEO_PROPERTIES = [ 'sphere', 'country', 'city', + 'timezone', + 'tz', ] as const; +/** + * Normalizes latitude to 3 decimal places if within [-90, 90] bounds. + * @internal + */ +const normalizeLat = (lat: any): number | undefined => + isNumber(lat) && lat >= -90 && lat <= 90 ? Math.round(lat * 1000) / 1000 : undefined; + +/** + * Normalizes longitude to 3 decimal places if within [-180, 180] bounds. + * @internal + */ +const normalizeLng = (lng: any): number | undefined => + isNumber(lng) && lng >= -180 && lng <= 180 ? Math.round(lng * 1000) / 1000 : undefined; + +/** + * Validates and normalizes coordinate pairs to 3 decimal places within Earth boundaries. + * @internal + */ +const normalizeCoords = (lat: any, lng: any): { lat: number; lng: number } | undefined => { + const nLat = normalizeLat(lat); + const nLng = normalizeLng(lng); + return (nLat !== undefined && nLng !== undefined) ? { lat: nLat, lng: nLng } : undefined; +}; + +/** + * Resolves hemisphere: preserves explicit sphere if valid, or infers from latitude (+/- 0.001 band). + * @internal + */ +const resolveSphere = (sphere?: any, lat?: number): GeoSphere | undefined => { + if (sphere === 'north' || sphere === 'south' || sphere === 'equator') return sphere; + if (isNumber(lat)) return lat > 0.001 ? 'north' : (lat < -0.001 ? 'south' : 'equator'); + return undefined; +}; + /** * Extracts and coerces latitude and longitude from input object (options, config, or instance) * into a canonical `{ latitude, longitude, ... }` GeoConfig object. + * Enforces coordinate boundaries (-90 to 90 for lat, -180 to 180 for lng), 3-decimal rounding, + * and automatic hemisphere inference ('north' | 'south' | 'equator'). * * @param input - Optional object containing coordinate or geo properties */ export const coerceGeo = (input?: any): GeoConfig | undefined => { - if (isNullish(input) || !isObject(input)) return undefined; + if (isString(input) && input.includes(',')) { + const parts = input.split(',').map((s: string) => Number(s.trim())); + if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { + const coords = normalizeCoords(parts[0], parts[1]); + if (coords) { + const result: GeoConfig = { latitude: coords.lat, longitude: coords.lng }; + const sphere = resolveSphere(undefined, coords.lat); + if (sphere) result.sphere = sphere; + return result; + } + } + return undefined; + } + + if (isPrimitive(input)) return undefined; + + if (Array.isArray(input) && input.length >= 2) { + const coords = normalizeCoords(input[0], input[1]); + if (coords) { + const result: GeoConfig = { latitude: coords.lat, longitude: coords.lng }; + const sphere = resolveSphere(undefined, coords.lat); + if (sphere) result.sphere = sphere; + return result; + } + return undefined; + } - const geoObj = input.geo ?? input; - const cfgGeo = input.config?.geo ?? input.config; + const geo = input.geo ?? input; + const cfg = input.config?.geo ?? input.config; - const lat = geoObj?.latitude ?? geoObj?.lat - ?? input.latitude ?? input.lat - ?? cfgGeo?.latitude ?? cfgGeo?.lat; + const lat = evaluate( + geo?.latitude, geo?.lat, + input.latitude, input.lat, + cfg?.latitude, cfg?.lat + ); - const lng = geoObj?.longitude ?? geoObj?.lng ?? geoObj?.lon ?? geoObj?.long - ?? input.longitude ?? input.lng ?? input.lon ?? input.long - ?? cfgGeo?.longitude ?? cfgGeo?.lng ?? cfgGeo?.lon ?? cfgGeo?.long; + const lng = evaluate( + geo?.longitude, geo?.lng, geo?.lon, geo?.long, + input.longitude, input.lng, input.lon, input.long, + cfg?.longitude, cfg?.lng, cfg?.lon, cfg?.long + ); - const elevation = geoObj?.elevation ?? input.elevation ?? cfgGeo?.elevation; - const sphere = geoObj?.sphere ?? input.sphere ?? cfgGeo?.sphere; - const country = geoObj?.country ?? input.country ?? cfgGeo?.country; - const city = geoObj?.city ?? input.city ?? cfgGeo?.city; + const elevation = evaluate(geo?.elevation, input.elevation, cfg?.elevation); + const sphere = evaluate(geo?.sphere, input.sphere, cfg?.sphere); + const country = evaluate(geo?.country, input.country, cfg?.country); + const city = evaluate(geo?.city, input.city, cfg?.city); + const timezone = evaluate(geo?.timezone, geo?.tz, input.timezone, input.tz, cfg?.timezone, cfg?.tz); const result: GeoConfig = {}; - if (isNumber(lat)) result.latitude = lat; - if (isNumber(lng)) result.longitude = lng; - if (isNumber(elevation)) result.elevation = elevation; - if (sphere === 'north' || sphere === 'south') result.sphere = sphere; + const nLat = normalizeLat(lat); + const nLng = normalizeLng(lng); + if (nLat !== undefined) result.latitude = nLat; + if (nLng !== undefined) result.longitude = nLng; + if (isNumber(elevation)) result.elevation = Math.round(elevation * 1000) / 1000; + + const resolvedSphere = resolveSphere(sphere, result.latitude); + if (resolvedSphere) result.sphere = resolvedSphere; + if (isString(country)) result.country = country; if (isString(city)) result.city = city; + if (isString(timezone)) result.timezone = timezone; - if (isObject(geoObj)) { - for (const key of Object.keys(geoObj)) { + if (isReference(geo)) { + for (const key of Object.keys(geo)) { if (isSafeKey(key) && !GEO_PROPERTIES.includes(key as any)) - (result as any)[key] = geoObj[key]; + (result as any)[key] = geo[key]; } } return Object.keys(result).length > 0 ? result : undefined; -} +}; /** * Helper to resolve storage cache keys for single-tenant default or multi-tenant scoped lookups. @@ -172,22 +302,29 @@ export const getStashedGeo = (keyOrOpts?: string | Record): GeoConf if (isString(parsed) && parsed.includes(',')) { const parts = parsed.split(',').map(s => parseFloat(s.trim())); - if (parts.length >= 2 && isNumber(parts[0]) && isNumber(parts[1])) { - return { latitude: parts[0], longitude: parts[1] }; + const coords = normalizeCoords(parts[0], parts[1]); + if (coords) { + return { + latitude: coords.lat, + longitude: coords.lng, + sphere: resolveSphere(undefined, coords.lat), + }; } } if (isObject(parsed)) { - const coords = parsed.geolocation?.coords ?? parsed.coords ?? parsed; - const lat = coords?.latitude ?? coords?.lat; - const lng = coords?.longitude ?? coords?.lng ?? coords?.lon ?? coords?.long; - if (isNumber(lat) && isNumber(lng)) { - const result: GeoConfig = { latitude: lat, longitude: lng }; - const elevation = parsed.elevation ?? coords.elevation; - if (isNumber(elevation)) result.elevation = elevation; - if (parsed.sphere === 'north' || parsed.sphere === 'south') result.sphere = parsed.sphere; + const rawCoords = parsed.geolocation?.coords ?? parsed.coords ?? parsed; + const lat = rawCoords?.latitude ?? rawCoords?.lat; + const lng = rawCoords?.longitude ?? rawCoords?.lng ?? rawCoords?.lon ?? rawCoords?.long; + const coords = normalizeCoords(lat, lng); + if (coords) { + const result: GeoConfig = { latitude: coords.lat, longitude: coords.lng }; + const elevation = parsed.elevation ?? rawCoords.elevation; + if (isNumber(elevation)) result.elevation = Math.round(elevation * 1000) / 1000; + result.sphere = resolveSphere(parsed.sphere, coords.lat); if (isString(parsed.country)) result.country = parsed.country; if (isString(parsed.city)) result.city = parsed.city; + if (isString(parsed.timezone)) result.timezone = parsed.timezone; return result; } } @@ -272,7 +409,7 @@ export const geoLookup = async (opts: Record = {}): Promise = {}): Promise = {}): Promise = {}): Promise = {} -): Promise<{ lat: number; lng: number } | null> => { +): Promise => { const coerced = coerceGeo(input); if (coerced && isNumber(coerced.latitude) && isNumber(coerced.longitude)) - return { lat: coerced.latitude, lng: coerced.longitude }; + return { + ...coerced, + lat: coerced.latitude, + lng: coerced.longitude, + }; const stashed = getStashedGeo(opts); if (stashed && isNumber(stashed.latitude) && isNumber(stashed.longitude)) - return { lat: stashed.latitude, lng: stashed.longitude }; + return { + ...stashed, + lat: stashed.latitude, + lng: stashed.longitude, + }; const lookup = await geoLookup(opts); - if (isNullish(lookup.error) && isNumber(lookup.lat) && isNumber(lookup.lng)) - return { lat: lookup.lat, lng: lookup.lng }; + const coords = normalizeCoords(lookup.lat, lookup.lng); + if (isNullish(lookup.error) && coords) { + const sphere = resolveSphere(lookup.sphere, coords.lat); + return { + ...lookup, + lat: coords.lat, + lng: coords.lng, + latitude: coords.lat, + longitude: coords.lng, + sphere, + }; + } return null; }; +/** + * Calculates the Great-Circle distance between two coordinates using the Haversine formula. + * Accepts coordinate objects, [lat, lng] tuples, strings, or instances exposing .geo. + * + * @param from - Origin coordinate, object, tuple, or instance + * @param to - Destination coordinate, object, tuple, or instance + * @param unit - Distance unit ('km', 'miles', or 'm'; default: 'km') + * @returns Calculated distance, or NaN if either coordinate pair is invalid + */ +export function haversineDistance(from: any, to: any, unit: DistanceUnit = 'km'): number { + const c1 = coerceGeo(from); + const c2 = coerceGeo(to); + + if (!c1 || !c2 || !isNumber(c1.latitude) || !isNumber(c1.longitude) || !isNumber(c2.latitude) || !isNumber(c2.longitude)) { + return NaN; + } + + const toRad = Math.PI / 180; + const lat1 = c1.latitude * toRad; + const lng1 = c1.longitude * toRad; + const lat2 = c2.latitude * toRad; + const lng2 = c2.longitude * toRad; + + const dLat = lat2 - lat1; + const dLng = lng2 - lng1; + + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + let radius: number; + if (unit === 'miles') { + radius = 3958.7613; // Mean Earth radius in miles + } else if (unit === 'm') { + radius = 6371008.8; // Mean Earth radius in meters + } else { + radius = 6371.0088; // Mean Earth radius in kilometers (default) + } + + const dist = radius * c; + return unit === 'm' ? Math.round(dist) : Math.round(dist * 1000) / 1000; +} + +/** + * Calculates the Natural Solar Time Offset between civil clock time and actual solar time. + * Based on longitude (approx 4 minutes per 1° offset from standard timezone meridian). + * + * @param coords - Coordinate input, object, tuple, or instance exposing .geo + * @param options - Configuration options for units, precision, apparent time, timezone, and date + * @returns Solar offset in requested unit (default: minutes, rounded to 2 decimal places), or NaN if invalid + */ +export function solarOffset(coords: any, options?: SolarOffsetOptions): number { + const geo = coerceGeo(coords); + if (!geo || !isNumber(geo.latitude) || !isNumber(geo.longitude) || isNaN(geo.latitude) || isNaN(geo.longitude)) { + return NaN; + } + + const lng = geo.longitude; + const tz = options?.timeZone + ?? (typeof coords === 'object' && coords !== null ? (coords.timezone ?? coords.tz) : undefined) + ?? geo.timezone; + + const dateVal = options?.date + ?? (typeof coords === 'object' && coords !== null && typeof (coords as any).epoch?.ms === 'number' ? (coords as any).epoch.ms : Date.now()); + + let offsetMinutes: number | undefined; + + const epochMs = typeof dateVal === 'number' + ? dateVal + : (typeof dateVal === 'string' + ? Date.parse(dateVal) + : (dateVal instanceof Date + ? dateVal.getTime() + : (typeof dateVal?.epoch?.ms === 'number' ? dateVal.epoch.ms : Date.now()))); + + if (isString(tz) && tz.trim() !== '') { + try { + if (typeof Temporal !== 'undefined' && typeof Temporal.Instant?.fromEpochMilliseconds === 'function') { + const zdt = Temporal.Instant.fromEpochMilliseconds(epochMs).toZonedDateTimeISO(tz); + offsetMinutes = zdt.offsetNanoseconds / 60_000_000_000; + } else { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + timeZoneName: 'longOffset', + year: 'numeric' + }).formatToParts(new Date(epochMs)); + const tzPart = parts.find(p => p.type === 'timeZoneName')?.value; + if (tzPart) { + const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/); + if (match) { + const sign = match[1] === '-' ? -1 : 1; + offsetMinutes = sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10)); + } + } + } + } catch { + // invalid timezone, fall back to natural meridian + } + } + + // If no civil timezone, use natural solar timezone meridian (round(lng / 15) * 15) + const refMeridian = offsetMinutes !== undefined + ? (offsetMinutes / 60) * 15 + : Math.round(lng / 15) * 15; + + const deltaLng = lng - refMeridian; + let offsetMin = deltaLng * 4; + + if (options?.apparent) { + const d = new Date(epochMs); + const startOfYear = Date.UTC(d.getUTCFullYear(), 0, 1); + const dayOfYear = Math.floor((epochMs - startOfYear) / 86400000) + 1; + const gamma = (2 * Math.PI / 365) * (dayOfYear - 1); + const eqTime = 229.18 * (0.000075 + 0.001868 * Math.cos(gamma) - 0.032077 * Math.sin(gamma) - 0.014615 * Math.cos(2 * gamma) - 0.040849 * Math.sin(2 * gamma)); + offsetMin += eqTime; + } + + const unit = options?.unit ?? 'minutes'; + let res: number; + if (unit === 'seconds') { + res = offsetMin * 60; + } else if (unit === 'hours') { + res = offsetMin / 60; + } else { + res = offsetMin; + } + + const precision = options?.precision ?? 2; + const factor = Math.pow(10, precision); + return Math.round(res * factor) / factor; +} + diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts index f4aaa3f1..59a7d01f 100644 --- a/packages/library/test/common/runtime/mapper.common.test.ts +++ b/packages/library/test/common/runtime/mapper.common.test.ts @@ -1,4 +1,4 @@ -import { coerceGeo, geoLookup, resolveGeoCoordinates, getStashedGeo, stashGeo, clearStashedGeo } from '../../../src/common/runtime/mapper.library.js'; +import { coerceGeo, geoLookup, resolveGeoCoordinates, getStashedGeo, stashGeo, clearStashedGeo, haversineDistance, solarOffset } from '../../../src/common/runtime/mapper.library.js'; import { setStorage, clearStorage } from '#library/storage.library.js'; describe('common/runtime/mapper.library', () => { @@ -13,12 +13,48 @@ describe('common/runtime/mapper.library', () => { it('resolveGeoCoordinates extracts coordinates synchronously if present', async () => { const coords = await resolveGeoCoordinates({ latitude: -33.8688, longitude: 151.2093 }); - expect(coords).toEqual({ lat: -33.8688, lng: 151.2093 }); + expect(coords).toMatchObject({ lat: -33.869, lng: 151.209, sphere: 'south' }); }); it('resolveGeoCoordinates extracts coordinates from config sub-object', async () => { const coords = await resolveGeoCoordinates({ config: { lat: 40.7128, lng: -74.0060 } }); - expect(coords).toEqual({ lat: 40.7128, lng: -74.0060 }); + expect(coords).toMatchObject({ lat: 40.713, lng: -74.006, sphere: 'north' }); + }); + + it('enforces coordinate boundary validation in coerceGeo', () => { + // Valid boundary edges + expect(coerceGeo({ lat: 90, lng: 180 })).toMatchObject({ latitude: 90, longitude: 180 }); + expect(coerceGeo({ lat: -90, lng: -180 })).toMatchObject({ latitude: -90, longitude: -180 }); + + // Out of bounds latitude (> 90 or < -90) is excluded + expect(coerceGeo({ lat: 90.001, lng: 0 })?.latitude).toBeUndefined(); + expect(coerceGeo({ lat: -90.001, lng: 0 })?.latitude).toBeUndefined(); + expect(coerceGeo({ lat: 100 })).toBeUndefined(); + + // Out of bounds longitude (> 180 or < -180) is excluded + expect(coerceGeo({ lat: 0, lng: 180.001 })?.longitude).toBeUndefined(); + expect(coerceGeo({ lat: 0, lng: -180.001 })?.longitude).toBeUndefined(); + expect(coerceGeo({ lng: -200 })).toBeUndefined(); + }); + + it('infers hemisphere (north | south | equator) with 3-decimal precision', () => { + // North (> 0.001) + expect(coerceGeo({ lat: 0.002, lng: 10 })?.sphere).toBe('north'); + expect(coerceGeo({ lat: 45.0, lng: 10 })?.sphere).toBe('north'); + + // South (< -0.001) + expect(coerceGeo({ lat: -0.002, lng: 10 })?.sphere).toBe('south'); + expect(coerceGeo({ lat: -33.869, lng: 151.209 })?.sphere).toBe('south'); + + // Equator (abs(lat) <= 0.001) + expect(coerceGeo({ lat: 0, lng: 10 })?.sphere).toBe('equator'); + expect(coerceGeo({ lat: 0.001, lng: 10 })?.sphere).toBe('equator'); + expect(coerceGeo({ lat: -0.001, lng: 10 })?.sphere).toBe('equator'); + expect(coerceGeo({ lat: 0.0005, lng: 10 })?.sphere).toBe('equator'); + + // Explicit sphere override wins + expect(coerceGeo({ lat: 45, lng: 10, sphere: 'south' })?.sphere).toBe('south'); + expect(coerceGeo({ lat: -45, lng: 10, sphere: 'equator' })?.sphere).toBe('equator'); }); it('coerceGeo excludes dangerous prototype keys (__proto__, constructor, prototype)', () => { @@ -42,7 +78,7 @@ describe('common/runtime/mapper.library', () => { try { const coords = await resolveGeoCoordinates({ city: 'Sydney', elevation: 150 }); - expect(coords).toEqual({ lat: -33.8688, lng: 151.2093 }); + expect(coords).toMatchObject({ lat: -33.869, lng: 151.209, sphere: 'south' }); expect(fetchSpy).not.toHaveBeenCalled(); } finally { vi.unstubAllGlobals(); @@ -65,21 +101,22 @@ describe('common/runtime/mapper.library', () => { try { // First call queries the network and stashes coordinates const result = await geoLookup(); - expect(result.lat).toBe(-33.8688); - expect(result.lng).toBe(151.2093); + expect(result.lat).toBe(-33.869); + expect(result.lng).toBe(151.209); expect(result.city).toBe('Sydney'); + expect(result.sphere).toBe('south'); expect(mockFetch).toHaveBeenCalledTimes(1); // Second call returns cached coordinates without hitting fetch const cached = await geoLookup(); - expect(cached.lat).toBe(-33.8688); + expect(cached.lat).toBe(-33.869); expect(cached.city).toBe('Sydney'); expect(cached.status).toBe('cached'); expect(mockFetch).toHaveBeenCalledTimes(1); // refresh: true forces a fresh network call const refreshed = await geoLookup({ refresh: true }); - expect(refreshed.lat).toBe(-33.8688); + expect(refreshed.lat).toBe(-33.869); expect(mockFetch).toHaveBeenCalledTimes(2); } finally { vi.unstubAllGlobals(); @@ -116,8 +153,8 @@ describe('common/runtime/mapper.library', () => { const stashed = getStashedGeo(); expect(stashed).toBeDefined(); - expect(stashed?.latitude).toBe(-33.8688); - expect(stashed?.longitude).toBe(151.2093); + expect(stashed?.latitude).toBe(-33.869); + expect(stashed?.longitude).toBe(151.209); expect(stashed?.city).toBe('Sydney'); }); @@ -126,8 +163,8 @@ describe('common/runtime/mapper.library', () => { const stashed = getStashedGeo(); expect(stashed).toBeDefined(); - expect(stashed?.latitude).toBe(37.7749); - expect(stashed?.longitude).toBe(-122.4194); + expect(stashed?.latitude).toBe(37.775); + expect(stashed?.longitude).toBe(-122.419); expect(stashed?.city).toBe('San Francisco'); }); @@ -136,8 +173,8 @@ describe('common/runtime/mapper.library', () => { const stashed = getStashedGeo(); expect(stashed).toBeDefined(); - expect(stashed?.latitude).toBe(51.5074); - expect(stashed?.longitude).toBe(-0.1278); + expect(stashed?.latitude).toBe(51.507); + expect(stashed?.longitude).toBe(-0.128); }); it('getStashedGeo does not read legacy _map_ storage key', () => { @@ -184,5 +221,97 @@ describe('common/runtime/mapper.library', () => { vi.unstubAllGlobals(); }); + + describe('haversineDistance', () => { + const sydney = { latitude: -33.8688, longitude: 151.2093 }; + const melbourne = { lat: -37.8136, lng: 144.9631 }; + + it('calculates great-circle distance between two cities in kilometers', () => { + const dist = haversineDistance(sydney, melbourne); + // Sydney to Melbourne is approximately 713.4 km + expect(dist).toBeGreaterThan(710); + expect(dist).toBeLessThan(720); + expect(dist).toBe(713.426); + }); + + it('calculates great-circle distance in miles', () => { + const distMiles = haversineDistance(sydney, melbourne, 'miles'); + expect(distMiles).toBeGreaterThan(440); + expect(distMiles).toBeLessThan(450); + expect(distMiles).toBe(443.303); + }); + + it('calculates great-circle distance in meters with integer rounding', () => { + const distMeters = haversineDistance(sydney, melbourne, 'm'); + expect(Number.isInteger(distMeters)).toBe(true); + expect(distMeters).toBe(713426); + }); + + it('returns 0 for identical points', () => { + expect(haversineDistance(sydney, sydney)).toBe(0); + expect(haversineDistance(sydney, sydney, 'miles')).toBe(0); + expect(haversineDistance(sydney, sydney, 'm')).toBe(0); + }); + + it('accepts various coordinate input formats including tuples, strings, and geo containers', () => { + const tupleCoords = [-33.8688, 151.2093]; + const container = { geo: { lat: -37.8136, lng: 144.9631 } }; + const dist = haversineDistance(tupleCoords, container); + expect(dist).toBe(713.426); + + const strCoords = '-33.8688, 151.2093'; + expect(haversineDistance(strCoords, container)).toBe(713.426); + }); + + it('returns NaN for missing or invalid coordinates', () => { + expect(haversineDistance(null, sydney)).toBeNaN(); + expect(haversineDistance(sydney, undefined)).toBeNaN(); + expect(haversineDistance({ lat: 100, lng: 50 }, sydney)).toBeNaN(); // out-of-bounds lat + expect(haversineDistance({}, {})).toBeNaN(); + }); + }); + + describe('solarOffset', () => { + it('calculates natural solar time offset for civil timezone in minutes', () => { + // Sydney (lng 151.209, AEST UTC+10, meridian 150°) -> (151.209 - 150) * 4 = +4.84 min + const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' }; + expect(solarOffset(sydney)).toBe(4.84); + + // Denver on standard time (lng -104.99, MST UTC-7, meridian -105°) -> (-104.99 - -105) * 4 = +0.04 min + const denver = { lat: 39.7392, lng: -104.9903, timezone: 'America/Denver' }; + expect(solarOffset(denver, { date: '2026-01-15T12:00:00Z' })).toBe(0.04); + }); + + it('supports unit conversion to seconds and hours with configurable precision', () => { + const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' }; + expect(solarOffset(sydney, { unit: 'seconds' })).toBe(290.16); + expect(solarOffset(sydney, { unit: 'hours', precision: 3 })).toBe(0.081); + }); + + it('falls back to natural 15-degree solar timezone meridian when no timezone is supplied', () => { + // lng 151.209 -> nearest meridian 150° -> +4.84 min + expect(solarOffset({ lat: -33.8688, lng: 151.2093 })).toBe(4.84); + + // lng 7° -> nearest meridian 0° -> 7 * 4 = 28 min + expect(solarOffset({ lat: 51.5, lng: 7 })).toBe(28); + }); + + it('calculates apparent solar time offset incorporating Equation of Time', () => { + const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' }; + const mean = solarOffset(sydney, { date: '2026-06-21T12:00:00Z' }); + const apparent = solarOffset(sydney, { date: '2026-06-21T12:00:00Z', apparent: true }); + + expect(typeof apparent).toBe('number'); + expect(apparent).not.toBe(mean); + expect(apparent).toBe(3.51); + }); + + it('returns NaN for invalid or unresolvable coordinates', () => { + expect(solarOffset(null)).toBeNaN(); + expect(solarOffset(undefined)).toBeNaN(); + expect(solarOffset({})).toBeNaN(); + expect(solarOffset({ lat: 100, lng: 50 })).toBeNaN(); + }); + }); }); diff --git a/packages/plugins/celestial/src/index.ts b/packages/plugins/celestial/src/index.ts index 3701de85..474ced4b 100644 --- a/packages/plugins/celestial/src/index.ts +++ b/packages/plugins/celestial/src/index.ts @@ -69,6 +69,7 @@ declare module '@magmacomputing/tempo' { millisecond: number; microsecond: number; nanosecond: number; + elevation: number | null; sunrise: Tempo | null; sunset: Tempo | null; noon: Tempo | null; @@ -118,34 +119,27 @@ declare module '@magmacomputing/tempo' { function getLunarScopeRange(t: Tempo, anchor?: any) { const coords = getCelestialCoordinates(t, anchor); const { refTempo, lat, lng, hasGeo, geo, timeZone, sphere } = coords; - const currentMs = refTempo.epoch.ms; + const { startOfDayMs } = (refTempo as any).startOfDay ? (refTempo as any).startOfDay() : { startOfDayMs: refTempo.epoch.ms }; - const range = getLunarPhaseRange(currentMs, { sphere }); - const moonEvents = hasGeo ? getMoonriseMoonset(currentMs, lat!, lng!) : { moonriseMs: undefined, moonsetMs: undefined }; + const lunarDetails = getLunarDetails(t, coords); - const startTempo = new Tempo(range.startMs, { timeZone, timeStamp: 'ms', sphere }); - const endTempo = new Tempo(range.endMs, { timeZone, timeStamp: 'ms', sphere }); - const moonrise = hasGeo ? toTempoOrNull(moonEvents.moonriseMs, timeZone) : null; - const moonset = hasGeo ? toTempoOrNull(moonEvents.moonsetMs, timeZone) : null; + const moonEvents = hasGeo ? getMoonriseMoonset(refTempo.epoch.ms, lat!, lng!) : null; + const moonrise = moonEvents ? toTempoOrNull(moonEvents.moonriseMs, timeZone, sphere) : null; + const moonset = moonEvents ? toTempoOrNull(moonEvents.moonsetMs, timeZone, sphere) : null; - const details = getLunarDetails(t, coords); + const { startMs, endMs } = getLunarPhaseRange(refTempo.epoch.ms, { sphere }); + const start = new Tempo(startMs, { timeZone, timeStamp: 'ms', ...(sphere ? { sphere } : {}) }); + const end = new Tempo(endMs, { timeZone, timeStamp: 'ms', ...(sphere ? { sphere } : {}) }); return { - key: details.key, - phase: details.phase, - index: details.index, - illumination: details.illumination, - ageDays: details.ageDays, - isWaxing: details.isWaxing, - ...(details.emoji !== undefined ? { emoji: details.emoji } : {}), - phases: LUNAR_PHASE_KEYS, + ...lunarDetails, + group: 'lunar' as const, + geo: hasGeo ? geo : null, + ...toDateTimeFields(start), moonrise, moonset, - group: 'lunar' as const, - geo, - ...toDateTimeFields(startTempo), - start: startTempo, - end: endTempo, + start, + end, }; } @@ -180,6 +174,7 @@ function getSolarScopeRange(t: Tempo, anchor?: any) { group: 'solar' as const, geo: null, ...toDateTimeFields(refTempo), + elevation: null, sunrise: null, sunset: null, noon: null, @@ -193,7 +188,15 @@ function getSolarScopeRange(t: Tempo, anchor?: any) { }; } - const res = getSunriseSunset(refTempo.epoch.ms, lat!, lng!); + const elevation = typeof (geo as any)?.elevation === 'number' + ? (geo as any).elevation + : (typeof (t.config?.geo as any)?.elevation === 'number' ? (t.config?.geo as any).elevation : undefined); + + const res = getSunriseSunset(refTempo.epoch.ms, { + latitude: lat!, + longitude: lng!, + ...(elevation !== undefined ? { elevation } : {}), + }); const sunrise = toTempoOrNull(res.sunriseMs, timeZone)!; const sunset = toTempoOrNull(res.sunsetMs, timeZone)!; @@ -250,6 +253,7 @@ function getSolarScopeRange(t: Tempo, anchor?: any) { index: res.index, group: 'solar' as const, geo, + elevation: (typeof geo?.elevation === 'number') ? geo.elevation : (typeof (t.config?.geo as any)?.elevation === 'number' ? (t.config?.geo as any).elevation : null), ...toDateTimeFields(start), sunrise, sunset, diff --git a/packages/plugins/celestial/test/celestial.test.ts b/packages/plugins/celestial/test/celestial.test.ts index 7c307170..b39e2758 100644 --- a/packages/plugins/celestial/test/celestial.test.ts +++ b/packages/plugins/celestial/test/celestial.test.ts @@ -39,6 +39,16 @@ describe('CelestialPlugin (Solar & Lunar Terms)', () => { expect(t.term.lunar.geo).toBe(t.geo); }); + it('factors elevation into solar sunrise/sunset and exposes elevation on solar term', () => { + const seaLevel = new Tempo('2026-06-21T12:00:00Z', { geo: { lat: 39.7392, lng: -104.9903, elevation: 0 } }); + const highAlt = new Tempo('2026-06-21T12:00:00Z', { geo: { lat: 39.7392, lng: -104.9903, elevation: 1600 } }); + + expect(highAlt.term.solar.elevation).toBe(1600); + expect(highAlt.term.solar.sunrise!.epoch.ms).toBeLessThan(seaLevel.term.solar.sunrise!.epoch.ms); + expect(highAlt.term.solar.sunset!.epoch.ms).toBeGreaterThan(seaLevel.term.solar.sunset!.epoch.ms); + expect(highAlt.term.solar.daylightDurationMs).toBeGreaterThan(seaLevel.term.solar.daylightDurationMs!); + }); + it('honors numeric anchor 0 in LunarTerm and SolarTerm resolvers', () => { const t = new Tempo('2026-06-21T12:00:00Z', { geo: { lat: 40.7128, lng: -74.006 } }); diff --git a/packages/plugins/geo/plan/README.md b/packages/plugins/geo/plan/README.md index 354cfb04..cfe9f839 100644 --- a/packages/plugins/geo/plan/README.md +++ b/packages/plugins/geo/plan/README.md @@ -1,204 +1,167 @@ # Tempo Plugin Geo: Gap Analysis & Feature Roadmap -> **Status:** Planning / Future Backlog -> **Target Release:** `v1.0.0` (Pre-publish review) & `v1.1.0+` -> **Related Source:** [`packages/library/src/browser/mapper.library.ts`](file:///home/michael/Project/magma/packages/library/src/browser/mapper.library.ts), [`packages/library/src/server/mapper.library.ts`](file:///home/michael/Project/magma/packages/library/src/server/mapper.library.ts) +> **Status:** v1.0.0 Foundation Delivered / v1.1.0+ Backlog Planned +> **Target Release:** `v1.0.0` (Foundation Delivered) & `v1.1.0+` (Feature Enhancements) +> **Source of Truth:** [`packages/library/src/common/runtime/mapper.library.ts`](../../../library/src/common/runtime/mapper.library.ts) +> **Platform Drivers:** [`packages/library/src/browser/mapper.library.ts`](../../../library/src/browser/mapper.library.ts), [`packages/library/src/server/mapper.library.ts`](../../../library/src/server/mapper.library.ts) --- ## 1. Executive Summary & Guiding Philosophy ### The Mission: "Humanizing Temporal" -The standard JavaScript `Temporal` API is mathematically rigorous, but it is notoriously low-level and clumsy. Answering simple human questions—such as *"What time is it where the client is right now?"*, *"Is it daylight or business hours at their location?"*, or *"What season is it there?"*—requires stitching together multiple disjoint APIs, external IP fetches, timezone resolvers, and coordinate conversions. +The standard JavaScript `Temporal` API is mathematically rigorous, but as a low-level engine specification, it is intentionally verbose and mechanically clunky. Answering simple human questions—such as *"What time is it where the client is right now?"*, *"Is it daylight at their location?"*, or *"What season is it there?"*—requires stitching together multiple disjoint types, external IP fetches, timezone resolvers, and coordinate conversions. -Tempo's primary mission is to **humanize Temporal**: making date-times intuitive, expressive, and effortless to work with. +Tempo's primary mission is to **humanize Temporal**: making date-times intuitive, expressive, and effortless to work with. -Geolocation is fundamental to this mission. **Time and space are inseparable**: civil time only has meaning relative to a physical location on Earth. By anchoring Tempo's deterministic, immutable API to spatial awareness—**while strictly adhering to ISO 8601 standards**—`tempo-plugin-geo` elevates Tempo from an abstract clock calculator into a deeply context-aware temporal tool. +### Overarching Architecture: Single Source of Truth & Repeatable Outcomes +Ease-of-use and repeatable outcomes dictate our architecture: +1. **`mapper.library.ts` as the Pure Source of Truth:** All coordinate coercion, format normalization, hemisphere deduction, and validation reside in `@magmacomputing/library`. +2. **Zero-Dependency Universal Behavior:** Sibling plugins (such as `tempo-plugin-celestial` and `tempo-plugin-astro`) and core Tempo consume `mapper.library.ts` directly. A user who passes coordinates via `new Tempo({ geo: { lat: -33.86, lng: 151.2 } })` gets the exact same normalized `GeoConfig` (with inferred `sphere`, 3-decimal alignment, and validation) **whether or not `tempo-plugin-geo` is installed**. +3. **The Role of `tempo-plugin-geo`:** This plugin is the dynamic execution layer—providing ambient storage, network IP resolution, browser hardware GPS querying, distance math, and instance mutation (`t.geoLocate()`). --- -## 2. Gap Analysis & Humanizing Opportunities - -### 2.1 Auto-Syncing Instance Timezone (`t.tz`) via Resolved Geolocation -* **Current State in `mapper.library.ts`:** - * Server IP queries (`https://ipwho.is/`) return IANA timezone identifiers (e.g. `"timezone": "America/New_York"` or `"timezone": "Australia/Sydney"`). - * Google Maps Geocoder responses can be coupled with Google TimeZone API or reverse lookup tables. -* **Current State in `tempo-plugin-geo`:** - * `t.geoLocate()` only assigns `this.config.geo = { latitude, longitude }`. - * The instance timezone (`this.tz`) remains unchanged (typically the ambient system local timezone or UTC). -* **Opportunity & Value:** - * Allow `t.geoLocate({ setTimezone: true })` (or configurable default). - * When geolocating across timezones or client IPs, the instance's wall-clock time automatically aligns to the local civil time of that geographic coordinate: - ```ts - const event = tempo('2026-09-08T12:00:00Z'); - const clientEvent = await event.geoLocate({ ip: req.clientIp, setTimezone: true }); - // clientEvent.tz === 'Australia/Sydney' - // clientEvent.format('h:mm a z') -> '10:00 pm AEST' - ``` +## 2. Gap Analysis & Feature Specifications + +### 2.1 Complete Instance Context Synchronization on `t.geoLocate()` +* **Principle:** When a developer explicitly awaits `t.geoLocate()`, the primary intent is to anchor the date-time instance to physical reality. +* **Specification:** + * `t.geoLocate()` updates `latitude`, `longitude`, `tz`, and `sphere` simultaneously. + * **`setTimezone` defaults to `true`:** Automatically converts the instance's wall-clock time to the IANA timezone of the resolved coordinates (`clientEvent.tz === 'Australia/Sydney'`). An explicit `{ setTimezone: false }` option allows opting out when callers wish to record coordinates while retaining UTC or original timezone. + ```typescript + const event = tempo('2026-09-08T12:00:00Z'); + const clientTime = await event.geoLocate({ ip: req.clientIp }); + // clientTime.tz === 'Australia/Sydney' + // clientTime.geo.sphere === 'south' + // clientTime.format('{h12}:{mi} {mer} [{tz}]') -> '10:00 pm [Australia/Sydney]' + ``` --- -### 2.2 Stationary Drift Filtering & GPS Noise Protection -* **Current State in `browser/mapper.library.ts`:** - * Implements coordinate delta testing before refreshing or invalidating cached geocoder results: - ```ts - const prevLat = coords?.latitude?.toFixed(3); - const prevLng = coords?.longitude?.toFixed(3); - const test1 = value.coords.latitude.toFixed(3) !== prevLat; - const test2 = value.coords.longitude.toFixed(3) !== prevLng; - const test3 = prevTime < (instant().epochMilliseconds - 3_600_000); // 1 hour TTL - ``` -* **Current State in `tempo-plugin-geo`:** - * Coordinates are cached and looked up as exact floating-point numbers. - * In mobile/browser environments, raw GPS jitter in the 4th–6th decimal places (~10m to 10cm) creates arbitrary cache misses. -* **Opportunity & Value:** - * Introduce stationary drift suppression in `Tempo.geo.coerce` and cache key generation. - * Rounding to 3 decimal places (~111 meters at equator) or providing a configurable threshold (`driftToleranceMeters: 100`) prevents cache thrashing, rate-limit exhaustion, and unnecessary battery drain. +### 2.2 Deterministic 3-Decimal Precision Alignment (GPS Noise Protection) +* **Principle:** Keep the interface clean and zero-config. +* **Specification:** + * Coordinates in `coerceGeo` are deterministically normalized to **3 decimal places** (`~111m` resolution at the equator). + * No config switches or drift threshold options: 3-decimal alignment eliminates GPS noise, prevents storage cache thrashing, and ensures identical key hashing without exposing configuration bloat to developers. --- -### 2.3 Hemisphere Awareness & Astronomical Seasons -* **Current State in `browser/mapper.library.ts`:** - * `mapHemisphere(coords)` returns `'north' | 'south' | null`. - * Evaluates `lat >= 0 ? 'north' : 'south'`, falling back to `getHemisphere()` based on timezone offset. -* **Current State in `tempo-plugin-geo`:** - * Latitude is stored, but no hemisphere helpers or properties exist on `Tempo` or `Tempo.geo`. -* **Opportunity & Value:** - * Seasons are flipped between Northern and Southern hemispheres: - * Solstices & Equinoxes: June is Summer Solstice in the North, Winter Solstice in the South. - * Meteorological & Astronomical Quarters: Q1 is Winter in North, Summer in South. - * Direct synergy with sibling plugins: - * `tempo-plugin-astro`: Zodiac signs, solar declination, equinox offsets. - * `tempo-plugin-celestial`: Sunrise, sunset, twilight calculations (which are inverted by hemisphere). - * **Proposed API:** - ```ts - Tempo.geo.hemisphere(coords: CoordinateInput): 'north' | 'south' | 'equator' | null; - tempoInstance.hemisphere; // getter -> 'north' | 'south' - ``` +### 2.3 Automatic Hemisphere Inference (`'north' | 'south' | 'equator'`) +* **Principle:** Passing coordinates anywhere in the ecosystem should immediately unlock hemisphere awareness with zero plugin dependencies. +* **Specification in `mapper.library.ts`:** + * `coerceGeo()` automatically derives `sphere`: + * $\text{lat} > 0.001^\circ \implies \text{'north'}$ + * $\text{lat} < -0.001^\circ \implies \text{'south'}$ + * $|\text{lat}| \le 0.001^\circ \implies \text{'equator'}$ + * **Cross-Plugin Synergy (Workflow without `tempo-plugin-geo`):** + What happens if a developer does **not** install `tempo-plugin-geo` and passes `{ lat, lng }` to `tempo-plugin-celestial` or `tempo-plugin-astro`? + 1. **Initialization:** User writes `const t = tempo({ geo: { lat: -33.8688, lng: 151.2093 } })` or passes coordinates to a celestial method. + 2. **Foundational Coercion:** Core Tempo calls `coerceGeo()` in `@magmacomputing/library`. + 3. **Zero-Plugin Deduction:** Coordinates are normalized to 3 decimal places (`-33.869`, `151.209`) and `sphere: 'south'` is derived and frozen onto `t.geo`. + 4. **Downstream Execution:** Sibling plugins (`celestial`, `astro`) read `t.geo.sphere` directly to invert solar curves and season cycles. + * **Conclusion:** `tempo-plugin-geo` is strictly required only for **dynamic network/hardware queries** (`await t.geoLocate()`, `Tempo.geo.lookup()`, ambient IP stashing, and `Tempo.geo.distance()`). All coordinate math, rounding, and hemisphere awareness are 100% universal and self-contained. --- -### 2.4 Explicit Anti-Goal: Strict ISO 8601 Week Invariant (Out of Scope) -* **Architectural Invariant:** - * Tempo is intentionally and strictly opinionated: it is architected directly around **ISO 8601** standards (Monday is day 1, standard ISO week numbering 1–53). -* **Boundary Rule for `tempo-plugin-geo`:** - * While reverse geocoding provides country codes (`US`, `AU`, `GB`, `AE`), **`tempo-plugin-geo` must NEVER alter Tempo's week alignments, day indexing, or calendar math.** - * Altering week starts (e.g. attempting Sunday-start or Saturday-start weeks) compromises Tempo's deterministic ISO-8601 contract and creates conflicting calendar state across instances. -* **Acceptable Scope (Informational Only):** - * Country codes should remain purely informational metadata (e.g. `t.geo.countryCode`), enabling external consumer logic (such as looking up regional public holiday sets or fiscal quarter offsets) without modifying Tempo's internal calendar rules. +### 2.4 ISO 8601 Calendar Invariant & Regional Metadata +* **Strict Boundary Rule:** + * Tempo is strictly opinionated around **ISO 8601** standards (Monday is always day 1, standard ISO week numbering 1–53). + * `tempo-plugin-geo` will **NEVER** alter Tempo's week alignments, day indexing, or calendar math. +* **Regional Metadata:** + * Reverse geocoded country codes (`US`, `AU`, `GB`, etc.) are retained purely as informational metadata on `t.geo.country`. + * Future specialized plugins (e.g. localized holiday engines or regional fiscal calendars) can inspect `t.geo.country` if needed. --- -### 2.5 Great-Circle / Haversine Distance & Transit Duration -* **Current State in `mapper.library.ts`:** - * Raw coordinates are retrieved, but distance math is not provided. -* **Opportunity & Value:** - * A lightweight, zero-dependency Haversine formula calculation directly within `Tempo.geo`: - ```ts - Tempo.geo.distance(t1: Tempo | CoordinateInput, t2: Tempo | CoordinateInput, opts?: { unit?: 'km' | 'mi' | 'nm' }): number; +### 2.5 Great-Circle / Haversine Distance (`Tempo.geo.distance`) +* **Specification:** + * Provide a zero-dependency Haversine distance calculator: + ```typescript + Tempo.geo.distance(t1: Tempo | CoordinateInput, t2: Tempo | CoordinateInput, unit?: 'km' | 'miles' | 'm'): number; ``` - * **Applications for Date-Time Workflows:** + * **Use Cases:** * **Transit Velocity:** Calculate average travel speed between two timestamped events: - $$\text{speed} = \frac{\text{Tempo.geo.distance}(t_1, t_2)}{\text{t2.diff}(t1, 'hours')}$$ + $$\text{speed} = \frac{\text{Tempo.geo.distance}(t_1, t_2, \text{'km'})}{t_2.\text{diff}(t_1, \text{'hours'})}$$ * **Impossible Travel Detection:** Flag security anomalies (e.g., login from London at 10:00 and Tokyo at 11:00). - * **Flight / Travel Time Validation:** Validate schedule consistency across time zones and physical locations. + * **Logistics & Scheduling:** Verify route consistency between deliveries or appointments. --- -### 2.6 Rich Spatial Metadata Retention on `Tempo` Instances -* **Current State in `browser/mapper.library.ts` & `server/mapper.library.ts`:** - * Server responses include `country`, `city`, `timezone`, `query` (IP). - * Google Maps responses include `formatted_address`, `locality`, `administrative_area_level_1`, `postal_code`. -* **Current State in `tempo-plugin-geo`:** - * `t.geo` is strictly `{ latitude: number, longitude: number }`. -* **Opportunity & Value:** - * Extend `GeoConfig` to preserve rich metadata when available: - ```ts +### 2.6 Rich Spatial Metadata Retention & Layout Format Tokens +* **Specification:** + * `GeoConfig` retains metadata returned from IP lookup or Google Maps: + ```typescript export interface GeoConfig { latitude: number; longitude: number; elevation?: number; - sphere?: 'north' | 'south'; + sphere?: 'north' | 'south' | 'equator'; country?: string; - countryCode?: string; city?: string; region?: string; postalCode?: string; timezone?: string; - formattedAddress?: string; } ``` - * Enables format tokens and metadata logging: - ```ts - t.format('[Event at] {city}, {country} ({lat}, {lng})'); + * **Integration with `format()` Tokens:** + When geo metadata is present on an instance, `t.format()` resolves bracketed dotted tokens: + ```typescript + t.format('{geo.city}, {geo.country} · {h12}:{mi} {mer} [{tz}]'); + // "Sydney, Australia · 10:00 pm [Australia/Sydney]" ``` + * **Resolution Mechanics & Graceful Fallback:** + * The format engine checks `token.startsWith('geo.')`: + * Extract key: `key = token.slice(4)`. + * Lookup value on instance: `val = t.geo?.[key]`. + * If resolved and non-empty: replace with `String(val)`. + * If `t.geo` is absent or `key` is undefined: cleanly replace with `''` (empty string) so formatting does not throw or print `undefined`. + * **Developer Ergonomics:** Allows building user-facing timestamps, localized event headers, and notifications without manual string stitching. --- -### 2.7 Elevation & Solar Horizon Adjustments -* **Current State in `browser/mapper.library.ts`:** - * Google Maps Geocoding and Elevation APIs supply elevation in meters. -* **Opportunity & Value:** - * Elevation alters apparent sunrise, sunset, and solar noon times due to horizon dip: +### 2.7 Elevation Integration in Solar & Astro Calculations +* **Specification:** + * When `elevation` (meters above sea level) is present in `t.geo.elevation`, downstream plugins (`tempo-plugin-celestial`, `tempo-plugin-astro`) incorporate it into apparent sunrise/sunset and solar noon calculations via atmospheric horizon dip: $$\Delta\theta \approx 0.0347^\circ \times \sqrt{h_{\text{meters}}}$$ - * Locations at significant altitude (e.g., Denver at 1,600m, Mexico City at 2,240m) see sunrises several minutes earlier and sunsets several minutes later than sea level. - * Preserving `elevation` in `t.geo.elevation` allows downstream plugins (`tempo-plugin-astro`, `tempo-plugin-celestial`) to produce high-precision solar calculations. + * High-altitude locations (e.g. Denver at 1,600m) accurately reflect earlier sunrises and later sunsets. --- -### 2.8 Unified Browser-Server Storage & Stash Semantics -* **Current State:** - * Browser uses `WebStore('local')` with key `_magma_geo_`. - * Server uses `BoundedCache` with key `_magma_geo_:` or `_magma_geo_:`. -* **Opportunity & Value:** - * Standardize storage abstraction so that cache keys, TTL expiration (24h default), and multi-tenant partitioning operate identically across Node.js, Deno, Bun, and browser environments. - -### 2.9 Human-Centric "Business Hours" Checker (`t.isBusinessHours()`) -* **The "Humanizing" Need:** - * Distributed teams and automated workflows constantly ask: *"Can I notify this user right now, or is it 3:00 AM there?"* / *"Is the client in their local business hours?"* - * Raw Temporal requires manual zone conversions, extracting `dayOfWeek`, checking bounds, and formatting. -* **ISO 8601 Compliant Design:** - * Default definition strictly adheres to ISO 8601: **Monday through Friday (days 1–5)**, 09:00 to 17:00 local time. - * Simple, expressive API: - ```ts - const clientNow = await tempo().geoLocate({ ip: req.clientIp }); - if (clientNow.isBusinessHours()) { - // Send real-time notification - } - ``` - ---- - -### 2.10 Natural Solar Time vs. Civil Clock Time (`Tempo.geo.solarOffset`) -* **The "Humanizing" Need:** - * Civil timezones are political artifacts. In many regions, the clock reads 12:00 PM, but the sun is nowhere near its zenith (e.g. western Spain, western China, or borders of time zones where solar noon differs by 1–2 hours from clock noon). - * For humans tracking circadian health, natural light exposure, or outdoor activities, understanding natural solar time humanizes the relationship between the clock and nature. -* **ISO 8601 Compliant Design:** - * Does not alter ISO timestamps or civil date math. - * Calculates solar time offset based on longitude ($\approx 4\text{ minutes per } 1^\circ \text{ offset from standard meridian}$): - ```ts - const offsetMinutes = Tempo.geo.solarOffset(coords); // e.g. -42 minutes +### 2.8 Natural Solar Time Offset (`Tempo.geo.solarOffset`) +* **Specification:** + * Quantifies the physical delta between civil clock time and actual solar noon based on longitude ($\approx 4\text{ minutes per } 1^\circ \text{ offset from standard timezone meridian}$): + ```typescript + const minutes = Tempo.geo.solarOffset(coords); // e.g. -38 minutes ``` + * **Human Value:** Supports circadian health, natural light tracking, and outdoor activities where solar position matters more than political clock boundaries. --- ## 3. Prioritized Implementation Roadmap -| Priority | Feature | Effort | Target | Humanizing Impact | Description | +| Priority | Feature | Status | Target | Scope / Target Package | Description | | :--- | :--- | :--- | :--- | :--- | :--- | -| **P1** | **Timezone Auto-Sync** (`setTimezone: true`) | Low | `v1.0.0` / `v1.0.1` | **High** | Syncs `t.tz` to local civil time at resolved location. | -| **P1** | **Stationary Drift Filtering** | Low | `v1.0.0` / `v1.0.1` | Medium | Prevents GPS noise cache thrashing (`toFixed(3)`). | -| **P2** | **Hemisphere Awareness** (`Tempo.geo.hemisphere`) | Low | `v1.1.0` | **High** | Flipped seasonal awareness (`t.season`) for southern hemisphere humans. | -| **P2** | **Rich Metadata Preservation** | Medium | `v1.1.0` | **High** | Retains `city`, `country`, `formattedAddress` for template formatting. | -| **P3** | **Business Hours Helper** (`t.isBusinessHours()`) | Low | `v1.1.0` | **High** | Instant check if local time is 9–5 Mon–Fri (ISO 8601). | -| **P3** | **Haversine Distance Utility** | Low | `v1.1.0` | Medium | `Tempo.geo.distance(t1, t2)` for transit speed and impossible travel. | -| **P4** | **Elevation Horizon Correction** | Medium | `v1.2.0` | Medium | Horizon dip correction for high-precision sunrise/sunset. | -| **P4** | **Natural Solar Offset** | Low | `v1.2.0` | Medium | Quantifies delta between civil clock time and actual solar zenith. | +| **P1** | **`t.geoLocate()` Full Context Sync** | ✅ **Complete** | `v1.0.0` | `tempo-plugin-geo` | Default `setTimezone: true`, Option B reality sync, Option C call-site overrides. | +| **P1** | **Deterministic 3-Decimal Precision** | ✅ **Complete** | `v1.0.0` | `mapper.library` | Standardize lat/lng to 3 decimal places; enforce $\text{lat} \in [-90, 90], \text{lng} \in [-180, 180]$. | +| **P1** | **Hemisphere Inference (`north`/`south`/`equator`)** | ✅ **Complete** | `v1.0.0` | `mapper.library` / `tempo` | Automatic `sphere` deduction in `coerceGeo` and `t.sphere` across all packages. | +| **P2** | **Haversine Distance Utility** | ✅ **Complete** | `v1.0.0` | `tempo-plugin-geo` | `Tempo.geo.distance(t1, t2, unit?)` and `t.geoDistance(other, unit?)` for impossible travel & transit. | +| **P2** | **Rich Metadata Layout Tokens** | ✅ **Complete** | `v1.0.0` | `tempo` / `tempo-plugin-geo` | Dynamic format tokens (`{geo.city}`, `{geo.country}`, `{geo.sphere}`) in `t.format()`. | +| **P3** | **Elevation Horizon Dip** | ✅ **Complete** | `v1.0.0` | `tempo-fns` / `celestial` | Factor `t.geo.elevation` into solar sunrise/sunset times via horizon dip ($\Delta\theta \approx 0.0347^\circ \times \sqrt{h_{\text{meters}}}$). | +| **P3** | **Natural Solar Time Offset** | ✅ **Complete** | `v1.0.0` | `mapper.library` / `tempo-plugin-geo` | `Tempo.geo.solarOffset(coords)` and `t.geoSolarOffset()` calculating solar delta. | --- -## 4. Architectural Invariants & Non-Goals - -1. **Strict ISO 8601 Week Invariant:** Tempo is strictly opinionated. Monday is ALWAYS day 1, and ISO week numbering rules are absolute. `tempo-plugin-geo` must never modify, reconfigure, or override week start or weekend alignments. -2. **Immutable Tempo Attachment:** As established in `packages/plugins/.setup/community-plugin-template.md`, the `Tempo.geo` namespace must remain locked-down (`deepFreeze()` via `@magmacomputing/tempo/plugin/sdk` + `writable: false`, `configurable: false`), and all instance methods (`geoLocate`) must return new Tempo instances. -3. **Partitioned Isolation:** All stashing and caching must preserve multi-tenant cache keys (`_magma_geo_:`). +## 4. Completed Foundation (Delivered in `v1.0.0`) + +- [x] **Deterministic 3-Decimal Precision & Boundary Validation:** Coordinates in `coerceGeo` are validated against physical bounds ($\text{lat} \in [-90, 90]$, $\text{lng} \in [-180, 180]$) and rounded to 3 decimal places ($\approx 111\text{m}$ resolution) with zero configuration flags. +- [x] **Automatic Hemisphere Inference:** `coerceGeo()` and `Tempo.prototype.sphere` automatically derive `'north'`, `'south'`, or `'equator'` (within the $\pm 0.001^\circ$ equatorial band) across all packages without requiring `tempo-plugin-geo`. +- [x] **Instance Full Context Synchronization:** `t.geoLocate()` synchronizes `latitude`, `longitude`, `tz`, and `sphere` simultaneously. Defaults `setTimezone: true` (with `{ setTimezone: false }` opt-out), applies Option B (physical reality updates location while preserving custom non-geographic keys), and respects Option C (authoritative call-site overrides). +- [x] **Great-Circle Haversine Distance Utility:** Pure calculation via `haversineDistance()` and OOP dispatch via `Tempo.geo.distance(t1, t2, unit?)` and `t.geoDistance(other, unit?)`. Supports `'km'`, `'miles'`, and `'m'`. Enables transit velocity and impossible travel anomaly detection. +- [x] **Rich Spatial Metadata Layout Tokens:** Core format engine resolves `{geo.}` bracketed tokens (e.g. `{geo.city}`, `{geo.country}`, `{geo.sphere}`, `{geo.elevation}`) with full modifier support (e.g. `:upper`, `:title`) and graceful empty-string fallback. +- [x] **Atmospheric Horizon Dip (`elevation`):** Downstream solar calculations in `tempo-fns` and `tempo-plugin-celestial` incorporate `t.geo.elevation` into apparent sunrise, sunset, and daylight duration ($\Delta\theta \approx 0.0347^\circ \times \sqrt{h_{\text{meters}}}$) with unchanged solar noon transit. +- [x] **Natural Solar Time Offset (`solarOffset`):** Computes physical delta between civil clock time and actual solar noon ($\Delta\lambda \times 4\text{ min}$) with support for civil timezones, natural 15-degree solar meridians, units (`'minutes'`, `'seconds'`, `'hours'`), and apparent solar time (Equation of Time). Exposed via `Tempo.geo.solarOffset` and `t.geoSolarOffset()`. +- [x] **Universal Geolocation Caching:** 24-hour TTL caching in `Tempo.geo.lookup()` with `{ refresh: true }` bypass. +- [x] **Scoped Multi-Tenant Isolation:** Standardized on `_magma_geo_` with tenant key isolation (`_magma_geo_:`). +- [x] **Immutability Hardening:** `Tempo.geo` is recursively frozen via `deepFreeze()`. +- [x] **Clean Non-Polluted Root:** Removed flat root methods in favor of cohesive `Tempo.geo.*`. diff --git a/packages/plugins/geo/src/index.ts b/packages/plugins/geo/src/index.ts index fdc1d755..4f05fc5b 100644 --- a/packages/plugins/geo/src/index.ts +++ b/packages/plugins/geo/src/index.ts @@ -7,9 +7,16 @@ import { getStashedGeo, stashGeo, clearStashedGeo, + GEO_PROPERTIES, + haversineDistance, + solarOffset, type GeoLookupResult, + type ResolvedCoordinates, type GeoConfig, type CoordinateInput, + type DistanceUnit, + type SolarOffsetOptions, + type SolarOffsetUnit, } from '@magmacomputing/library/runtime/mapper.library.js'; import { serverGeoLocation, @@ -21,6 +28,7 @@ import { import { geoLocation, } from '@magmacomputing/library/browser/mapper.library.js'; +import { isString, isNumber, isEmpty, isSafeKey } from '@magmacomputing/library/primitives/assertion.library.js'; export { geoLookup, @@ -29,6 +37,9 @@ export { getStashedGeo, stashGeo, clearStashedGeo, + GEO_PROPERTIES, + haversineDistance, + solarOffset, serverGeoLocation, serverGeoCoords, serverMapHemisphere, @@ -37,8 +48,12 @@ export { export type { GeoLookupResult, + ResolvedCoordinates, GeoConfig, CoordinateInput, + DistanceUnit, + SolarOffsetOptions, + SolarOffsetUnit, ServerMapOpts, ServerGeolocationResult, }; @@ -53,6 +68,10 @@ export interface TempoGeoNamespace { readonly resolve: typeof resolveGeoCoordinates; /** Coerces coordinates and configurations into a canonical GeoConfig object */ readonly coerce: typeof coerceGeo; + /** Calculates Great-Circle distance between two coordinates using Haversine formula */ + readonly distance: typeof haversineDistance; + /** Calculates Natural Solar Time Offset between civil clock time and actual solar noon */ + readonly solarOffset: typeof solarOffset; /** Explicitly stashes coordinates into storage with optional TTL (default 24h) and multi-tenant partitioning */ readonly stash: typeof stashGeo; /** Clears stashed coordinates from storage */ @@ -77,6 +96,8 @@ export const GeoPlugin: TempoPlugin = definePlugin({ lookup: geoLookup, resolve: resolveGeoCoordinates, coerce: coerceGeo, + distance: haversineDistance, + solarOffset, stash: stashGeo, clear: clearStashedGeo, get: getStashedGeo, @@ -85,7 +106,7 @@ export const GeoPlugin: TempoPlugin = definePlugin({ get current(): GeoConfig | undefined { return getStashedGeo() ?? TempoClass.config?.geo; }, - } + }; Object.defineProperty(TempoClass, 'geo', { value: deepFreeze(geoNamespace), @@ -96,19 +117,66 @@ export const GeoPlugin: TempoPlugin = definePlugin({ /** * Asynchronously resolves coordinates for the current instance (or uses existing coordinates), - * returning a new Tempo instance with the resolved `geo` configuration attached. + * returning a new Tempo instance with full context synchronization. + * + * - setTimezone defaults to true: automatically shifts instance wall-clock time to the resolved location. + * - Option B (Physical Reality): Fresh location metadata updates geographic fields (lat, lng, country, city, sphere, timezone). + * - Custom non-geographic metadata (e.g. { venue: 'HQ', officeId: 42 }) is preserved. + * - Option C (Call-Site Overrides): Explicit parameters passed to .geoLocate(opts) take absolute precedence. */ TempoClass.prototype.geoLocate = async function (this: Tempo, opts?: Record): Promise { + const setTimezone = opts?.setTimezone !== false; const coords = await resolveGeoCoordinates(this, opts); if (coords) { const existingGeo = (typeof this.config.geo === 'object' && this.config.geo !== null) ? this.config.geo : {}; - return new TempoClass(this, { - ...this.config, - geo: { - ...existingGeo, - latitude: coords.lat, - longitude: coords.lng, - }, + + // 1. Separate custom non-geo keys from existingGeo to preserve them (Option B) + const customKeys: Record = {}; + for (const key of Object.keys(existingGeo)) { + if (isSafeKey(key) && !GEO_PROPERTIES.includes(key as any)) + customKeys[key] = (existingGeo as any)[key]; + } + + // 2. Extract call-site overrides (Option C) + const callSiteGeo = coerceGeo(opts) ?? {}; + if (opts && typeof opts === 'object') { + for (const key of Object.keys(opts)) { + if (isSafeKey(key) && !['setTimezone', 'refresh', 'ttl', 'endpoint', 'timeout', 'catch', 'debug', 'geo'].includes(key)) { + if (!GEO_PROPERTIES.includes(key as any)) + customKeys[key] = (opts as any)[key]; + } + } + } + + // Preserve existing elevation if network query does not provide elevation data + const preservedElevation = isNumber((coords as any)?.elevation) + ? (coords as any).elevation + : (isNumber(existingGeo.elevation) ? existingGeo.elevation : undefined); + + // 3. Compose final merged geo object: custom keys + physical reality (Option B) + call-site overrides (Option C) + const mergedGeo: GeoConfig = { + ...customKeys, + ...coords, + ...(isNumber(preservedElevation) ? { elevation: preservedElevation } : {}), + ...callSiteGeo, + }; + + // Clean up coordinates and infer sphere + if (isNumber(mergedGeo.latitude)) mergedGeo.latitude = Math.round(mergedGeo.latitude * 1000) / 1000; + if (isNumber(mergedGeo.longitude)) mergedGeo.longitude = Math.round(mergedGeo.longitude * 1000) / 1000; + if (!mergedGeo.sphere && isNumber(mergedGeo.latitude)) { + mergedGeo.sphere = mergedGeo.latitude > 0.001 ? 'north' : (mergedGeo.latitude < -0.001 ? 'south' : 'equator'); + } + + let instance: Tempo = this; + const targetTz = mergedGeo.timezone; + if (setTimezone && isString(targetTz) && !isEmpty(targetTz)) + instance = this.set({ timeZone: targetTz }); + + return new TempoClass(instance, { + ...instance.config, + geo: deepFreeze(mergedGeo), + ...(mergedGeo.sphere ? { sphere: mergedGeo.sphere } : {}), }); } return this; @@ -117,9 +185,23 @@ export const GeoPlugin: TempoPlugin = definePlugin({ /** * Resolves coordinates for this instance via explicit coordinates or automatic IP/hardware lookup. */ - TempoClass.prototype.geoLookup = async function (this: Tempo, opts?: Record): Promise<{ lat: number; lng: number } | null> { + TempoClass.prototype.geoLookup = async function (this: Tempo, opts?: Record): Promise { return resolveGeoCoordinates(this, opts); }; + + /** + * Calculates Great-Circle distance from this instance's coordinates to target coordinates using Haversine formula. + */ + TempoClass.prototype.geoDistance = function (this: Tempo, other: any, unit?: DistanceUnit): number { + return haversineDistance(this, other, unit); + }; + + /** + * Calculates Natural Solar Time Offset between civil clock time and actual solar noon for this instance. + */ + TempoClass.prototype.geoSolarOffset = function (this: Tempo, options?: SolarOffsetOptions): number { + return solarOffset(this, options); + }; }, }); @@ -135,7 +217,15 @@ declare module '@magmacomputing/tempo' { /** * Resolves coordinates for this instance via explicit coordinates or automatic IP/hardware lookup. */ - geoLookup(opts?: Record): Promise<{ lat: number; lng: number } | null>; + geoLookup(opts?: Record): Promise; + /** + * Calculates Great-Circle distance from this instance's coordinates to target coordinates using Haversine formula. + */ + geoDistance(other: any, unit?: DistanceUnit): number; + /** + * Calculates Natural Solar Time Offset between civil clock time and actual solar noon for this instance. + */ + geoSolarOffset(options?: SolarOffsetOptions): number; } namespace Tempo { diff --git a/packages/plugins/geo/test/geo.test.ts b/packages/plugins/geo/test/geo.test.ts index 589ecf09..519b595b 100644 --- a/packages/plugins/geo/test/geo.test.ts +++ b/packages/plugins/geo/test/geo.test.ts @@ -8,6 +8,8 @@ import { stashGeo, clearStashedGeo, getStashedGeo, + haversineDistance, + solarOffset, } from '../src/index.js'; describe('Tempo Plugin: Geo', () => { @@ -25,15 +27,24 @@ describe('Tempo Plugin: Geo', () => { }); describe('Pure functional coordinate utilities', () => { - it('should coerce various coordinate input formats', () => { + it('should coerce various coordinate input formats with 3-decimal precision and sphere inference', () => { expect(coerceGeo({ lat: -33.8688, lng: 151.2093 })).toEqual({ - latitude: -33.8688, - longitude: 151.2093, + latitude: -33.869, + longitude: 151.209, + sphere: 'south', }); expect(coerceGeo({ latitude: 40.7128, longitude: -74.006 })).toEqual({ - latitude: 40.7128, + latitude: 40.713, longitude: -74.006, + sphere: 'north', + }); + + // Equator boundary band + expect(coerceGeo({ lat: 0.0005, lng: 100 })).toEqual({ + latitude: 0.001, + longitude: 100, + sphere: 'equator', }); expect(coerceGeo(undefined)).toBeUndefined(); @@ -46,9 +57,10 @@ describe('Tempo Plugin: Geo', () => { }); const coords = await resolveGeoCoordinates(t); - expect(coords).toEqual({ - lat: 51.5074, - lng: -0.1278, + expect(coords).toMatchObject({ + lat: 51.507, + lng: -0.128, + sphere: 'north', }); }); @@ -74,7 +86,7 @@ describe('Tempo Plugin: Geo', () => { expect(result.city).toBe('Mountain View'); }); - it('should perform mocked geoLookup on server', async () => { + it('should perform mocked geoLookup on server with 3-decimal rounding', async () => { const mockPayload = { ip: '1.1.1.1', success: true, @@ -90,8 +102,9 @@ describe('Tempo Plugin: Geo', () => { ); const result = await geoLookup(); - expect(result.lat).toBe(-33.8688); - expect(result.lng).toBe(151.2093); + expect(result.lat).toBe(-33.869); + expect(result.lng).toBe(151.209); + expect(result.sphere).toBe('south'); }); }); @@ -125,14 +138,15 @@ describe('Tempo Plugin: Geo', () => { }); const coords = await t.geoLookup(); - expect(coords).toEqual({ - lat: 35.6762, - lng: 139.6503, + expect(coords).toMatchObject({ + lat: 35.676, + lng: 139.65, + sphere: 'north', }); }); - it('should allow instance method .geoLocate() to return a new Tempo instance with geo set', async () => { - const t = new Tempo('2026-01-01'); + it('should allow instance method .geoLocate() to return a new Tempo instance with full context sync', async () => { + const t = new Tempo('2026-01-01T12:00:00Z'); expect(t.geo).toBeUndefined(); const mockPayload = { @@ -140,6 +154,9 @@ describe('Tempo Plugin: Geo', () => { success: true, lat: 48.8566, lon: 2.3522, + city: 'Paris', + country: 'France', + timezone: 'Europe/Paris', }; vi.spyOn(globalThis, 'fetch').mockImplementation( @@ -149,17 +166,22 @@ describe('Tempo Plugin: Geo', () => { const located = await t.geoLocate(); expect(located).toBeInstanceOf(Tempo); expect(located.geo).toBeDefined(); - expect(located.geo?.latitude).toBe(48.8566); - expect(located.geo?.longitude).toBe(2.3522); + expect(located.geo?.latitude).toBe(48.857); + expect(located.geo?.longitude).toBe(2.352); + expect(located.geo?.city).toBe('Paris'); + expect(located.geo?.country).toBe('France'); + expect(located.geo?.sphere).toBe('north'); + expect(located.tz).toBe('Europe/Paris'); + expect(located.sphere).toBe('north'); }); - it('should preserve existing geo properties (elevation, sphere, custom keys) when calling .geoLocate()', async () => { + it('should preserve custom non-geo keys while physical reality (Option B) updates location', async () => { const t = new Tempo('2026-01-01', { geo: { elevation: 150, - sphere: 'south', - city: 'Sydney', + city: 'OldCity', customKey: 'customValue', + venue: 'Harbour', } as any, }); @@ -168,6 +190,9 @@ describe('Tempo Plugin: Geo', () => { success: true, lat: -33.8688, lon: 151.2093, + city: 'Sydney', + country: 'Australia', + timezone: 'Australia/Sydney', }; vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( @@ -175,12 +200,64 @@ describe('Tempo Plugin: Geo', () => { ); const located = await t.geoLocate(); - expect(located.geo?.latitude).toBe(-33.8688); - expect(located.geo?.longitude).toBe(151.2093); + expect(located.geo?.latitude).toBe(-33.869); + expect(located.geo?.longitude).toBe(151.209); expect((located.geo as any)?.elevation).toBe(150); - expect((located.geo as any)?.sphere).toBe('south'); - expect((located.geo as any)?.city).toBe('Sydney'); - expect((located.geo as any)?.customKey).toBe('customValue'); + expect(located.geo?.city).toBe('Sydney'); // Option B: fresh city overwrites OldCity + expect(located.geo?.country).toBe('Australia'); + expect(located.geo?.sphere).toBe('south'); + expect((located.geo as any)?.customKey).toBe('customValue'); // Custom non-geo keys preserved + expect((located.geo as any)?.venue).toBe('Harbour'); + expect(located.tz).toBe('Australia/Sydney'); + }); + + it('should allow call-site overrides (Option C) to win over network reality', async () => { + const t = new Tempo('2026-01-01T12:00:00Z'); + + const mockPayload = { + ip: '8.8.8.8', + success: true, + lat: 37.4223, + lon: -122.0848, + city: 'Mountain View', + country: 'United States', + timezone: 'America/Los_Angeles', + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockPayload), { status: 200 }) + ); + + // Call-site override: specify custom country and city + const located = await t.geoLocate({ country: 'GB', city: 'London' }); + expect(located.geo?.country).toBe('GB'); // Option C wins + expect(located.geo?.city).toBe('London'); // Option C wins + expect(located.geo?.latitude).toBe(37.422); + expect(located.geo?.longitude).toBe(-122.085); + }); + + it('should support setTimezone: false opt-out to retain original timezone', async () => { + const t = new Tempo('2026-01-01T12:00:00Z', { timeZone: 'UTC' }); + + const mockPayload = { + ip: '1.1.1.1', + success: true, + lat: 35.6762, + lon: 139.6503, + city: 'Tokyo', + country: 'Japan', + timezone: 'Asia/Tokyo', + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockPayload), { status: 200 }) + ); + + const located = await t.geoLocate({ setTimezone: false }); + expect(located.tz).toBe('UTC'); // Original tz preserved + expect(located.geo?.latitude).toBe(35.676); + expect(located.geo?.city).toBe('Tokyo'); + expect(located.geo?.timezone).toBe('Asia/Tokyo'); }); }); @@ -191,8 +268,9 @@ describe('Tempo Plugin: Geo', () => { expect(typeof Tempo.geo.get).toBe('function'); Tempo.geo.stash({ latitude: -33.8688, longitude: 151.2093, city: 'Sydney' }); expect(Tempo.geo.current).toEqual({ - latitude: -33.8688, - longitude: 151.2093, + latitude: -33.869, + longitude: 151.209, + sphere: 'south', city: 'Sydney', }); Tempo.geo.clear(); @@ -217,17 +295,17 @@ describe('Tempo Plugin: Geo', () => { ); const first = await geoLookup(); - expect(first.lat).toBe(34.0522); + expect(first.lat).toBe(34.052); expect(mockFetch).toHaveBeenCalledTimes(1); // Second lookup should be served from BoundedCache (zero network calls) const cached = await geoLookup(); - expect(cached.lat).toBe(34.0522); + expect(cached.lat).toBe(34.052); expect(mockFetch).toHaveBeenCalledTimes(1); // Calling with { refresh: true } should bypass cache const refreshed = await geoLookup({ refresh: true }); - expect(refreshed.lat).toBe(34.0522); + expect(refreshed.lat).toBe(34.052); expect(mockFetch).toHaveBeenCalledTimes(2); }); @@ -236,13 +314,15 @@ describe('Tempo Plugin: Geo', () => { stashGeo({ latitude: 48.8566, longitude: 2.3522, city: 'Paris' }, undefined, 'tenant-fr'); expect(getStashedGeo('tenant-uk')).toEqual({ - latitude: 51.5074, - longitude: -0.1278, + latitude: 51.507, + longitude: -0.128, + sphere: 'north', city: 'London', }); expect(getStashedGeo('tenant-fr')).toEqual({ - latitude: 48.8566, - longitude: 2.3522, + latitude: 48.857, + longitude: 2.352, + sphere: 'north', city: 'Paris', }); @@ -266,10 +346,96 @@ describe('Tempo Plugin: Geo', () => { ); const result = await Tempo.geo.lookup(); - expect(result.lat).toBe(-37.8136); - expect(result.lng).toBe(144.9631); + expect(result.lat).toBe(-37.814); + expect(result.lng).toBe(144.963); expect(result.city).toBe('Melbourne'); expect(Tempo.geo.current?.city).toBe('Melbourne'); }); }); + + describe('Great-Circle Distance & Layout Formatting', () => { + const sydney = new Tempo('2026-10-24T10:00:00', { + geo: { + latitude: -33.8688, + longitude: 151.2093, + city: 'sydney', + country: 'au', + sphere: 'south', + elevation: 42, + }, + }); + + const melbourne = new Tempo('2026-10-24T12:00:00', { + geo: { + latitude: -37.8136, + longitude: 144.9631, + city: 'melbourne', + country: 'au', + sphere: 'south', + }, + }); + + it('should calculate distance via pure haversineDistance function', () => { + const dist = haversineDistance(sydney, melbourne, 'km'); + expect(dist).toBe(713.426); + }); + + it('should calculate distance via Tempo.geo.distance static method', () => { + const km = Tempo.geo.distance(sydney, melbourne, 'km'); + expect(km).toBe(713.426); + + const miles = Tempo.geo.distance(sydney, melbourne, 'miles'); + expect(miles).toBe(443.303); + + const meters = Tempo.geo.distance(sydney, melbourne, 'm'); + expect(meters).toBe(713426); + }); + + it('should calculate distance via instance method t.geoDistance()', () => { + const km = sydney.geoDistance(melbourne, 'km'); + expect(km).toBe(713.426); + + const miles = sydney.geoDistance(melbourne, 'miles'); + expect(miles).toBe(443.303); + }); + + it('should detect velocity / impossible travel between timestamped instances', () => { + const km = sydney.geoDistance(melbourne, 'km'); + const hours = sydney.until(melbourne, 'hours'); + expect(hours).toBe(2); + + const speedKmH = km / hours; + expect(speedKmH).toBeCloseTo(356.713, 2); + }); + + it('should format geographic layout tokens with modifiers and fallback on geo-enabled instances', () => { + const formatted = sydney.format('{geo.city:title}, {geo.country:upper} ({geo.sphere}) @ {h12}:{mi} {mer}'); + expect(formatted).toBe('Sydney, AU (south) @ 10:00 am'); + + // Missing custom token cleanly returns empty string + expect(sydney.format('{geo.venue}')).toBe(''); + }); + + it('should calculate natural solar time offset via Tempo.geo.solarOffset and t.geoSolarOffset()', () => { + // Sydney on October 24 observes AEDT (UTC+11, meridian 165°) -> (151.209 - 165) * 4 = -55.16 min + const dstOffset = sydney.geoSolarOffset(); + expect(dstOffset).toBe(-55.16); + + // Sydney on standard time June 21 observes AEST (UTC+10, meridian 150°) -> (151.209 - 150) * 4 = +4.84 min + const staticOffset = Tempo.geo.solarOffset(sydney, { date: '2026-06-21T12:00:00Z' }); + expect(staticOffset).toBe(4.84); + + const instanceOffset = sydney.geoSolarOffset({ date: '2026-06-21T12:00:00Z' }); + expect(instanceOffset).toBe(4.84); + + // Unit conversions on standard time + expect(sydney.geoSolarOffset({ date: '2026-06-21T12:00:00Z', unit: 'seconds' })).toBe(290.16); + expect(sydney.geoSolarOffset({ date: '2026-06-21T12:00:00Z', unit: 'hours', precision: 3 })).toBe(0.081); + + // Apparent solar time on June 21 (incorporating Equation of Time: ~ -1.33 min) + const apparent = sydney.geoSolarOffset({ date: '2026-06-21T12:00:00Z', apparent: true }); + expect(typeof apparent).toBe('number'); + expect(apparent).toBe(3.51); + }); + }); }); diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index e2dff00f..b87b1c67 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,24 @@ 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). +## [4.2.0] - 2026-09-09 + +### Added +- **Dynamic Namespace & Spatial Layout Format Tokens (`{namespace.key}`)**: + - Generalized token resolution in `t.format()` to dynamically interpolate any active plugin namespace (`t.finance`, `t.astro`, `t.term`) or custom property container attached to the `Tempo` instance. + - Dedicated spatial formatting: `{geo.city}`, `{geo.country}`, `{geo.sphere}`, `{geo.elevation}`, `{geo.timezone}`, and custom location metadata (e.g. `{geo.venue}`). + - Full compatibility with format modifiers: e.g. `{geo.country:upper}`, `{geo.city:title}`, `{finance.taxYear}`. + - Graceful fallback: Safely resolves missing properties in known contexts (like `geo`) to `''` (empty string) while leaving unhandled tokens intact. + - Compile-time format validation: Updated `_ValidToken` in `tempo.type.ts` to accept `${string}.${string}`. +- **Universal Geographic Context in Parsing & Construction**: + - Support for `options.geo` (`latitude`, `longitude`, `city`, `country`, `elevation`, `timezone`, etc.) in `new Tempo(input, options)` and `parse(input, options)`. + - Enforces deterministic 3-decimal rounding and Earth boundary limits ($\text{lat} \in [-90, 90]$, $\text{lng} \in [-180, 180]$) on input coordinates. + - Inferred hemisphere (`'north'`, `'south'`, `'equator'`) automatically derived and exposed on `t.sphere` and `t.geo.sphere`. + - Canonical read-only getter `t.geo` returning frozen `GeoConfig`. +- **Equator Compass Alignment**: + - Added `Equator: 'equator'` to `COMPASS` enum. + - Updated `t.sphere` to return `'equator'` when coordinates fall within the $\pm 0.001^\circ$ equatorial band. + ## [4.1.1] - 2026-09-07 ### Added diff --git a/packages/tempo/doc/2-core-concepts/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md index e304cfb6..f1492c2e 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.config.md +++ b/packages/tempo/doc/2-core-concepts/tempo.config.md @@ -304,6 +304,39 @@ When a `Tempo` instance is constructed: --- +### 4.2 Geographic Coordinates & Location Configuration (`geo`) + +You can configure geographic coordinates and location metadata globally or override them on individual instances: + +```typescript +// Global baseline in tempo.config.ts or Tempo.init(): +Tempo.init({ + geo: { + lat: -33.8688, + lng: 151.2093, + city: 'Sydney', + country: 'Australia' + } +}); + +// Or per-instance override: +const t = new Tempo('now', { + geo: { + lat: 51.5074, + lng: -0.1278, + city: 'London', + country: 'United Kingdom' + } +}); +``` + +* **Deterministic Validation**: Latitudes outside $[-90, 90]$ and longitudes outside $[-180, 180]$ are rejected. +* **3-Decimal Precision**: Automatically rounded (`Math.round(val * 1000) / 1000`) to guarantee cache hit consistency. +* **Hemisphere Inference**: Automatically sets `t.sphere` (`'north'`, `'south'`, or `'equator'`). +* **Canonical Access**: Read through `t.geo` (recursively frozen `GeoConfig`) and `t.sphere`. + +--- + ## 5. Advanced Parsing Rules Beyond basic settings, Tempo's parsing engine can be extended with custom rules and behaviors to handle specialized natural language or high-volume processing requirements. diff --git a/packages/tempo/doc/2-core-concepts/tempo.format.md b/packages/tempo/doc/2-core-concepts/tempo.format.md index e44b3031..dddfb369 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.format.md +++ b/packages/tempo/doc/2-core-concepts/tempo.format.md @@ -187,6 +187,57 @@ t.format('{wkd:lower} afternoon'); --- +## 📍 Dynamic Namespace & Spatial Tokens (`{namespace.key}`) + +Tempo's format engine dynamically resolves bracketed dot-notated tokens (`{namespace.key}`) against any active plugin namespace or property container on the `Tempo` instance (such as `t.geo`, `t.finance`, `t.astro`, `t.term`, or custom attached metadata). + +### Geographic Tokens (`{geo.*}`) +When geographic coordinates or metadata are attached to a Tempo instance (via `options.geo`, `new Tempo('...', { geo: { city: 'Sydney', country: 'AU', lat: -33.8688, lng: 151.2093 } })`, or resolved via `t.geoLocate()`), you can format location fields directly using `{geo.}` tokens: + +| Token | Description | Example | +| :--- | :--- | :--- | +| `{geo.city}` | Locality or City name | `Sydney` | +| `{geo.country}` | ISO Country code or Country name | `AU` / `Australia` | +| `{geo.sphere}` | Hemisphere (`north`, `south`, or `equator`) | `south` | +| `{geo.elevation}`| Altitude / Elevation in meters above sea level | `42` | +| `{geo.timezone}` | IANA Timezone resolved from coordinates | `Australia/Sydney` | +| `{geo.}` | Any custom metadata key attached to `t.geo` | `Opera House` | + +### Custom & Plugin Namespace Tokens +Any namespace plugin mounted via `defineNamespace` or property object on the instance can be formatted directly: + +```typescript +// Plugin namespace (e.g. tempo-plugin-finance): +t.format('Fiscal Quarter: Q{finance.fiscalQuarter} ({finance.taxYear})'); + +// Term plugin (e.g. season or solar terms): +t.format('Current Season: {term.season} · {geo.city:title}'); +``` + +### Modifiers & Graceful Fallback +All standard formatting modifiers apply seamlessly: +* `{geo.country:upper}` → `AU` +* `{geo.city:title}` → `Sydney` +* `{geo.sphere:upper}` → `SOUTH` + +For known property namespaces (like `geo`), if the context is unpopulated or the requested property is missing, the token cleanly resolves to an empty string (`''`) rather than throwing an exception or printing `undefined`: + +```typescript +const t = new Tempo('2026-10-24T15:30:00', { + geo: { city: 'Sydney', country: 'AU', lat: -33.869, lng: 151.209 } +}); + +t.format('{geo.city}, {geo.country} · {h12}:{mi} {mer}'); +// "Sydney, AU · 03:30 pm" + +// Graceful fallback for instances without geo: +const tNoGeo = new Tempo('2026-10-24T15:30:00'); +tNoGeo.format('Time: {hh}:{mi} [{geo.city}]'); +// "Time: 15:30 []" +``` + +--- + ## 🌍 Complex Native Intl Formatting While Tempo's template tokens (`{dd}`, `{mon}`, etc.) combined with the `:locale` modifier are incredibly powerful for structured formats, there are times when you want the full power of the native `Intl.DateTimeFormat` API for complete, culturally-specific sentence formatting (like Arabic numerals or full-length descriptive dates). diff --git a/packages/tempo/doc/2-core-concepts/tempo.parse.md b/packages/tempo/doc/2-core-concepts/tempo.parse.md index 11ff23cf..0c4bbd14 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.parse.md +++ b/packages/tempo/doc/2-core-concepts/tempo.parse.md @@ -324,6 +324,34 @@ Tempo.init({ --- +## 📍 Geographic Context & Coordinates (`geo` Option) + +When constructing a `Tempo` instance or calling `parse()`, you can supply a `geo` object directly in the options: + +```typescript +const t = new Tempo('2026-10-24 15:30', { + geo: { + lat: -33.8688, + lng: 151.2093, + city: 'Sydney', + country: 'Australia' + } +}); + +console.log(t.geo?.latitude); // -33.869 (rounded to 3 decimals) +console.log(t.geo?.longitude); // 151.209 +console.log(t.sphere); // 'south' +``` + +### Deterministic Normalization & Validation +Tempo applies strict validation and standardization to all incoming coordinate payloads with **zero external network dependencies**: +* **Boundary Validation**: Coordinates outside physical Earth limits ($\text{lat} \in [-90, 90]$, $\text{lng} \in [-180, 180]$) are strictly rejected. +* **3-Decimal Precision**: Coordinates are rounded deterministically to 3 decimal places ($\approx 111\,\text{m}$ resolution at the equator), eliminating sensor noise and storage cache fragmentation. +* **Automatic Hemisphere Inference**: The hemisphere (`'north'`, `'south'`, or `'equator'`) is automatically deduced from latitude, recognizing the $\pm 0.001^\circ$ equatorial band. +* **Immutable Access**: Normalized geographic metadata is accessible on the instance via `t.geo` (recursively frozen `GeoConfig`) and `t.sphere`. + +--- + ## 🛡️ Performance: The Master Guard Tempo uses a "Scan-and-Consume" engine called the **Master Guard**. This allows it to check your input string against dozens of patterns (weekdays, months, custom events) in a single pass. diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 92d6fc42..bf34bab6 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "4.1.2", + "version": "4.2.0", "engines": { "node": ">=20.0.0" }, diff --git a/packages/tempo/public/esm_sh.index.html b/packages/tempo/public/esm_sh.index.html index 48a0f6af..bf661927 100644 --- a/packages/tempo/public/esm_sh.index.html +++ b/packages/tempo/public/esm_sh.index.html @@ -270,7 +270,7 @@

Tempo

{ "imports": { "@js-temporal/polyfill": "https://esm.sh/@js-temporal/polyfill@0.5.1", - "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.1.2" + "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@4.2.0" } } diff --git a/packages/tempo/public/llms.txt b/packages/tempo/public/llms.txt index d519347f..d34b9e8a 100644 --- a/packages/tempo/public/llms.txt +++ b/packages/tempo/public/llms.txt @@ -1,6 +1,6 @@ -# Tempo: Immutable Date-Time Engine & AI Syntax Rules (v4.1.2) +# Tempo: Immutable Date-Time Engine & AI Syntax Rules (v4.2.0) -> Tempo (v4.1.2) is an immutable TypeScript date-time engine built around the ECMAScript Temporal API. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments. +> Tempo (v4.2.0) is an immutable TypeScript date-time engine built around the ECMAScript Temporal API. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments. ## Core Architectural Rules & Philosophy - **Temporal Engine**: Tempo uses native `Temporal` in modern runtimes or `@js-temporal/polyfill`. Never instantiate legacy JavaScript `Date`. diff --git a/packages/tempo/src/module/module.format.ts b/packages/tempo/src/module/module.format.ts index fceb438a..b07141d1 100644 --- a/packages/tempo/src/module/module.format.ts +++ b/packages/tempo/src/module/module.format.ts @@ -2,7 +2,7 @@ import '#library/temporal.polyfill.js'; import { pad, toTitleCase } from '#library/string.library.js'; import { deepMerge } from '#library/object.library.js'; import { suffix } from '#library/number.library.js'; -import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined, isDefined, isFunction } from '#library/assertion.library.js'; +import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined, isDefined, isFunction, isSafeKey, isNullish } from '#library/assertion.library.js'; import { formatDayPeriod, getDTF, getPR, getISOWeekOfYear } from '#library/international.library.js'; import { delegator } from '#library/proxy.library.js'; @@ -34,6 +34,32 @@ declare module '../tempo.class.js' { } } +/** + * Resolves dot-delimited namespace tokens (e.g. '{geo.city}', '{custom.tag.name}') + * against a Tempo instance with safety guards and graceful fallback. + */ +function resolveNamespaceToken(obj: unknown, token: string): string { + const parts = token.split('.'); + if (!parts.every(isSafeKey)) return `{${token}}`; + + const [root, ...rest] = parts; + let curr = (obj as any)[root]; + + if (isNullish(curr)) { + return root === 'geo' ? '' : `{${token}}`; + } + + for (const key of rest) { + if (isNullish(curr)) return ''; + curr = curr[key]; + } + + if (isNullish(curr) || isFunction(curr)) return ''; + return isObject(curr) + ? String(curr.label ?? curr.key ?? curr.name ?? curr.value ?? curr) + : String(curr); +} + /** * Standalone Formatter * Returns a formatted string from a Temporal.ZonedDateTime or Tempo instance. @@ -254,11 +280,11 @@ export function format(obj?: any, fmt?: any, options?: any): any { res = customTokenFn(zdt, { modifiers, config }); } else if (token.startsWith('#') && isTempo(obj)) { const termObj = (obj as unknown as Tempo).term[token.slice(1)]; - if (isObject(termObj)) { - res = termObj.label ?? termObj.key ?? `{${token}}`; - } else { - res = termObj ?? `{${token}}`; - } + res = isObject(termObj) + ? (termObj.label ?? termObj.key ?? `{${token}}`) + : (termObj ?? `{${token}}`); + } else if (token.includes('.') && isTempo(obj)) { + res = resolveNamespaceToken(obj, token); } else { res = `{${token}}`; } diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index a2637b1c..cd2e656e 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -10,7 +10,7 @@ export const SEASON = enumify({ /** Spring season */ Spring: 'spring', /** Summer season */ Summer: 'summer', /** Autumn season */ Autumn: 'autumn', - /** Winter season */ Winter: 'winter' + /** Winter season */ Winter: 'winter', }, false); export type SEASON = ValueOf @@ -19,7 +19,8 @@ export const COMPASS = looseIndex()(enumify({ /** North direction */ North: 'north', /** South direction */ South: 'south', /** East direction */ East: 'east', - /** West direction */ West: 'west' + /** West direction */ West: 'west', + /** Equator zone */ Equator: 'equator', }, false)); export type COMPASS = ValueOf @@ -41,7 +42,7 @@ export const DEFAULTS = { /** number value 7 */ seven: 7, /** number value 8 */ eight: 8, /** number value 9 */ nine: 9, - /** number value 10 */ ten: 10 + /** number value 10 */ ten: 10, }, DURATION: { /** approx number of seconds in a year */ year: 31_536_000, diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 7f3ea26a..7ff54e90 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -239,7 +239,7 @@ export class Tempo { const geo = coerceGeo(options) ?? shape.config.geo ?? getStashedGeo(); if (isDefined(geo?.sphere)) return geo.sphere as t.COMPASS; if (isNumber(geo?.latitude)) - return geo.latitude >= 0 ? 'north' : 'south'; + return (geo.latitude > 0.001 ? 'north' : (geo.latitude < -0.001 ? 'south' : 'equator')) as t.COMPASS; const resolvedTz = options.timeZone ?? shape.config.timeZone; if (isDefined(resolvedTz) && String(resolvedTz).toLowerCase() !== 'utc') { @@ -1664,7 +1664,7 @@ export class Tempo { const res = evaluate( this.#local.options && hasOwn(this.#local.options, 'sphere') ? this.#local.options.sphere : undefined, geoSphere, - isNumber(lat) ? (lat >= 0 ? 'north' : 'south') : undefined, + isNumber(lat) ? (lat > 0.001 ? 'north' : (lat < -0.001 ? 'south' : 'equator')) : undefined, hasInstanceTzOverride ? () => getHemisphere(String(this.tz)) : undefined, hasOwn(this.#local.config, 'sphere') ? this.#local.config.sphere : undefined, () => (isDefined(this.tz) && String(this.tz).toLowerCase() !== 'utc' ? getHemisphere(String(this.tz)) : undefined), @@ -1876,12 +1876,25 @@ export class Tempo { setProperty(this.#local.config, 'sphere', evalSphere); } + const explicitGeo = coerceGeo(options); + if (isDefined(explicitGeo)) { + setProperty(this.#local.config, 'geo', explicitGeo); + this.#local.userProvidedKeys.add('geo'); + if (isUndefined(evalSphere) && isDefined(explicitGeo.sphere)) { + evalSphere = explicitGeo.sphere as t.COMPASS; + setProperty(this.#local.config, 'sphere', evalSphere); + } + } else if (classState.userProvidedKeys?.has('geo')) { + setProperty(this.#local.config, 'geo', classState.config.geo); + } + const optionsSnapshot = { ...options, ...(isDefined(explicitTz) ? { timeZone: resolvedZone } : {}), ...(isDefined(explicitCal) ? { calendar: String(evaluatedCal) } : {}), ...(isDefined(explicitLoc) ? { locale: finalLocale } : {}), ...(isDefined(explicitSphere) ? { sphere: explicitSphere } : {}), + ...(isDefined(explicitGeo) ? { geo: explicitGeo } : {}), }; this.#local.options = optionsSnapshot; diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 2eee79b2..415bd7ce 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -240,8 +240,9 @@ type _CoreToken = keyof TempoFormatTokens; * - a core token alone: `{yyyy}` * - a core token with one or more modifiers: `{dd:ord}`, `{tz:zzzzz}` * - a term-plugin key (always accepted, cannot be statically known): `{#season.key}` + * - a dynamic namespace or property path token: `{geo.city}`, `{finance.taxYear}`, `{term.quarter}` */ -type _ValidToken = _CoreToken | `${_CoreToken}:${string}` | `#${string}` | `term.${string}`; +type _ValidToken = _CoreToken | `${_CoreToken}:${string}` | `#${string}` | `${string}.${string}`; /** * Compile-time validator for Tempo format strings. diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 59fe4e59..c45a4a32 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 = '4.1.2'; +export const TEMPO_VERSION = '4.2.0'; diff --git a/packages/tempo/test/core/constructor.core.test.ts b/packages/tempo/test/core/constructor.core.test.ts index cb2f63a9..5927995d 100644 --- a/packages/tempo/test/core/constructor.core.test.ts +++ b/packages/tempo/test/core/constructor.core.test.ts @@ -74,14 +74,20 @@ describe('Tempo Core', () => { it('infers sphere as south when latitude is negative', () => { const t = new Tempo('2026-09-02', { geo: { lat: -33.8688, lng: 151.2093 } }); expect(t.sphere).toBe('south'); - expect(t.geo).toEqual({ latitude: -33.8688, longitude: 151.2093 }); + expect(t.geo).toEqual({ latitude: -33.869, longitude: 151.209, sphere: 'south' }); expect(Object.isFrozen(t.geo)).toBe(true); }); it('infers sphere as north when latitude is positive', () => { const t = new Tempo('2026-09-02', { geo: { latitude: 40.7128, longitude: -74.006 } }); expect(t.sphere).toBe('north'); - expect(t.geo).toEqual({ latitude: 40.7128, longitude: -74.006 }); + expect(t.geo).toEqual({ latitude: 40.713, longitude: -74.006, sphere: 'north' }); + }); + + it('infers sphere as equator when latitude is within +/- 0.001', () => { + const t = new Tempo('2026-09-02', { geo: { lat: 0, lng: 100 } }); + expect(t.sphere).toBe('equator'); + expect(t.geo).toEqual({ latitude: 0, longitude: 100, sphere: 'equator' }); }); it('allows explicit sphere to override latitude inference', () => { @@ -93,14 +99,14 @@ describe('Tempo Core', () => { Tempo.init({ geo: { latitude: -33.8688, longitude: 151.2093 } }); const t = new Tempo('2026-09-02'); expect(t.sphere).toBe('south'); - expect(t.geo).toEqual({ latitude: -33.8688, longitude: 151.2093 }); + expect(t.geo).toEqual({ latitude: -33.869, longitude: 151.209, sphere: 'south' }); }); it('infers sphere from sandbox Tempo.create config', () => { const CustomTempo = Tempo.create({ geo: { latitude: -33.8688, longitude: 151.2093 } }); const t = new CustomTempo('2026-09-02'); expect(t.sphere).toBe('south'); - expect(t.geo).toEqual({ latitude: -33.8688, longitude: 151.2093 }); + expect(t.geo).toEqual({ latitude: -33.869, longitude: 151.209, sphere: 'south' }); }); }); }); diff --git a/packages/tempo/test/instance/instance.format.test.ts b/packages/tempo/test/instance/instance.format.test.ts index 4765e248..15bbf0b5 100644 --- a/packages/tempo/test/instance/instance.format.test.ts +++ b/packages/tempo/test/instance/instance.format.test.ts @@ -132,4 +132,63 @@ describe(`${label} format method`, () => { expect(t.format('{ymd:year}')).toBe('240520'); }); + test('formats spatial tokens {geo.*} with modifiers and graceful fallback', () => { + const tWithGeo = new Tempo('2026-10-24T15:30:00', { + geo: { + latitude: -33.8688, + longitude: 151.2093, + city: 'sydney', + country: 'au', + sphere: 'south', + elevation: 42, + timezone: 'Australia/Sydney', + venue: 'opera house', + }, + }); + + expect(tWithGeo.format('{geo.city}')).toBe('sydney'); + expect(tWithGeo.format('{geo.city:title}')).toBe('Sydney'); + expect(tWithGeo.format('{geo.country:upper}')).toBe('AU'); + expect(tWithGeo.format('{geo.sphere}')).toBe('south'); + expect(tWithGeo.format('{geo.elevation}')).toBe('42'); + expect(tWithGeo.format('{geo.venue:title}')).toBe('Opera house'); + expect(tWithGeo.format('{geo.city:title}, {geo.country:upper} · {h12}:{mi} {mer}')).toBe('Sydney, AU · 03:30 pm'); + + // Missing property returns empty string + expect(tWithGeo.format('{geo.nonexistent}')).toBe(''); + + // Instance without geo returns empty string without error + const tNoGeo = new Tempo('2026-10-24T15:30:00'); + expect(tNoGeo.format('{geo.city}')).toBe(''); + expect(tNoGeo.format('{geo.country}')).toBe(''); + expect(tNoGeo.format('Time: {hh}:{mi} [{geo.city}]')).toBe('Time: 15:30 []'); + }); + + test('dynamically resolves arbitrary {namespace.key} properties on instance', () => { + Object.defineProperty(Tempo.prototype, 'custom', { + value: { + project: 'apollo', + iteration: 11, + tag: { name: 'core-team' }, + }, + configurable: true, + writable: true, + }); + + try { + const t = new Tempo('2026-10-24T15:30:00'); + expect(t.format('Project: {custom.project:upper} (Sprint {custom.iteration})')).toBe('Project: APOLLO (Sprint 11)'); + expect(t.format('{custom.tag.name}')).toBe('core-team'); + // Undefined property in known container resolves cleanly to empty string + expect(t.format('{custom.missing}')).toBe(''); + // Entirely unknown namespace without object on instance stays intact + expect(t.format('{unknown.token}')).toBe('{unknown.token}'); + // Security: Prototype pollution or unsafe keys are rejected and left intact + expect(t.format('{__proto__.polluted}')).toBe('{__proto__.polluted}'); + expect(t.format('{constructor.name}')).toBe('{constructor.name}'); + } finally { + delete (Tempo.prototype as any).custom; + } + }); + }); From 154636caa731ad0a8a8aec1ede2a2762580e1f51 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 9 Sep 2026 15:29:27 +1000 Subject: [PATCH 09/11] PR 5th review --- packages/functions/CHANGELOG.md | 6 +- packages/functions/src/celestial/index.ts | 4 +- packages/functions/test/celestial.test.ts | 18 +++ .../src/common/runtime/mapper.library.ts | 19 +-- .../test/common/runtime/mapper.common.test.ts | 28 ++++- packages/plugins/.setup/catalog.json | 4 +- packages/plugins/celestial/CHANGELOG.md | 6 + packages/plugins/celestial/README.md | 3 - packages/plugins/celestial/doc/index.md | 3 - packages/plugins/celestial/package.json | 2 +- packages/plugins/geo/test/geo.test.ts | 4 + .../tempo/.vitepress/theme/data/catalog.json | 4 +- packages/tempo/CHANGELOG.md | 6 + .../tempo/doc/2-core-concepts/tempo.config.md | 3 +- packages/tempo/src/config/config.resolve.ts | 118 +++++++++++++----- 15 files changed, 168 insertions(+), 60 deletions(-) diff --git a/packages/functions/CHANGELOG.md b/packages/functions/CHANGELOG.md index c40e4621..28509222 100644 --- a/packages/functions/CHANGELOG.md +++ b/packages/functions/CHANGELOG.md @@ -5,9 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.2.0] - 2026-09-02 +## [0.2.0] - 2026-09-09 ### Added +- **Elevation Horizon Dip**: Integrated observer elevation (meters above sea level) into `getSunriseSunset()` apparent solar timing calculations: + - Added optional `elevation` parameter to `SolarOptions` and included resolved `elevation` in `SunriseSunsetResult`. + - Factors atmospheric horizon dip ($\Delta\theta \approx 0.0347^\circ \times \sqrt{\max(0, \text{elevation})}$) into the solar zenith angle ($90.833^\circ + \Delta\theta$). + - Correctly shifts sunrise earlier, sunset later, and expands daylight duration for elevated observers while keeping true solar noon transit invariant. - **Celestial Utilities**: Introduced new pure astronomical, celestial, solar, lunar, and zodiac utility module (`@magmacomputing/tempo-fns/celestial`): - `getLunarPhase`: Calculates lunar phase name, 1-based index (1..8), illumination 0.0–1.0 fraction, age in days, waxing status, and hemisphere-aware emojis. - `getLunarPhaseRange`: Resolves start/end boundaries for active lunar phase cycles. diff --git a/packages/functions/src/celestial/index.ts b/packages/functions/src/celestial/index.ts index 6058fd17..ddd04798 100644 --- a/packages/functions/src/celestial/index.ts +++ b/packages/functions/src/celestial/index.ts @@ -474,7 +474,7 @@ function resolveCoordinates(latOrOptions: number | SolarOptions = 0, lngInput = const lat = geo.latitude ?? geo.lat ?? (latOrOptions as any).latitude ?? (latOrOptions as any).lat ?? 0; const lng = geo.longitude ?? geo.lng ?? geo.lon ?? geo.long ?? (latOrOptions as any).longitude ?? (latOrOptions as any).lng ?? (latOrOptions as any).lon ?? (latOrOptions as any).long ?? 0; const rawElevation = geo.elevation ?? (latOrOptions as any).elevation; - const elevation = typeof rawElevation === 'number' && !isNaN(rawElevation) ? rawElevation : 0; + const elevation = typeof rawElevation === 'number' && Number.isFinite(rawElevation) ? rawElevation : 0; return { lat, lng, elevation }; } return { lat: 0, lng: 0, elevation: 0 }; @@ -586,7 +586,7 @@ export function getSunriseSunset( return { latitude: lat, longitude: lng, - ...(elevation > 0 ? { elevation } : {}), + elevation, sunriseMs, sunsetMs, solarNoonMs, diff --git a/packages/functions/test/celestial.test.ts b/packages/functions/test/celestial.test.ts index cc502b8f..0d3352c3 100644 --- a/packages/functions/test/celestial.test.ts +++ b/packages/functions/test/celestial.test.ts @@ -103,6 +103,24 @@ describe('Astro Pure Functions (tempo-fns)', () => { expect(highAltitude.elevation).toBe(1600); }); + it('handles non-finite elevation gracefully and produces valid sunrise/sunset timings', () => { + const date = '2026-06-21T12:00:00Z'; + const baseline = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: 0 }); + const infResult = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: Infinity }); + const negInfResult = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: -Infinity }); + const nanResult = getSunriseSunset(date, { lat: 39.7392, lng: -104.9903, elevation: NaN }); + + expect(infResult.elevation).toBe(0); + expect(Number.isFinite(infResult.sunriseMs)).toBe(true); + expect(Number.isFinite(infResult.sunsetMs)).toBe(true); + expect(Number.isFinite(infResult.daylightDurationMs)).toBe(true); + expect(infResult.sunriseMs).toBe(baseline.sunriseMs); + expect(infResult.sunsetMs).toBe(baseline.sunsetMs); + + expect(negInfResult.elevation).toBe(0); + expect(nanResult.elevation).toBe(0); + }); + it('calculates Western Tropical Zodiac sign', () => { expect(getZodiacSign('2026-03-25')).toBe('Aries'); expect(getZodiacSign('2026-07-25')).toBe('Leo'); diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts index 7b00aad7..98710186 100644 --- a/packages/library/src/common/runtime/mapper.library.ts +++ b/packages/library/src/common/runtime/mapper.library.ts @@ -246,10 +246,11 @@ export const coerceGeo = (input?: any): GeoConfig | undefined => { if (isString(city)) result.city = city; if (isString(timezone)) result.timezone = timezone; - if (isReference(geo)) { - for (const key of Object.keys(geo)) { + const explicitGeo = isReference(input.geo) ? input.geo : (isReference(input.config?.geo) ? input.config.geo : undefined); + if (isReference(explicitGeo)) { + for (const key of Object.keys(explicitGeo)) { if (isSafeKey(key) && !GEO_PROPERTIES.includes(key as any)) - (result as any)[key] = geo[key]; + (result as any)[key] = explicitGeo[key]; } } @@ -591,10 +592,14 @@ export function solarOffset(coords: any, options?: SolarOffsetOptions): number { }).formatToParts(new Date(epochMs)); const tzPart = parts.find(p => p.type === 'timeZoneName')?.value; if (tzPart) { - const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/); - if (match) { - const sign = match[1] === '-' ? -1 : 1; - offsetMinutes = sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10)); + if (tzPart === 'GMT' || tzPart === 'UTC') { + offsetMinutes = 0; + } else { + const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/); + if (match) { + const sign = match[1] === '-' ? -1 : 1; + offsetMinutes = sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10)); + } } } } diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts index 59a7d01f..d2fb1b77 100644 --- a/packages/library/test/common/runtime/mapper.common.test.ts +++ b/packages/library/test/common/runtime/mapper.common.test.ts @@ -66,6 +66,21 @@ describe('common/runtime/mapper.library', () => { expect(({} as any).polluted).toBeUndefined(); }); + it('coerceGeo only copies custom non-geo keys when an explicit geo object is provided', () => { + const directWithCustom = { latitude: 10, longitude: 20, venue: 'HQ', locale: 'en' }; + const coercedDirect = coerceGeo(directWithCustom); + expect(coercedDirect?.latitude).toBe(10); + expect(coercedDirect?.longitude).toBe(20); + expect((coercedDirect as any)?.venue).toBeUndefined(); + expect((coercedDirect as any)?.locale).toBeUndefined(); + + const explicitWithCustom = { geo: { latitude: 10, longitude: 20, venue: 'HQ' } }; + const coercedExplicit = coerceGeo(explicitWithCustom); + expect(coercedExplicit?.latitude).toBe(10); + expect(coercedExplicit?.longitude).toBe(20); + expect((coercedExplicit as any)?.venue).toBe('HQ'); + }); + it('resolveGeoCoordinates falls back to getStashedGeo when input only has metadata without coordinates', async () => { const fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); @@ -273,9 +288,9 @@ describe('common/runtime/mapper.library', () => { describe('solarOffset', () => { it('calculates natural solar time offset for civil timezone in minutes', () => { - // Sydney (lng 151.209, AEST UTC+10, meridian 150°) -> (151.209 - 150) * 4 = +4.84 min + // Sydney on standard time (lng 151.209, AEST UTC+10, meridian 150°) -> (151.209 - 150) * 4 = +4.84 min const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' }; - expect(solarOffset(sydney)).toBe(4.84); + expect(solarOffset(sydney, { date: '2026-06-21T12:00:00Z' })).toBe(4.84); // Denver on standard time (lng -104.99, MST UTC-7, meridian -105°) -> (-104.99 - -105) * 4 = +0.04 min const denver = { lat: 39.7392, lng: -104.9903, timezone: 'America/Denver' }; @@ -284,8 +299,13 @@ describe('common/runtime/mapper.library', () => { it('supports unit conversion to seconds and hours with configurable precision', () => { const sydney = { lat: -33.8688, lng: 151.2093, timezone: 'Australia/Sydney' }; - expect(solarOffset(sydney, { unit: 'seconds' })).toBe(290.16); - expect(solarOffset(sydney, { unit: 'hours', precision: 3 })).toBe(0.081); + expect(solarOffset(sydney, { date: '2026-06-21T12:00:00Z', unit: 'seconds' })).toBe(290.16); + expect(solarOffset(sydney, { date: '2026-06-21T12:00:00Z', unit: 'hours', precision: 3 })).toBe(0.081); + }); + + it('handles plain GMT and UTC timezones correctly', () => { + expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'UTC' })).toBe(0); + expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'GMT' })).toBe(0); }); it('falls back to natural 15-degree solar timezone meridian when no timezone is supplied', () => { diff --git a/packages/plugins/.setup/catalog.json b/packages/plugins/.setup/catalog.json index 5a5cab59..3b54a6e8 100644 --- a/packages/plugins/.setup/catalog.json +++ b/packages/plugins/.setup/catalog.json @@ -14,8 +14,8 @@ "description": "Tempo plugin for location-aware solar twilight events and lunar phase calculations", "packageName": "@magmacomputing/tempo-plugin-celestial", "plan": "community", - "status": "experimental", - "version": "0.1.0" + "status": "active", + "version": "0.2.0" }, { "id": "batch", diff --git a/packages/plugins/celestial/CHANGELOG.md b/packages/plugins/celestial/CHANGELOG.md index 22319578..224c4850 100644 --- a/packages/plugins/celestial/CHANGELOG.md +++ b/packages/plugins/celestial/CHANGELOG.md @@ -5,6 +5,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). +## [0.2.0] - 2026-09-09 + +### Added +- **Elevation Support on Solar Term**: Exposed `elevation: number | null` on `t.term.solar` ([`TempoTermRegistry['solar']`](file:///home/michael/Project/magma/packages/plugins/celestial/src/index.ts#L52)). +- **Atmospheric Horizon Dip**: Factored `t.geo.elevation` into solar sunrise, sunset, and daylight duration calculations via `@magmacomputing/tempo-fns`. Observers at high elevations experience earlier sunrise and later sunset due to geometric horizon dip. + ## [0.1.0] - 2026-09-02 ### Added diff --git a/packages/plugins/celestial/README.md b/packages/plugins/celestial/README.md index 2508e008..882a54c8 100644 --- a/packages/plugins/celestial/README.md +++ b/packages/plugins/celestial/README.md @@ -2,9 +2,6 @@ # @magmacomputing/tempo-plugin-celestial -> [!WARNING] -> **Experimental Plugin**: `@magmacomputing/tempo-plugin-celestial` is currently in active development (`v0.1.0`). Ephemeris calculation algorithms and API properties are subject to refinement prior to a stable `1.0.0` release. -

npm version npm peer dependency version License TypeScript Ready

diff --git a/packages/plugins/celestial/doc/index.md b/packages/plugins/celestial/doc/index.md index 329660c2..100f5b3c 100644 --- a/packages/plugins/celestial/doc/index.md +++ b/packages/plugins/celestial/doc/index.md @@ -2,9 +2,6 @@ # @magmacomputing/tempo-plugin-celestial -> [!WARNING] -> **Experimental Plugin**: `@magmacomputing/tempo-plugin-celestial` is currently in active development (`v0.1.0`). Ephemeris calculation algorithms and API properties are subject to refinement prior to a stable `1.0.0` release. -

npm version npm peer dependency version License TypeScript Ready

diff --git a/packages/plugins/celestial/package.json b/packages/plugins/celestial/package.json index 7b2befcb..4ade3463 100644 --- a/packages/plugins/celestial/package.json +++ b/packages/plugins/celestial/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-celestial", - "version": "0.1.0", + "version": "0.2.0", "description": "Tempo plugin for location-aware solar twilight events and lunar phase calculations", "type": "module", "main": "dist/index.js", diff --git a/packages/plugins/geo/test/geo.test.ts b/packages/plugins/geo/test/geo.test.ts index 519b595b..9f4054fc 100644 --- a/packages/plugins/geo/test/geo.test.ts +++ b/packages/plugins/geo/test/geo.test.ts @@ -355,6 +355,7 @@ describe('Tempo Plugin: Geo', () => { describe('Great-Circle Distance & Layout Formatting', () => { const sydney = new Tempo('2026-10-24T10:00:00', { + timeZone: 'Australia/Sydney', geo: { latitude: -33.8688, longitude: 151.2093, @@ -362,16 +363,19 @@ describe('Tempo Plugin: Geo', () => { country: 'au', sphere: 'south', elevation: 42, + timezone: 'Australia/Sydney', }, }); const melbourne = new Tempo('2026-10-24T12:00:00', { + timeZone: 'Australia/Melbourne', geo: { latitude: -37.8136, longitude: 144.9631, city: 'melbourne', country: 'au', sphere: 'south', + timezone: 'Australia/Melbourne', }, }); diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index 5a5cab59..3b54a6e8 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -14,8 +14,8 @@ "description": "Tempo plugin for location-aware solar twilight events and lunar phase calculations", "packageName": "@magmacomputing/tempo-plugin-celestial", "plan": "community", - "status": "experimental", - "version": "0.1.0" + "status": "active", + "version": "0.2.0" }, { "id": "batch", diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index b87b1c67..61f8c4d4 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `Equator: 'equator'` to `COMPASS` enum. - Updated `t.sphere` to return `'equator'` when coordinates fall within the $\pm 0.001^\circ$ equatorial band. +### Security +- **Configuration Resolution Decoupling (Socket.dev Anomaly Mitigation)**: + - Strictly decoupled recursive `extends` loading from top-level project config resolution in `config.resolve.ts`. + - Dedicated `resolveExtendsTarget()` function exclusively reads and parses static `.json` and `.jsonc` files via `fs.promises.readFile` and `parseJSONC`, completely eliminating dynamic module imports (`import()`) from the `extends` pipeline. + - Added strict file extension allowlisting (`.json`, `.jsonc`, `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`) to top-level configuration file discovery. + ## [4.1.1] - 2026-09-07 ### Added diff --git a/packages/tempo/doc/2-core-concepts/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md index f1492c2e..51340c21 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.config.md +++ b/packages/tempo/doc/2-core-concepts/tempo.config.md @@ -228,7 +228,8 @@ Tempo.init({ | `pivot` | `number` | `75` | Cutoff for parsing two-digit years. | | `monthDay` | `MonthDay \| boolean` | `undefined` | Regional date-parsing configuration (grouped). Includes `active`, `locales`, `layouts`, and `timezones`. | | `timeStamp`| `'ss' \| 'ms' \| 'us' \| 'ns'` | `'ms'` | Precision for numeric inputs and the `.ts` property. | -| `sphere` | `Evaluable<'north' \| 'south'>`| Auto-inferred | Hemisphere for seasonal plugins or dynamic supplier. | +| `sphere` | `Evaluable<'north' \| 'south' \| 'equator'>`| Auto-inferred | Hemisphere for seasonal plugins or dynamic supplier. | +| `geo` | `GeoOptions` | `undefined` | Geographic coordinates, city, country, elevation, and timezone metadata. | | `intl` | `IntlOptions` | `undefined` | Internationalization configuration grouping `relativeTimeFormat`, `numberFormat`, and `durationFormat`. | | `registry` | `{ formats?, locales?, numbers?, events?, periods?, snippets?, layouts?, ignores?, modifiers? }` | Built-in registries | Custom data augmentation registries (e.g., format aliases, number-to-word mappings, parsing logic, localization). | | `extends` | `string \| string[]` | `undefined` | Local file path(s) or `file://` URL(s) to inherit base configuration from. | diff --git a/packages/tempo/src/config/config.resolve.ts b/packages/tempo/src/config/config.resolve.ts index 3b450977..d9fe865d 100644 --- a/packages/tempo/src/config/config.resolve.ts +++ b/packages/tempo/src/config/config.resolve.ts @@ -189,7 +189,7 @@ async function processExtends( const currentDir = path ? path.dirname(baseLocation) : (typeof process !== 'undefined' ? process.cwd() : ''); const branchLoadedSet = new Set(loadedSet); - const parentConfig = await loadConfigTarget(targetUrlOrPath, currentDir, fs, path, urlMod, branchLoadedSet, depth + 1, budget); + const parentConfig = await resolveExtendsTarget(targetUrlOrPath, currentDir, fs, path, urlMod, branchLoadedSet, depth + 1, budget); if (parentConfig) mergedParentConfig = mergeConfigs(mergedParentConfig, parentConfig); } @@ -199,20 +199,50 @@ async function processExtends( } /** - * Loads and resolves a configuration target from a local path or `file://` URL. - * - * @param target - The configuration path or URL to load - * @param currentDir - The base directory for resolving relative paths - * @returns The resolved configuration, or `undefined` if loading fails or the target is already being processed + * Allowed file extensions for top-level Tempo configuration files. + */ +const ALLOWED_CONFIG_EXTENSIONS = new Set(['.json', '.jsonc', '.js', '.mjs', '.cjs', '.ts', '.mts']); + +/** + * Resolves a local path from a file URL or relative path, ensuring non-remote security. + */ +function resolveLocalConfigPath(target: string, currentDir: string, path?: any, urlMod?: any): string | undefined { + if (isFileUrl(target)) { + try { + const parsed = new URL(target); + if (parsed.hostname && parsed.hostname !== 'localhost') { + console.warn(`[Tempo] Remote file URL with host is not supported: ${target}`); + return undefined; + } + } catch { + console.warn(`[Tempo] Invalid file URL config target: ${target}`); + return undefined; + } + + if (urlMod?.fileURLToPath) + return urlMod.fileURLToPath(target); + return target.replace(/^file:\/\//i, ''); + } + + if (path && !path.isAbsolute(target)) { + return path.resolve(currentDir, target); + } + + return target; +} + +/** + * Strictly loads and resolves a static inherited configuration target for `extends`. + * Config extends only supports static .json and .jsonc data files, NEVER dynamic modules. */ -async function loadConfigTarget( +async function resolveExtendsTarget( target: string, currentDir: string, fs?: any, path?: any, urlMod?: any, loadedSet = new Set(), - depth = 0, + depth = 1, budget: ExtendsBudget = { remaining: MAX_EXTENDS_BUDGET }, ): Promise { if (loadedSet.has(target)) return undefined; @@ -222,52 +252,72 @@ async function loadConfigTarget( return undefined; } - if (depth > 0) { - if (checkAndWarnBudget(budget)) return undefined; - budget.remaining--; - } + if (checkAndWarnBudget(budget)) return undefined; + budget.remaining--; loadedSet.add(target); - let localPath = target; - if (isFileUrl(target)) { + const localPath = resolveLocalConfigPath(target, currentDir, path, urlMod); + if (!localPath) return undefined; + + if (fs && fs.existsSync(localPath)) { try { - const parsed = new URL(target); - if (parsed.hostname && parsed.hostname !== 'localhost') { - console.warn(`[Tempo] Remote file URL with host is not supported: ${target}`); + const ext = path ? path.extname(localPath) : '.json'; + if (ext !== '.json' && ext !== '.jsonc') { + console.warn(`[Tempo] Config extends only supports static .json and .jsonc data files, skipping: ${localPath}`); return undefined; } - } catch { - console.warn(`[Tempo] Invalid file URL config target: ${target}`); - return undefined; + + const content = await fs.promises.readFile(localPath, 'utf8'); + const loaded = parseJSONC(content) as Options; + if (loaded) + return processExtends(loaded, localPath, fs, path, urlMod, loadedSet, depth, budget); + } catch (err) { + console.warn(`[Tempo] Failed to load config file at ${localPath}:`, err); } + } - if (urlMod?.fileURLToPath) - localPath = urlMod.fileURLToPath(target); - else - localPath = target.replace(/^file:\/\//i, ''); - } else if (path && !path.isAbsolute(localPath)) { - localPath = path.resolve(currentDir, localPath); + return undefined; +} + +/** + * Loads a top-level configuration file discovered in the project or passed via `options.configFile`. + * Only permits explicitly supported configuration extensions. + */ +async function loadTopLevelConfig( + target: string, + currentDir: string, + fs?: any, + path?: any, + urlMod?: any, +): Promise { + if (isHttpUrl(target)) { + console.warn(`[Tempo] Remote HTTP(S) config target is not supported: ${target}`); + return undefined; } + const localPath = resolveLocalConfigPath(target, currentDir, path, urlMod); + if (!localPath) return undefined; + if (fs && fs.existsSync(localPath)) { try { const ext = path ? path.extname(localPath) : '.json'; - let loaded: Options | undefined = undefined; + if (!ALLOWED_CONFIG_EXTENSIONS.has(ext)) { + console.warn(`[Tempo] Unsupported configuration file format, skipping: ${localPath}`); + return undefined; + } + let loaded: Options | undefined = undefined; if (ext === '.json' || ext === '.jsonc') { const content = await fs.promises.readFile(localPath, 'utf8'); loaded = parseJSONC(content) as Options; - } else if (depth === 0 && urlMod) { - // Only allow JS/TS module execution for top-level project config files, never for extends targets + } else if (urlMod) { const imported = await import(/* @vite-ignore */ urlMod.pathToFileURL(localPath).href); loaded = imported.default || imported; - } else { - console.warn(`[Tempo] Config extends only supports static .json and .jsonc data files, skipping: ${localPath}`); } if (loaded) - return processExtends(loaded, localPath, fs, path, urlMod, loadedSet, depth, budget); + return processExtends(loaded, localPath, fs, path, urlMod, new Set([target]), 0); } catch (err) { console.warn(`[Tempo] Failed to load config file at ${localPath}:`, err); } @@ -443,7 +493,7 @@ export async function resolveConfig(options?: { cwd?: string, configFile?: strin const target = isFileUrl(options.configFile) ? options.configFile : path.resolve(currentDir, options.configFile); - return await loadConfigTarget(target, currentDir, fs, path, urlMod); + return await loadTopLevelConfig(target, currentDir, fs, path, urlMod); } const rootPath = path.parse(currentDir).root; @@ -456,7 +506,7 @@ export async function resolveConfig(options?: { cwd?: string, configFile?: strin const configPath = path.join(currentDir, `tempo.config${ext}`); if (fs.existsSync(configPath)) { try { - const loaded = await loadConfigTarget(configPath, currentDir, fs, path, urlMod); + const loaded = await loadTopLevelConfig(configPath, currentDir, fs, path, urlMod); if (loaded) return loaded; } catch (err) { console.warn(`[Tempo] Found config file at ${configPath} but failed to load it:`, err); From e3b1e9f0b053c8b13d5f6e0d8e50690bc843e51f Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 9 Sep 2026 15:38:39 +1000 Subject: [PATCH 10/11] PR 6th review --- packages/library/CHANGELOG.md | 8 ++--- .../src/common/runtime/storage.library.ts | 5 +++ packages/library/src/server/mapper.library.ts | 2 ++ .../library/test/server/mapper.server.test.ts | 35 +++++++++++++++++++ packages/plugins/ai/CHANGELOG.md | 8 +++++ .../plugins/batch/src/BatchOrchestrator.ts | 10 +++--- packages/plugins/celestial/CHANGELOG.md | 2 +- packages/tempo-cli/commands/version-sync.js | 5 ++- .../tempo/doc/2-core-concepts/tempo.config.md | 2 +- 9 files changed, 64 insertions(+), 13 deletions(-) diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index c585e3cd..eee2f232 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -5,16 +5,16 @@ 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). -## [4.1.2] - 2026-09-08 +## [4.2.0] - 2026-09-09 ### Added - **Generic Bounded LRU & TTL Cache Engine (`BoundedCache`)**: - - Implemented high-performance `BoundedCache` in `#library/common/runtime/cache.class.js` supporting configurable capacity constraints (`maxSize`, default 1000) and time-to-live expiration (`ttl`, default `Infinity`). + - Implemented high-performance `BoundedCache` in `#library/cache.class.js` supporting configurable capacity constraints (`maxSize`, default 1000) and time-to-live expiration (`ttl`, default 24 hours / `86,400,000 ms`). - Added per-entry TTL override support in `set(key, val, ttl?)` with precomputed absolute expiration deadlines (`expiresAt = Date.now() + ttl`). - - Implemented $O(1)$ fast-path expiration checks: bypasses `#expires` map checks and eliminates clock reads (`Date.now()`) when `ttl === Infinity` or `#expires.size === 0`. + - Implemented $O(1)$ fast-path expiration checks: eliminates clock reads (`Date.now()`) when keys have no entry-level expiration deadline recorded in `#expires`. - Added bulk clear (`clear()`), lazy eviction (`evictExpired()`), iteration (`keys()`, `values()`, `entries()`, `forEach()`, `[Symbol.iterator]()`), and size inspection (`size`). - **Bounded In-Memory Server Storage (`storage.library`)**: - - Replaced unbounded `Map` backing `nodeStorage` with `BoundedCache(1000, Infinity)`. + - Replaced unbounded `Map` backing `nodeStorage` with `BoundedCache(1000, Infinity)` via `#library/cache.class.js`. - Added `ServerStorageOptions` interface and updated `setStorage(key, value, options?)` overload to support optional custom `ttl`. - Added `clearStorage()` utility to purge in-memory storage entries across test environments and lifecycle boundaries while preserving tombstone deletion semantics (`undefined` value). - **Geolocation Caching & Multi-Tenant Partitioning (`mapper.library`)**: diff --git a/packages/library/src/common/runtime/storage.library.ts b/packages/library/src/common/runtime/storage.library.ts index ecd92f84..fabbd8f4 100644 --- a/packages/library/src/common/runtime/storage.library.ts +++ b/packages/library/src/common/runtime/storage.library.ts @@ -229,6 +229,11 @@ export function clearStorage(): void { case CONTEXT.NodeJS: nodeStorage.clear(); + storage.clear(); + break; + + default: + storage.clear(); break; } } diff --git a/packages/library/src/server/mapper.library.ts b/packages/library/src/server/mapper.library.ts index b41a1786..958aa6a2 100644 --- a/packages/library/src/server/mapper.library.ts +++ b/packages/library/src/server/mapper.library.ts @@ -59,6 +59,8 @@ export const serverGeoLocation = async (opts = {} as ServerMapOpts): Promise { vi.unstubAllGlobals(); }); + + it('serverGeoLocation replaces {ip} in custom endpoint URL', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'success', + lat: 34.0522, + lon: -118.2437, + country: 'United States', + city: 'Los Angeles', + query: '1.1.1.1', + }), + }); + + vi.stubGlobal('fetch', mockFetch); + + const geo = await serverGeoLocation({ endpoint: 'https://custom.geo.api/v1/{ip}/json', ip: '1.1.1.1' }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://custom.geo.api/v1/1.1.1.1/json', + expect.anything() + ); + expect(geo.status).toBe('success'); + + vi.unstubAllGlobals(); + }); + + it('serverGeoLocation fails fast when custom endpoint lacks {ip} placeholder and ip is provided', async () => { + const geo = await serverGeoLocation({ endpoint: 'https://custom.geo.api/lookup', ip: '1.1.1.1' }); + expect(geo.status).toBe('fail'); + expect(geo.error).toContain("Custom endpoint must include an '{ip}' placeholder"); + + await expect( + serverGeoLocation({ endpoint: 'https://custom.geo.api/lookup', ip: '1.1.1.1', catch: false }) + ).rejects.toThrow("Custom endpoint must include an '{ip}' placeholder"); + }); }); diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 75504373..84294500 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -15,6 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Decoupled network transport entirely from `@magmacomputing/tempo/library` (removing reliance on core internal `HttpError` and `fetchRequest`). - Implemented proactive stream consumption via `res.body.getReader()` with strict byte accounting and upfront `Content-Length` enforcement against `maxBytes` to prevent memory exhaustion. +## [1.2.1] - 2026-09-07 + +### Security & Reliability +- **Self-Contained Network Transport (`fetch.ts`)**: + - Decoupled network request utilities from `@magmacomputing/tempo/library` into a self-contained local transport helper. + - Added chunk-by-chunk stream consumption via `res.body.getReader()` with proactive byte accounting and immediate reader cancellation (`await reader.cancel()`). + - Enforced upfront `Content-Length` checks against `maxBytes` and strictly bounded stream reads to prevent unbounded memory allocation and OS thread starvation. + ## [1.2.0] - 2026-09-06 ### Added diff --git a/packages/plugins/batch/src/BatchOrchestrator.ts b/packages/plugins/batch/src/BatchOrchestrator.ts index 75fc01ff..124eb10c 100644 --- a/packages/plugins/batch/src/BatchOrchestrator.ts +++ b/packages/plugins/batch/src/BatchOrchestrator.ts @@ -136,8 +136,7 @@ export class BatchOrchestrator { const workers: Promise[] = []; const actualThreads = Math.min(threadCount, Math.ceil(epochs.length / chunkSize)); - const sanitized = this.sanitizeExecArgv(); - const execArgv = sanitized.length > 0 ? sanitized : undefined; + const execArgv = this.sanitizeExecArgv(); for (let i = 0; i < actualThreads; i++) { const startIdx = i * chunkSize; @@ -153,7 +152,7 @@ export class BatchOrchestrator { endIdx, operation, }, - ...(execArgv ? { execArgv } : {}), + execArgv, }); worker.on('message', (msg: any) => { if (msg.status === 'done') resolve(); @@ -193,8 +192,7 @@ export class BatchOrchestrator { private static async _transformWithPostMessage(epochs: number[], operation: string, threadCount: number, chunkSize: number, options: BatchOptions): Promise { const workers: Promise[] = []; const actualThreads = Math.min(threadCount, Math.ceil(epochs.length / chunkSize)); - const sanitized = this.sanitizeExecArgv(); - const execArgv = sanitized.length > 0 ? sanitized : undefined; + const execArgv = this.sanitizeExecArgv(); for (let i = 0; i < actualThreads; i++) { const startIdx = i * chunkSize; @@ -208,7 +206,7 @@ export class BatchOrchestrator { chunk, operation, }, - ...(execArgv ? { execArgv } : {}), + execArgv, }); worker.on('message', (msg: any) => { if (msg.status === 'done') resolve(msg.result); diff --git a/packages/plugins/celestial/CHANGELOG.md b/packages/plugins/celestial/CHANGELOG.md index 224c4850..2639ad3a 100644 --- a/packages/plugins/celestial/CHANGELOG.md +++ b/packages/plugins/celestial/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.0] - 2026-09-09 ### Added -- **Elevation Support on Solar Term**: Exposed `elevation: number | null` on `t.term.solar` ([`TempoTermRegistry['solar']`](file:///home/michael/Project/magma/packages/plugins/celestial/src/index.ts#L52)). +- **Elevation Support on Solar Term**: Exposed `elevation: number | null` on `t.term.solar` ([`TempoTermRegistry['solar']`](src/index.ts)). - **Atmospheric Horizon Dip**: Factored `t.geo.elevation` into solar sunrise, sunset, and daylight duration calculations via `@magmacomputing/tempo-fns`. Observers at high elevations experience earlier sunrise and later sunset due to geometric horizon dip. ## [0.1.0] - 2026-09-02 diff --git a/packages/tempo-cli/commands/version-sync.js b/packages/tempo-cli/commands/version-sync.js index e9991231..e9d83e8e 100644 --- a/packages/tempo-cli/commands/version-sync.js +++ b/packages/tempo-cli/commands/version-sync.js @@ -49,7 +49,10 @@ export async function versionSync(_args) { if (existsSync(wsPkgPath)) { try { const rawContent = readFileSync(wsPkgPath, 'utf8'); - const updatedContent = rawContent.replace(/"version":\s*"[^"]+"/, `"version": "${version}"`); + const updatedContent = rawContent.replace(/^([ \t]*"version"[ \t]*:[ \t]*)"[^"]+"/m, `$1"${version}"`); + if (updatedContent === rawContent) + throw new Error(`Top-level version key was not found or already matched in ${wsPkgPath}`); + writeFileSync(wsPkgPath, updatedContent, 'utf8'); console.log(`✅ Synced ${ws} to ${version} (file fallback)`); syncedCount++; diff --git a/packages/tempo/doc/2-core-concepts/tempo.config.md b/packages/tempo/doc/2-core-concepts/tempo.config.md index 51340c21..c9dbc0c4 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.config.md +++ b/packages/tempo/doc/2-core-concepts/tempo.config.md @@ -305,7 +305,7 @@ When a `Tempo` instance is constructed: --- -### 4.2 Geographic Coordinates & Location Configuration (`geo`) +## 4.2 Geographic Coordinates & Location Configuration (`geo`) You can configure geographic coordinates and location metadata globally or override them on individual instances: From 3ea04a37f2f207700fb094b7bb6e9abc145718d1 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 9 Sep 2026 17:07:47 +1000 Subject: [PATCH 11/11] PR local review --- package.json | 5 +- .../library/src/common/runtime/cache.class.ts | 6 +- .../src/common/runtime/mapper.library.ts | 98 +++++++++---------- .../test/common/runtime/cache.class.test.ts | 64 ++++++++---- .../test/common/runtime/mapper.common.test.ts | 43 ++++++++ .../common/runtime/storage.library.test.ts | 11 ++- packages/plugins/geo/CHANGELOG.md | 2 +- packages/plugins/geo/README.md | 2 +- packages/plugins/geo/doc/index.md | 2 +- packages/tempo/test/support/cache.test.ts | 8 +- 10 files changed, 161 insertions(+), 80 deletions(-) diff --git a/package.json b/package.json index 13862ef2..e6f6e7a9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ ], "scripts": { "test": "vitest run", + "review:agent": "coderabbit review --agent", + "review:cli": "coderabbit review", + "review:findings": "coderabbit review findings", "build:tempo": "npm run build:library && npm run build:std && npm run build --workspace=@magmacomputing/tempo", "build:library": "npm run build --workspace=@magmacomputing/library", "build:std": "npm run build --workspace=@magmacomputing/tempo-std", @@ -77,4 +80,4 @@ "magic-string": "^1.1.1", "typescript-7": "npm:typescript@^7.0.2" } -} +} \ No newline at end of file diff --git a/packages/library/src/common/runtime/cache.class.ts b/packages/library/src/common/runtime/cache.class.ts index 50137b59..05ac4e8c 100644 --- a/packages/library/src/common/runtime/cache.class.ts +++ b/packages/library/src/common/runtime/cache.class.ts @@ -1,4 +1,4 @@ -import { isDefined, isString, isUndefined } from '#library/assertion.library.js'; +import { isDefined, isEmpty, isString, isUndefined } from '#library/assertion.library.js'; import { StringTag } from '#library/decorator.library.js'; /** @@ -172,7 +172,7 @@ export class BoundedCache extends Map { this.#ttls.delete(key); } - while (this.size > this.maxSize) { + while ((this.size - this.#staticKeys.size) > this.maxSize) { let evicted = false; const keysIter = super.keys(); let res = keysIter.next(); @@ -213,6 +213,8 @@ export class BoundedCache extends Map { */ deletePrefix(prefix: string): number { const normalizedPrefix = String(prefix).trim().toLowerCase(); + if (isEmpty(normalizedPrefix)) return 0; + const toDelete: K[] = []; for (const key of super.keys()) { if (isString(key) && key.toLowerCase().startsWith(normalizedPrefix)) diff --git a/packages/library/src/common/runtime/mapper.library.ts b/packages/library/src/common/runtime/mapper.library.ts index 98710186..c55141b7 100644 --- a/packages/library/src/common/runtime/mapper.library.ts +++ b/packages/library/src/common/runtime/mapper.library.ts @@ -1,5 +1,5 @@ import { CONTEXT, getContext } from '#library/utility.library.js'; -import { isNullish, isNumber, isString, isSafeKey, isObject, isEmpty, isReference, isPrimitive } from '#library/assertion.library.js'; +import { isNullish, isNumber, isString, isSafeKey, isObject, isEmpty, isReference, isPrimitive, isDate, isText } from '#library/assertion.library.js'; import { getStorage, setStorage } from '#library/storage.library.js'; import { evaluate } from '#library/evaluation.library.js'; @@ -185,8 +185,9 @@ const resolveSphere = (sphere?: any, lat?: number): GeoSphere | undefined => { */ export const coerceGeo = (input?: any): GeoConfig | undefined => { if (isString(input) && input.includes(',')) { - const parts = input.split(',').map((s: string) => Number(s.trim())); - if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { + const segments = input.split(',').map((s: string) => s.trim()); + const parts = segments.map((s: string) => s === '' ? NaN : Number(s)); + if (parts.length >= 2 && !isNaN(parts[0]!) && !isNaN(parts[1]!)) { const coords = normalizeCoords(parts[0], parts[1]); if (coords) { const result: GeoConfig = { latitude: coords.lat, longitude: coords.lng }; @@ -403,20 +404,11 @@ export const geoLookup = async (opts: Record = {}): Promise { + try { + if (typeof Temporal !== 'undefined' && typeof Temporal.Instant?.fromEpochMilliseconds === 'function') { + const zdt = Temporal.Instant.fromEpochMilliseconds(epochMs).toZonedDateTimeISO(tz); + return zdt.offsetNanoseconds / 60_000_000_000; + } + + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + timeZoneName: 'longOffset', + year: 'numeric' + }).formatToParts(new Date(epochMs)); + + const tzPart = parts.find(p => p.type === 'timeZoneName')?.value; + if (tzPart) { + if (tzPart === 'GMT' || tzPart === 'UTC') return 0; + const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/); + if (match) { + const sign = match[1] === '-' ? -1 : 1; + return sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10)); + } + } + } catch { + // Invalid timezone strings gracefully fall back to natural solar meridian + } + return undefined; +}; + /** * Calculates the Natural Solar Time Offset between civil clock time and actual solar time. * Based on longitude (approx 4 minutes per 1° offset from standard timezone meridian). @@ -563,50 +588,23 @@ export function solarOffset(coords: any, options?: SolarOffsetOptions): number { const lng = geo.longitude; const tz = options?.timeZone - ?? (typeof coords === 'object' && coords !== null ? (coords.timezone ?? coords.tz) : undefined) + ?? (isReference(coords) ? ((coords as any).timezone ?? (coords as any).tz) : undefined) ?? geo.timezone; const dateVal = options?.date - ?? (typeof coords === 'object' && coords !== null && typeof (coords as any).epoch?.ms === 'number' ? (coords as any).epoch.ms : Date.now()); - - let offsetMinutes: number | undefined; + ?? (isReference(coords) && isNumber((coords as any).epoch?.ms) ? (coords as any).epoch.ms : Date.now()); const epochMs = typeof dateVal === 'number' ? dateVal - : (typeof dateVal === 'string' + : (isString(dateVal) ? Date.parse(dateVal) - : (dateVal instanceof Date + : (isDate(dateVal) ? dateVal.getTime() - : (typeof dateVal?.epoch?.ms === 'number' ? dateVal.epoch.ms : Date.now()))); + : (isReference(dateVal) && isNumber((dateVal as any)?.epoch?.ms) ? (dateVal as any).epoch.ms : Date.now()))); - if (isString(tz) && tz.trim() !== '') { - try { - if (typeof Temporal !== 'undefined' && typeof Temporal.Instant?.fromEpochMilliseconds === 'function') { - const zdt = Temporal.Instant.fromEpochMilliseconds(epochMs).toZonedDateTimeISO(tz); - offsetMinutes = zdt.offsetNanoseconds / 60_000_000_000; - } else { - const parts = new Intl.DateTimeFormat('en-US', { - timeZone: tz, - timeZoneName: 'longOffset', - year: 'numeric' - }).formatToParts(new Date(epochMs)); - const tzPart = parts.find(p => p.type === 'timeZoneName')?.value; - if (tzPart) { - if (tzPart === 'GMT' || tzPart === 'UTC') { - offsetMinutes = 0; - } else { - const match = tzPart.match(/GMT([+-])(\d{2}):(\d{2})/); - if (match) { - const sign = match[1] === '-' ? -1 : 1; - offsetMinutes = sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10)); - } - } - } - } - } catch { - // invalid timezone, fall back to natural meridian - } - } + if (!Number.isFinite(epochMs)) return NaN; + + const offsetMinutes = isText(tz) ? resolveCivilTimezoneOffset(tz, epochMs) : undefined; // If no civil timezone, use natural solar timezone meridian (round(lng / 15) * 15) const refMeridian = offsetMinutes !== undefined diff --git a/packages/library/test/common/runtime/cache.class.test.ts b/packages/library/test/common/runtime/cache.class.test.ts index 25e1e09b..492ae993 100644 --- a/packages/library/test/common/runtime/cache.class.test.ts +++ b/packages/library/test/common/runtime/cache.class.test.ts @@ -45,19 +45,30 @@ describe('common/runtime/cache.class', () => { expect(cache.size).toBe(2); }); - it('protects static keys from LRU capacity eviction and TTL expiration', () => { - const cache = new BoundedCache(2, 10); + it('protects static keys from LRU capacity eviction and does not allow static keys to consume non-static capacity', () => { + const cache = new BoundedCache(2, 60000); cache.setStatic('static_term', 'IMMORTAL'); expect(cache.isStatic('static_term')).toBe(true); expect(cache.isStatic('other')).toBe(false); + // With maxSize = 2, 2 non-static entries should both fit alongside the static key cache.set('a', '1'); - cache.set('b', '2'); // 'a' should be evicted, static_term must remain + cache.set('b', '2'); expect(cache.has('static_term')).toBe(true); expect(cache.get('static_term')).toBe('IMMORTAL'); + expect(cache.has('a')).toBe(true); + expect(cache.get('a')).toBe('1'); + expect(cache.has('b')).toBe(true); + expect(cache.get('b')).toBe('2'); + expect(cache.size).toBe(3); // 1 static + 2 non-static + + // Adding a 3rd non-static entry evicts the oldest non-static key ('a'), while static_term remains + cache.set('c', '3'); + expect(cache.has('static_term')).toBe(true); expect(cache.has('a')).toBe(false); expect(cache.get('b')).toBe('2'); + expect(cache.get('c')).toBe('3'); }); it('evicts expired entries after TTL has elapsed', async () => { @@ -84,6 +95,11 @@ describe('common/runtime/cache.class', () => { expect(cache.has('geo:sydney')).toBe(false); expect(cache.has('geo:melbourne')).toBe(false); expect(cache.has('user:alice')).toBe(true); + + // Empty and whitespace prefixes return 0 and do not modify the cache + expect(cache.deletePrefix('')).toBe(0); + expect(cache.deletePrefix(' ')).toBe(0); + expect(cache.has('user:alice')).toBe(true); }); it('clears all entries or only up to count oldest non-static entries', () => { @@ -157,24 +173,30 @@ describe('common/runtime/cache.class', () => { expect(cache.get('short_lived')).toBeUndefined(); }); - it('preserves absolute expiration deadline without renewal on get() or set() updates', async () => { - const cache = new BoundedCache(10, 40); // 40ms TTL - cache.set('item', 'v1'); - - // Access item at 20ms - await new Promise(resolve => setTimeout(resolve, 20)); - expect(cache.get('item')).toBe('v1'); - - // Update item value at 25ms without specifying a new TTL - cache.set('item', 'v2'); - expect(cache.get('item')).toBe('v2'); - - // Wait past original 40ms deadline (e.g. at 55ms total) - await new Promise(resolve => setTimeout(resolve, 35)); - - // Must be expired because original absolute deadline was not renewed - expect(cache.has('item')).toBe(false); - expect(cache.get('item')).toBeUndefined(); + it('preserves absolute expiration deadline without renewal on get() or set() updates', () => { + vi.useFakeTimers(); + try { + const cache = new BoundedCache(10, 40); // 40ms TTL + cache.set('item', 'v1'); + + // Access item at 20ms + vi.advanceTimersByTime(20); + expect(cache.get('item')).toBe('v1'); + + // Update item value at 25ms without specifying a new TTL + vi.advanceTimersByTime(5); + cache.set('item', 'v2'); + expect(cache.get('item')).toBe('v2'); + + // Advance past original 40ms deadline (at 55ms total) + vi.advanceTimersByTime(30); + + // Must be expired because original absolute deadline was not renewed + expect(cache.has('item')).toBe(false); + expect(cache.get('item')).toBeUndefined(); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/packages/library/test/common/runtime/mapper.common.test.ts b/packages/library/test/common/runtime/mapper.common.test.ts index d2fb1b77..0b897ff0 100644 --- a/packages/library/test/common/runtime/mapper.common.test.ts +++ b/packages/library/test/common/runtime/mapper.common.test.ts @@ -81,6 +81,44 @@ describe('common/runtime/mapper.library', () => { expect((coercedExplicit as any)?.venue).toBe('HQ'); }); + it('coerceGeo rejects comma-delimited strings with empty or whitespace-only coordinate segments', () => { + expect(coerceGeo(',')).toBeUndefined(); + expect(coerceGeo(' , ')).toBeUndefined(); + expect(coerceGeo('10,')).toBeUndefined(); + expect(coerceGeo(',20')).toBeUndefined(); + expect(coerceGeo('10, 20')).toEqual({ latitude: 10, longitude: 20, sphere: 'north' }); + }); + + it('resolveGeoCoordinates bypasses stashed coordinates when refresh is true', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'success', + lat: 40.7128, + lon: -74.0060, + city: 'New York', + }), + }); + vi.stubGlobal('fetch', mockFetch); + + stashGeo({ latitude: -33.8688, longitude: 151.2093, city: 'Sydney' }); + + try { + // Normal call returns stashed coordinates + const cached = await resolveGeoCoordinates(); + expect(cached?.city).toBe('Sydney'); + expect(mockFetch).not.toHaveBeenCalled(); + + // Refresh call bypasses stashed coordinates and invokes geoLookup + const refreshed = await resolveGeoCoordinates(undefined, { refresh: true }); + expect(refreshed?.city).toBe('New York'); + expect(refreshed?.lat).toBe(40.713); + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + it('resolveGeoCoordinates falls back to getStashedGeo when input only has metadata without coordinates', async () => { const fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); @@ -308,6 +346,11 @@ describe('common/runtime/mapper.library', () => { expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'GMT' })).toBe(0); }); + it('returns NaN immediately when date input is invalid or non-finite', () => { + expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'UTC' }, { date: 'invalid-date-string' })).toBeNaN(); + expect(solarOffset({ lat: 51.5, lng: 0, timezone: 'UTC' }, { date: NaN as any })).toBeNaN(); + }); + it('falls back to natural 15-degree solar timezone meridian when no timezone is supplied', () => { // lng 151.209 -> nearest meridian 150° -> +4.84 min expect(solarOffset({ lat: -33.8688, lng: 151.2093 })).toBe(4.84); diff --git a/packages/library/test/common/runtime/storage.library.test.ts b/packages/library/test/common/runtime/storage.library.test.ts index 7f887338..57e5fb86 100644 --- a/packages/library/test/common/runtime/storage.library.test.ts +++ b/packages/library/test/common/runtime/storage.library.test.ts @@ -123,10 +123,17 @@ describe('Storage Tombstone & BoundedCache Integration (NodeJS)', () => { setStorage('d1', 'dyn1'); setStorage('d2', 'dyn2'); - // glossary_term is static so it must NOT be evicted even though capacity=2 is exceeded by dynamic keys + // Static keys do not consume non-static capacity expect(getStorage('glossary_term')).toBe('immutable_definition'); - expect(getStorage('d1')).toBeUndefined(); // d1 evicted + expect(getStorage('d1')).toBe('dyn1'); expect(getStorage('d2')).toBe('dyn2'); + + // Adding 3rd dynamic key exceeds maxSize=2 and evicts oldest non-static key (d1) + setStorage('d3', 'dyn3'); + expect(getStorage('glossary_term')).toBe('immutable_definition'); + expect(getStorage('d1')).toBeUndefined(); + expect(getStorage('d2')).toBe('dyn2'); + expect(getStorage('d3')).toBe('dyn3'); }); }); diff --git a/packages/plugins/geo/CHANGELOG.md b/packages/plugins/geo/CHANGELOG.md index 4d5fc1fe..0a4b8fa1 100644 --- a/packages/plugins/geo/CHANGELOG.md +++ b/packages/plugins/geo/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Consolidated `Tempo.geo` Namespace**: - Removed loose static methods mounted directly on the `Tempo` root class (`Tempo.geoLookup`, `Tempo.resolveGeoCoordinates`, `Tempo.serverGeoLocation`, `Tempo.geoLocation`) to prevent flat base class pollution. - All static geolocation utilities are now organized under the dedicated, immutable **`Tempo.geo`** namespace (`Tempo.geo.lookup`, `Tempo.geo.resolve`, `Tempo.geo.server`, `Tempo.geo.browser`, `Tempo.geo.stash`, `Tempo.geo.clear`, `Tempo.geo.get`, and `Tempo.geo.current`). - - Standalone pure functions (`geoLookup`, `resolveGeoCoordinates`, `serverGeoLocation`, etc.) remain available as direct named exports from the package for tree-shaking. + - Standalone tree-shakeable functions (`geoLookup`, `resolveGeoCoordinates`, `serverGeoLocation`, etc.) remain available as direct named exports from the package for tree-shaking. - **Storage Key Scoping**: - Standardized ambient coordinate storage on `_magma_geo_`. diff --git a/packages/plugins/geo/README.md b/packages/plugins/geo/README.md index 0dd96f49..e5f84be2 100644 --- a/packages/plugins/geo/README.md +++ b/packages/plugins/geo/README.md @@ -52,7 +52,7 @@ console.log(localTime.geo?.latitude, localTime.geo?.longitude); ### 2. Functional Tree-Shakeable APIs -All underlying utilities can be imported as standalone pure functions without augmenting `Tempo`: +All underlying utilities can be imported as standalone tree-shakeable functions without augmenting `Tempo`: ```typescript import { Tempo } from '@magmacomputing/tempo'; diff --git a/packages/plugins/geo/doc/index.md b/packages/plugins/geo/doc/index.md index 9c3baeac..c2d308a8 100644 --- a/packages/plugins/geo/doc/index.md +++ b/packages/plugins/geo/doc/index.md @@ -75,7 +75,7 @@ console.log(localTime.geo?.latitude, localTime.geo?.longitude); ### 2. Functional Tree-Shakeable APIs -All underlying utilities can be imported as standalone pure functions: +All underlying utilities can be imported as standalone tree-shakeable functions: ```typescript import { Tempo } from '@magmacomputing/tempo'; diff --git a/packages/tempo/test/support/cache.test.ts b/packages/tempo/test/support/cache.test.ts index be140f9f..517a462e 100644 --- a/packages/tempo/test/support/cache.test.ts +++ b/packages/tempo/test/support/cache.test.ts @@ -40,12 +40,18 @@ describe('Tempo Core Caching Architecture', () => { const cache = new BoundedCache(2, 10000); cache.setStatic('static_term', 'IMMORTAL'); cache.set('a', '1'); - cache.set('b', '2'); // 'a' should be evicted, not static_term + cache.set('b', '2'); expect(cache.has('static_term')).toBe(true); expect(cache.get('static_term')).toBe('IMMORTAL'); + expect(cache.has('a')).toBe(true); + expect(cache.get('b')).toBe('2'); + + cache.set('c', '3'); // 'a' should be evicted, not static_term + expect(cache.has('static_term')).toBe(true); expect(cache.has('a')).toBe(false); expect(cache.get('b')).toBe('2'); + expect(cache.get('c')).toBe('3'); }); it('should serialize and rehydrate via entries() and fromEntries()', () => {