@ultimat3/money 15.0.0 → 17.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.
- package/CLAUDE.md +10 -1
- package/package.json +3 -3
- package/src/format.ts +14 -14
package/CLAUDE.md
CHANGED
|
@@ -99,10 +99,19 @@ the wide shape and answer with the row type.
|
|
|
99
99
|
- **Never cache an `Intl` formatter on a raw caller string.** `locale` arrives from
|
|
100
100
|
`Accept-Language`, so an unbounded `Map` keyed on it is memory the client chooses: 20,000 valid
|
|
101
101
|
`en-US-x-*` tags through `formatMoney` retained +55.1 MB of RSS, at ~2.7 KB per
|
|
102
|
-
`Intl.NumberFormat`. Both halves, always — `
|
|
102
|
+
`Intl.NumberFormat`. Both halves, always — `assertLocale` for the key, `cachedFormatter` for
|
|
103
103
|
the bound, both `@ultimat3/core`'s and shared with `@ultimat3/time` (tier 1 may not import
|
|
104
104
|
sideways, so the mechanism lives a tier down rather than twice). Every formatter in `format.ts`
|
|
105
105
|
goes through that pair; a `new Intl.NumberFormat` outside one is the bug written again.
|
|
106
|
+
- **A malformed locale tag is `X_LOCALE_INVALID`, never a bare `RangeError`, `As of 2026-08-26`.**
|
|
107
|
+
`formatMoney`, `formatMoneyParts`, `formatMoneyDecimal` and `currencySymbol` all screen through
|
|
108
|
+
`assertLocale` (`@ultimat3/core`), which validates and canonicalizes in ONE step — so the key the
|
|
109
|
+
cache is bounded on is the tag the screen accepted. The pass-through it replaced was argued as
|
|
110
|
+
"this seam decides a cache key, never whether a locale is acceptable", which is true of the cache
|
|
111
|
+
and was never an argument for handing the tag to `Intl.NumberFormat`: `en_US` off an
|
|
112
|
+
`Accept-Language` header took the request down with an uncoded throw several frames away.
|
|
113
|
+
`@ultimat3/time` had closed the identical hole one release earlier, and money was the last
|
|
114
|
+
tier-1 package still doing what time deleted.
|
|
106
115
|
- **One place decides a sign.** `formatMoney` is `formatMoneyParts` joined, and `accounting`
|
|
107
116
|
reaches `Intl` as `currencySign` — so the locale places the minus and picks the parenthesised
|
|
108
117
|
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": "
|
|
3
|
+
"version": "17.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",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
37
|
+
"@ultimat3/core": "17.0.0",
|
|
38
|
+
"@ultimat3/schema": "17.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/format.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* JPY renders without decimals and KWD with three, without a per-locale special case.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { assertLocale, cachedFormatter } from '@ultimat3/core';
|
|
7
7
|
import { exponentOf } from './currency';
|
|
8
8
|
import { type Money, toDecimalNumber } from './money';
|
|
9
9
|
import { moneyScale } from './scale';
|
|
@@ -74,7 +74,7 @@ export function currencySymbol(currency: string, locale: string): string {
|
|
|
74
74
|
/** Digits only, no symbol — for editable inputs and CSV exports. */
|
|
75
75
|
export function formatMoneyDecimal(amount: Money, locale: string): string {
|
|
76
76
|
const digits = moneyScale(amount);
|
|
77
|
-
const tag =
|
|
77
|
+
const tag = assertLocale(locale);
|
|
78
78
|
// Through the same cache as `formatterFor`, for the same reason: this took the caller's raw
|
|
79
79
|
// locale too, and a second way to build a formatter in one file is a second place to forget.
|
|
80
80
|
return cachedFormatter(
|
|
@@ -90,12 +90,6 @@ export function formatMoneyDecimal(amount: Money, locale: string): string {
|
|
|
90
90
|
).format(toDecimalNumber(amount));
|
|
91
91
|
}
|
|
92
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
|
-
|
|
99
93
|
const cache = new Map<string, Intl.NumberFormat>();
|
|
100
94
|
const decimalCache = new Map<string, Intl.NumberFormat>();
|
|
101
95
|
|
|
@@ -103,11 +97,17 @@ const decimalCache = new Map<string, Intl.NumberFormat>();
|
|
|
103
97
|
* `scale` is the amount's own, not the currency's: rendering $0.000002 with two digits shows
|
|
104
98
|
* `$0.00`, which is the sub-cent bug back again, in the one place a human would read it.
|
|
105
99
|
*
|
|
106
|
-
* **
|
|
107
|
-
* raw into an unbounded `Map`, 20,000 valid-but-distinct tags
|
|
108
|
-
* memory the client chooses. `
|
|
109
|
-
* `cachedFormatter` caps the rest; neither half is sufficient alone,
|
|
110
|
-
* the one place `@ultimat3/time` reads them from too.
|
|
100
|
+
* **Screened, canonically keyed and hard-capped, because `locale` arrives from
|
|
101
|
+
* `Accept-Language`.** Keyed raw into an unbounded `Map`, 20,000 valid-but-distinct tags
|
|
102
|
+
* (`en-US-x-a0` …) retained 55 MB — memory the client chooses. `assertLocale` collapses `EN-us`
|
|
103
|
+
* and `en-US` onto one key and `cachedFormatter` caps the rest; neither half is sufficient alone,
|
|
104
|
+
* which is why both come from the one place `@ultimat3/time` reads them from too.
|
|
105
|
+
*
|
|
106
|
+
* It also REFUSES a tag `Intl` cannot parse (`X_LOCALE_INVALID`) instead of handing it on. Passing
|
|
107
|
+
* it through was argued as "this seam decides a cache key, never whether a locale is acceptable",
|
|
108
|
+
* which is true of the cache and was never an argument for the throw that followed: the
|
|
109
|
+
* `Intl.NumberFormat` constructor then raised a bare, uncoded `RangeError` at a caller holding a
|
|
110
|
+
* request header.
|
|
111
111
|
*/
|
|
112
112
|
function formatterFor(
|
|
113
113
|
currency: string,
|
|
@@ -118,7 +118,7 @@ function formatterFor(
|
|
|
118
118
|
const digits =
|
|
119
119
|
options.fractionDigits ?? (options.trimZeroFraction === true ? undefined : exponent);
|
|
120
120
|
const sign = options.accounting === true ? 'accounting' : 'standard';
|
|
121
|
-
const tag =
|
|
121
|
+
const tag = assertLocale(locale);
|
|
122
122
|
// `exponent` is in the key because it stopped being derivable from `currency` the moment it
|
|
123
123
|
// started coming from the amount's own scale. On the `trimZeroFraction` path `digits` is
|
|
124
124
|
// `undefined`, so without it every scale of one currency shared a formatter: format 12.99 EUR
|