quario 0.4.0 → 0.6.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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * How many fraction digits a `format` kind presents when the declaration
3
+ * names no count of its own. One table, read once — by `./style.js`, where a
4
+ * `format` declaration resolves — so an author's own `digits` beats a default
5
+ * here exactly as it beats any other (docs/adr/0056).
6
+ *
7
+ * The counts are fixed rather than maxima — `number` and `percent` are 2 —
8
+ * and they are stated here rather than read back out of Intl's own defaults,
9
+ * which would tie a workbook's bytes to the host's ICU version (docs/adr/0025)
10
+ * and would settle `percent` at whatever ICU happens to prefer.
11
+ *
12
+ * `currency` is the one hybrid: its minor units come from Intl, because the
13
+ * alternative is vendoring ISO 4217. Nothing else asks ICU anything.
14
+ *
15
+ * The public `fractionDigits` helper is **not** here, and the split is the
16
+ * point. A resolved `format` crosses the stream carrying its own `digits`, so
17
+ * every target reads the count off the declaration it already has and asks
18
+ * nothing (docs/adr/0054's authority moved from this table to the stream).
19
+ * What remains public is a reader of that resolved declaration, and it lives
20
+ * beside the resolution in `./style.js`.
21
+ */
22
+
23
+ /** Fixed counts for the kinds that carry one of their own. @type {Record<string, number>} */
24
+ let COUNTS = { number: 2, percent: 2 };
25
+
26
+ // Minor units are a property of the currency, not of the locale — Intl gives
27
+ // JPY 0 under every one — so the code alone is the whole key. Bounded by the
28
+ // distinct codes a process ever sees, and memoising a fact that cannot change
29
+ // within an ICU version: an uncached probe costs ~10 µs, so ~100 ms for every
30
+ // ten thousand currency cells a target presents, against ~19 ns memoised.
31
+ /** @type {Map<string, number | undefined>} */
32
+ let minorUnits = new Map();
33
+
34
+ // Every unreadable code lands in the catch and memoises as nothing — a
35
+ // missing one included, since Intl demands a currency under this style. So
36
+ // there is no separate guard: one answer, reached one way.
37
+ /** @type {(currency: unknown) => number | undefined} */
38
+ let unitsOf = (currency) => {
39
+ // Gate before the map, not inside the try: a non-string would otherwise be
40
+ // retained as a key for the life of the process for no answer.
41
+ if (typeof currency !== "string") return undefined;
42
+ if (minorUnits.has(currency)) return minorUnits.get(currency);
43
+ let units;
44
+ try {
45
+ // Intl takes any well-formed three-letter code and throws on the rest,
46
+ // which is the same line `format()` already falls back on.
47
+ units = new Intl.NumberFormat("en-US", {
48
+ style: "currency",
49
+ currency: /** @type {string} */ (currency),
50
+ }).resolvedOptions().maximumFractionDigits;
51
+ } catch {
52
+ units = undefined;
53
+ }
54
+ minorUnits.set(currency, units);
55
+ return units;
56
+ };
57
+
58
+ /**
59
+ * The count a kind presents when the declaration names none.
60
+ *
61
+ * @param {string} kind A `format` kind.
62
+ * @param {unknown} [currency] The code the cell wears, under `"currency"`.
63
+ * @returns {number | undefined} The digits, or nothing when the kind carries
64
+ * no count — `date`, an unknown kind, and a `currency` whose code Intl will
65
+ * not read. A kind that resolves to no count presents no digits anywhere:
66
+ * `format()` falls back to `display()` and the worksheet writes no number
67
+ * format at all.
68
+ */
69
+ export function defaultDigits(kind, currency) {
70
+ if (kind === "currency") return unitsOf(currency);
71
+ // Own-key lookup, as everywhere a name from a document indexes a table: a
72
+ // kind named after an Object.prototype member is unknown, not an inherited
73
+ // function (`style.js`).
74
+ return Object.hasOwn(COUNTS, kind) ? COUNTS[kind] : undefined;
75
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Every fact the engine holds about a reducer, in one table.
3
+ *
4
+ * A reducer is three things at once: a fold over a whole array — the function
5
+ * form `sum(@.lines, l => l.total)` and a declared aggregate alike — a step
6
+ * folded across rows in render order for a declared running value, and a
7
+ * spelling rule, since `count` is the one declared form that takes no
8
+ * expression. Those three used to live in two hand-enumerated tables, a
9
+ * derived name set, and a reducer named in the validator, so adding a reducer
10
+ * was three edits and forgetting the runner passed validation and threw a raw
11
+ * TypeError at render. One entry per reducer; every role derives from it.
12
+ *
13
+ * `prev` is the entry with no fold. There is no whole-array "previous", so it
14
+ * is a running value only — and that missing half is exactly what keeps it out
15
+ * of an `aggregates` block, where `RUN = AGG ∪ {prev}` used to say so by hand.
16
+ *
17
+ * Coercion is quiet: only a finite number contributes, everything else folds
18
+ * to 0, as the spec promises.
19
+ */
20
+
21
+ /** @typedef {(rows: any[] | null | undefined, of?: ((row: any) => any) | null) => any} Reducer */
22
+ /** @typedef {{ fold: Reducer | null, run: () => (value: any) => any, expr: boolean }} Entry */
23
+ /** @typedef {"aggregate" | "run"} Role */
24
+
25
+ /** @type {(value: any) => number} */
26
+ let num = (value) => {
27
+ let n = +value;
28
+ return Number.isFinite(n) ? n : 0;
29
+ };
30
+ // The per-row read every reducer but `count` makes of its lambda: a projection
31
+ // rather than a predicate. `count` is the one that filters instead, which is
32
+ // why `countDistinct` reads its lambda this way despite the shared prefix.
33
+ /** @type {(rows: any[] | null | undefined, of?: ((row: any) => any) | null) => any[]} */
34
+ let project = (rows, of) => (rows || []).map((row) => (of ? of(row) : row));
35
+ // `min`/`max` share one shape: project through the lambda, then pick a winner.
36
+ /** @type {(pick: (a: any, b: any) => any) => Reducer} */
37
+ let extreme = (pick) => (rows, of) => {
38
+ let values = project(rows, of);
39
+ return values.length ? values.reduce(pick) : null;
40
+ };
41
+ /** @type {Reducer} */
42
+ let sum = (rows, of) => (rows || []).reduce((total, row) => total + num(of ? of(row) : row), 0);
43
+
44
+ /**
45
+ * The table. Each entry is one reducer entire: how it folds an array, how it
46
+ * steps a row, and whether its declared form carries `:=<expression>`.
47
+ *
48
+ * The running halves fold in render order. `min`/`max` have no zero, so they
49
+ * seed to the first row's value — the one deliberate `undefined` here, since
50
+ * xprsn reads absent values as null.
51
+ * @type {Record<string, Entry>}
52
+ */
53
+ let REGISTRY = {
54
+ sum: {
55
+ fold: sum,
56
+ run: () => {
57
+ let total = 0;
58
+ return (value) => (total += num(value));
59
+ },
60
+ expr: true,
61
+ },
62
+ count: {
63
+ fold: (rows, of) => (of ? (rows || []).filter(of) : rows || []).length,
64
+ run: () => {
65
+ let seen = 0;
66
+ return () => ++seen;
67
+ },
68
+ expr: false,
69
+ },
70
+ countDistinct: {
71
+ // Distinctness is a `Set`'s — SameValueZero, which is also what
72
+ // `Map.groupBy` gives a group's `by` key, so this counts exactly what that
73
+ // key would partition into. Objects compare by reference there and here
74
+ // alike.
75
+ fold: (rows, of) => new Set(project(rows, of)).size,
76
+ run: () => {
77
+ let seen = new Set();
78
+ return (value) => seen.add(value).size;
79
+ },
80
+ expr: true,
81
+ },
82
+ avg: {
83
+ fold: (rows, of) => (rows && rows.length ? sum(rows, of) / rows.length : null),
84
+ run: () => {
85
+ let total = 0,
86
+ seen = 0;
87
+ return (value) => (total += num(value)) / ++seen;
88
+ },
89
+ expr: true,
90
+ },
91
+ min: {
92
+ fold: extreme((a, b) => (a < b ? a : b)),
93
+ run: () => {
94
+ let lowest = /** @type {any} */ (undefined);
95
+ return (value) => (lowest = lowest === undefined || value < lowest ? value : lowest);
96
+ },
97
+ expr: true,
98
+ },
99
+ max: {
100
+ fold: extreme((a, b) => (a > b ? a : b)),
101
+ run: () => {
102
+ let highest = /** @type {any} */ (undefined);
103
+ return (value) => (highest = highest === undefined || value > highest ? value : highest);
104
+ },
105
+ expr: true,
106
+ },
107
+ prev: {
108
+ fold: null,
109
+ run: () => {
110
+ let previous = /** @type {any} */ (null);
111
+ return (value) => {
112
+ let out = previous;
113
+ previous = value;
114
+ return out;
115
+ };
116
+ },
117
+ expr: true,
118
+ },
119
+ };
120
+
121
+ /**
122
+ * The reducer a declaration names, in the role it is declared under, or
123
+ * `undefined` if that role does not have it: an entry with no fold is a
124
+ * running value only, which is what refuses `prev` in an `aggregates` block.
125
+ *
126
+ * The name comes from splitting an author's spec string, so the lookup asks
127
+ * for an own property — `"constructor:=@.v"` must be an unknown reducer, not
128
+ * a function off `Object.prototype`.
129
+ * @type {(role: Role, fn: string) => Entry | undefined}
130
+ */
131
+ export let reducerFor = (role, fn) => {
132
+ if (!Object.hasOwn(REGISTRY, fn)) return undefined;
133
+ let entry = REGISTRY[fn];
134
+ return role === "run" || entry.fold ? entry : undefined;
135
+ };
136
+
137
+ /**
138
+ * Every reducer name a role accepts, in table order. The vocabulary as a whole,
139
+ * where `reducerFor` answers about one name — a sweep over the registry needs
140
+ * it, so nothing re-enumerates the reducers to iterate them.
141
+ * @type {(role: Role) => string[]}
142
+ */
143
+ export let namesFor = (role) =>
144
+ Object.keys(REGISTRY).filter((fn) => reducerFor(role, fn) !== undefined);
145
+
146
+ /**
147
+ * The running step a declared run value folds with. Only ever asked for a name
148
+ * `reducerFor("run", …)` has already accepted, so every entry answers.
149
+ * @type {(fn: string) => (value: any) => any}
150
+ */
151
+ export let runnerFor = (fn) => REGISTRY[fn].run();
152
+
153
+ /**
154
+ * The reducers as a plain name→function record: what an expression's function
155
+ * scope spreads in, and what a declared aggregate evaluates through. It is the
156
+ * aggregate role's own entries, so it asks for them rather than restating the
157
+ * has-a-fold rule `reducerFor` already owns.
158
+ * @type {Record<string, Reducer>}
159
+ */
160
+ export let REDUCERS = Object.fromEntries(
161
+ // The cast is the derivation's own guarantee: the aggregate role is exactly
162
+ // the entries carrying a fold, which is why this asks for that role rather
163
+ // than testing for the fold a second time.
164
+ namesFor("aggregate").map((fn) => [fn, /** @type {Reducer} */ (REGISTRY[fn].fold)]),
165
+ );
package/lib/scope.js CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
- * The scope model at render time: the anchors a band binds, the reducers that
3
- * fold a scope's rows, and the runners that fold them in render order. One
4
- * home for every running value, so a new fold has one place to be declared.
2
+ * The scope model at render time: the anchors a band binds, and the runner set
3
+ * a band renders under. What a reducer *is* — its fold, its step, its spelling
4
+ * — lives in `reducers.js`; this module only asks it for the step and folds
5
+ * the result in render order.
5
6
  */
7
+ import { REDUCERS, runnerFor } from "./reducers.js";
6
8
  /** @typedef {Record<string, any>} Scope */
7
9
  /** @typedef {(scope: Scope) => any} Eval */
8
10
  /** @typedef {{ fn: string, per: Eval | null }} Fold */
@@ -15,42 +17,6 @@
15
17
  * bind: (scope: Scope, row: any) => Scope }} RunnerSet
16
18
  */
17
19
 
18
- // Built-in reducers. Each takes an array and an optional xprsn lambda, so an
19
- // author can aggregate a row's own sub-array inline (`sum(@.lines, l => l.total)`);
20
- // the same reducers back the declared group aggregates. Coercion is quiet: only
21
- // a finite number contributes, everything else folds to 0, as the spec promises.
22
- /** @type {(value: any) => number} */
23
- let num = (value) => {
24
- let n = +value;
25
- return Number.isFinite(n) ? n : 0;
26
- };
27
- /** @typedef {(rows: any[] | null | undefined, of?: ((row: any) => any) | null) => any} Reducer */
28
- // The per-row read every reducer but `count` makes of its lambda: a projection
29
- // rather than a predicate. `count` is the one that filters instead, which is
30
- // why `countDistinct` reads its lambda this way despite the shared prefix.
31
- /** @type {(rows: any[] | null | undefined, of?: ((row: any) => any) | null) => any[]} */
32
- let project = (rows, of) => (rows || []).map((row) => (of ? of(row) : row));
33
- // `min`/`max` share one shape: project through the lambda, then pick a winner.
34
- /** @type {(pick: (a: any, b: any) => any) => Reducer} */
35
- let extreme = (pick) => (rows, of) => {
36
- let values = project(rows, of);
37
- return values.length ? values.reduce(pick) : null;
38
- };
39
- /** @type {Record<string, Reducer>} */
40
- export let REDUCERS = {
41
- sum: (rows, of) => (rows || []).reduce((total, row) => total + num(of ? of(row) : row), 0),
42
- count: (rows, of) => (of ? (rows || []).filter(of) : rows || []).length,
43
- // Distinctness is a `Set`'s — SameValueZero, which is also what `Map.groupBy`
44
- // gives a group's `by` key, so this counts exactly what that key would
45
- // partition into. Objects compare by reference there and here alike.
46
- countDistinct: (rows, of) => new Set(project(rows, of)).size,
47
- avg: (rows, of) => (rows && rows.length ? REDUCERS.sum(rows, of) / rows.length : null),
48
- min: extreme((a, b) => (a < b ? a : b)),
49
- max: extreme((a, b) => (a > b ? a : b)),
50
- };
51
- export let AGG = new Set(Object.keys(REDUCERS));
52
- export let RUN = new Set([...AGG, "prev"]);
53
-
54
20
  // Bind a row as `@` on a child of `scope`, ready for an xprsn per-row read.
55
21
  /** @type {(scope: Scope, row: any) => Scope} */
56
22
  export let withRow = (scope, row) => {
@@ -68,57 +34,12 @@ export let withPage = (scope, page) => {
68
34
  return child;
69
35
  };
70
36
 
71
- // Evaluate one declared aggregate over `rows` by delegating to the same reducers
37
+ // Evaluate one declared aggregate over `rows` by delegating to the same folds
72
38
  // authors call inline: the per-row xprsn expression becomes the lambda.
73
39
  /** @type {(fold: Fold, rows: any[], scope: Scope) => any} */
74
40
  export let aggregateValue = ({ fn, per }, rows, scope) =>
75
41
  REDUCERS[fn](rows, per ? (row) => per(withRow(scope, row)) : null);
76
42
 
77
- // Folded across rows in render order, one factory per name. `min`/`max` have
78
- // no zero, so they seed to the first row's value — the one deliberate
79
- // `undefined` here, since xprsn reads absent values as null.
80
- //
81
- // Every name in `REDUCERS` needs a factory here. `RUN` derives from `AGG`
82
- // rather than enumerating a second list, so a reducer added there without a
83
- // runner added here passes validation and then throws at render. The invariant
84
- // is a test rather than a comment alone — semantics.test.js, "every reducer
85
- // the run block accepts has a runner behind it".
86
- /** @type {Record<string, () => (value: any) => any>} */
87
- let RUNNERS = {
88
- sum: () => {
89
- let total = 0;
90
- return (value) => (total += num(value));
91
- },
92
- count: () => {
93
- let seen = 0;
94
- return () => ++seen;
95
- },
96
- countDistinct: () => {
97
- let seen = new Set();
98
- return (value) => seen.add(value).size;
99
- },
100
- avg: () => {
101
- let total = 0,
102
- seen = 0;
103
- return (value) => (total += num(value)) / ++seen;
104
- },
105
- min: () => {
106
- let lowest = /** @type {any} */ (undefined);
107
- return (value) => (lowest = lowest === undefined || value < lowest ? value : lowest);
108
- },
109
- max: () => {
110
- let highest = /** @type {any} */ (undefined);
111
- return (value) => (highest = highest === undefined || value > highest ? value : highest);
112
- },
113
- prev: () => {
114
- let previous = /** @type {any} */ (null);
115
- return (value) => {
116
- let out = previous;
117
- previous = value;
118
- return out;
119
- };
120
- },
121
- };
122
43
  // The runner set as a value, and the two things a band does with it. `extend`
123
44
  // is the whole of persist-vs-reset: a group's own runners are fresh per
124
45
  // instance, the outer ones fold on unchanged.
@@ -131,7 +52,7 @@ let runnerSet = (runners) => {
131
52
  // block is absent renders under this very value rather than a copy.
132
53
  if (!folds.length) return value;
133
54
  let started = folds.map(([name, { fn, per }]) => {
134
- let step = RUNNERS[fn]();
55
+ let step = runnerFor(fn);
135
56
  return /** @type {[string, Eval]} */ ([name, (scope) => step(per ? per(scope) : 1)]);
136
57
  });
137
58
  return runnerSet([...runners, ...started]);
package/lib/stream.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // the scalar `display()` rule — re-exported together so event consumers
13
13
  // derive display text from a cell's tokens without hand-rolling the join.
14
14
  export { display, text } from "sjabloon";
15
+ import { kindOf } from "./style.js";
15
16
 
16
17
  /** @type {(value: any) => boolean} */
17
18
  export let finiteNum = (value) => typeof value === "number" && Number.isFinite(value);
@@ -24,9 +25,14 @@ let asTyped = (value) => {
24
25
  if (finiteDate(value)) return value;
25
26
  return undefined;
26
27
  };
27
- // The seam's one coercion, kept behind the declared kind (ADR 0041).
28
- /** @type {(value: any, kind: unknown) => Date | undefined} */
29
- let asDeclared = (value, kind) => (kind === "date" ? reviveDate(value) : undefined);
28
+ // The seam's one coercion, kept behind the declared kind (ADR 0041). It asks
29
+ // `kindOf` rather than reading `decl.kind`, because the stream carries the
30
+ // resolved object while a host may still hold the shorthand and both must
31
+ // answer alike -- and because reading one field is all this needs, where
32
+ // resolving the whole declaration would allocate per cell for nothing
33
+ // (docs/adr/0056).
34
+ /** @type {(value: any, decl: unknown) => Date | undefined} */
35
+ let asDeclared = (value, decl) => (kindOf(decl) === "date" ? reviveDate(value) : undefined);
30
36
 
31
37
  // The two RFC 3339 forms a JSON document conventionally carries a date in:
32
38
  // a calendar date, and a timestamp that names its offset. A zoneless
@@ -38,8 +44,6 @@ let asDeclared = (value, kind) => (kind === "date" ? reviveDate(value) : undefin
38
44
  let RFC3339 =
39
45
  /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2})))?$/;
40
46
 
41
- /** @type {(year: string, month: string) => number} */
42
- let monthLength = (year, month) => new Date(Date.UTC(+year, +month, 0)).getUTCDate();
43
47
  // An absent group is the timestamp half of an optional match, not a breach.
44
48
  /** @type {(bound: [string | undefined, number, number]) => boolean} */
45
49
  let within = ([n, lo, hi]) => n === undefined || (+n >= lo && +n <= hi);
@@ -69,7 +73,7 @@ export let reviveDate = (value) => {
69
73
  /** @type {[string | undefined, number, number][]} */
70
74
  let bounds = [
71
75
  [month, 1, 12],
72
- [day, 1, monthLength(year, month)],
76
+ [day, 1, new Date(Date.UTC(+year, +month, 0)).getUTCDate()],
73
77
  [hour, 0, 23],
74
78
  [minute, 0, 59],
75
79
  [second, 0, 59],
@@ -86,23 +90,24 @@ export let reviveDate = (value) => {
86
90
  * including a lone null, matching the display-text treatment everywhere
87
91
  * else — reports `undefined` and joins to text.
88
92
  *
89
- * Passing the cell's declared `format` kind opts into the one coercion the
90
- * seam performs: under `"date"`, a string in either accepted RFC 3339 form
91
- * revives to the `Date` it names. The gate is the declaration, and the kind
92
- * is `date` alone, because a date is the one type a JSON document cannot
93
- * carry — a number arrives as a number, so no other kind has anything to
94
- * revive (ADR 0041). Callers that read no style omit the argument and see
93
+ * Passing the cell's resolved `format` declaration opts into the one coercion
94
+ * the seam performs: under the `date` kind, a string in either accepted RFC
95
+ * 3339 form revives to the `Date` it names. The gate is the declaration, and
96
+ * the kind is `date` alone, because a date is the one type a JSON document
97
+ * cannot carry — a number arrives as a number, so no other kind has anything
98
+ * to revive (ADR 0041). Callers that read no style omit the argument and see
95
99
  * the seam exactly as it was.
96
100
  *
97
101
  * @param {any[]} tokens A cell's tokens.
98
- * @param {unknown} [kind] The cell's resolved `format` declaration.
102
+ * @param {unknown} [decl] The cell's resolved `format` declaration — the
103
+ * `{ kind, digits? | form? }` object the stream carries (docs/adr/0056).
99
104
  * @returns {number | boolean | Date | undefined} The native value, if any.
100
105
  */
101
- export function typed(tokens, kind) {
106
+ export function typed(tokens, decl) {
102
107
  let [token] = tokens;
103
108
  if (tokens.length !== 1 || !("value" in token)) return undefined;
104
109
  // `??` and not `||`: a boolean cell's own `false` is a typed value.
105
- return asTyped(token.value) ?? asDeclared(token.value, kind);
110
+ return asTyped(token.value) ?? asDeclared(token.value, decl);
106
111
  }
107
112
 
108
113
  // The report's own bands, by the role their items wear (CONTEXT.md, "Band").