@reventlessdev/reventless-spec 3.0.0-alpha.88 → 3.0.0-alpha.89

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/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.89 (2026-07-31)
7
+
8
+ ### Features
9
+
10
+ * **spec:** add Money and a closed ISO 4217 Currency ([d4852ab](https://github.com/ReventlessDev/reventless-core/commit/d4852ab63e823e39fac793c4fa5ac31470db9655))
11
+
12
+
6
13
  # 3.0.0-alpha.88 (2026-07-30)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.88",
3
+ "version": "3.0.0-alpha.89",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -20,11 +20,12 @@
20
20
  "jsonschema2graphql": "1.1.1",
21
21
  "sury": "11.0.0-alpha.4",
22
22
  "sury-ppx": "11.0.0-alpha.2",
23
- "yaml": "^2.8.3"
23
+ "yaml": "^2.8.3",
24
+ "@reventlessdev/rescript-node": "2.0.0-alpha.0"
24
25
  },
25
26
  "devDependencies": {
26
27
  "rescript": "12.3.0",
27
- "@reventlessdev/rescript-jest": "1.0.0-alpha.9"
28
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "rescript": "12.3.0"
@@ -41,6 +42,7 @@
41
42
  "build": "rescript build",
42
43
  "start": "rescript start",
43
44
  "clean": "rescript clean",
45
+ "generate:currency": "node ./scripts/generate-currency.mjs",
44
46
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
45
47
  "dev": "NODE_OPTIONS='--experimental-vm-modules' jest --watchAll"
46
48
  }
package/rescript.json CHANGED
@@ -24,7 +24,8 @@
24
24
  ],
25
25
  "dependencies": [
26
26
  "sury",
27
- "@reventlessdev/rescript-jest"
27
+ "@reventlessdev/rescript-jest",
28
+ "@reventlessdev/rescript-node"
28
29
  ],
29
30
  "compiler-flags": [],
30
31
  "suffix": ".res.mjs"
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ // Generates `src/semantic/Currency.res` from the ISO 4217 table beside this
3
+ // script (`iso-4217-list-one.xml`, the standard's own "current currency & funds"
4
+ // publication).
5
+ //
6
+ // Why generated rather than hand-written: the codes and the minor-unit
7
+ // exponents have to agree, and the exponent is what makes `Money.format`
8
+ // derivable instead of a hardcoded `/100`. Taking both from one source makes
9
+ // them agree by construction — nobody has to remember that JPY has no decimals
10
+ // and TND has three.
11
+ //
12
+ // Updating to a newer ISO publication:
13
+ //
14
+ // curl -sL https://www.six-group.com/dam/download/financial-information/\
15
+ // data-center/iso-currrency/lists/list-one.xml \
16
+ // -o reventless/spec/scripts/iso-4217-list-one.xml
17
+ // pnpm --filter @reventlessdev/reventless-spec run generate:currency
18
+ //
19
+ // The output is committed: it is read in review, and a currency appearing or
20
+ // disappearing is exactly the kind of change that has to show up in a diff.
21
+
22
+ import {readFileSync, writeFileSync} from 'node:fs'
23
+ import {dirname, join} from 'node:path'
24
+ import {fileURLToPath} from 'node:url'
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url))
27
+ const source = join(here, 'iso-4217-list-one.xml')
28
+ const target = join(here, '..', 'src', 'semantic', 'Currency.res')
29
+
30
+ const xml = readFileSync(source, 'utf8')
31
+
32
+ const published = xml.match(/<ISO_4217[^>]*Pblshd="([^"]+)"/)?.[1]
33
+ if (!published) throw new Error(`no Pblshd date in ${source} — is this the ISO 4217 list?`)
34
+
35
+ const field = (entry, tag) => entry.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1]?.trim()
36
+
37
+ // One entry per country, so a currency used in several countries repeats. Keyed
38
+ // by code; a repeat that disagrees about the exponent is a corrupt table, not
39
+ // something to pick a winner from.
40
+ const byCode = new Map()
41
+ const skipped = []
42
+
43
+ for (const [, entry] of xml.matchAll(/<CcyNtry>([\s\S]*?)<\/CcyNtry>/g)) {
44
+ const code = field(entry, 'Ccy')
45
+ // Territories with no currency of their own (Antarctica) carry no <Ccy>.
46
+ if (!code) continue
47
+ const minorUnits = field(entry, 'CcyMnrUnts')
48
+ const name = field(entry, 'CcyNm')
49
+
50
+ // `N.A.` means the entry has no minor unit at all: the precious metals (XAU,
51
+ // XAG, XPD, XPT), the bond market units (XBA–XBD), XDR, XUA, XSU, the testing
52
+ // code XTS and the "no currency" sentinel XXX. Admitting them would make
53
+ // `exponent` partial, which is the one property this type exists to have — so
54
+ // the standard's own table draws the line rather than a curated opinion. A
55
+ // field holding a weight of gold is not holding money.
56
+ if (!/^\d+$/.test(minorUnits ?? '')) {
57
+ if (!skipped.some(s => s.code === code)) skipped.push({code, name, minorUnits})
58
+ continue
59
+ }
60
+
61
+ const exponent = Number(minorUnits)
62
+ const seen = byCode.get(code)
63
+ if (seen && seen.exponent !== exponent) {
64
+ throw new Error(
65
+ `${code} has two exponents in ${source}: ${seen.exponent} and ${exponent}`,
66
+ )
67
+ }
68
+ if (!seen) byCode.set(code, {code, name, exponent})
69
+ }
70
+
71
+ const currencies = [...byCode.values()].sort((a, b) => a.code.localeCompare(b.code))
72
+ if (currencies.length < 100) {
73
+ throw new Error(`only ${currencies.length} currencies parsed — the table did not parse`)
74
+ }
75
+
76
+ // The generated file quotes each currency's ISO name beside its constructor, so
77
+ // a reviewer reading a three-letter code does not have to look it up.
78
+ const constructors = currencies
79
+ .map(c => ` | /** ${c.name} */ ${c.code}`)
80
+ .join('\n')
81
+
82
+ const exponentArms = currencies
83
+ .map(c => ` | ${c.code} => ${c.exponent}`)
84
+ .join('\n')
85
+
86
+ const toStringArms = currencies.map(c => ` | ${c.code} => "${c.code}"`).join('\n')
87
+
88
+ // Wrapped rather than one 1,000-character line, so a code added or removed by a
89
+ // future ISO publication shows up as a one-line diff.
90
+ const all = currencies
91
+ .map(c => c.code)
92
+ .reduce((lines, code) => {
93
+ const last = lines[lines.length - 1]
94
+ if (last && `${last} ${code},`.length <= 96) lines[lines.length - 1] = `${last} ${code},`
95
+ else lines.push(` ${code},`)
96
+ return lines
97
+ }, [])
98
+ .join('\n')
99
+
100
+ const exponentCounts = [...new Set(currencies.map(c => c.exponent))]
101
+ .sort()
102
+ .map(e => `${currencies.filter(c => c.exponent === e).length}×${e}`)
103
+ .join(', ')
104
+
105
+ const skippedCodes = skipped.map(s => s.code).sort()
106
+ const group = codes => codes.filter(c => skippedCodes.includes(c)).join(', ')
107
+ const metals = group(['XAG', 'XAU', 'XPD', 'XPT'])
108
+ const bondUnits = group(['XBA', 'XBB', 'XBC', 'XBD'])
109
+ const rights = group(['XDR', 'XSU', 'XUA'])
110
+
111
+ const out = `// AUTO-GENERATED from ISO 4217 (published ${published}) — do not edit.
112
+ // Run \`pnpm --filter @reventlessdev/reventless-spec run generate:currency\`,
113
+ // or see \`scripts/generate-currency.mjs\` to update the source table first.
114
+
115
+ /**
116
+ A currency, closed to the ${currencies.length} codes ISO 4217 defines a minor unit for.
117
+
118
+ ## Why a type and not a three-letter string
119
+
120
+ A string field invites \`"eur"\` beside \`"EUR"\`, and two spellings of one
121
+ currency is a class of bug that reads as a data problem long after it became a
122
+ correctness problem — the values are present, and they simply never match. A
123
+ closed type makes the second spelling unwritable.
124
+
125
+ ## Why generated, and why every code
126
+
127
+ The alternative was a curated handful (EUR, USD, GBP, JPY, …), which is small
128
+ and readable and wrong the first time an application needs a currency nobody
129
+ listed — a compile error in someone else's domain, fixable only by a framework
130
+ release. The thing being avoided by curating is ${currencies.length} constructors that are
131
+ machine-written and never read in full; the thing being risked is a release.
132
+
133
+ Generation also buys the property that makes this type worth having: \`exponent\`
134
+ comes from the *same* source as the codes, so it cannot drift from them
135
+ (${exponentCounts} decimal places across the set). That is what lets
136
+ \`Money.format\` derive its decimal placement instead of hardcoding \`/100\`, and
137
+ therefore what makes it correct for JPY and TND without anyone remembering that
138
+ those two are special.
139
+
140
+ ## What is deliberately absent
141
+
142
+ The ${skipped.length} entries ISO lists with no minor unit: the precious metals
143
+ (${metals}), the bond market units (${bondUnits}), the accounting
144
+ units (${rights}), the testing code XTS, and the "no currency"
145
+ sentinel XXX. Each would make \`exponent\` partial, and a weight of gold is not
146
+ an amount of money. The standard's own table draws that line, so it is not a
147
+ curated opinion after all.
148
+
149
+ ## The wire form
150
+
151
+ A payload-less variant, so the stored and transmitted form is the three-letter
152
+ code itself — \`{"amount": 1000, "currency": "EUR"}\`. Standard at the boundary,
153
+ a checked type in the domain.
154
+ */
155
+ @schema
156
+ type t =
157
+ ${constructors}
158
+
159
+ /** Every currency, in code order. \`fromString\` is derived from this, so a code
160
+ that parses and a code that exists are the same set by construction. */
161
+ let all: array<t> = [
162
+ ${all}
163
+ ]
164
+
165
+ /** The currency's ISO 4217 alphabetic code. */
166
+ let toString = (currency: t): string =>
167
+ switch currency {
168
+ ${toStringArms}
169
+ }
170
+
171
+ /**
172
+ How many decimal places the currency's minor unit is: 2 for EUR, **0 for JPY**,
173
+ **3 for TND**, 4 for the Chilean Unidad de Fomento.
174
+
175
+ Total by construction — this is the whole reason the type is closed and the
176
+ table is generated. An amount is stored in integer minor units, so this is the
177
+ only thing that says where its decimal point goes.
178
+ */
179
+ let exponent = (currency: t): int =>
180
+ switch currency {
181
+ ${exponentArms}
182
+ }
183
+
184
+ let byCode: dict<t> = {
185
+ let d = Dict.make()
186
+ all->Array.forEach(c => d->Dict.set(toString(c), c))
187
+ d
188
+ }
189
+
190
+ /**
191
+ Parse an ISO 4217 alphabetic code, saying why when it is not one.
192
+
193
+ Case-sensitive on purpose: \`"eur"\` is rejected rather than repaired. This type
194
+ exists because a silent case mismatch is expensive to find, and quietly
195
+ accepting the wrong spelling at the boundary would put it back — a producer
196
+ sending lowercase codes should learn that at its first request, not at the first
197
+ report that two halves of a ledger disagree.
198
+ */
199
+ let fromString = (raw: string): result<t, string> =>
200
+ switch byCode->Dict.get(raw) {
201
+ | Some(c) => Ok(c)
202
+ | None =>
203
+ Error(
204
+ \`expected an ISO 4217 currency code such as "EUR", got \${raw
205
+ ->JSON.Encode.string
206
+ ->JSON.stringify}. Codes are upper-case and exactly three letters.\`,
207
+ )
208
+ }
209
+ `
210
+
211
+ writeFileSync(target, out)
212
+ console.log(
213
+ `Currency.res: ${currencies.length} currencies (${exponentCounts} decimals), ` +
214
+ `ISO 4217 published ${published}, ${skipped.length} minor-unit-less entries skipped.`,
215
+ )