@ultimat3/money 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CLAUDE.md +7 -0
  2. package/package.json +3 -3
  3. package/src/format.ts +45 -22
package/CLAUDE.md CHANGED
@@ -79,6 +79,13 @@ shape is still additive and this is still a minor version.
79
79
  `USD/EUR: 0.92` names 23/25, so EUR→USD is exactly 25/23, where `1 / 0.92` is a double whose own
80
80
  decimal spelling rounds a large amount one minor unit low. `rate` stays the readable number the
81
81
  audit trail records; `convert` scales by `ratio` whenever the provider supplied one.
82
+ - **Never cache an `Intl` formatter on a raw caller string.** `locale` arrives from
83
+ `Accept-Language`, so an unbounded `Map` keyed on it is memory the client chooses: 20,000 valid
84
+ `en-US-x-*` tags through `formatMoney` retained +55.1 MB of RSS, at ~2.7 KB per
85
+ `Intl.NumberFormat`. Both halves, always — `canonicalLocale` for the key, `cachedFormatter` for
86
+ the bound, both `@ultimat3/core`'s and shared with `@ultimat3/time` (tier 1 may not import
87
+ sideways, so the mechanism lives a tier down rather than twice). Every formatter in `format.ts`
88
+ goes through that pair; a `new Intl.NumberFormat` outside one is the bug written again.
82
89
  - **One place decides a sign.** `formatMoney` is `formatMoneyParts` joined, and `accounting`
83
90
  reaches `Intl` as `currencySign` — so the locale places the minus and picks the parenthesised
84
91
  form, and a UI styling the parts cannot render a different format from the label beside it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/money",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "2.0.0",
35
- "@ultimat3/schema": "2.0.0"
34
+ "@ultimat3/core": "3.0.0",
35
+ "@ultimat3/schema": "3.0.0"
36
36
  }
37
37
  }
package/src/format.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * JPY renders without decimals and KWD with three, without a per-locale special case.
4
4
  */
5
5
 
6
+ import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
6
7
  import { exponentOf } from './currency';
7
8
  import { type Money, toDecimalNumber } from './money';
8
9
  import { moneyScale } from './scale';
@@ -73,19 +74,40 @@ export function currencySymbol(currency: string, locale: string): string {
73
74
  /** Digits only, no symbol — for editable inputs and CSV exports. */
74
75
  export function formatMoneyDecimal(amount: Money, locale: string): string {
75
76
  const digits = moneyScale(amount);
76
- return new Intl.NumberFormat(locale, {
77
- style: 'decimal',
78
- minimumFractionDigits: digits,
79
- maximumFractionDigits: digits,
80
- useGrouping: false,
81
- }).format(toDecimalNumber(amount));
77
+ const tag = canonicalTag(locale);
78
+ // Through the same cache as `formatterFor`, for the same reason: this took the caller's raw
79
+ // locale too, and a second way to build a formatter in one file is a second place to forget.
80
+ return cachedFormatter(
81
+ decimalCache,
82
+ `${tag}|${digits}`,
83
+ () =>
84
+ new Intl.NumberFormat(tag, {
85
+ style: 'decimal',
86
+ minimumFractionDigits: digits,
87
+ maximumFractionDigits: digits,
88
+ useGrouping: false,
89
+ }),
90
+ ).format(toDecimalNumber(amount));
82
91
  }
83
92
 
93
+ /**
94
+ * A tag `Intl` cannot parse falls through unchanged, so the `Intl.NumberFormat` constructor still
95
+ * raises it — this seam decides a cache key, never whether a locale is acceptable.
96
+ */
97
+ const canonicalTag = (locale: string): string => canonicalLocale(locale) ?? locale;
98
+
84
99
  const cache = new Map<string, Intl.NumberFormat>();
100
+ const decimalCache = new Map<string, Intl.NumberFormat>();
85
101
 
86
102
  /**
87
103
  * `scale` is the amount's own, not the currency's: rendering $0.000002 with two digits shows
88
104
  * `$0.00`, which is the sub-cent bug back again, in the one place a human would read it.
105
+ *
106
+ * **Canonically keyed and hard-capped, because `locale` arrives from `Accept-Language`.** Keyed
107
+ * raw into an unbounded `Map`, 20,000 valid-but-distinct tags (`en-US-x-a0` …) retained 55 MB —
108
+ * memory the client chooses. `canonicalLocale` collapses `EN-us` and `en-US` onto one key and
109
+ * `cachedFormatter` caps the rest; neither half is sufficient alone, which is why both come from
110
+ * the one place `@ultimat3/time` reads them from too.
89
111
  */
90
112
  function formatterFor(
91
113
  currency: string,
@@ -96,13 +118,14 @@ function formatterFor(
96
118
  const digits =
97
119
  options.fractionDigits ?? (options.trimZeroFraction === true ? undefined : exponent);
98
120
  const sign = options.accounting === true ? 'accounting' : 'standard';
121
+ const tag = canonicalTag(locale);
99
122
  // `exponent` is in the key because it stopped being derivable from `currency` the moment it
100
123
  // started coming from the amount's own scale. On the `trimZeroFraction` path `digits` is
101
124
  // `undefined`, so without it every scale of one currency shared a formatter: format 12.99 EUR
102
125
  // first and 12.990001 EUR then rendered as `12,99 €` — the sub-cent bug back, silently, in the
103
126
  // one place a human reads the number.
104
127
  const key = [
105
- locale,
128
+ tag,
106
129
  currency,
107
130
  options.display ?? 'symbol',
108
131
  digits ?? 'auto',
@@ -110,19 +133,19 @@ function formatterFor(
110
133
  options.grouping ?? 'auto',
111
134
  sign,
112
135
  ].join('|');
113
- const cached = cache.get(key);
114
- if (cached !== undefined) return cached;
115
-
116
- const formatter = new Intl.NumberFormat(locale, {
117
- style: 'currency',
118
- currency,
119
- currencyDisplay: options.display ?? 'symbol',
120
- currencySign: sign,
121
- ...(digits === undefined
122
- ? { minimumFractionDigits: 0, maximumFractionDigits: exponent }
123
- : { minimumFractionDigits: digits, maximumFractionDigits: digits }),
124
- ...(options.grouping === 'never' ? { useGrouping: false } : {}),
125
- });
126
- cache.set(key, formatter);
127
- return formatter;
136
+ return cachedFormatter(
137
+ cache,
138
+ key,
139
+ () =>
140
+ new Intl.NumberFormat(tag, {
141
+ style: 'currency',
142
+ currency,
143
+ currencyDisplay: options.display ?? 'symbol',
144
+ currencySign: sign,
145
+ ...(digits === undefined
146
+ ? { minimumFractionDigits: 0, maximumFractionDigits: exponent }
147
+ : { minimumFractionDigits: digits, maximumFractionDigits: digits }),
148
+ ...(options.grouping === 'never' ? { useGrouping: false } : {}),
149
+ }),
150
+ );
128
151
  }