@c9up/atom 0.1.3

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/atlas.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `@c9up/atom/atlas` — Atlas column-type adapter for {@link Decimal}.
3
+ *
4
+ * Wires `Atom.Decimal` into Atlas's `@Column({ prepare, consume })` opt-in
5
+ * column pipeline. Lives on a sub-export so the default `@c9up/atom` import
6
+ * surface stays adapter-free.
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.
11
+ *
12
+ * Usage:
13
+ *
14
+ * import { Column, Entity, BaseEntity, PrimaryKey } from '@c9up/atlas'
15
+ * import { decimalAtlasAdapter } from '@c9up/atom/atlas'
16
+ *
17
+ * @Entity('accounts')
18
+ * class Account extends BaseEntity {
19
+ * @PrimaryKey() id!: number
20
+ * @Column(decimalAtlasAdapter) balance!: Decimal | null
21
+ * }
22
+ *
23
+ * @implements Story 35.10
24
+ */
25
+
26
+ import { Decimal } from "./Decimal.js";
27
+
28
+ /**
29
+ * Atlas adapter for postgres `numeric` / `decimal` (and equivalent on mysql /
30
+ * sqlite) columns. `consume` lifts string/number/bigint DB values into a
31
+ * {@link Decimal}; `prepare` lowers a `Decimal` back to its lossless string
32
+ * form for the SQL bind parameter.
33
+ *
34
+ * - `consume(null)` / `consume(undefined)` returns `null` so nullable columns
35
+ * keep their semantics through the adapter pipeline.
36
+ * - `prepare(null)` / `prepare(undefined)` returns `null` symmetrically.
37
+ * - `prepare` rejects anything that is not a `Decimal` — protects against the
38
+ * common "I forgot to wrap" footgun where a JS number would otherwise
39
+ * silently coerce via `String(x)` and lose precision.
40
+ *
41
+ * **Driver requirement:** configure your DB driver to return `numeric` /
42
+ * `decimal` columns as `string` (postgres-js does this by default; mysql2
43
+ * needs `decimalNumbers: false`; better-sqlite3 returns whatever the bound
44
+ * type was). If the driver returns numeric values as JS `number`, precision
45
+ * is already lost before `consume` is called — `9007199254740993` (one beyond
46
+ * `Number.MAX_SAFE_INTEGER`) arrives as `9007199254740992` and the adapter
47
+ * faithfully wraps the rounded value. The "lossless" round-trip claim only
48
+ * holds when DB → driver → adapter stays in the string/bigint domain.
49
+ *
50
+ * The shape `{ prepare, consume }` is spreadable directly into `@Column(...)`:
51
+ * `@Column(decimalAtlasAdapter)` is identical to
52
+ * `@Column({ prepare: decimalAtlasAdapter.prepare, consume: decimalAtlasAdapter.consume })`.
53
+ *
54
+ * The exported object is `Object.freeze`d so a stray test setup file or
55
+ * plugin cannot monkey-patch `prepare` / `consume` at runtime and silently
56
+ * corrupt every repository sharing this import.
57
+ */
58
+ export const decimalAtlasAdapter = Object.freeze({
59
+ consume(raw: unknown): Decimal | null {
60
+ if (raw === null || raw === undefined) return null;
61
+ if (raw instanceof Decimal) return raw;
62
+ if (
63
+ typeof raw === "string" ||
64
+ typeof raw === "number" ||
65
+ typeof raw === "bigint"
66
+ ) {
67
+ return new Decimal(raw);
68
+ }
69
+ throw new TypeError(
70
+ `decimalAtlasAdapter.consume: expected string | number | bigint | Decimal | null, got ${typeof raw}`,
71
+ );
72
+ },
73
+ prepare(value: unknown): string | null {
74
+ if (value === null || value === undefined) return null;
75
+ if (!(value instanceof Decimal)) {
76
+ throw new TypeError(
77
+ `decimalAtlasAdapter.prepare: expected a Decimal instance, got ${typeof value === "object" ? Object.prototype.toString.call(value) : typeof value}. ` +
78
+ "Wrap the value with `new Decimal(...)` before assigning to a column tagged with this adapter.",
79
+ );
80
+ }
81
+ return value.toString();
82
+ },
83
+ });
package/src/index.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * @c9up/atom — exact decimal arithmetic.
3
+ * The Rust engine is required (NAPI in Node, WASM in browser) — there is no JS/TS fallback.
4
+ */
5
+
6
+ export type {
7
+ BetweenOptions,
8
+ DecimalInput,
9
+ DecimalScaled,
10
+ DivOptions,
11
+ MedianOptions,
12
+ PowOptions,
13
+ QuantizeOptions,
14
+ RoundMode,
15
+ SqrtOptions,
16
+ StddevOptions,
17
+ ToMinorUnitsOptions,
18
+ } from "./Decimal.js";
19
+ export { Decimal } from "./Decimal.js";
20
+ export { isNativeAvailable } from "./native.js";
21
+
22
+ import type { MedianOptions, StddevOptions } from "./Decimal.js";
23
+ import { Decimal, type DecimalInput } from "./Decimal.js";
24
+
25
+ export function decimal(value: DecimalInput): Decimal {
26
+ return Decimal.from(value);
27
+ }
28
+
29
+ function isDecimalIterable(value: unknown): value is Iterable<DecimalInput> {
30
+ if (typeof value === "string") return false;
31
+ return (
32
+ typeof value === "object" && value !== null && Symbol.iterator in value
33
+ );
34
+ }
35
+
36
+ function resolveValues(
37
+ args: [Iterable<DecimalInput>] | DecimalInput[],
38
+ ): Iterable<DecimalInput> {
39
+ if (args.length === 1 && isDecimalIterable(args[0])) {
40
+ return args[0] as Iterable<DecimalInput>;
41
+ }
42
+ return args as DecimalInput[];
43
+ }
44
+
45
+ function sumImpl(values: Iterable<DecimalInput>): Decimal {
46
+ let total = Decimal.zero();
47
+ for (const value of values) {
48
+ total = total.plus(value);
49
+ }
50
+ return total;
51
+ }
52
+
53
+ function avgImpl(values: Iterable<DecimalInput>): Decimal {
54
+ let total = Decimal.zero();
55
+ let count = 0;
56
+ for (const value of values) {
57
+ total = total.plus(value);
58
+ count++;
59
+ }
60
+ if (count === 0) {
61
+ throw new Error("Atom.avg requires at least one value");
62
+ }
63
+ return total.div(String(count));
64
+ }
65
+
66
+ function minImpl(values: Iterable<DecimalInput>): Decimal {
67
+ const list = [...values].map((value) => new Decimal(value));
68
+ if (list.length === 0) {
69
+ throw new Error("Atom.min requires at least one value");
70
+ }
71
+ let best = list[0];
72
+ for (let i = 1; i < list.length; i++) {
73
+ if (list[i].lt(best)) best = list[i];
74
+ }
75
+ return best;
76
+ }
77
+
78
+ function maxImpl(values: Iterable<DecimalInput>): Decimal {
79
+ const list = [...values].map((value) => new Decimal(value));
80
+ if (list.length === 0) {
81
+ throw new Error("Atom.max requires at least one value");
82
+ }
83
+ let best = list[0];
84
+ for (let i = 1; i < list.length; i++) {
85
+ if (list[i].gt(best)) best = list[i];
86
+ }
87
+ return best;
88
+ }
89
+
90
+ function medianImpl(
91
+ values: Iterable<DecimalInput>,
92
+ options: MedianOptions = {},
93
+ ): Decimal {
94
+ const list = [...values].map((value) => new Decimal(value));
95
+ if (list.length === 0) {
96
+ throw new Error("Atom.median requires at least one value");
97
+ }
98
+ list.sort((a, b) => a.cmp(b));
99
+ const mid = Math.floor(list.length / 2);
100
+ if (list.length % 2 === 1) return list[mid];
101
+ const precision = options.precision ?? 18;
102
+ return list[mid - 1].plus(list[mid]).div("2", { precision });
103
+ }
104
+
105
+ function modeImpl(values: Iterable<DecimalInput>): Decimal[] {
106
+ const frequencies = new Map<string, number>();
107
+ for (const value of values) {
108
+ const key = new Decimal(value).toString();
109
+ frequencies.set(key, (frequencies.get(key) ?? 0) + 1);
110
+ }
111
+ if (frequencies.size === 0) {
112
+ throw new Error("Atom.mode requires at least one value");
113
+ }
114
+ let maxCount = 0;
115
+ for (const count of frequencies.values()) {
116
+ if (count > maxCount) maxCount = count;
117
+ }
118
+ if (maxCount <= 1) return [];
119
+ return [...frequencies.entries()]
120
+ .filter(([, count]) => count === maxCount)
121
+ .map(([value]) => new Decimal(value))
122
+ .sort((a, b) => a.cmp(b));
123
+ }
124
+
125
+ function stddevImpl(
126
+ values: Iterable<DecimalInput>,
127
+ options: StddevOptions = {},
128
+ ): Decimal {
129
+ const list = [...values].map((value) => new Decimal(value));
130
+ if (list.length === 0) {
131
+ throw new Error("Atom.stddev requires at least one value");
132
+ }
133
+ const sample = options.sample ?? false;
134
+ const precision = options.precision ?? 18;
135
+ const mode = options.mode ?? "trunc";
136
+ const divisor = sample ? list.length - 1 : list.length;
137
+ if (divisor <= 0) {
138
+ throw new Error("Atom.stddev sample mode requires at least two values");
139
+ }
140
+ const mean = avgImpl(list);
141
+ let sumSquares = Decimal.zero();
142
+ for (const value of list) {
143
+ const diff = value.minus(mean);
144
+ sumSquares = sumSquares.plus(diff.times(diff));
145
+ }
146
+ const variance = sumSquares.div(String(divisor), {
147
+ precision: precision + 8,
148
+ });
149
+ return variance.sqrt({ precision, mode });
150
+ }
151
+
152
+ function parseMedianArgs(
153
+ args: [Iterable<DecimalInput>, MedianOptions?] | DecimalInput[],
154
+ ): { values: Iterable<DecimalInput>; options: MedianOptions } {
155
+ if (args.length >= 1 && isDecimalIterable(args[0])) {
156
+ const values = args[0] as Iterable<DecimalInput>;
157
+ const options =
158
+ args.length > 1 && typeof args[1] === "object" && args[1] !== null
159
+ ? (args[1] as MedianOptions)
160
+ : {};
161
+ return { values, options };
162
+ }
163
+ return { values: args as DecimalInput[], options: {} };
164
+ }
165
+
166
+ function parseStddevArgs(
167
+ args: [Iterable<DecimalInput>, StddevOptions?] | DecimalInput[],
168
+ ): { values: Iterable<DecimalInput>; options: StddevOptions } {
169
+ if (args.length >= 1 && isDecimalIterable(args[0])) {
170
+ const values = args[0] as Iterable<DecimalInput>;
171
+ const options =
172
+ args.length > 1 && typeof args[1] === "object" && args[1] !== null
173
+ ? (args[1] as StddevOptions)
174
+ : {};
175
+ return { values, options };
176
+ }
177
+ return { values: args as DecimalInput[], options: {} };
178
+ }
179
+
180
+ function sumFn(...args: [Iterable<DecimalInput>] | DecimalInput[]): Decimal {
181
+ return sumImpl(resolveValues(args));
182
+ }
183
+
184
+ function avgFn(...args: [Iterable<DecimalInput>] | DecimalInput[]): Decimal {
185
+ return avgImpl(resolveValues(args));
186
+ }
187
+
188
+ function minFn(...args: [Iterable<DecimalInput>] | DecimalInput[]): Decimal {
189
+ return minImpl(resolveValues(args));
190
+ }
191
+
192
+ function maxFn(...args: [Iterable<DecimalInput>] | DecimalInput[]): Decimal {
193
+ return maxImpl(resolveValues(args));
194
+ }
195
+
196
+ function modeFn(...args: [Iterable<DecimalInput>] | DecimalInput[]): Decimal[] {
197
+ return modeImpl(resolveValues(args));
198
+ }
199
+
200
+ function medianFn(
201
+ ...args: [Iterable<DecimalInput>, MedianOptions?] | DecimalInput[]
202
+ ): Decimal {
203
+ const { values, options } = parseMedianArgs(args);
204
+ return medianImpl(values, options);
205
+ }
206
+
207
+ function stddevFn(
208
+ ...args: [Iterable<DecimalInput>, StddevOptions?] | DecimalInput[]
209
+ ): Decimal {
210
+ const { values, options } = parseStddevArgs(args);
211
+ return stddevImpl(values, options);
212
+ }
213
+
214
+ /**
215
+ * Atom — namespace bundling the public functional API.
216
+ *
217
+ * Two equivalent ways to access aggregates: `Atom.sum(...)` for namespaced
218
+ * usage, or named imports (`import { sum } from '@c9up/atom'`).
219
+ *
220
+ * @example
221
+ * import { Atom, decimal } from '@c9up/atom'
222
+ * const total = Atom.sum('1.10', '2.20', '3.33') // → Decimal('6.63')
223
+ * const mean = Atom.avg(['10', '20', '30']) // → Decimal('20')
224
+ * const value = decimal('99.99').times('0.20') // → Decimal('19.998')
225
+ */
226
+ export const Atom = {
227
+ /** Construct a `Decimal` from a string / number / bigint / Decimal. Alias for `Decimal.from`. */
228
+ decimal,
229
+ /** Exact sum of N values. Empty input → `Decimal('0')`. */
230
+ sum: sumFn,
231
+ /** Arithmetic mean of N values. Throws on empty input. */
232
+ avg: avgFn,
233
+ /** Sorted-middle of N values. Even-length lists return the average of the two middle elements at the configured precision (default 18). Throws on empty input. */
234
+ median: medianFn,
235
+ /**
236
+ * Statistical mode — values appearing the most often. Returns an array
237
+ * (multi-modal lists possible).
238
+ *
239
+ * **Edge case**: returns `[]` when no value repeats (every value has count 1).
240
+ * This is intentional — there is no "mode" in a strictly unique list. If
241
+ * your use case requires "first encountered" semantics on unique lists, use
242
+ * `[...new Set(values)][0]` instead.
243
+ */
244
+ mode: modeFn,
245
+ /**
246
+ * Standard deviation. `sample: false` (default) computes the population
247
+ * stddev (`/ N`); `sample: true` computes the sample stddev (`/ N-1`),
248
+ * which requires at least two values. Internal precision is bumped by 8
249
+ * extra digits to mitigate compounding rounding error.
250
+ */
251
+ stddev: stddevFn,
252
+ /** Smallest of N values. Throws on empty input. */
253
+ min: minFn,
254
+ /** Largest of N values. Throws on empty input. */
255
+ max: maxFn,
256
+ /** Parse a locale-formatted decimal string (e.g. `'1.234,56'` in `fr-FR`). */
257
+ parseLocale: (value: string, locales?: Intl.LocalesArgument) =>
258
+ Decimal.parseLocale(value, locales),
259
+ };
260
+
261
+ export const sum = sumFn;
262
+ export const avg = avgFn;
263
+ export const median = medianFn;
264
+ export const mode = modeFn;
265
+ export const stddev = stddevFn;
266
+ export const min = minFn;
267
+ export const max = maxFn;
package/src/math.ts ADDED
@@ -0,0 +1,203 @@
1
+ export interface ParsedDecimal {
2
+ int: bigint;
3
+ scale: number;
4
+ }
5
+
6
+ export function parseDecimal(input: string): ParsedDecimal {
7
+ const s = input.trim();
8
+ if (!s) {
9
+ throw new Error("Invalid decimal: empty string");
10
+ }
11
+
12
+ let sign = 1n;
13
+ let body = s;
14
+ if (body.startsWith("-")) {
15
+ sign = -1n;
16
+ body = body.slice(1);
17
+ } else if (body.startsWith("+")) {
18
+ body = body.slice(1);
19
+ }
20
+
21
+ const parts = body.split(".");
22
+ if (parts.length > 2) {
23
+ throw new Error(`Invalid decimal: ${input}`);
24
+ }
25
+ const whole = parts[0] ?? "";
26
+ const frac = parts[1] ?? "";
27
+ if (!/^\d*$/.test(whole) || !/^\d*$/.test(frac)) {
28
+ throw new Error(`Invalid decimal: ${input}`);
29
+ }
30
+
31
+ const digits = `${whole}${frac}` || "0";
32
+ const int = BigInt(digits) * sign;
33
+ return { int, scale: frac.length };
34
+ }
35
+
36
+ export function formatDecimal(int: bigint, scale: number): string {
37
+ if (scale === 0) return int.toString();
38
+
39
+ const negative = int < 0n;
40
+ let s = (negative ? -int : int).toString();
41
+ if (s.length <= scale) {
42
+ s = `${"0".repeat(scale + 1 - s.length)}${s}`;
43
+ }
44
+
45
+ const split = s.length - scale;
46
+ const whole = s.slice(0, split);
47
+ let frac = s.slice(split);
48
+ frac = frac.replace(/0+$/, "");
49
+
50
+ let out = frac ? `${whole}.${frac}` : whole;
51
+ if (negative && out !== "0") out = `-${out}`;
52
+ return out;
53
+ }
54
+
55
+ export function addTs(a: string, b: string): string {
56
+ const da = parseDecimal(a);
57
+ const db = parseDecimal(b);
58
+ const [ai, bi, scale] = alignScale(da, db);
59
+ return formatDecimal(ai + bi, scale);
60
+ }
61
+
62
+ export function subTs(a: string, b: string): string {
63
+ const da = parseDecimal(a);
64
+ const db = parseDecimal(b);
65
+ const [ai, bi, scale] = alignScale(da, db);
66
+ return formatDecimal(ai - bi, scale);
67
+ }
68
+
69
+ export function mulTs(a: string, b: string): string {
70
+ const da = parseDecimal(a);
71
+ const db = parseDecimal(b);
72
+ return formatDecimal(da.int * db.int, da.scale + db.scale);
73
+ }
74
+
75
+ export function divTs(a: string, b: string, precision: number): string {
76
+ const da = parseDecimal(a);
77
+ const db = parseDecimal(b);
78
+ if (db.int === 0n) {
79
+ throw new Error("Division by zero");
80
+ }
81
+ const numerator = da.int * pow10BigInt(precision + db.scale);
82
+ const denominator = db.int * pow10BigInt(da.scale);
83
+ const q = numerator / denominator;
84
+ return formatDecimal(q, precision);
85
+ }
86
+
87
+ export function cmpTs(a: string, b: string): -1 | 0 | 1 {
88
+ const da = parseDecimal(a);
89
+ const db = parseDecimal(b);
90
+ const [ai, bi] = alignScale(da, db);
91
+ if (ai < bi) return -1;
92
+ if (ai > bi) return 1;
93
+ return 0;
94
+ }
95
+
96
+ /**
97
+ * Align two parsed decimals to the same scale by multiplying the less-precise
98
+ * one by `10^(scaleDelta)`. Exported so `Decimal.ts` can reuse it — previously
99
+ * both files had their own copy under different names (`alignScale` vs
100
+ * `alignScaleParts`), which is a maintenance hazard.
101
+ */
102
+ export function alignScale(
103
+ a: ParsedDecimal,
104
+ b: ParsedDecimal,
105
+ ): [bigint, bigint, number] {
106
+ if (a.scale === b.scale) return [a.int, b.int, a.scale];
107
+ if (a.scale > b.scale) {
108
+ const factor = pow10BigInt(a.scale - b.scale);
109
+ return [a.int, b.int * factor, a.scale];
110
+ }
111
+ const factor = pow10BigInt(b.scale - a.scale);
112
+ return [a.int * factor, b.int, b.scale];
113
+ }
114
+
115
+ /** Exact `10^exp` as a `bigint`. Exported — single definition for the whole TS side. */
116
+ export function pow10BigInt(exp: number): bigint {
117
+ let acc = 1n;
118
+ for (let i = 0; i < exp; i++) acc *= 10n;
119
+ return acc;
120
+ }
121
+
122
+ /**
123
+ * Pure-TS modulo — fallback for when the native engine is unavailable. The
124
+ * Rust `rem` path should be preferred when `isNativeAvailable()`.
125
+ */
126
+ export function modTs(a: string, b: string): string {
127
+ const da = parseDecimal(a);
128
+ const db = parseDecimal(b);
129
+ if (db.int === 0n) {
130
+ throw new Error("Division by zero");
131
+ }
132
+ const [ai, bi, scale] = alignScale(da, db);
133
+ return formatDecimal(ai % bi, scale);
134
+ }
135
+
136
+ /**
137
+ * Pure-TS integer exponentiation with truncating div for negative exponents.
138
+ * Mirrors the Rust `pow` contract so the two paths produce identical results.
139
+ */
140
+ export function powTs(a: string, exp: number, precision: number): string {
141
+ if (!Number.isInteger(exp)) {
142
+ throw new Error(`Invalid exponent: ${exp}`);
143
+ }
144
+ if (exp === 0) return "1";
145
+ if (exp < 0) {
146
+ return divTs("1", powTs(a, -exp, precision), precision);
147
+ }
148
+
149
+ const base = parseDecimal(a);
150
+ let resultInt = 1n;
151
+ let resultScale = 0;
152
+ let currentInt = base.int;
153
+ let currentScale = base.scale;
154
+ let e = exp;
155
+
156
+ while (e > 0) {
157
+ if (e & 1) {
158
+ resultInt *= currentInt;
159
+ resultScale += currentScale;
160
+ }
161
+ e >>= 1;
162
+ if (e > 0) {
163
+ currentInt *= currentInt;
164
+ currentScale *= 2;
165
+ }
166
+ }
167
+ return formatDecimal(resultInt, resultScale);
168
+ }
169
+
170
+ /**
171
+ * Pure-TS integer square root via Newton's iteration on BigInt, scaled to
172
+ * produce `precision` fractional digits. Truncates toward zero; rounding
173
+ * modes are applied by the caller.
174
+ */
175
+ export function sqrtTs(a: string, precision: number): string {
176
+ const parsed = parseDecimal(a);
177
+ if (parsed.int < 0n) {
178
+ throw new Error("Cannot compute sqrt of a negative decimal");
179
+ }
180
+ if (parsed.int === 0n) return "0";
181
+
182
+ const factorExp = 2 * precision - parsed.scale;
183
+ let radicand = parsed.int;
184
+ if (factorExp >= 0) {
185
+ radicand *= pow10BigInt(factorExp);
186
+ } else {
187
+ radicand /= pow10BigInt(-factorExp);
188
+ }
189
+ const root = bigintSqrt(radicand);
190
+ return formatDecimal(root, precision);
191
+ }
192
+
193
+ function bigintSqrt(value: bigint): bigint {
194
+ if (value < 0n) throw new Error("Cannot compute sqrt of negative bigint");
195
+ if (value < 2n) return value;
196
+ let x0 = value;
197
+ let x1 = (x0 + value / x0) / 2n;
198
+ while (x1 < x0) {
199
+ x0 = x1;
200
+ x1 = (x0 + value / x0) / 2n;
201
+ }
202
+ return x0;
203
+ }
package/src/native.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Universal engine loader — auto-detects Node (NAPI) vs Browser (WASM).
3
+ *
4
+ * - **Node**: loads the prebuilt `.node` binary via `createRequire` (sync, fast)
5
+ * - **Browser**: loads the `.wasm` binary via the wasm-pack JS glue (async init on
6
+ * first module import via top-level await, then sync function calls)
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.
10
+ */
11
+
12
+ export interface NativeAtom {
13
+ add(a: string, b: string): string;
14
+ sub(a: string, b: string): string;
15
+ mul(a: string, b: string): string;
16
+ div(a: string, b: string, precision: number): string;
17
+ rem(a: string, b: string): string;
18
+ pow(a: string, exp: number, precision: number): string;
19
+ sqrt(a: string, precision: number): string;
20
+ cmp(a: string, b: string): number;
21
+ }
22
+
23
+ let native: NativeAtom | undefined;
24
+ let loadError: unknown;
25
+
26
+ const isNode =
27
+ typeof globalThis.process !== "undefined" &&
28
+ typeof globalThis.process.versions?.node === "string";
29
+
30
+ if (isNode) {
31
+ // Node path: load NAPI binary (sync).
32
+ try {
33
+ const { createRequire } = await import("node:module");
34
+ const { dirname, join } = await import("node:path");
35
+ const { fileURLToPath } = await import("node:url");
36
+ const { arch, platform } = await import("node:process");
37
+
38
+ const nodeRequire = createRequire(import.meta.url);
39
+ const currentDir = dirname(fileURLToPath(import.meta.url));
40
+
41
+ const platformMap: Record<string, string> = {
42
+ "linux-x64": "linux-x64-gnu",
43
+ "linux-arm64": "linux-arm64-gnu",
44
+ "darwin-x64": "darwin-x64",
45
+ "darwin-arm64": "darwin-arm64",
46
+ "win32-x64": "win32-x64-msvc",
47
+ };
48
+
49
+ const suffix = platformMap[`${platform}-${arch}`];
50
+ if (suffix) {
51
+ native = nodeRequire(join(currentDir, `../index.${suffix}.node`));
52
+ }
53
+ } catch (e) {
54
+ loadError = e;
55
+ }
56
+ } else {
57
+ // Browser path: load WASM (async init, then sync calls).
58
+ try {
59
+ const wasm: { default: () => Promise<unknown> } & NativeAtom = await import(
60
+ "../wasm/atom_engine_wasm.js"
61
+ );
62
+ await wasm.default();
63
+ native = wasm;
64
+ } catch (e) {
65
+ loadError = e;
66
+ }
67
+ }
68
+
69
+ /** Whether the native engine (NAPI or WASM) loaded successfully. */
70
+ export function isNativeAvailable(): boolean {
71
+ if (overrideNative === null) return false;
72
+ if (overrideNative !== undefined) return true;
73
+ return native !== undefined;
74
+ }
75
+
76
+ export function nativeAtom(): NativeAtom {
77
+ if (overrideNative !== undefined) {
78
+ if (overrideNative === null) {
79
+ throw new Error(
80
+ "[ATOM_NAPI_DISABLED] Native engine is disabled (test override)",
81
+ );
82
+ }
83
+ return overrideNative;
84
+ }
85
+ if (!native) {
86
+ throw new Error(
87
+ `[ATOM_ENGINE_NOT_FOUND] Decimal engine not available.\n` +
88
+ ` Environment: ${isNode ? "Node" : "Browser"}\n` +
89
+ ` Reason: ${loadError ?? "binary not found"}\n` +
90
+ ` Fix (Node): cd packages/atom && pnpm build:napi\n` +
91
+ ` Fix (Browser): cd packages/atom && pnpm build:wasm`,
92
+ );
93
+ }
94
+ return native;
95
+ }
96
+
97
+ // Test override (unchanged from before)
98
+ let overrideNative: NativeAtom | null | undefined;
99
+
100
+ export function __overrideNativeForTesting(
101
+ impl: NativeAtom | null | undefined,
102
+ ): void {
103
+ overrideNative = impl;
104
+ }
@@ -0,0 +1,19 @@
1
+ // Hand-written stub for the wasm-pack-generated glue file. Lets `tsc --noEmit`
2
+ // pass on a fresh checkout before `pnpm build:wasm` has been run. wasm-pack
3
+ // will overwrite this file with its real generated declarations on the next
4
+ // `build:wasm`; the runtime shape stays compatible.
5
+ //
6
+ // Story 52.1 review patch (2026-05-09): typecheck was broken on a fresh
7
+ // checkout because src/native.ts:60 imports this glue file, and `tsc` could
8
+ // not find it without first running `build:wasm`.
9
+
10
+ export default function init(): Promise<unknown>;
11
+
12
+ export function add(a: string, b: string): string;
13
+ export function sub(a: string, b: string): string;
14
+ export function mul(a: string, b: string): string;
15
+ export function div(a: string, b: string, precision: number): string;
16
+ export function rem(a: string, b: string): string;
17
+ export function pow(a: string, exp: number, precision: number): string;
18
+ export function sqrt(a: string, precision: number): string;
19
+ export function cmp(a: string, b: string): number;