@c9up/atom 0.1.11 → 0.1.12

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/src/Decimal.ts CHANGED
@@ -43,9 +43,16 @@ export interface BetweenOptions {
43
43
  inclusive?: boolean;
44
44
  }
45
45
 
46
+ /**
47
+ * `quantize` takes a rounding mode and nothing else.
48
+ *
49
+ * It used to accept a `precision` too, which it validated and then ignored:
50
+ * `quantize('0.01', { precision: 4 })` answered `1.23`, the same as without it.
51
+ * There is nothing the option could have meant — the result's scale comes from
52
+ * the step, which is the whole point of quantizing to one.
53
+ */
46
54
  export interface QuantizeOptions {
47
55
  mode?: RoundMode;
48
- precision?: number;
49
56
  }
50
57
 
51
58
  export interface MedianOptions {
@@ -139,9 +146,13 @@ export class Decimal {
139
146
  localesOrRosetta?: Intl.LocalesArgument | RosettaLike,
140
147
  ): Decimal {
141
148
  if (isRosettaLike(localesOrRosetta)) {
142
- return new Decimal(normalizeViaRosetta(value, localesOrRosetta));
149
+ return new Decimal(
150
+ normalizeLocalized(value, shapeFromRosetta(localesOrRosetta)),
151
+ );
143
152
  }
144
- return new Decimal(normalizeLocaleNumber(value, localesOrRosetta));
153
+ return new Decimal(
154
+ normalizeLocalized(value, shapeFromIntl(localesOrRosetta)),
155
+ );
145
156
  }
146
157
 
147
158
  plus(other: DecimalInput): Decimal {
@@ -332,9 +343,6 @@ export class Decimal {
332
343
  if (!stepValue.gt(0)) {
333
344
  throw new Error("Quantize step must be greater than zero");
334
345
  }
335
- if (options.precision !== undefined) {
336
- assertScale(options.precision);
337
- }
338
346
  const thisParts = parseDecimal(this.#value);
339
347
  const stepParts = parseDecimal(stepValue.#value);
340
348
  const numerator = thisParts.int * pow10BigInt(stepParts.scale);
@@ -466,8 +474,7 @@ export class Decimal {
466
474
  const remainders: Array<{ index: number; remainder: bigint }> = [];
467
475
  let consumed = 0n;
468
476
 
469
- for (let index = 0; index < normalized.length; index++) {
470
- const ratio = normalized[index];
477
+ for (const [index, ratio] of normalized.entries()) {
471
478
  const weighted = total * ratio;
472
479
  const share = weighted / ratioTotal;
473
480
  const remainder = weighted % ratioTotal;
@@ -485,7 +492,13 @@ export class Decimal {
485
492
 
486
493
  let pointer = 0;
487
494
  while (left > 0n) {
488
- baseShares[remainders[pointer].index] += 1n;
495
+ // `remainders` has one entry per ratio and the pointer wraps inside
496
+ // it, so both reads land; naming them is what says so.
497
+ const target = remainders[pointer];
498
+ if (target === undefined) break;
499
+ const share = baseShares[target.index];
500
+ if (share === undefined) break;
501
+ baseShares[target.index] = share + 1n;
489
502
  left -= 1n;
490
503
  pointer++;
491
504
  if (pointer >= remainders.length) pointer = 0;
@@ -571,10 +584,7 @@ export class Decimal {
571
584
  if (isRosettaLike(localesOrRosetta)) {
572
585
  return localesOrRosetta.formatNumberString(this.#value, options);
573
586
  }
574
- const formatter = new Intl.NumberFormat(
575
- localesOrRosetta,
576
- options,
577
- ) as StringFormatter;
587
+ const formatter = stringFormatter(localesOrRosetta, options);
578
588
  if (this.isInteger()) {
579
589
  return formatter.format(BigInt(this.#value));
580
590
  }
@@ -593,6 +603,20 @@ interface StringFormatter extends Intl.NumberFormat {
593
603
  format(value: number | bigint | string): string;
594
604
  }
595
605
 
606
+ /**
607
+ * A formatter typed with that overload.
608
+ *
609
+ * A method parameter is compared bivariantly, so the constructed formatter
610
+ * satisfies the wider signature on its own — the assertion that used to sit
611
+ * here was claiming something the compiler could check.
612
+ */
613
+ function stringFormatter(
614
+ locales?: Intl.LocalesArgument,
615
+ options?: Intl.NumberFormatOptions,
616
+ ): StringFormatter {
617
+ return new Intl.NumberFormat(locales, options);
618
+ }
619
+
596
620
  function normalizeInput(value: DecimalInput): string {
597
621
  if (value instanceof Decimal) {
598
622
  return value.toString();
@@ -642,7 +666,7 @@ function decimalStringFromNumber(value: number): string {
642
666
 
643
667
  const [coefficient, exponentRaw] = raw.toLowerCase().split("e");
644
668
  const exponent = Number(exponentRaw);
645
- if (!Number.isInteger(exponent)) {
669
+ if (coefficient === undefined || !Number.isInteger(exponent)) {
646
670
  throw new Error(`Invalid decimal: ${value}`);
647
671
  }
648
672
  const negative = coefficient.startsWith("-");
@@ -650,7 +674,7 @@ function decimalStringFromNumber(value: number): string {
650
674
  negative || coefficient.startsWith("+")
651
675
  ? coefficient.slice(1)
652
676
  : coefficient;
653
- const [wholeRaw, fracRaw = ""] = unsigned.split(".");
677
+ const [wholeRaw = "", fracRaw = ""] = unsigned.split(".");
654
678
  const digits = `${wholeRaw}${fracRaw}`;
655
679
  const decimalIndex = wholeRaw.length + exponent;
656
680
 
@@ -866,85 +890,177 @@ function isRosettaLike(arg: unknown): arg is RosettaLike {
866
890
  typeof arg === "object" &&
867
891
  arg !== null &&
868
892
  "getNumberFormatData" in arg &&
869
- typeof (arg as RosettaLike).getNumberFormatData === "function" &&
893
+ typeof arg.getNumberFormatData === "function" &&
870
894
  "formatNumberString" in arg &&
871
- typeof (arg as RosettaLike).formatNumberString === "function"
895
+ typeof arg.formatNumberString === "function"
872
896
  );
873
897
  }
874
898
 
875
- function normalizeViaRosetta(input: string, rosetta: RosettaLike): string {
876
- const trimmed = input.trim();
877
- if (!trimmed) {
878
- throw new Error("Invalid localized decimal: empty string");
879
- }
899
+ /**
900
+ * Everything reading one locale's numbers takes.
901
+ *
902
+ * There is one normalizer, and both ways of naming a locale build one of
903
+ * these for it. Written as two normalizers, the Rosetta one carried a subset
904
+ * of the rules: it substituted the separators but never mapped the locale's
905
+ * digits and validated grouping against the 3-3 of en-US whatever the locale
906
+ * actually grouped by. `parseLocale('١٬٢٣٤٫٥٦', 'ar-EG')` and
907
+ * `parseLocale('١٬٢٣٤٫٥٦', rosetta)` therefore disagreed about the same locale
908
+ * — the second refused it — and so did every Indic locale, whose 3-2 grouping
909
+ * only the first one asked about.
910
+ */
911
+ interface LocaleNumberShape {
912
+ decimal: string;
913
+ group: string;
914
+ minus: string;
915
+ plusSign: string;
916
+ /** The locale's digits, mapped back to ASCII. Empty when they are ASCII. */
917
+ digits: ReadonlyMap<string, string>;
918
+ grouping: GroupingSpec;
919
+ }
920
+
921
+ interface GroupingSpec {
922
+ primary: number;
923
+ secondary: number;
924
+ }
925
+
926
+ /** Format a decimal string the way one locale writes it. */
927
+ type FormatNumber = (
928
+ value: string,
929
+ options?: Intl.NumberFormatOptions,
930
+ ) => string;
931
+
932
+ function shapeFromIntl(locales?: Intl.LocalesArgument): LocaleNumberShape {
933
+ const format: FormatNumber = (value, options) =>
934
+ stringFormatter(locales, options).format(value);
935
+ const parts = new Intl.NumberFormat(locales).formatToParts(-12345.6);
936
+ const signed = new Intl.NumberFormat(locales, {
937
+ signDisplay: "always",
938
+ }).formatToParts(1);
939
+ const group = parts.find((part) => part.type === "group")?.value ?? ",";
940
+ const digits = digitsOf(format);
941
+ return {
942
+ decimal: parts.find((part) => part.type === "decimal")?.value ?? ".",
943
+ group,
944
+ minus: parts.find((part) => part.type === "minusSign")?.value ?? "-",
945
+ plusSign: signed.find((part) => part.type === "plusSign")?.value ?? "+",
946
+ digits,
947
+ grouping: groupingOf(format, group, digits),
948
+ };
949
+ }
950
+
951
+ /**
952
+ * The same shape, read off a Rosetta.
953
+ *
954
+ * `getNumberFormatData` names the separators; the digits and the grouping come
955
+ * out of `formatNumberString`, which the same interface already declares — so
956
+ * the Rosetta path honours its fallback chain for all four without asking the
957
+ * caller for a locale tag it deliberately does not take.
958
+ */
959
+ function shapeFromRosetta(rosetta: RosettaLike): LocaleNumberShape {
960
+ const format: FormatNumber = (value, options) =>
961
+ rosetta.formatNumberString(value, options);
880
962
  const raw = rosetta.getNumberFormatData();
881
- // Guard: a malformed `RosettaLike` returning empty separators
882
- // would produce regexes matching every position. Fall back to
883
- // ASCII defaults rather than corrupting the input.
884
- const data = {
963
+ // A malformed `RosettaLike` returning empty separators would build regexes
964
+ // matching every position. Fall back to ASCII rather than corrupt the input.
965
+ const group = raw.group || ",";
966
+ const digits = digitsOf(format);
967
+ return {
885
968
  decimal: raw.decimal || ".",
886
- group: raw.group || ",",
969
+ group,
887
970
  minus: raw.minus || "-",
888
971
  plusSign: raw.plusSign || "+",
972
+ digits,
973
+ grouping: groupingOf(format, group, digits),
889
974
  };
890
- let normalized = trimmed.replace(/\s| | /g, "");
891
- if (data.minus !== "-") {
892
- normalized = normalized.replace(
893
- new RegExp(escapeRegExp(data.minus), "g"),
894
- "-",
895
- );
896
- }
897
- normalized = normalized.replace(/[−﹣-]/g, "-");
898
- // Substitute the locale's plus sign (e.g., U+FF0B `+`,
899
- // U+FB29 `﬩`) to ASCII so the strict validator accepts it.
900
- if (data.plusSign !== "+") {
901
- normalized = normalized.replace(
902
- new RegExp(escapeRegExp(data.plusSign), "g"),
903
- "+",
904
- );
905
- }
975
+ }
906
976
 
907
- if (/^\(.*\)$/.test(normalized)) {
908
- normalized = `-${normalized.slice(1, -1)}`;
977
+ /**
978
+ * The locale's ten digits, read off the formatter rather than a table.
979
+ *
980
+ * One call names all ten in a known order, so a numbering system whose code
981
+ * points are not contiguous — or one the runtime added after this was written
982
+ * — needs nothing here.
983
+ */
984
+ function digitsOf(format: FormatNumber): ReadonlyMap<string, string> {
985
+ const map = new Map<string, string>();
986
+ let formatted: string;
987
+ try {
988
+ formatted = format("9876543210", { useGrouping: false });
989
+ } catch {
990
+ return map;
909
991
  }
910
-
911
- validateLocalizedSyntax(normalized, data.group, data.decimal);
912
- normalized = normalized.replace(
913
- new RegExp(escapeRegExp(data.group), "g"),
914
- "",
915
- );
916
- normalized = normalized.replace(
917
- new RegExp(escapeRegExp(data.decimal), "g"),
918
- ".",
919
- );
920
-
921
- if (!/^[+-]?\d+(\.\d+)?$/.test(normalized)) {
922
- throw new Error(`Invalid localized decimal: ${input}`);
992
+ let value = 9;
993
+ for (const char of formatted) {
994
+ if (!map.has(char) && value >= 0) {
995
+ map.set(char, String(value));
996
+ value--;
997
+ }
923
998
  }
924
- return normalized;
999
+ return map;
925
1000
  }
926
1001
 
927
- function normalizeLocaleNumber(
1002
+ /**
1003
+ * How wide the locale's groups are — 3-3 for `1,234,567`, 3-2 for the Indic
1004
+ * `12,34,567`. Measured on a number long enough to show both.
1005
+ */
1006
+ function groupingOf(
1007
+ format: FormatNumber,
1008
+ group: string,
1009
+ digits: ReadonlyMap<string, string>,
1010
+ ): GroupingSpec {
1011
+ const fallback: GroupingSpec = { primary: 3, secondary: 3 };
1012
+ let formatted: string;
1013
+ try {
1014
+ formatted = format("1234567890123");
1015
+ } catch {
1016
+ return fallback;
1017
+ }
1018
+ const segments = toAsciiDigits(formatted, digits).split(group);
1019
+ if (segments.length < 2) return fallback;
1020
+ // Code points, not UTF-16 units: a digit outside the BMP counts once.
1021
+ const lengths = segments.map((segment) => [...segment].length);
1022
+ const primary = lengths.at(-1) ?? 3;
1023
+ const secondary = lengths.at(-2) ?? primary;
1024
+ return { primary, secondary };
1025
+ }
1026
+
1027
+ function toAsciiDigits(
928
1028
  input: string,
929
- locales?: Intl.LocalesArgument,
1029
+ digits: ReadonlyMap<string, string>,
930
1030
  ): string {
1031
+ if (digits.size === 0) return input;
1032
+ let out = "";
1033
+ for (const char of input) {
1034
+ out += digits.get(char) ?? char;
1035
+ }
1036
+ return out;
1037
+ }
1038
+
1039
+ function normalizeLocalized(input: string, shape: LocaleNumberShape): string {
931
1040
  const trimmed = input.trim();
932
1041
  if (!trimmed) {
933
1042
  throw new Error("Invalid localized decimal: empty string");
934
1043
  }
935
1044
 
936
- const formatter = new Intl.NumberFormat(locales);
937
- const parts = formatter.formatToParts(-12345.6);
938
- const group = parts.find((part) => part.type === "group")?.value ?? ",";
939
- const decimal = parts.find((part) => part.type === "decimal")?.value ?? ".";
940
- const minus = parts.find((part) => part.type === "minusSign")?.value ?? "-";
941
-
942
- let normalized = normalizeLocaleDigits(trimmed, locales);
943
- normalized = normalized.replace(/\s|\u00A0|\u202F/g, "");
944
- if (minus !== "-") {
945
- normalized = normalized.replace(new RegExp(escapeRegExp(minus), "g"), "-");
1045
+ let normalized = toAsciiDigits(trimmed, shape.digits);
1046
+ // `\s` already covers U+00A0 and U+202F, which is what the space-grouping
1047
+ // locales separate with.
1048
+ normalized = normalized.replace(/\s/g, "");
1049
+ if (shape.minus !== "-") {
1050
+ normalized = normalized.replace(
1051
+ new RegExp(escapeRegExp(shape.minus), "g"),
1052
+ "-",
1053
+ );
946
1054
  }
947
1055
  normalized = normalized.replace(/[−﹣-]/g, "-");
1056
+ // The locale's plus sign (e.g. U+FF0B `+`, U+FB29 `﬩`) becomes ASCII so the
1057
+ // strict validator below accepts it.
1058
+ if (shape.plusSign !== "+") {
1059
+ normalized = normalized.replace(
1060
+ new RegExp(escapeRegExp(shape.plusSign), "g"),
1061
+ "+",
1062
+ );
1063
+ }
948
1064
 
949
1065
  if (/^\(.*\)$/.test(normalized)) {
950
1066
  normalized = `-${normalized.slice(1, -1)}`;
@@ -952,12 +1068,18 @@ function normalizeLocaleNumber(
952
1068
 
953
1069
  validateLocalizedSyntax(
954
1070
  normalized,
955
- group,
956
- decimal,
957
- getLocaleGrouping(locales),
1071
+ shape.group,
1072
+ shape.decimal,
1073
+ shape.grouping,
1074
+ );
1075
+ normalized = normalized.replace(
1076
+ new RegExp(escapeRegExp(shape.group), "g"),
1077
+ "",
1078
+ );
1079
+ normalized = normalized.replace(
1080
+ new RegExp(escapeRegExp(shape.decimal), "g"),
1081
+ ".",
958
1082
  );
959
- normalized = normalized.replace(new RegExp(escapeRegExp(group), "g"), "");
960
- normalized = normalized.replace(new RegExp(escapeRegExp(decimal), "g"), ".");
961
1083
 
962
1084
  if (!/^[+-]?\d+(\.\d+)?$/.test(normalized)) {
963
1085
  throw new Error(`Invalid localized decimal: ${input}`);
@@ -965,62 +1087,6 @@ function normalizeLocaleNumber(
965
1087
  return normalized;
966
1088
  }
967
1089
 
968
- function normalizeLocaleDigits(
969
- input: string,
970
- locales?: Intl.LocalesArgument,
971
- ): string {
972
- const digitMap = getLocaleDigitMap(locales);
973
- let out = "";
974
- for (const char of input) {
975
- out += digitMap.get(char) ?? char;
976
- }
977
- return out;
978
- }
979
-
980
- function getLocaleDigitMap(
981
- locales?: Intl.LocalesArgument,
982
- ): Map<string, string> {
983
- const formatter = new Intl.NumberFormat(locales, { useGrouping: false });
984
- const digits = formatter.format(9876543210);
985
- const map = new Map<string, string>();
986
- let value = 9;
987
- for (const char of digits) {
988
- if (!map.has(char) && value >= 0) {
989
- map.set(char, String(value));
990
- value--;
991
- }
992
- }
993
- return map;
994
- }
995
-
996
- interface GroupingSpec {
997
- primary: number;
998
- secondary: number;
999
- }
1000
-
1001
- function getLocaleGrouping(locales?: Intl.LocalesArgument): GroupingSpec {
1002
- const parts = new Intl.NumberFormat(locales)
1003
- .formatToParts(1234567890123)
1004
- .filter((part) => part.type === "integer" || part.type === "group");
1005
- const lengths: number[] = [];
1006
- let current = 0;
1007
- for (const part of parts) {
1008
- if (part.type === "integer") {
1009
- current += [...part.value].length;
1010
- } else {
1011
- lengths.push(current);
1012
- current = 0;
1013
- }
1014
- }
1015
- lengths.push(current);
1016
- if (lengths.length < 2) {
1017
- return { primary: 3, secondary: 3 };
1018
- }
1019
- const primary = lengths[lengths.length - 1];
1020
- const secondary = lengths[lengths.length - 2] ?? primary;
1021
- return { primary, secondary };
1022
- }
1023
-
1024
1090
  function validateLocalizedSyntax(
1025
1091
  value: string,
1026
1092
  group: string,
@@ -1054,11 +1120,14 @@ function validateLocalizedSyntax(
1054
1120
  }
1055
1121
  for (let i = groups.length - 1, distance = 0; i >= 0; i--, distance++) {
1056
1122
  const size = distance === 0 ? grouping.primary : grouping.secondary;
1123
+ const group = groups[i];
1124
+ if (group === undefined)
1125
+ throw new Error(`Invalid localized decimal: ${value}`);
1057
1126
  if (i === 0) {
1058
- if (groups[i].length < 1 || groups[i].length > size) {
1127
+ if (group.length < 1 || group.length > size) {
1059
1128
  throw new Error(`Invalid localized decimal: ${value}`);
1060
1129
  }
1061
- } else if (groups[i].length !== size) {
1130
+ } else if (group.length !== size) {
1062
1131
  throw new Error(`Invalid localized decimal: ${value}`);
1063
1132
  }
1064
1133
  }
package/src/Money.ts CHANGED
@@ -7,18 +7,53 @@ export interface MoneyOptions {
7
7
  mode?: RoundMode;
8
8
  }
9
9
 
10
+ /**
11
+ * What `times` and `div` take.
12
+ *
13
+ * The rounding mode, and only that. They used to declare the whole
14
+ * {@link MoneyOptions} and forward one field of it: `scale` and `exact` were
15
+ * overwritten on the way through, so `.times("1.5", { scale: 4 })` came back at
16
+ * the currency's scale and `.times("1.333", { exact: true })` rounded anyway.
17
+ * Neither could ever have been honoured — a multiplied amount of money is still
18
+ * money, and money is held at the scale its currency has.
19
+ */
20
+ export interface MoneyRoundingOptions {
21
+ mode?: RoundMode;
22
+ }
23
+
10
24
  export interface MoneyFormatOptions
11
25
  extends Omit<Intl.NumberFormatOptions, "style" | "currency"> {
12
26
  locale?: Intl.LocalesArgument;
13
27
  }
14
28
 
29
+ /**
30
+ * ISO 4217 minor units, for every currency that does not have two.
31
+ *
32
+ * Only the exceptions are listed; anything absent is two, which is what the
33
+ * lookups below fall back to. Written as a partial list of the ones someone
34
+ * happened to think of, the fallback answered for the rest — and answered
35
+ * wrong. A `Money` built for ISK, whose króna has no subunit, was held at two
36
+ * decimals: `toMinorUnits()` returned 123400 for 1234 króna, a hundredfold
37
+ * error on its way into an integer column, `toString()` printed a fractional
38
+ * part the currency does not have, and `format()` asked Intl for two decimals
39
+ * it would otherwise have refused to print. JPY, three lines away in the same
40
+ * table, was correct — the difference between the two was which one someone
41
+ * had typed in.
42
+ *
43
+ * ISO is the source of truth here, not CLDR, and they disagree on one entry:
44
+ * ISO gives IQD three minor units, `Intl` prints zero. `format()` passes this
45
+ * scale to Intl explicitly, so the value keeps the fils ISO says it has.
46
+ */
15
47
  const ISO_MINOR_UNITS: Record<string, number> = Object.freeze({
16
48
  BHD: 3,
49
+ BIF: 0,
17
50
  CLF: 4,
18
51
  CLP: 0,
19
52
  DJF: 0,
20
- EUR: 2,
21
- GBP: 2,
53
+ GNF: 0,
54
+ IQD: 3,
55
+ ISK: 0,
56
+ JOD: 3,
22
57
  JPY: 0,
23
58
  KMF: 0,
24
59
  KRW: 0,
@@ -26,9 +61,13 @@ const ISO_MINOR_UNITS: Record<string, number> = Object.freeze({
26
61
  LYD: 3,
27
62
  OMR: 3,
28
63
  PYG: 0,
64
+ RWF: 0,
29
65
  TND: 3,
30
- USD: 2,
66
+ UGX: 0,
67
+ UYI: 0,
68
+ UYW: 4,
31
69
  VND: 0,
70
+ VUV: 0,
32
71
  XAF: 0,
33
72
  XOF: 0,
34
73
  XPF: 0,
@@ -102,7 +141,7 @@ export class Money {
102
141
  });
103
142
  }
104
143
 
105
- times(multiplier: DecimalInput, options: MoneyOptions = {}): Money {
144
+ times(multiplier: DecimalInput, options: MoneyRoundingOptions = {}): Money {
106
145
  return new Money(this.#amount.times(multiplier), this.#currency, {
107
146
  scale: this.#scale,
108
147
  exact: false,
@@ -110,7 +149,7 @@ export class Money {
110
149
  });
111
150
  }
112
151
 
113
- div(divisor: DecimalInput, options: MoneyOptions = {}): Money {
152
+ div(divisor: DecimalInput, options: MoneyRoundingOptions = {}): Money {
114
153
  return new Money(this.#amount.div(divisor), this.#currency, {
115
154
  scale: this.#scale,
116
155
  exact: false,
package/src/atlas.ts CHANGED
@@ -5,9 +5,8 @@
5
5
  * column pipeline. Lives on a sub-export so the default `@c9up/atom` import
6
6
  * surface stays adapter-free.
7
7
  *
8
- * Mirrors Adonis Lucid's `@column.prepare` / `@column.consume` pattern
9
- * callbacks are baked into the entity definition; no global registry, no
10
- * boot-time wiring.
8
+ * The callbacks are baked into the entity definition: no global registry, no
9
+ * boot-time wiring, nothing to import in a provider.
11
10
  *
12
11
  * Usage:
13
12
  *
@@ -19,8 +18,6 @@
19
18
  * @PrimaryKey() id!: number
20
19
  * @Column(decimalAtlasAdapter) balance!: Decimal | null
21
20
  * }
22
- *
23
- * @implements Story 35.10
24
21
  */
25
22
 
26
23
  import { Decimal, type RoundMode } from "./Decimal.js";