@meantjs/format 0.2.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  The five Candid Profile library packages (`@meantjs/profile`, `/format`, `/consent`, `/tools`, `/reconcile`) release together under one version and pin each other exactly; a version appears here even when this package did not change. Release steps: `docs/releasing.md` in the repository.
4
4
 
5
+ ## 0.4.0 — 2026-10-30
6
+
7
+ No changes in this package.
8
+
9
+ ## 0.3.0 — 2026-10-29
10
+
11
+ - `timestamp` takes `threshold` with `message`, and `labels` (spec §5.3, §15.9, D-055): `displayTimestamp` shows a
12
+ labelled value as its words, and a value at or above the threshold as the message ("Never"), never as a date.
13
+ Parsing is unchanged. The two come together, with no default word (`E_ARG`).
14
+ - `formatterFor(node, { token })`: an `amount` whose `token` names its token by a field takes that token's decimals
15
+ and symbol from the caller; without them it is `E_REF_UNRESOLVED`.
16
+
5
17
  ## 0.2.0 — 2026-10-28
6
18
 
7
19
  - EVM integer types (spec §15, D-054): `amount` on `uint8`…`uint256` and `int8`…`int256`, `timestamp` and `duration`
package/README.md CHANGED
@@ -21,6 +21,10 @@ npm install @meantjs/format
21
21
  works like a Candid one. `amount` takes `uint8`…`uint256` and `int8`…`int256`, `timestamp` and `duration`
22
22
  `uint32`…`uint256` and `int64`…`int256`, each with the bounds of its bits; `bytes` takes `bytes` and `bytesN`
23
23
  (exactly N bytes). `amount`'s `threshold` and `message` show a value at or above the threshold as `Unlimited USDC`.
24
+ - **Words for times** (spec §5.3, §15.9): `timestamp`'s `labels` show exact values as words, and `threshold` with
25
+ `message` a value at or above it ("Never" for a deadline of `uint256` max, which is no date). Display only.
26
+ - **An amount in a named token** (spec §15.9): an `amount` whose `token` names its token by a field takes that token's
27
+ decimals and symbol from the caller, `formatterFor(node, { token: { decimals: 6, symbol: "USDC" } })`.
24
28
 
25
29
  ```sh
26
30
  npm test -w packages/format # or: npm test at the repo root
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meantjs/format",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Exact value conversion for Candid Profile value tags (amount, timestamp, duration, account, subaccount, bytes): parse human input, display canonical text, never round.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/amount.mjs CHANGED
@@ -30,23 +30,29 @@ export function checkSymbol(symbol) {
30
30
  return symbol;
31
31
  }
32
32
 
33
- /** @returns {bigint | undefined} a validated threshold: a canonical decimal integer string or a bigint, not negative */
34
- export function checkThreshold(threshold) {
33
+ /**
34
+ * @param {string} [what] how errors name the argument: "amount" (base units), or "timestamp" (the tagged unit)
35
+ * @returns {bigint | undefined} a validated threshold: a canonical decimal integer string or a bigint, not negative
36
+ */
37
+ export function checkThreshold(threshold, what = "amount") {
35
38
  if (threshold === undefined || threshold === null) return undefined;
36
39
  if (typeof threshold === "bigint" ? threshold < 0n : typeof threshold !== "string" || !/^(?:0|[1-9][0-9]{0,77})$/.test(threshold)) {
37
- fail(CODES.ARG, `amount threshold must be a decimal string of base units (0 or more, at most 78 digits) or a bigint, got ${typeof threshold === "string" ? JSON.stringify(threshold) : String(threshold)}`);
40
+ fail(CODES.ARG, `${what} threshold must be a decimal string of ${what === "amount" ? "base units" : "the tagged unit"} (0 or more, at most 78 digits) or a bigint, got ${typeof threshold === "string" ? JSON.stringify(threshold) : String(threshold)}`);
38
41
  }
39
42
  return BigInt(threshold);
40
43
  }
41
44
 
42
- /** @returns {string | undefined} a validated threshold message: 1 to 32 characters, nothing invisible */
43
- export function checkMessage(message) {
45
+ /**
46
+ * @param {string} [what] how errors name the text: "amount message", "timestamp message", a timestamp label
47
+ * @returns {string | undefined} a validated threshold message: 1 to 32 characters, nothing invisible
48
+ */
49
+ export function checkMessage(message, what = "amount message") {
44
50
  if (message === undefined || message === null) return undefined;
45
- if (typeof message !== "string") fail(CODES.ARG, `amount message must be a string, got ${typeof message}`);
51
+ if (typeof message !== "string") fail(CODES.ARG, `${what} must be a string, got ${typeof message}`);
46
52
  const n = [...message].length;
47
- if (n < 1 || n > 32) fail(CODES.ARG, `amount message must be 1 to 32 characters, got ${n}`);
53
+ if (n < 1 || n > 32) fail(CODES.ARG, `${what} must be 1 to 32 characters, got ${n}`);
48
54
  if (message !== message.trim() || UNSAFE_DISPLAY_CHAR.test(message) || !message.isWellFormed()) {
49
- fail(CODES.ARG, `amount message ${JSON.stringify(message)} has surrounding whitespace, control or invisible characters`);
55
+ fail(CODES.ARG, `${what} ${JSON.stringify(message)} has surrounding whitespace, control or invisible characters`);
50
56
  }
51
57
  return message;
52
58
  }
package/src/formatter.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { CODES, FormatError, fail } from "./errors.mjs";
9
9
  import { AMOUNT_TYPES, DEFAULT_THRESHOLD_MESSAGE, checkDecimals, checkMessage, checkSymbol, checkThreshold, displayAmount, parseAmount } from "./amount.mjs";
10
- import { TIME_TYPES, displayDuration, displayTimestamp, parseDuration, parseTimestamp, unitFactor } from "./time.mjs";
10
+ import { TIME_TYPES, checkTimeLabels, displayDuration, displayTimestamp, parseDuration, parseTimestamp, unitFactor } from "./time.mjs";
11
11
  import { displayAccount, parseAccount } from "./account.mjs";
12
12
  import { displaySubaccount, parseSubaccount } from "./subaccount.mjs";
13
13
  import { BYTES_ENCODINGS, checkMaxLen, displayBytes, parseBytes } from "./bytes.mjs";
@@ -65,13 +65,15 @@ function requireUnit(unit, tag) {
65
65
 
66
66
  /**
67
67
  * @param {{ candid: string, tags?: Record<string, { args?: Record<string, unknown> }> }} node
68
- * @param {{ refs?: Record<string, unknown>, now?: bigint }} [options]
68
+ * @param {{ refs?: Record<string, unknown>, now?: bigint, token?: { decimals: number | bigint | string, symbol?: string } }} [options]
69
69
  * `refs`: resolved dynamic values keyed by their `from` string, for example
70
70
  * `{ "query:icrc1_decimals": 8, "query:icrc1_symbol": "ICP" }`.
71
71
  * `now`: default clock (nanoseconds, bigint) for relative timestamps; `parse(input, { now })` overrides it.
72
+ * `token`: for an amount whose `token` argument names the token by a field (spec section 15.9), the decimals and
73
+ * symbol of the token that field holds in this value; the caller reads them, as it resolves `refs`.
72
74
  * @returns {Formatter | null} null when the position has no value tag (render it as Candid text, spec §6)
73
75
  */
74
- export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
76
+ export function formatterFor(node, { refs = {}, now: defaultNow, token } = {}) {
75
77
  if (node !== null && typeof node === "object" && node.candid === undefined && typeof node.abi === "string") node = { ...node, candid: node.abi }; // an EVM position (spec section 15)
76
78
  if (node === null || typeof node !== "object" || typeof node.candid !== "string") fail(CODES.TYPE, "node must be a resolved-form position with a `candid` type (or an EVM position's `abi` type)");
77
79
  const tags = node.tags ?? {};
@@ -88,14 +90,27 @@ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
88
90
  switch (tag) {
89
91
  case "amount": {
90
92
  requireCandid(node, AMOUNT_TYPES, tag);
91
- if (raw.decimals === undefined) fail(CODES.ARG, "the amount tag needs decimals");
92
- const decimals = checkDecimals(arg("decimals"));
93
- const symbol = checkSymbol(arg("symbol"));
93
+ let decimals;
94
+ let symbol;
95
+ if (raw.token !== undefined) {
96
+ // The token is the one a field of the value names (spec section 15.9): its metadata comes with the value.
97
+ if (typeof raw.token !== "string" || raw.token === "") fail(CODES.ARG, "amount token must be a relative path to an address");
98
+ if (raw.decimals !== undefined || raw.symbol !== undefined) fail(CODES.ARG, "an amount with a token takes that token's decimals and symbol, not its own");
99
+ if (token === undefined || token === null || typeof token !== "object") {
100
+ fail(CODES.REF_UNRESOLVED, `amount.token: the amount is in the token at ${JSON.stringify(raw.token)}; pass that token's decimals and symbol as \`token\``);
101
+ }
102
+ decimals = checkDecimals(token.decimals);
103
+ symbol = checkSymbol(token.symbol);
104
+ } else {
105
+ if (raw.decimals === undefined) fail(CODES.ARG, "the amount tag needs decimals");
106
+ decimals = checkDecimals(arg("decimals"));
107
+ symbol = checkSymbol(arg("symbol"));
108
+ }
94
109
  const threshold = checkThreshold(raw.threshold);
95
110
  const message = checkMessage(raw.message);
96
111
  if (message !== undefined && threshold === undefined) fail(CODES.ARG, "amount message needs a threshold");
97
112
  const o = { decimals, symbol, candid: node.candid, threshold, message };
98
- args = { decimals, symbol, ...(threshold === undefined ? {} : { threshold: threshold.toString(), message: message ?? DEFAULT_THRESHOLD_MESSAGE }) };
113
+ args = { decimals, symbol, ...(raw.token === undefined ? {} : { token: raw.token }), ...(threshold === undefined ? {} : { threshold: threshold.toString(), message: message ?? DEFAULT_THRESHOLD_MESSAGE }) };
99
114
  parse = (input) => parseAmount(input, o);
100
115
  display = (value) => displayAmount(value, o);
101
116
  break;
@@ -104,10 +119,20 @@ export function formatterFor(node, { refs = {}, now: defaultNow } = {}) {
104
119
  requireCandid(node, TIME_TYPES, tag);
105
120
  if (raw.epoch !== undefined && raw.epoch !== "unix") fail(CODES.ARG, `timestamp epoch must be "unix", got ${JSON.stringify(raw.epoch)}`);
106
121
  const unit = requireUnit(arg("unit"), tag);
122
+ const threshold = checkThreshold(raw.threshold, "timestamp");
123
+ const message = checkMessage(raw.message, "timestamp message");
124
+ if ((threshold === undefined) !== (message === undefined)) fail(CODES.ARG, "a timestamp threshold and its message go together: give both or neither");
125
+ const labels = checkTimeLabels(raw.labels);
107
126
  const o = { unit, candid: node.candid };
108
- args = { unit, epoch: "unix" };
127
+ const shown = { ...o, threshold, message, labels: raw.labels };
128
+ args = {
129
+ unit,
130
+ epoch: "unix",
131
+ ...(threshold === undefined ? {} : { threshold: threshold.toString(), message }),
132
+ ...(labels === undefined ? {} : { labels: Object.fromEntries([...labels].map(([k, v]) => [k.toString(), v])) }),
133
+ };
109
134
  parse = (input, ctx = {}) => parseTimestamp(input, { ...o, now: ctx.now ?? defaultNow });
110
- display = (value) => displayTimestamp(value, o);
135
+ display = (value) => displayTimestamp(value, shown);
111
136
  break;
112
137
  }
113
138
  case "duration": {
package/src/index.d.mts CHANGED
@@ -70,8 +70,21 @@ export function convertUnit(value: string | bigint, from: TimeUnit, to: TimeUnit
70
70
  export function alignNow(nowNs: bigint, unit: TimeUnit): bigint;
71
71
  /** An RFC 3339 time, or "+" and an ISO 8601 duration from `now` (ns, required then). Returns the count of `unit`. */
72
72
  export function parseTimestamp(input: string, options: { unit: TimeUnit; candid?: string; now?: bigint }): string;
73
- /** Canonical RFC 3339 UTC, for example "2026-10-09T12:00:00.5Z". */
74
- export function displayTimestamp(value: string | bigint, options: { unit: TimeUnit; candid?: string }): string;
73
+ export interface TimestampOptions {
74
+ unit: TimeUnit;
75
+ candid?: string;
76
+ /** Display only (spec section 15.9): a value at or above it (in `unit`) displays as `message`; needs `message`. */
77
+ threshold?: string | bigint;
78
+ /** What a value at or above `threshold` displays as ("Never"); needs `threshold`. */
79
+ message?: string;
80
+ /** Display only: exact values (decimal strings of `unit`) shown as words, `{ "0": "Only in the block it is used" }`. */
81
+ labels?: Record<string, string>;
82
+ }
83
+ /**
84
+ * Canonical RFC 3339 UTC, for example "2026-10-09T12:00:00.5Z"; a value `labels` names, or at or above `threshold`,
85
+ * displays as its words instead, so that it is never read as a date.
86
+ */
87
+ export function displayTimestamp(value: string | bigint, options: TimestampOptions): string;
75
88
  /** An ISO 8601 duration of W, D, H, M and S ("PT24H"); returns the count of `unit`. */
76
89
  export function parseDuration(input: string, options: { unit: TimeUnit; candid?: string }): string;
77
90
  /** Canonical ISO 8601: "P1D", "PT1H30M", "PT0S". */
@@ -142,8 +155,10 @@ export const VALUE_TAGS: readonly ValueTag[];
142
155
  *
143
156
  * @param options.refs dynamic tag values keyed by their `from`: `{ "query:icrc1_decimals": 8 }`
144
157
  * @param options.now default clock for relative timestamps, ns since the Unix epoch
158
+ * @param options.token for an amount whose `token` names its token by a field (spec section 15.9): the decimals and
159
+ * symbol of the token that field holds in this value, which the caller reads as it resolves `refs`
145
160
  */
146
- export function formatterFor(node: FormatNode, options?: { refs?: Record<string, unknown>; now?: bigint }): Formatter | null;
161
+ export function formatterFor(node: FormatNode, options?: { refs?: Record<string, unknown>; now?: bigint; token?: { decimals: number | bigint | string; symbol?: string } }): Formatter | null;
147
162
  /** `Formatter.describe` without a formatter; `accepts` keeps only the examples it returns true for. */
148
163
  export function describeInput(tag: ValueTag, args: Readonly<Record<string, unknown>>, candid: string, options?: { accepts?: (input: string) => boolean }): Description;
149
164
 
package/src/time.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  // millisecond precision.
11
11
 
12
12
  import { CODES, fail } from "./errors.mjs";
13
+ import { checkMessage, checkThreshold } from "./amount.mjs";
13
14
  import { ABI_INT_TYPES, checkRange, intType, requireString, toBigInt, typeWord } from "./util.mjs";
14
15
 
15
16
  export const TIME_TYPES = ["nat", "int", "nat32", "nat64", "int64", ...ABI_INT_TYPES.filter((t) => (t.startsWith("u") ? Number(t.slice(4)) >= 32 : Number(t.slice(3)) > 64))];
@@ -203,15 +204,43 @@ export function parseTimestamp(input, { unit, candid = "nat64", now } = {}) {
203
204
  }
204
205
 
205
206
  /**
206
- * Display a timestamp as canonical RFC 3339 UTC, for example "2026-10-09T12:00:00.5Z".
207
+ * A timestamp's `labels` (spec section 15.9): words for exact values, keyed by the value in the tagged unit as a
208
+ * canonical decimal string. At most 16, each 1 to 32 characters with nothing invisible.
209
+ *
210
+ * @returns {Map<bigint, string> | undefined}
211
+ */
212
+ export function checkTimeLabels(labels) {
213
+ if (labels === undefined || labels === null) return undefined;
214
+ if (typeof labels !== "object" || Array.isArray(labels)) fail(CODES.ARG, "timestamp labels must be an object of value to words");
215
+ const entries = Object.entries(labels);
216
+ if (entries.length < 1 || entries.length > 16) fail(CODES.ARG, `timestamp labels must have 1 to 16 entries, got ${entries.length}`);
217
+ const out = new Map();
218
+ for (const [k, v] of entries) {
219
+ if (!/^(?:0|[1-9][0-9]{0,77})$/.test(k)) fail(CODES.ARG, `a timestamp label's value must be a decimal string of the tagged unit (0 or more, at most 78 digits), got ${JSON.stringify(k)}`);
220
+ out.set(BigInt(k), checkMessage(v, `timestamp label of ${k}`));
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * Display a timestamp as canonical RFC 3339 UTC, for example "2026-10-09T12:00:00.5Z". Display only (spec section
227
+ * 15.9): a value that `labels` names shows its words, and a value at or above `threshold` shows `message` ("Never"
228
+ * for a deadline of uint256 max), so that such a value is never read as a date.
207
229
  *
208
230
  * @param {string | bigint} value integer count of `unit` since the Unix epoch
231
+ * @param {{ unit: string, candid?: string, threshold?: string | bigint, message?: string, labels?: Record<string, string> }} opts
209
232
  */
210
- export function displayTimestamp(value, { unit, candid = "nat64" } = {}) {
233
+ export function displayTimestamp(value, { unit, candid = "nat64", threshold, message, labels } = {}) {
211
234
  const f = unitFactor(unit);
212
235
  const type = intType(candid, TIME_TYPES, "timestamp");
236
+ const limit = checkThreshold(threshold, "timestamp");
237
+ const text = checkMessage(message, "timestamp message");
238
+ if ((limit === undefined) !== (text === undefined)) fail(CODES.ARG, "a timestamp threshold and its message go together: give both or neither");
239
+ const words = checkTimeLabels(labels);
213
240
  const v = toBigInt(value, "timestamp");
214
241
  checkRange(v, type, "timestamp");
242
+ if (words?.has(v)) return words.get(v);
243
+ if (limit !== undefined && v >= limit) return text;
215
244
  return rfc3339FromNs(v * f);
216
245
  }
217
246