@c9up/atom 0.1.6 → 0.1.9

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/Money.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { defaultQuantizeMode } from "./context.js";
2
+ import { Decimal, type DecimalInput, type RoundMode } from "./Decimal.js";
3
+
4
+ export interface MoneyOptions {
5
+ scale?: number;
6
+ exact?: boolean;
7
+ mode?: RoundMode;
8
+ }
9
+
10
+ export interface MoneyFormatOptions
11
+ extends Omit<Intl.NumberFormatOptions, "style" | "currency"> {
12
+ locale?: Intl.LocalesArgument;
13
+ }
14
+
15
+ const ISO_MINOR_UNITS: Record<string, number> = Object.freeze({
16
+ BHD: 3,
17
+ CLF: 4,
18
+ CLP: 0,
19
+ DJF: 0,
20
+ EUR: 2,
21
+ GBP: 2,
22
+ JPY: 0,
23
+ KMF: 0,
24
+ KRW: 0,
25
+ KWD: 3,
26
+ LYD: 3,
27
+ OMR: 3,
28
+ PYG: 0,
29
+ TND: 3,
30
+ USD: 2,
31
+ VND: 0,
32
+ XAF: 0,
33
+ XOF: 0,
34
+ XPF: 0,
35
+ });
36
+
37
+ export class Money {
38
+ #amount: Decimal;
39
+ #currency: string;
40
+ #scale: number;
41
+
42
+ constructor(
43
+ amount: DecimalInput,
44
+ currency: string,
45
+ options: MoneyOptions = {},
46
+ ) {
47
+ this.#currency = normalizeCurrency(currency);
48
+ this.#scale = options.scale ?? ISO_MINOR_UNITS[this.#currency] ?? 2;
49
+ const mode = options.mode ?? defaultQuantizeMode();
50
+ const exact = options.exact ?? true;
51
+ const decimal = new Decimal(amount);
52
+ this.#amount = exact
53
+ ? Decimal.fromMinorUnits(decimal.toMinorUnits(this.#scale), this.#scale)
54
+ : decimal.toScale(this.#scale, mode);
55
+ }
56
+
57
+ static fromMajor(
58
+ amount: DecimalInput,
59
+ currency: string,
60
+ options: MoneyOptions = {},
61
+ ): Money {
62
+ return new Money(amount, currency, options);
63
+ }
64
+
65
+ static fromMinorUnits(
66
+ minorUnits: string | number | bigint,
67
+ currency: string,
68
+ options: Omit<MoneyOptions, "exact"> = {},
69
+ ): Money {
70
+ const normalized = normalizeCurrency(currency);
71
+ const scale = options.scale ?? ISO_MINOR_UNITS[normalized] ?? 2;
72
+ return new Money(Decimal.fromMinorUnits(minorUnits, scale), normalized, {
73
+ ...options,
74
+ scale,
75
+ exact: true,
76
+ });
77
+ }
78
+
79
+ get amount(): Decimal {
80
+ return this.#amount;
81
+ }
82
+
83
+ get currency(): string {
84
+ return this.#currency;
85
+ }
86
+
87
+ get scale(): number {
88
+ return this.#scale;
89
+ }
90
+
91
+ plus(other: Money): Money {
92
+ this.#assertSameCurrency(other);
93
+ return new Money(this.#amount.plus(other.#amount), this.#currency, {
94
+ scale: this.#scale,
95
+ });
96
+ }
97
+
98
+ minus(other: Money): Money {
99
+ this.#assertSameCurrency(other);
100
+ return new Money(this.#amount.minus(other.#amount), this.#currency, {
101
+ scale: this.#scale,
102
+ });
103
+ }
104
+
105
+ times(multiplier: DecimalInput, options: MoneyOptions = {}): Money {
106
+ return new Money(this.#amount.times(multiplier), this.#currency, {
107
+ scale: this.#scale,
108
+ exact: false,
109
+ mode: options.mode,
110
+ });
111
+ }
112
+
113
+ div(divisor: DecimalInput, options: MoneyOptions = {}): Money {
114
+ return new Money(this.#amount.div(divisor), this.#currency, {
115
+ scale: this.#scale,
116
+ exact: false,
117
+ mode: options.mode,
118
+ });
119
+ }
120
+
121
+ allocate(ratios: Array<string | number | bigint>): Money[] {
122
+ return this.#amount.allocate(ratios).map(
123
+ (part) =>
124
+ new Money(part, this.#currency, {
125
+ scale: this.#scale,
126
+ }),
127
+ );
128
+ }
129
+
130
+ toMinorUnits(): bigint {
131
+ return this.#amount.toMinorUnits(this.#scale);
132
+ }
133
+
134
+ format(options: MoneyFormatOptions = {}): string {
135
+ const { locale, ...intlOptions } = options;
136
+ return this.#amount.toLocale(locale, {
137
+ style: "currency",
138
+ currency: this.#currency,
139
+ minimumFractionDigits: this.#scale,
140
+ maximumFractionDigits: this.#scale,
141
+ ...intlOptions,
142
+ });
143
+ }
144
+
145
+ toString(): string {
146
+ return `${this.#amount.toFixed(this.#scale)} ${this.#currency}`;
147
+ }
148
+
149
+ toJSON(): { amount: string; currency: string } {
150
+ return {
151
+ amount: this.#amount.toFixed(this.#scale),
152
+ currency: this.#currency,
153
+ };
154
+ }
155
+
156
+ #assertSameCurrency(other: Money): void {
157
+ if (this.#currency !== other.#currency || this.#scale !== other.#scale) {
158
+ throw new Error(
159
+ `Currency mismatch: ${this.#currency}/${this.#scale} !== ${other.#currency}/${other.#scale}`,
160
+ );
161
+ }
162
+ }
163
+ }
164
+
165
+ export function money(
166
+ amount: DecimalInput,
167
+ currency: string,
168
+ options: MoneyOptions = {},
169
+ ): Money {
170
+ return new Money(amount, currency, options);
171
+ }
172
+
173
+ function normalizeCurrency(currency: string): string {
174
+ const normalized = currency.trim().toUpperCase();
175
+ if (!/^[A-Z]{3}$/.test(normalized)) {
176
+ throw new Error(`Invalid currency code: ${currency}`);
177
+ }
178
+ return normalized;
179
+ }
package/src/atlas.ts CHANGED
@@ -23,7 +23,29 @@
23
23
  * @implements Story 35.10
24
24
  */
25
25
 
26
- import { Decimal } from "./Decimal.js";
26
+ import { Decimal, type RoundMode } from "./Decimal.js";
27
+
28
+ export interface DecimalColumnOptions {
29
+ scale?: number;
30
+ exact?: boolean;
31
+ mode?: RoundMode;
32
+ nullable?: boolean;
33
+ columnType?: "decimal" | "numeric";
34
+ }
35
+
36
+ export interface DecimalAtlasAdapter {
37
+ consume(raw: unknown): Decimal | null;
38
+ prepare(value: unknown): string | null;
39
+ }
40
+
41
+ export interface DecimalAtlasColumn extends DecimalAtlasAdapter {
42
+ meta: {
43
+ atomDecimal: true;
44
+ columnType: "decimal" | "numeric";
45
+ scale?: number;
46
+ nullable: boolean;
47
+ };
48
+ }
27
49
 
28
50
  /**
29
51
  * Atlas adapter for postgres `numeric` / `decimal` (and equivalent on mysql /
@@ -55,7 +77,7 @@ import { Decimal } from "./Decimal.js";
55
77
  * plugin cannot monkey-patch `prepare` / `consume` at runtime and silently
56
78
  * corrupt every repository sharing this import.
57
79
  */
58
- export const decimalAtlasAdapter = Object.freeze({
80
+ export const decimalAtlasAdapter: DecimalAtlasAdapter = Object.freeze({
59
81
  consume(raw: unknown): Decimal | null {
60
82
  if (raw === null || raw === undefined) return null;
61
83
  if (raw instanceof Decimal) return raw;
@@ -81,3 +103,57 @@ export const decimalAtlasAdapter = Object.freeze({
81
103
  return value.toString();
82
104
  },
83
105
  });
106
+
107
+ export function decimalColumn(
108
+ options: DecimalColumnOptions = {},
109
+ ): DecimalAtlasColumn {
110
+ const columnType = options.columnType ?? "decimal";
111
+ const nullable = options.nullable ?? true;
112
+ const adapter: DecimalAtlasColumn = {
113
+ meta: {
114
+ atomDecimal: true,
115
+ columnType,
116
+ scale: options.scale,
117
+ nullable,
118
+ },
119
+ consume(raw: unknown): Decimal | null {
120
+ const value = decimalAtlasAdapter.consume(raw);
121
+ if (value === null) {
122
+ if (!nullable) {
123
+ throw new TypeError(
124
+ "decimalColumn.consume: non-nullable column got null",
125
+ );
126
+ }
127
+ return null;
128
+ }
129
+ return normalizeColumnDecimal(value, options);
130
+ },
131
+ prepare(value: unknown): string | null {
132
+ const prepared = decimalAtlasAdapter.prepare(value);
133
+ if (prepared === null) {
134
+ if (!nullable) {
135
+ throw new TypeError(
136
+ "decimalColumn.prepare: non-nullable column got null",
137
+ );
138
+ }
139
+ return null;
140
+ }
141
+ return normalizeColumnDecimal(new Decimal(prepared), options).toString();
142
+ },
143
+ };
144
+ return Object.freeze(adapter);
145
+ }
146
+
147
+ function normalizeColumnDecimal(
148
+ value: Decimal,
149
+ options: DecimalColumnOptions,
150
+ ): Decimal {
151
+ if (options.scale === undefined) return value;
152
+ if (options.exact ?? true) {
153
+ return Decimal.fromMinorUnits(
154
+ value.toMinorUnits(options.scale),
155
+ options.scale,
156
+ );
157
+ }
158
+ return value.toScale(options.scale, options.mode ?? "half-up");
159
+ }
package/src/context.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { RoundMode } from "./Decimal.js";
2
+
3
+ export interface AtomContext {
4
+ precision: number;
5
+ roundMode: RoundMode;
6
+ quantizeMode: RoundMode;
7
+ }
8
+
9
+ export type AtomContextOptions = Partial<AtomContext>;
10
+
11
+ const DEFAULT_CONTEXT: AtomContext = Object.freeze({
12
+ precision: 18,
13
+ roundMode: "trunc",
14
+ quantizeMode: "half-up",
15
+ });
16
+
17
+ let currentContext: AtomContext = DEFAULT_CONTEXT;
18
+
19
+ export function getAtomContext(): AtomContext {
20
+ return { ...currentContext };
21
+ }
22
+
23
+ export function configureAtomContext(options: AtomContextOptions): AtomContext {
24
+ currentContext = normalizeContext({ ...currentContext, ...options });
25
+ return getAtomContext();
26
+ }
27
+
28
+ export function resetAtomContext(): AtomContext {
29
+ currentContext = DEFAULT_CONTEXT;
30
+ return getAtomContext();
31
+ }
32
+
33
+ export function withAtomContext<T>(
34
+ options: AtomContextOptions,
35
+ callback: () => T,
36
+ ): T {
37
+ const previous = currentContext;
38
+ currentContext = normalizeContext({ ...currentContext, ...options });
39
+ try {
40
+ return callback();
41
+ } finally {
42
+ currentContext = previous;
43
+ }
44
+ }
45
+
46
+ export function defaultPrecision(): number {
47
+ return currentContext.precision;
48
+ }
49
+
50
+ export function defaultRoundMode(): RoundMode {
51
+ return currentContext.roundMode;
52
+ }
53
+
54
+ export function defaultQuantizeMode(): RoundMode {
55
+ return currentContext.quantizeMode;
56
+ }
57
+
58
+ function normalizeContext(context: AtomContext): AtomContext {
59
+ assertContextPrecision(context.precision);
60
+ return Object.freeze({ ...context });
61
+ }
62
+
63
+ function assertContextPrecision(precision: number): void {
64
+ if (!Number.isInteger(precision) || precision < 0 || precision > 10_000) {
65
+ throw new Error(`Invalid Atom context precision: ${precision}`);
66
+ }
67
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,20 @@
1
1
  /**
2
2
  * @c9up/atom — exact decimal arithmetic.
3
- * The Rust engine is required (NAPI in Node, WASM in browser) there is no JS/TS fallback.
3
+ * The Rust engine is preferred (NAPI in Node, WASM in browser), with a
4
+ * pure TypeScript BigInt fallback for unsupported platforms.
4
5
  */
5
6
 
7
+ export type { AtomContext, AtomContextOptions } from "./context.js";
8
+ export {
9
+ configureAtomContext,
10
+ getAtomContext,
11
+ resetAtomContext,
12
+ withAtomContext,
13
+ } from "./context.js";
6
14
  export type {
7
15
  BetweenOptions,
8
16
  DecimalInput,
17
+ DecimalSafeParseResult,
9
18
  DecimalScaled,
10
19
  DivOptions,
11
20
  MedianOptions,
@@ -17,10 +26,14 @@ export type {
17
26
  ToMinorUnitsOptions,
18
27
  } from "./Decimal.js";
19
28
  export { Decimal } from "./Decimal.js";
29
+ export type { MoneyFormatOptions, MoneyOptions } from "./Money.js";
30
+ export { Money, money } from "./Money.js";
20
31
  export { isNativeAvailable } from "./native.js";
21
32
 
33
+ import { defaultPrecision, defaultRoundMode } from "./context.js";
22
34
  import type { MedianOptions, StddevOptions } from "./Decimal.js";
23
35
  import { Decimal, type DecimalInput } from "./Decimal.js";
36
+ import { money } from "./Money.js";
24
37
 
25
38
  export function decimal(value: DecimalInput): Decimal {
26
39
  return Decimal.from(value);
@@ -98,7 +111,7 @@ function medianImpl(
98
111
  list.sort((a, b) => a.cmp(b));
99
112
  const mid = Math.floor(list.length / 2);
100
113
  if (list.length % 2 === 1) return list[mid];
101
- const precision = options.precision ?? 18;
114
+ const precision = options.precision ?? defaultPrecision();
102
115
  return list[mid - 1].plus(list[mid]).div("2", { precision });
103
116
  }
104
117
 
@@ -109,7 +122,7 @@ function modeImpl(values: Iterable<DecimalInput>): Decimal[] {
109
122
  frequencies.set(key, (frequencies.get(key) ?? 0) + 1);
110
123
  }
111
124
  if (frequencies.size === 0) {
112
- throw new Error("Atom.mode requires at least one value");
125
+ return [];
113
126
  }
114
127
  let maxCount = 0;
115
128
  for (const count of frequencies.values()) {
@@ -131,8 +144,8 @@ function stddevImpl(
131
144
  throw new Error("Atom.stddev requires at least one value");
132
145
  }
133
146
  const sample = options.sample ?? false;
134
- const precision = options.precision ?? 18;
135
- const mode = options.mode ?? "trunc";
147
+ const precision = options.precision ?? defaultPrecision();
148
+ const mode = options.mode ?? defaultRoundMode();
136
149
  const divisor = sample ? list.length - 1 : list.length;
137
150
  if (divisor <= 0) {
138
151
  throw new Error("Atom.stddev sample mode requires at least two values");
@@ -256,6 +269,8 @@ export const Atom = {
256
269
  /** Parse a locale-formatted decimal string (e.g. `'1.234,56'` in `fr-FR`). */
257
270
  parseLocale: (value: string, locales?: Intl.LocalesArgument) =>
258
271
  Decimal.parseLocale(value, locales),
272
+ /** Construct a currency-bound `Money` value. */
273
+ money,
259
274
  };
260
275
 
261
276
  export const sum = sumFn;
package/src/math.ts CHANGED
@@ -3,11 +3,61 @@ export interface ParsedDecimal {
3
3
  scale: number;
4
4
  }
5
5
 
6
+ /**
7
+ * How far an exponent may move the decimal point.
8
+ *
9
+ * An expression like `1E1000000` is syntactically fine and would expand to a
10
+ * megabyte of zeroes before anything could reject it. The bound is far past any
11
+ * real monetary or measurement scale, and refuses the pathological input as
12
+ * input rather than as an out-of-memory later.
13
+ */
14
+ const MAX_EXPONENT = 10_000;
15
+
16
+ /**
17
+ * Expand scientific notation into plain decimal text.
18
+ *
19
+ * `1E-10` is what `JSON.stringify` emits for a small number and what many APIs
20
+ * return, so a value that survives as a `number` has to survive as the string
21
+ * carrying it too — otherwise the same amount parses or throws depending on
22
+ * which side of a JSON boundary it arrived from.
23
+ */
24
+ function expandScientific(input: string): string {
25
+ const matched = /^([+-]?)(\d*)(?:\.(\d*))?[eE]([+-]?\d+)$/.exec(input);
26
+ if (!matched) {
27
+ throw new Error(`Invalid decimal: ${input}`);
28
+ }
29
+ const [, sign, wholeRaw = "", fracRaw = "", exponentRaw = "0"] = matched;
30
+ if (wholeRaw.length === 0 && fracRaw.length === 0) {
31
+ throw new Error(`Invalid decimal: ${input}`);
32
+ }
33
+
34
+ const exponent = Number(exponentRaw);
35
+ if (!Number.isInteger(exponent) || Math.abs(exponent) > MAX_EXPONENT) {
36
+ throw new Error(`Invalid decimal: ${input}`);
37
+ }
38
+
39
+ const digits = `${wholeRaw}${fracRaw}`;
40
+ const pointIndex = wholeRaw.length + exponent;
41
+
42
+ let expanded: string;
43
+ if (pointIndex <= 0) {
44
+ expanded = `0.${"0".repeat(-pointIndex)}${digits}`;
45
+ } else if (pointIndex >= digits.length) {
46
+ expanded = `${digits}${"0".repeat(pointIndex - digits.length)}`;
47
+ } else {
48
+ expanded = `${digits.slice(0, pointIndex)}.${digits.slice(pointIndex)}`;
49
+ }
50
+ return sign === "-" ? `-${expanded}` : expanded;
51
+ }
52
+
6
53
  export function parseDecimal(input: string): ParsedDecimal {
7
- const s = input.trim();
54
+ let s = input.trim();
8
55
  if (!s) {
9
56
  throw new Error("Invalid decimal: empty string");
10
57
  }
58
+ if (/[eE]/.test(s)) {
59
+ s = expandScientific(s);
60
+ }
11
61
 
12
62
  let sign = 1n;
13
63
  let body = s;
@@ -27,6 +77,9 @@ export function parseDecimal(input: string): ParsedDecimal {
27
77
  if (!/^\d*$/.test(whole) || !/^\d*$/.test(frac)) {
28
78
  throw new Error(`Invalid decimal: ${input}`);
29
79
  }
80
+ if (whole.length + frac.length === 0) {
81
+ throw new Error(`Invalid decimal: ${input}`);
82
+ }
30
83
 
31
84
  const digits = `${whole}${frac}` || "0";
32
85
  const int = BigInt(digits) * sign;
@@ -154,11 +207,11 @@ export function powTs(a: string, exp: number, precision: number): string {
154
207
  let e = exp;
155
208
 
156
209
  while (e > 0) {
157
- if (e & 1) {
210
+ if (e % 2 === 1) {
158
211
  resultInt *= currentInt;
159
212
  resultScale += currentScale;
160
213
  }
161
- e >>= 1;
214
+ e = Math.floor(e / 2);
162
215
  if (e > 0) {
163
216
  currentInt *= currentInt;
164
217
  currentScale *= 2;
package/src/native.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * - **Browser**: loads the `.wasm` binary via the wasm-pack JS glue (async init on
6
6
  * first module import via top-level await, then sync function calls)
7
7
  *
8
- * The consumer imports `nativeAtom()` and gets the same `NativeAtom` interface
9
- * regardless of the environment. No conditional imports needed at the call site.
8
+ * The Decimal facade prefers this engine when available and falls back to the
9
+ * pure TypeScript BigInt implementation when unavailable.
10
10
  */
11
11
 
12
12
  export interface NativeAtom {
@@ -94,6 +94,13 @@ export function nativeAtom(): NativeAtom {
94
94
  return native;
95
95
  }
96
96
 
97
+ export function tryNativeAtom(): NativeAtom | undefined {
98
+ if (overrideNative !== undefined) {
99
+ return overrideNative ?? undefined;
100
+ }
101
+ return native;
102
+ }
103
+
97
104
  // Test override (unchanged from before)
98
105
  let overrideNative: NativeAtom | null | undefined;
99
106