quario 0.11.0 → 0.12.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
@@ -1,5 +1,41 @@
1
1
  # quario
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - A registered function may be asynchronous. The engine resolves every such call before the banded walk, in one wave, and the walk itself stays synchronous.
8
+
9
+ Write the call as the whole value of a cell in a detail row, and take its arguments from the row:
10
+
11
+ ```jsonc
12
+ { "data": "$.orders[*]", "detail": [{ "type": "text", "value": "{{ customerName(@.customerId) }}" }] }
13
+ ```
14
+
15
+ The engine calls the function once for every row it renders, starts every call together, and waits for all of them before the first event reaches a target. A row that `where` or `take` removed is never fetched. Deduplication and any limit on how many calls run at once belong to the function itself: a report over ten thousand rows starts ten thousand calls.
16
+
17
+ Three rules hold. The call is the whole value of a cell, so `{{ lookup(@.id).name }}` and `{{ "#" + lookup(@.id) }}` are errors that name the function and the cell. The arguments come from the row, so a cell that reads no row cannot hold one. And `render()` resolves, while `stream()` reports the call it cannot wait for.
18
+
19
+ A function that rejects fails the render with an error that names the function and the cell, and keeps the class the function threw.
20
+
21
+ ### Patch Changes
22
+
23
+ - **The declarations now refuse what the engine refuses.** Eight style declarations type-checked at nodes the engine rejects them on, so a report that compiled could still fail to render: `valign` on a stacked item, flow spacing on a table cell or a total cell, and `format`, `currency`, `href` and flow spacing on a row box. Each site now states the narrowing the engine has always applied, through four new exported types — `StackedStyleDeclarations`, `CellStyleDeclarations`, `RowStyleDeclarations`, and the `SlotStyleDeclarations` that was already there.
24
+
25
+ No document that the engine accepts has stopped type-checking. The narrowing removes only names the engine was already refusing.
26
+
27
+ **`SCHEMA.md` now states two keys it always admitted.** The document-shape block never listed `required`, and the group-definition block never listed `collapsed` — the latter while three target sections linked to that section for it, and while the section said a group is collapsible without a field for it. Both are stated now, `collapsed` with the paragraph it never had.
28
+
29
+ - **The declarations now carry `required`, and a group's `label` and `collapsed`.** `ReportSchema` states `required`, and `Group` states both `label` and `collapsed`. The engine has admitted all three and `SCHEMA.md` specifies them, so a TypeScript author marking a declaration required, naming an instance for navigation, or opening a group shut was told it was an error and had to cast.
30
+
31
+ `required` is keyed by a new exported type, `RequirableName`, which spells the same names `REQUIRABLE` carries. A marking no target could answer for is therefore an error where an author writes it, rather than a `plan` problem after the fact — `{ "negative": true }` names a modifier inside `format` rather than a declaration a target answers for, and the compiler now says so. `collapsed` reads `boolean`, because the engine accepts `false` for it as plainly as `true`.
32
+
33
+ ## 0.11.1
34
+
35
+ ### Patch Changes
36
+
37
+ - **The declarations now carry `href` and `schemes`.** `StyleDeclarations` states `href`, `RunStyleDeclarations` picks it up as the twelfth inline name, and `QuarioOptions` states the `schemes` allowlist beside the three names that present `format`. The engine has admitted both since 0.11.0 and `SCHEMA.md` specifies them; the published types said otherwise, so a TypeScript consumer writing a link, or the allowlist that admits one, was told it was an error. The `format` declaration's own comment said a kind carries one modifier, which stopped being true when a number kind took three. `test/types.check.ts` exercises the allowlist and both spellings of a link, since a hand-written surface drifts exactly where nothing reads it.
38
+
3
39
  ## 0.11.0
4
40
 
5
41
  ### Minor Changes
package/lib/index.d.ts CHANGED
@@ -95,8 +95,9 @@ export interface StyleDeclarations extends BoxDeclarations {
95
95
  valign?: ExpressionValue<VAlign>;
96
96
  /**
97
97
  * How a number or date is presented: a kind, or an object naming one and
98
- * carrying the single modifier that kind understands — `digits` for the
99
- * three number kinds, `form` for `date`. Locale stays on the instance.
98
+ * carrying the modifiers that kind understands — `digits`, `negative` and
99
+ * `zero` for the three number kinds, `form` for `date`. Locale stays on the
100
+ * instance.
100
101
  */
101
102
  format?: ExpressionValue<FormatDeclaration>;
102
103
  /**
@@ -107,6 +108,14 @@ export interface StyleDeclarations extends BoxDeclarations {
107
108
  * than falling back to the instance's currency. Unread without the kind.
108
109
  */
109
110
  currency?: ExpressionValue<string>;
111
+ /**
112
+ * Where this cell's text points: a URL, or `#` and a group's `label` for a
113
+ * destination inside the document. The one declaration the engine judges
114
+ * rather than passes on — a URL is admitted against the instance's `schemes`
115
+ * allowlist before it crosses, and a refused value is omitted, so the text
116
+ * renders and the link does not. A table row refuses it.
117
+ */
118
+ href?: ExpressionValue<string>;
110
119
  /** Blank space before this item, in points. */
111
120
  spaceBefore?: ExpressionValue<number>;
112
121
  /** Blank space after this item, in points. */
@@ -147,6 +156,36 @@ export const ZERO_DASH: string;
147
156
  */
148
157
  export const REQUIRABLE: readonly string[];
149
158
 
159
+ /**
160
+ * The same names as a type, so a marking no target could answer for is an
161
+ * error where an author writes it rather than a
162
+ * [`plan`](../../../SCHEMA.md#validation) problem after the fact. This
163
+ * package's suite holds the two to each other in both directions, so neither
164
+ * can gain a name the other lacks.
165
+ */
166
+ export type RequirableName =
167
+ | "page.header"
168
+ | "page.footer"
169
+ | "break"
170
+ | "reset"
171
+ | "page.margin"
172
+ | "header.height"
173
+ | "groups"
174
+ | "label"
175
+ | "collapsed"
176
+ | "column.width"
177
+ | "slot.width"
178
+ | "span"
179
+ | "uppercase"
180
+ | "href"
181
+ | "spaceBefore"
182
+ | "spaceAfter"
183
+ | "format"
184
+ | "family"
185
+ | "valign"
186
+ | "runs"
187
+ | "alt";
188
+
150
189
  /**
151
190
  * The declarations a styled run accepts: the inline half of the vocabulary,
152
191
  * and nothing else. The box and flow spacing describe a block and `align`
@@ -167,6 +206,7 @@ export type RunStyleDeclarations = Pick<
167
206
  | "uppercase"
168
207
  | "format"
169
208
  | "currency"
209
+ | "href"
170
210
  >;
171
211
 
172
212
  /**
@@ -240,8 +280,10 @@ export interface Cell {
240
280
  style?: StyleDeclarations;
241
281
  }
242
282
 
243
- export interface TextItem extends Cell {
283
+ export interface TextItem extends Omit<Cell, "style"> {
244
284
  type: "text";
285
+ /** Literal only — never an expression. */
286
+ style?: StackedStyleDeclarations;
245
287
  }
246
288
 
247
289
  /** How an image is sized: at its intrinsic size, or scaled to the content width. */
@@ -292,6 +334,32 @@ export interface ImageItem {
292
334
  */
293
335
  export type SlotStyleDeclarations = Omit<StyleDeclarations, "spaceBefore" | "spaceAfter">;
294
336
 
337
+ /**
338
+ * What a stacked item declares: the vocabulary less `valign`. A stacked item
339
+ * sits in a band's flow and its box is its own height, so there is nothing for
340
+ * it to align against — where a slot's box is the split's height, which is why
341
+ * a slot keeps the name.
342
+ */
343
+ export type StackedStyleDeclarations = Omit<StyleDeclarations, "valign">;
344
+
345
+ /**
346
+ * What a table cell declares: the vocabulary less flow spacing. The same
347
+ * narrowing a slot takes, under its own name because the engine refuses the
348
+ * two in their own words and a reader follows the wording to the node.
349
+ */
350
+ export type CellStyleDeclarations = Omit<StyleDeclarations, "spaceBefore" | "spaceAfter">;
351
+
352
+ /**
353
+ * What a row box declares: a cell's names less the three that present a value.
354
+ * A box wraps cells and holds no value of its own, so `format`, `currency` and
355
+ * `href` have nothing to act on. Worn by the detail row, the header row's box
356
+ * and both halves of the totals block.
357
+ */
358
+ export type RowStyleDeclarations = Omit<
359
+ StyleDeclarations,
360
+ "format" | "currency" | "href" | "spaceBefore" | "spaceAfter"
361
+ >;
362
+
295
363
  /**
296
364
  * What an image slot declares: an image item's names less flow spacing, plus
297
365
  * `valign` — the one name an image accepts in a slot and nowhere else, because
@@ -343,7 +411,9 @@ export interface TableHeader {
343
411
  span?: number;
344
412
  }
345
413
 
346
- export interface TableColumn extends Cell {
414
+ export interface TableColumn extends Omit<Cell, "style"> {
415
+ /** Literal only — never an expression. */
416
+ style?: CellStyleDeclarations;
347
417
  /**
348
418
  * Absent exactly when a neighbour's header `span` covers this column: every
349
419
  * column's header slot is filled once, by its own or by a span.
@@ -355,14 +425,16 @@ export interface TableColumn extends Cell {
355
425
 
356
426
  export interface TableRow {
357
427
  visible?: ExpressionValue<boolean>;
358
- style?: StyleDeclarations;
428
+ style?: RowStyleDeclarations;
359
429
  }
360
430
 
361
431
  /**
362
432
  * One cell of a total row: an ordinary cell, plus the columns it covers. The
363
433
  * spans in a row must sum to the column count exactly.
364
434
  */
365
- export type TotalCell = Cell & {
435
+ export type TotalCell = Omit<Cell, "style"> & {
436
+ /** Literal only — never an expression. */
437
+ style?: CellStyleDeclarations;
366
438
  /**
367
439
  * How many columns this cell covers — a literal integer >= 1, never an
368
440
  * expression; absent is 1.
@@ -373,7 +445,7 @@ export type TotalCell = Cell & {
373
445
  export interface TotalRow {
374
446
  cells: [TotalCell, ...TotalCell[]];
375
447
  visible?: ExpressionValue<boolean>;
376
- style?: StyleDeclarations;
448
+ style?: RowStyleDeclarations;
377
449
  }
378
450
 
379
451
  /**
@@ -392,7 +464,7 @@ export interface TotalBlock extends TableRow {
392
464
  * header cells on every page continuation.
393
465
  */
394
466
  export interface TableHeaderBox {
395
- style?: StyleDeclarations;
467
+ style?: RowStyleDeclarations;
396
468
  }
397
469
 
398
470
  export interface TableDetail {
@@ -405,6 +477,23 @@ export interface TableDetail {
405
477
  export interface Group {
406
478
  name: string;
407
479
  by: `=${string}`;
480
+ /**
481
+ * This instance's name for **navigation** rather than for the page: one
482
+ * template, resolved once per instance in group scope, where the handle and
483
+ * `$` are bound and `@` is unbound. A target with somewhere to put a
484
+ * navigation name spends it there and the rest withdraw it, so it never
485
+ * changes what the page shows.
486
+ */
487
+ label?: string;
488
+ /**
489
+ * Open every instance of this group shut: its own header and footer show,
490
+ * and the content between them does not. A target with a way to hide a row
491
+ * and keep it -- the worksheet -- hides those rows, where a reader opens the
492
+ * group again. The rest drop them, and the
493
+ * [matrix](../../../SCHEMA.md#what-each-target-makes-of-a-declaration) says
494
+ * which does which.
495
+ */
496
+ collapsed?: boolean;
408
497
  /**
409
498
  * Where this group turns a page (paginated targets). Every boundary between
410
499
  * consecutive instances turns; `before` adds the run's leading edge, `after`
@@ -448,6 +537,15 @@ export interface PageBands {
448
537
 
449
538
  export interface ReportSchema {
450
539
  params?: Record<string, JsonValue>;
540
+ /**
541
+ * The declarations this document cannot do without, each marked `true`.
542
+ * Only [`plan`](../../../SCHEMA.md#validation) reads it, against the target
543
+ * descriptors its caller names: every required declaration one of them
544
+ * withdraws or leaves unread becomes a problem of its own at
545
+ * `required.<name>`, which nulls the report as any other problem does. No
546
+ * target sees the marking, and no render changes because of it.
547
+ */
548
+ required?: Partial<Record<RequirableName, true>>;
451
549
  data: string;
452
550
  where?: `=${string}`;
453
551
  sort?: SortKey[];
@@ -516,6 +614,14 @@ export interface QuarioOptions {
516
614
  currency?: string;
517
615
  /** Timezone for `format: "date"`. Defaults to `"UTC"`. */
518
616
  timeZone?: string;
617
+ /**
618
+ * The URL schemes an `href` may use, each lower-case and ending in a colon.
619
+ * Defaults to `https:` and `mailto:`. The allowlist is the host's, never the
620
+ * document's, and a value this cannot read throws at construction rather
621
+ * than falling back — an allowlist quietly replaced is a host believing it
622
+ * restricted something it had not.
623
+ */
624
+ schemes?: readonly string[];
519
625
  }
520
626
 
521
627
  /** The settled result of an instance's license key verification. */
package/lib/index.js CHANGED
@@ -18,7 +18,7 @@ import { MARKING, verify } from "./license.js";
18
18
  import { err, fault, locate } from "./locate.js";
19
19
  import { BLOCKED, NAME, record } from "./names.js";
20
20
  import { plan } from "./plan.js";
21
- import { aggregateValue, startRunners, withPage, withRow } from "./scope.js";
21
+ import { aggregateValue, RESOLVED, startRunners, withPage, withRow } from "./scope.js";
22
22
  import { opt } from "./stream.js";
23
23
 
24
24
  // The event stream's public seam, single-sourced in ./stream.js, and the
@@ -199,7 +199,8 @@ let freezeAnchors = (anchors) =>
199
199
  * @param {() => string | false} marking This render's unlicensed marking — its
200
200
  * wording, or false when the render is covered. A thunk, so what it answers
201
201
  * is this render's answer and not the compile's.
202
- * @returns {{(data?: any): Generator<object, void, undefined>, names: string[],
202
+ * @returns {{(data?: any, pre?: any): Generator<object, void, undefined>,
203
+ * names: string[],
203
204
  * functions: { name: string, arity: number, doc?: string }[],
204
205
  * paths: readonly any[]}} Event stream factory.
205
206
  */
@@ -239,6 +240,12 @@ function* guardPin(events) {
239
240
  }
240
241
  }
241
242
 
243
+ // How `render` reaches the resolve wave without publishing it. A host drives a
244
+ // compiled report through `render()` or `stream()` and never settles one
245
+ // itself, so this is the engine talking to itself: a symbol, so the compiled
246
+ // report's public shape stays exactly what `lib/index.d.ts` declares.
247
+ let SETTLE = Symbol("quario.settle");
248
+
242
249
  /**
243
250
  * Build the event stream factory from a settled traversal — the half of the
244
251
  * one compile that runs only when the plan carried no problems, shared by
@@ -250,7 +257,8 @@ function* guardPin(events) {
250
257
  * @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number},
251
258
  * locale?: string, currency?: string, timeZone?: string} | undefined} options
252
259
  * Host controls closed over this compile.
253
- * @returns {{(data?: any): Generator<object, void, undefined>, names: string[],
260
+ * @returns {{(data?: any, pre?: any): Generator<object, void, undefined>,
261
+ * names: string[],
254
262
  * functions: { name: string, arity: number, doc?: string }[],
255
263
  * paths: readonly any[]}} Event stream factory.
256
264
  */
@@ -276,6 +284,7 @@ function assemble(planned, schema, marking, options) {
276
284
  pageFooter,
277
285
  headerHeight,
278
286
  margin,
287
+ slotted,
279
288
  } = /** @type {NonNullable<typeof planned.compiled>} */ (planned.compiled);
280
289
 
281
290
  // The runner's own faults are located per render: a budget is spent while
@@ -289,23 +298,99 @@ function assemble(planned, schema, marking, options) {
289
298
  }
290
299
  };
291
300
 
292
- /** @type {(data: any) => Generator<any>} */
293
- let emit = (data) => {
301
+ // The eager pre-pass: select → filter → report sort → report take →
302
+ // aggregates (over the rows that remain). It cannot be lazy because the
303
+ // report header may interpolate report aggregates; only the banded walk
304
+ // itself streams. Lifted out of `emit` so a render that has to settle async
305
+ // calls first runs it once and hands the result on: a second run would
306
+ // re-spend the data query's budget and could fail a render that works.
307
+ /** @type {(data: any) => any} */
308
+ let prepare = (data) => {
294
309
  let root = Object.create(null);
295
310
  root.params = params;
296
311
  root.input = inputOf(data);
297
312
  let base = Object.create(null);
298
313
  base["$"] = root;
299
- // The eager pre-pass: select → filter → report sort → report take →
300
- // aggregates (over the rows that remain). It cannot be lazy because the
301
- // report header may interpolate report aggregates; only the banded walk
302
- // itself streams.
303
314
  let rows = runSelect(root.input);
304
315
  if (where) rows = rows.filter((row) => where(withRow(base, row)));
305
316
  if (sort) rows = sort(rows, base);
306
317
  if (take != null) rows = rows.slice(0, take);
307
- let aggregates = foldAggs(aggs, rows, base, root);
308
- // The report's runners, started per render like everything else here.
318
+ return {
319
+ root,
320
+ base,
321
+ rows,
322
+ aggregates: foldAggs(aggs, rows, base, root),
323
+ };
324
+ };
325
+
326
+ // The wave settles what it can and leaves the rest to the walk. A slot it
327
+ // cannot evaluate against the row alone -- a synchronous host call reading
328
+ // `run.x` or a group handle, which is ordinary and stays ordinary -- goes
329
+ // back unsupplied, so the walk evaluates it with the scope it always had.
330
+ // Nothing is swallowed: a fault that is real throws again there, at the same
331
+ // cell path, and an async call whose arguments were not row-local reaches
332
+ // `runCell` as a promise nothing resolved and is refused by name.
333
+ let UNSETTLED = Symbol("quario.unsettled");
334
+ /** @type {(slot: any, scope: any) => any} */
335
+ let attempt = (slot, scope) => {
336
+ try {
337
+ return slot(scope);
338
+ } catch {
339
+ return UNSETTLED;
340
+ }
341
+ };
342
+
343
+ // The resolve wave (docs/adr/0088): every async call the surviving rows make,
344
+ // issued together and settled once, before the walk sees a single row. The
345
+ // walk then reads the answers off the render base and stays synchronous.
346
+ // A row the pre-pass dropped is never fetched.
347
+ /** @type {(data: any) => Promise<any>} */
348
+ let resolve = async (data) => {
349
+ let pre = prepare(data),
350
+ held = new Map(),
351
+ waiting = [];
352
+ // The map is parked whether or not anything filled it: its presence is how
353
+ // a cell tells a render from a caller draining `stream()`, and the two owe
354
+ // the author different sentences.
355
+ pre.base[RESOLVED] = held;
356
+ if (!slotted.length) return pre;
357
+ for (let row of pre.rows) {
358
+ let scope = withRow(pre.base, row),
359
+ forRow = Object.create(null);
360
+ held.set(row, forRow);
361
+ for (let { path, source, live } of slotted) {
362
+ /** @type {any[]} */
363
+ let supply = [];
364
+ forRow[path] = supply;
365
+ // A slot's own scope is the row and nothing else: no runners, no group
366
+ // handle. That is the rule, and it is what makes one call per row
367
+ // answerable without the walk having run.
368
+ for (let [at, slot] of live) {
369
+ let answer = attempt(slot, scope);
370
+ if (answer === UNSETTLED) continue;
371
+ supply[at] = answer;
372
+ if (typeof answer?.then === "function")
373
+ waiting.push(
374
+ Promise.resolve(answer).then(
375
+ (value) => (supply[at] = value),
376
+ // No relocation: the template finished evaluating before this
377
+ // promise settled, so what rejected is host code and keeps
378
+ // its own class behind the cell path (docs/adr/0057).
379
+ (error) => locate(path, source, error),
380
+ ),
381
+ );
382
+ }
383
+ }
384
+ }
385
+ await Promise.all(waiting);
386
+ return pre;
387
+ };
388
+
389
+ /** @type {(data: any, pre?: any) => Generator<any>} */
390
+ let emit = (data, pre = prepare(data)) => {
391
+ let { root, base, rows, aggregates } = pre;
392
+ // The report's runners belong to this drain, not to the pre-pass: a target
393
+ // that drains the same stream twice counts from one again both times.
309
394
  let runners = startRunners(running);
310
395
  // Per-render page band closures: a paginated target calls them once per
311
396
  // page with `{ number, total }` (1-based) and receives resolved item
@@ -347,6 +432,7 @@ function assemble(planned, schema, marking, options) {
347
432
  return walked();
348
433
  };
349
434
  return Object.assign(emit, {
435
+ [SETTLE]: resolve,
350
436
  names: Array.from(planned.names),
351
437
  // Already signature objects — plan() paired each called name with its
352
438
  // registry metadata through xprsn's signatures().
@@ -412,7 +498,26 @@ export function quario(options) {
412
498
  // render is always a promise, even over a synchronous renderer.
413
499
  render: (/** @type {any} */ target, /** @type {any} */ data) => {
414
500
  checkTarget(target);
415
- return settled.then(() => runTarget(target.compile, stream, data));
501
+ // The resolve wave runs before the target compiles, so the stream the
502
+ // target drains is the one carrying its answers (docs/adr/0088). The
503
+ // bound stream keeps the compiled report's own metadata: a target reads
504
+ // it off the function it was handed.
505
+ return settled
506
+ .then(() => /** @type {any} */ (stream)[SETTLE](data))
507
+ .then((pre) =>
508
+ runTarget(
509
+ target.compile,
510
+ // Only the render's own document reads the settled wave. A target
511
+ // that drains the stream over something else gets an ordinary
512
+ // stream, because nothing resolved that document's rows.
513
+ Object.assign((/** @type {any} */ d) => (d === data ? stream(d, pre) : stream(d)), {
514
+ names: stream.names,
515
+ functions: stream.functions,
516
+ paths: stream.paths,
517
+ }),
518
+ data,
519
+ ),
520
+ );
416
521
  },
417
522
  });
418
523
  return {
package/lib/plan.js CHANGED
@@ -51,7 +51,7 @@ import { LOCATION, fault, isDiagnostic, locate } from "./locate.js";
51
51
  import { ANCHORS, BLOCKED, NAME, RESERVED, isRequirable, record } from "./names.js";
52
52
  import { MATH } from "./math.js";
53
53
  import { REDUCERS, reducerFor } from "./reducers.js";
54
- import { aggregateValue, withRow } from "./scope.js";
54
+ import { aggregateValue, RESOLVED, withRow } from "./scope.js";
55
55
  import { dimensions, sniff } from "./image.js";
56
56
  import { oneValue, opt, sameStyle } from "./stream.js";
57
57
  import {
@@ -149,12 +149,68 @@ let collect = (from, into) => {
149
149
  /** @type {(visible: any, scope: any) => any} */
150
150
  let hidden = (visible, scope) => visible && visible(scope) === false;
151
151
 
152
- // A text item's closed key set, and the same set inside a split, where the
153
- // slot's width share joins it.
154
- let TEXT_KEYS = ["type", "value", "visible", "style"];
155
- let TEXT_SLOT_KEYS = [...TEXT_KEYS, "width"];
156
- let IMAGE_KEYS = ["type", "source", "fit", "alt", "visible", "style"];
157
- let IMAGE_SLOT_KEYS = [...IMAGE_KEYS, "width"];
152
+ // Every closed key set on the authoring surface, named in one place. The
153
+ // readers below are what enforce them, and the gates that hold the other two
154
+ // descriptions of the surface to this one -- the declarations and SCHEMA.md --
155
+ // read the same object, so no gate keeps a list of its own (ADR 0086). A set
156
+ // that lives only at its call site is one nothing can check.
157
+ //
158
+ // A slot's set is its item's plus the width share, derived rather than
159
+ // restated, so the two cannot part.
160
+ let TEXT = ["type", "value", "visible", "style"];
161
+ let IMAGE = ["type", "source", "fit", "alt", "visible", "style"];
162
+ export let KEY_SETS = Object.freeze({
163
+ report: [
164
+ "params",
165
+ "required",
166
+ "data",
167
+ "where",
168
+ "sort",
169
+ "take",
170
+ "aggregates",
171
+ "run",
172
+ "style",
173
+ "header",
174
+ "empty",
175
+ "columns",
176
+ "groups",
177
+ "detail",
178
+ "footer",
179
+ "page",
180
+ ],
181
+ reportHeader: ["height", "items"],
182
+ page: ["header", "footer", "margin"],
183
+ group: [
184
+ "name",
185
+ "by",
186
+ "label",
187
+ "collapsed",
188
+ "break",
189
+ "reset",
190
+ "columns",
191
+ "sort",
192
+ "take",
193
+ "aggregates",
194
+ "run",
195
+ "header",
196
+ "footer",
197
+ ],
198
+ sortKey: ["by", "dir"],
199
+ text: TEXT,
200
+ textSlot: [...TEXT, "width"],
201
+ image: IMAGE,
202
+ imageSlot: [...IMAGE, "width"],
203
+ split: ["type", "slots", "visible", "style"],
204
+ styledRun: ["value", "style"],
205
+ detail: ["header", "row", "columns", "total"],
206
+ detailHeader: ["style"],
207
+ detailRow: ["visible", "style"],
208
+ column: ["header", "value", "visible", "style", "width"],
209
+ columnHeader: ["value", "style", "span"],
210
+ totalBlock: ["style", "visible", "rows"],
211
+ totalRow: ["cells", "style", "visible"],
212
+ totalCell: ["value", "visible", "style", "span"],
213
+ });
158
214
 
159
215
  // A group's two structural literals. `break` names a position rather than a
160
216
  // unit (ADR 0077), and this table is the whole of what its four values mean:
@@ -446,6 +502,10 @@ let planState = (schema, funcs) => {
446
502
  // keeps the anchor-and-handle subset a consumer placing a node needs.
447
503
  /** @type {Map<string, Set<string>>} */
448
504
  let anchors = new Map();
505
+ /** @type {{ path: string, source: string,
506
+ * live: [number, (scope: any) => any][] }[]} */
507
+ let slotted = [];
508
+
449
509
  /** @type {(path: string, reads: { name: string }[]) => void} */
450
510
  let anchorsOf = (path, reads) => {
451
511
  for (let { name } of reads) {
@@ -458,11 +518,19 @@ let planState = (schema, funcs) => {
458
518
 
459
519
  return {
460
520
  FNS,
521
+ // The host's own registry keys, which is what separates a call that may
522
+ // hand back a promise from one of the engine's synchronous built-ins. A
523
+ // host that shadows a built-in owns that name, and its key says so.
524
+ HOSTS: new Set(Object.keys(funcs || {})),
461
525
  names,
462
526
  functions,
463
527
  BND,
464
528
  problems,
465
529
  warnings,
530
+ // Every cell the resolve wave has to settle before the walk, in compile
531
+ // order (docs/adr/0088). Empty for a document that calls nothing async,
532
+ // which is what lets a render skip the wave entirely.
533
+ slotted,
466
534
  runNames,
467
535
  anchors,
468
536
  anchorsOf,
@@ -492,13 +560,27 @@ let compileProp = (source, path, FNS, BND) => {
492
560
  locate(path, source, error, isXprsnDiagnostic(error) && relocateXprsn, 1);
493
561
  }
494
562
  };
563
+ // A promise nothing settled, refused where it stands. It is worse than a wrong
564
+ // value, because it is truthy: an unrefused one keeps every row a `where`
565
+ // should drop and shows every item a `visible` should hide. One helper, so the
566
+ // three places that can meet one say it the same way (docs/adr/0088).
567
+ /** @type {(value: any, path: string, source: any, said: string) => any} */
568
+ let refuseThenable = (value, path, source, said) => {
569
+ if (typeof value?.then === "function") locate(path, source, TypeError(said));
570
+ return value;
571
+ };
572
+ let ONLY_A_CELL = "an async function is read from a cell's value and nowhere else";
495
573
  /** @type {(expression: any, path: string, source: any) => Eval} */
496
574
  let runProp = (expression, path, source) => (scope) => {
575
+ let out;
497
576
  try {
498
- return expression(scope);
577
+ out = expression(scope);
499
578
  } catch (error) {
500
579
  locate(path, source, error, expression.isDiagnostic(error) && relocateXprsn, 1);
501
580
  }
581
+ // The resolve wave settles a cell's value and only that, so nothing ever
582
+ // settles a computed property.
583
+ return refuseThenable(out, path, source, ONLY_A_CELL);
502
584
  };
503
585
  // The [live field](../../../CONTEXT.md#live-field): which page value a token
504
586
  // *is*, for a target whose own format numbers pages. sjabloon offers each
@@ -524,13 +606,52 @@ let compileCell = (src, path, FNS, BND) => {
524
606
  locate(path, src, error, isTemplateDiagnostic(error) && relocateTemplate);
525
607
  }
526
608
  };
527
- /** @type {(tokens: any, path: string, source: any) => (scope: Scope) => any} */
528
- let runCell = (tokens, path, source) => (scope) => {
529
- try {
530
- return tokens.scoped(scope);
531
- } catch (error) {
532
- locate(path, source, error, tokens.isDiagnostic(error) && relocateTemplate);
533
- }
609
+ // A promise that reached a token is one nothing resolved: either the caller
610
+ // drained `stream()`, which cannot wait, or the cell sits where no row does.
611
+ // xprsn already refuses one an operator consumed, so what arrives here is a
612
+ // whole-token call, and saying so is the only thing left to do about it.
613
+ /** @type {(out: any, path: string, source: any, resolving: boolean) => any} */
614
+ let refusePending = (out, path, source, resolving) => {
615
+ let said = resolving
616
+ ? "an async function is read from a detail row's cell and nowhere else"
617
+ : "an async function is resolved by render(), and stream() cannot wait for one";
618
+ for (let token of out) refuseThenable(token.value, path, source, said);
619
+ return out;
620
+ };
621
+ // This cell's settled values for the row in scope, or nothing when the wave
622
+ // did not reach it. `held` is the render's whole map; a caller that already
623
+ // read it passes it in, because its presence is also how a cell tells a render
624
+ // from a caller draining `stream()`.
625
+ /** @type {(held: any, scope: Scope, path: string) => any} */
626
+ let settledFor = (held, scope, path) => {
627
+ let forRow = held && held.get(scope["@"]);
628
+ return forRow && forRow[path];
629
+ };
630
+ /** @type {(tokens: any, path: string, source: any, live: any[]) => (scope: Scope) => any} */
631
+ let runCell = (tokens, path, source, live) => {
632
+ // The branch is the compile's, not the render's: a cell that calls no host
633
+ // function is the overwhelming majority and reads nothing off the chain.
634
+ if (!live.length)
635
+ return (scope) => {
636
+ try {
637
+ return tokens.scoped(scope);
638
+ } catch (error) {
639
+ locate(path, source, error, tokens.isDiagnostic(error) && relocateTemplate);
640
+ }
641
+ };
642
+ return (scope) => {
643
+ let held = /** @type {any} */ (scope)[RESOLVED],
644
+ supply = settledFor(held, scope, path);
645
+ let out;
646
+ try {
647
+ out = tokens.scoped(scope, supply);
648
+ } catch (error) {
649
+ locate(path, source, error, tokens.isDiagnostic(error) && relocateTemplate);
650
+ }
651
+ // Outside the catch: the refusal locates itself, and locating it twice
652
+ // would print the cell path twice.
653
+ return supply ? out : refusePending(out, path, source, !!held);
654
+ };
534
655
  };
535
656
  /** @type {(spec: string) => { fn: string, src: string, colon: number }} */
536
657
  let splitFold = (spec) => {
@@ -554,7 +675,7 @@ let checkExprFold = (src, fn, path) => {
554
675
  };
555
676
 
556
677
  /** @param {State} state */
557
- let primitives = ({ FNS, BND, names, functions, anchorsOf }) => {
678
+ let primitives = ({ FNS, HOSTS, BND, names, functions, anchorsOf, slotted }) => {
558
679
  // A computed property: a literal, or an `=` xprsn expression. xprsn's
559
680
  // `bound` keeps engine-provided anchors/handles out of the reported `names`.
560
681
  /** @type {(spec: any, path: string) => Eval} */
@@ -583,7 +704,25 @@ let primitives = ({ FNS, BND, names, functions, anchorsOf }) => {
583
704
  collect(tokens.names, names);
584
705
  collect(tokens.functions, functions);
585
706
  anchorsOf(path, tokens.reads);
586
- return runCell(tokens, path, source);
707
+ // A slot is any registry call, and most of them are the engine's own
708
+ // reducers and rounders, which are synchronous by construction. Only a
709
+ // call into the host's registry can hand back a promise, so only those
710
+ // reach the resolve wave; every index stays sjabloon's, because the
711
+ // supply array is indexed by slot.
712
+ /** @type {[number, any][]} */
713
+ let live = tokens.slots
714
+ .map((/** @type {any} */ slot, /** @type {number} */ at) => [at, slot])
715
+ .filter((/** @type {any} */ pair) =>
716
+ pair[1].functions.some((/** @type {string} */ n) => HOSTS.has(n)),
717
+ );
718
+ // A host call's arguments come from the row (docs/adr/0088), so only a cell
719
+ // that reads `@` can be projected. The rest are not refused here: most
720
+ // host functions are synchronous, a footer calling one is ordinary, and
721
+ // whether a call is async is not a fact a compile holds. A promise that
722
+ // reaches a token unresolved is what says so, and `runCell` refuses it.
723
+ if (live.length && tokens.reads.some((/** @type {any} */ read) => read.name === "@"))
724
+ slotted.push({ path, source, live });
725
+ return runCell(tokens, path, source, live);
587
726
  };
588
727
 
589
728
  // Parse "reducer:=expr" into a reducer name and a compiled per-row expression,
@@ -958,7 +1097,7 @@ let readers = ({ bad, warn, attempt }, { prop, cell, parseFold }, /** @type {any
958
1097
  bad(path, "expected an object");
959
1098
  return NO_STYLED_RUN;
960
1099
  }
961
- keys(def, ["value", "style"], path);
1100
+ keys(def, KEY_SETS.styledRun, path);
962
1101
  let tpl = textValue(def.value, path + ".value");
963
1102
  unreadFormat(def.value, def.style, path);
964
1103
  let entries = entriesOf(def, path, checkRunStyle);
@@ -1043,7 +1182,7 @@ let readers = ({ bad, warn, attempt }, { prop, cell, parseFold }, /** @type {any
1043
1182
  let sortKey = (spec, i, path) => {
1044
1183
  let keyPath = path + "[" + i + "]";
1045
1184
  if (!obj(spec, keyPath)) return [];
1046
- keys(spec, ["by", "dir"], keyPath);
1185
+ keys(spec, KEY_SETS.sortKey, keyPath);
1047
1186
  let by = expression(spec.by, keyPath + ".by");
1048
1187
  checkDir(spec.dir, keyPath + ".dir");
1049
1188
  return [{ by, sign: spec.dir === "desc" ? -1 : 1 }];
@@ -1261,7 +1400,7 @@ let nodes = (
1261
1400
  bad(path, "expected a template string or object");
1262
1401
  return { cell: cellShape(NO_STYLED_RUNS, null, NO_LOOK), span: 1, box: NO_LOOK.box };
1263
1402
  }
1264
- keys(def, ["value", "style", "span"], path);
1403
+ keys(def, KEY_SETS.columnHeader, path);
1265
1404
  return { ...cellOf(def, path), span: spanOf(def, path) };
1266
1405
  };
1267
1406
 
@@ -1298,7 +1437,7 @@ let nodes = (
1298
1437
  /** @type {(def: any, path: string, slot: string) => any} */
1299
1438
  let columnOf = (def, path, slot) => {
1300
1439
  if (!obj(def, path)) return null;
1301
- keys(def, ["header", "value", "visible", "style", "width"], path);
1440
+ keys(def, KEY_SETS.column, path);
1302
1441
  let header = headerSlot(def.header, path + ".header", slot);
1303
1442
  let { styled, visible, look } = cellParts(def, path, checkCellStyle);
1304
1443
  // A width is a percentage of the table width, resolved by every target.
@@ -1319,7 +1458,7 @@ let nodes = (
1319
1458
  */
1320
1459
  let totalOf = (def, path) => {
1321
1460
  if (!obj(def, path)) return null;
1322
- keys(def, ["value", "visible", "style", "span"], path);
1461
+ keys(def, KEY_SETS.totalCell, path);
1323
1462
  return { ...cellOf(def, path), span: spanOf(def, path) };
1324
1463
  };
1325
1464
 
@@ -1329,7 +1468,7 @@ let nodes = (
1329
1468
  // the line's geometry does not move with the data (SCHEMA.md, "Split item").
1330
1469
  /** @type {(def: any, path: string, role: string, inSlot?: boolean) => (scope: Scope) => any} */
1331
1470
  let textOf = (def, path, role, inSlot = false) => {
1332
- keys(def, inSlot ? TEXT_SLOT_KEYS : TEXT_KEYS, path);
1471
+ keys(def, inSlot ? KEY_SETS.textSlot : KEY_SETS.text, path);
1333
1472
  checkTextType(def.type, path);
1334
1473
  let { styled, visible, look } = cellParts(def, path, inSlot ? checkSlotStyle : checkItemStyle);
1335
1474
  let shape = cellShape(styled, inSlot ? visible : null, look);
@@ -1415,7 +1554,7 @@ let nodes = (
1415
1554
  let imageLook = (inSlot) => (inSlot ? checkSlotImageStyle : checkImageStyle);
1416
1555
  /** @type {(def: any, path: string, role: string, inSlot?: boolean) => (scope: Scope) => any} */
1417
1556
  let imageOf = (def, path, role, inSlot = false) => {
1418
- keys(def, inSlot ? IMAGE_SLOT_KEYS : IMAGE_KEYS, path);
1557
+ keys(def, inSlot ? KEY_SETS.imageSlot : KEY_SETS.image, path);
1419
1558
  let source = expression(def.source, path + ".source");
1420
1559
  checkFit(def.fit, path + ".fit");
1421
1560
  // Read in the documented key order, and each read once: `stylesOf` reports
@@ -1469,7 +1608,7 @@ let nodes = (
1469
1608
  // every slot crosses as the ordinary item or image event it already is.
1470
1609
  /** @type {(def: any, path: string, role: string) => (scope: Scope) => any} */
1471
1610
  let splitOf = (def, path, role) => {
1472
- keys(def, ["type", "slots", "visible", "style"], path);
1611
+ keys(def, KEY_SETS.split, path);
1473
1612
  let list = arr(def.slots, path + ".slots", true);
1474
1613
  let defs = list || [];
1475
1614
  // Two is the floor: a one-slot split is a text item wearing more syntax,
@@ -1589,7 +1728,7 @@ let bands = (
1589
1728
  let rowLook = (value) => {
1590
1729
  if (value.row == null || !obj(value.row, "detail.row"))
1591
1730
  return { rowVisible: null, rowStyles: NO_ROW_STYLE };
1592
- keys(value.row, ["visible", "style"], "detail.row");
1731
+ keys(value.row, KEY_SETS.detailRow, "detail.row");
1593
1732
  return {
1594
1733
  rowVisible: visibleOf(value.row, "detail.row"),
1595
1734
  rowStyles: rowStylesOf(value.row, "detail.row"),
@@ -1600,7 +1739,7 @@ let bands = (
1600
1739
  /** @type {(value: any) => RowStyles} */
1601
1740
  let headerLook = (value) => {
1602
1741
  if (value.header == null || !obj(value.header, "detail.header")) return NO_ROW_STYLE;
1603
- keys(value.header, ["style"], "detail.header");
1742
+ keys(value.header, KEY_SETS.detailHeader, "detail.header");
1604
1743
  return rowStylesOf(value.header, "detail.header");
1605
1744
  };
1606
1745
  // Which slot each column's header sits in, walked positionally rather than
@@ -1675,7 +1814,7 @@ let bands = (
1675
1814
  /** @type {(value: any, defs: any[] | null) => TotalBlock} */
1676
1815
  let tableTotals = (value, defs) => {
1677
1816
  if (value.total == null || !obj(value.total, "detail.total")) return NO_TOTALS;
1678
- keys(value.total, ["style", "visible", "rows"], "detail.total");
1817
+ keys(value.total, KEY_SETS.totalBlock, "detail.total");
1679
1818
  let block = rowEntriesOf(value.total, "detail.total");
1680
1819
  return {
1681
1820
  visible: visibleOf(value.total, "detail.total"),
@@ -1701,7 +1840,7 @@ let bands = (
1701
1840
  /** @type {(def: any, path: string, defs: any[] | null, block: RowEntries) => TotalRow} */
1702
1841
  let totalRowOf = (def, path, defs, block) => {
1703
1842
  if (!obj(def, path)) return { styles: foldRow(block), visible: null, cells: [], fan: [] };
1704
- keys(def, ["cells", "style", "visible"], path);
1843
+ keys(def, KEY_SETS.totalRow, path);
1705
1844
  let cells = counted(arr(def.cells, path + ".cells", true), defs, path);
1706
1845
  let styles = foldRow(underRow(block, rowEntriesOf(def, path)));
1707
1846
  let mapped = mapTotals(cells, path + ".cells");
@@ -1789,7 +1928,7 @@ let bands = (
1789
1928
  };
1790
1929
  /** @type {(value: any) => Band} */
1791
1930
  let tableOf = (value) => {
1792
- keys(value, ["header", "row", "columns", "total"], "detail");
1931
+ keys(value, KEY_SETS.detail, "detail");
1793
1932
  let headerStyles = headerLook(value);
1794
1933
  let { rowVisible, rowStyles } = rowLook(value);
1795
1934
  let { defs, columns } = tableColumns(value);
@@ -1950,25 +2089,7 @@ let bands = (
1950
2089
  };
1951
2090
  /** @type {(def: any, path: string, defs: any[], index: number, detail: any, columned?: boolean) => Band} */
1952
2091
  let compileGroup = (def, path, defs, index, detail, columned) => {
1953
- keys(
1954
- def,
1955
- [
1956
- "name",
1957
- "by",
1958
- "label",
1959
- "collapsed",
1960
- "break",
1961
- "reset",
1962
- "columns",
1963
- "sort",
1964
- "take",
1965
- "aggregates",
1966
- "run",
1967
- "header",
1968
- "footer",
1969
- ],
1970
- path,
1971
- );
2092
+ keys(def, KEY_SETS.group, path);
1972
2093
  named(def.name, path + ".name", RESERVED);
1973
2094
  noteGroup(def.name, path + ".name");
1974
2095
  let by = expression(def.by, path + ".by");
@@ -2101,7 +2222,7 @@ let reportHeaderOf = (value, keys, itemsOf, bad) => {
2101
2222
  bad("header", "expected an array of items or { height, items }");
2102
2223
  return { items: itemsOf(null, "header", "report-header"), height: null };
2103
2224
  }
2104
- keys(value, ["height", "items"], "header");
2225
+ keys(value, KEY_SETS.reportHeader, "header");
2105
2226
  return {
2106
2227
  items: itemsOf(value.items, "header.items", "report-header"),
2107
2228
  height: headerHeightOf(value.height, bad),
@@ -2118,7 +2239,7 @@ let pageMarginOf = (margin, bad) => {
2118
2239
  let pageBands = (schema, obj, keys, itemsOf, bad) => {
2119
2240
  if (schema.page == null || !obj(schema.page, "page"))
2120
2241
  return { pageHeader: null, pageFooter: null, margin: null };
2121
- keys(schema.page, ["header", "footer", "margin"], "page");
2242
+ keys(schema.page, KEY_SETS.page, "page");
2122
2243
  return {
2123
2244
  pageHeader: maybeItems(schema.page.header, "page.header", "page-header", itemsOf),
2124
2245
  pageFooter: maybeItems(schema.page.footer, "page.footer", "page-footer", itemsOf),
@@ -2148,7 +2269,7 @@ let needPinnedMargin = (headerHeight, schema, bad) => {
2148
2269
  * render may traverse, never what this descent reports.
2149
2270
  */
2150
2271
  let traverse = (
2151
- { add, bad, attempt, pin, runNames },
2272
+ { add, bad, attempt, pin, runNames, slotted },
2152
2273
  { arr, obj, keys, named, expression, columnsOf, takeOf, foldsOf, sortOf, stylesOf },
2153
2274
  { itemsOf },
2154
2275
  { bandOf },
@@ -2159,28 +2280,7 @@ let traverse = (
2159
2280
  add("schema: expected an object");
2160
2281
  return null;
2161
2282
  }
2162
- keys(
2163
- schema,
2164
- [
2165
- "params",
2166
- "required",
2167
- "data",
2168
- "where",
2169
- "sort",
2170
- "take",
2171
- "aggregates",
2172
- "run",
2173
- "style",
2174
- "header",
2175
- "empty",
2176
- "columns",
2177
- "groups",
2178
- "detail",
2179
- "footer",
2180
- "page",
2181
- ],
2182
- "",
2183
- );
2283
+ keys(schema, KEY_SETS.report, "");
2184
2284
  // Frozen either way, so `$.params` reads the same whether or not a report
2185
2285
  // declares any. A count here columns every group inside it, so the band
2186
2286
  // chain below carries the verdict down as `enclosed`.
@@ -2234,6 +2334,7 @@ let traverse = (
2234
2334
  pageFooter,
2235
2335
  margin,
2236
2336
  required,
2337
+ slotted,
2237
2338
  };
2238
2339
  };
2239
2340
 
package/lib/scope.js CHANGED
@@ -17,6 +17,13 @@ import { REDUCERS, runnerFor } from "./reducers.js";
17
17
  * bind: (scope: Scope, row: any) => Scope }} RunnerSet
18
18
  */
19
19
 
20
+ // Where a render parks the values its resolve wave settled: a Map from row to
21
+ // that row's supplies, keyed by cell path (docs/adr/0088). It sits on the
22
+ // render base, so every scope under it inherits the one object and a cell that
23
+ // holds no slot never looks. A symbol, so it cannot collide with an author's
24
+ // name and never reaches an expression.
25
+ export let RESOLVED = Symbol("quario.resolved");
26
+
20
27
  // Bind a row as `@` on a child of `scope`, ready for an xprsn per-row read.
21
28
  /** @type {(scope: Scope, row: any) => Scope} */
22
29
  export let withRow = (scope, row) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quario",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Tiny, CSP-safe report engine for JavaScript. One JSON document renders to HTML, PDF, XLSX, CSV and Word.",
5
5
  "keywords": [
6
6
  "csp",
@@ -43,12 +43,12 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "padvinder": "^0.9.0",
46
- "sjabloon": "^0.13.0",
47
- "xprsn": "^0.11.1"
46
+ "sjabloon": "^0.14.0",
47
+ "xprsn": "^0.12.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@arethetypeswrong/cli": "^0.18.3",
51
- "@size-limit/preset-small-lib": "^13.0.3",
51
+ "@size-limit/preset-small-lib": "^13.1.1",
52
52
  "size-limit": "^13.0.3",
53
53
  "typescript": "^7.0.2"
54
54
  },
@@ -60,7 +60,7 @@
60
60
  "sjabloon",
61
61
  "padvinder"
62
62
  ],
63
- "limit": "15.3 kB"
63
+ "limit": "16.2 kB"
64
64
  }
65
65
  ],
66
66
  "engines": {