@datarails-ai/dr-appspace-cli 0.12.36 → 0.12.37

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.
Files changed (2) hide show
  1. package/dist/cli.js +692 -102
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { fileURLToPath as fileURLToPath6 } from "url";
13
13
  // package.json
14
14
  var package_default = {
15
15
  name: "@datarails-ai/dr-appspace-cli",
16
- version: "0.12.36",
16
+ version: "0.12.37",
17
17
  description: "Datarails App Space CLI \u2014 build, try, push and publish App Space apps",
18
18
  type: "module",
19
19
  engines: {
@@ -57,7 +57,7 @@ var package_default = {
57
57
  };
58
58
 
59
59
  // src/buildInfo.ts
60
- var BUILD_TIME = "2026-10-26T13:45:46.965Z";
60
+ var BUILD_TIME = "2026-10-26T14:01:26.088Z";
61
61
  function formatBuildTime(iso) {
62
62
  if (iso === "") return "";
63
63
  const d = new Date(iso);
@@ -6140,6 +6140,15 @@ function rendererOf(pages) {
6140
6140
  }
6141
6141
  return {};
6142
6142
  }
6143
+ function metricsOf(pages) {
6144
+ for (const page of pages ?? []) {
6145
+ const embedded = embeddedContractOf(page);
6146
+ if (embedded === null) continue;
6147
+ const metrics = embedded.data?.metrics;
6148
+ return isPlainObject(metrics) ? { metrics } : {};
6149
+ }
6150
+ return {};
6151
+ }
6143
6152
  function cardSpecOf(element) {
6144
6153
  const card = element?.props?.card;
6145
6154
  return element?.type === "widget" && typeof card === "string" && card !== "" ? card : null;
@@ -6729,7 +6738,9 @@ function dataSection(app, manifest, spec, elements, describe7, problems) {
6729
6738
  ...application === null ? {} : { app: application },
6730
6739
  // Only when there are any: a contract written before sources existed
6731
6740
  // reads back byte for byte as it always did.
6732
- ...Object.keys(sources).length > 0 ? { sources } : {}
6741
+ ...Object.keys(sources).length > 0 ? { sources } : {},
6742
+ // The same for declared metrics, which only an app's pages carry.
6743
+ ...isApp ? metricsOf(app.pages) : {}
6733
6744
  };
6734
6745
  if (describe7) {
6735
6746
  const widgets2 = isPlainObject(describe7.widgets) ? describe7.widgets : {};
@@ -7780,8 +7791,11 @@ var contract_schema_default = {
7780
7791
  required: ["from"],
7781
7792
  properties: {
7782
7793
  from: {
7783
- description: 'one of four spellings: {"financeos": {...}} (the dashboard SPEC source vocabulary), {"table": "<app table>"}, {"call": "<dotted method>", "args": {...}}, or ["<source>", ...] to compose others (with a transform, or with an expr the platform evaluates)',
7784
- oneOf: [{ type: "object" }, { type: "array", items: { type: "string" } }]
7794
+ description: 'one of four spellings: {"financeos": {...}} (the dashboard SPEC source vocabulary; a metric is {"metric": "@<key>"}, a key data.metrics declares, or {"metric_id": <id>}, which ties the app to one company), {"table": "<app table>"}, {"call": "<dotted method>", "args": {...}}, or ["<source>", ...] to compose others (with a transform, or with an expr the platform evaluates)',
7795
+ oneOf: [
7796
+ { type: "object", properties: { financeos: { type: "object", properties: { metric: { type: "string", description: 'OPTIONAL. The metric this source reads, as "@<key>": a key data.metrics declares ("@revenue_mom"). `app-space ship` binds the key to the company\'s own metric, so the contract names no metric id; the read parameters beside it (scenario, aggregation_type, period, honors, drill_rows, filterable) are what they are for metric_id, and the two never stand on one source. The compiler refuses the rest (source_metric).' } } } } },
7797
+ { type: "array", items: { type: "string" } }
7798
+ ]
7785
7799
  },
7786
7800
  expr: { type: "string", description: "OPTIONAL, beside from as a list and no transform (lineage tier 3, docs/appspace/design-computed-card-lineage.md): the source's figure as a formula over its from sources' figures, which the PLATFORM evaluates, so the figure is made of Datarails reads and never computed by the app. It may use numbers, the operands' names, + - * / and parentheses (/ is a safe divide: a zero or missing divisor is no figure, and any missing figure makes the result none), and abs(x), min(a, b, ...), max(a, b, ...), round(x) or round(x, decimals 0-6, half away from zero), and if(comparison, then, else), the one place a comparison (< <= > >= == !=) may stand. Shape kpi (the value, and the compare where every operand carries one), series (point by point: each series operand draws one line, and a kpi operand is one figure at every point) or breakdown (member by member: every breakdown operand states top: null, and a kpi operand is one figure for every member). Its figure comes from an operand on every path: each branch of if() and each argument of min()/max() reads an operand, so a number alone (870000, or a fallback 0) is never the figure; write actual / budget, not if(budget > 0, actual / budget, 0). Every operand is a FinanceOS table read or metric (from.financeos) or another expr source over them - never a transform's output, a composed source, a call or an app table (a KPI belongs in the Datarails semantic layer, not in app data) - and every operand is used; compare is not stated here (it is the formula over the operands' compares). The compiler refuses the rest (source_expr)." },
7787
7801
  shape: { enum: ["rows", "series", "kpi", "breakdown", "table", "ohlc", "scatter", "narrative"] },
@@ -7798,6 +7812,53 @@ var contract_schema_default = {
7798
7812
  }
7799
7813
  }
7800
7814
  },
7815
+ metrics: {
7816
+ $comment: "Present only for kind: app, and only when the contract declares any (docs/appspace/research/2026-10-25-datarails-calc-layer/06-design-contract-declared-metrics.md \xA72). The package carries it in the contract its pages embed. The key pattern and the per-kind rules are the compiler's (this schema has no propertyNames and no conditional keyword): metric_decl, metric_kind_later, metric_formula, metric_base, metric_format.",
7817
+ type: "object",
7818
+ description: `the Datarails metrics this app reads, declared rather than addressed by id, keyed by a local name: a lowercase letter, then lowercase letters, digits or _ (revenue_mom), never sent to Datarails. A source reads one with from.financeos.metric "@<key>" and a CALC formula with [@<key>]. \`app-space ship\` binds each key in the company it ships to: it reuses the company's metric with the same definition, or the one with the same name when reuse is "name", and creates it otherwise (create), as the person shipping; a metric with the declared name and another definition is refused, never suffixed. So the contract names no company's metric, and every figure stays a Datarails metric read, the same number MCP and DR.METRIC give. A declaration with no definition binds the company's own metric by name: {"name": "Revenue", "reuse": "name", "create": false}.`,
7819
+ additionalProperties: {
7820
+ type: "object",
7821
+ required: ["name"],
7822
+ additionalProperties: false,
7823
+ properties: {
7824
+ name: { type: "string", minLength: 1, maxLength: 128, description: `the metric's name in Datarails, which finance users, MCP and DR.METRIC see, so a name a finance team would use ("Revenue MoM growth"), never an app id: at most 128 characters, no ' [ or ] (a CALC formula's reference delimiters), not starting with @, and not DAYS_IN_PERIOD or PERIODS_IN_YEAR (Datarails reserves both)` },
7825
+ kind: { enum: ["base", "calc", "driver"], description: "base (a table's value field aggregated by its date field) or calc (a formula over other declared metrics). driver is reserved: the compiler refuses it (metric_kind_later) until driver declarations ship. Absent only on a declaration with no definition (create: false, reuse: name)" },
7826
+ formula: { type: "string", description: "calc only: the metric in Datarails' CALC syntax: numbers, + - * / and parentheses over operands, each [@<key>] (another metric this block declares) or a system variable written bare, PERIODS_IN_YEAR or DAYS_IN_PERIOD. An operand may carry one modifier: .SHIFT(unit, offset), unit year, quarter, month, day or consumption_period and offset 0 to -1200, or .AS_OF(anchor), anchor year_end, quarter_end or month_end. A window function avg|min|max([@<key>], unit, periods), unit year, quarter or month and periods 1 to 120, compiles with a warning (metric_window): Datarails evaluates it only where the company has scenario_bound_calculations on. Never a metric's name, quoted or bracketed ('Revenue', [Revenue]): the contract names no company's metric. No scenario pin (| Budget), at least one [@<key>], at most 5000 characters, and no declaration that reads itself, however far round. ([@revenue] - [@revenue].SHIFT(month, -1)) / [@revenue].SHIFT(month, -1)" },
7827
+ table: {
7828
+ type: "object",
7829
+ additionalProperties: false,
7830
+ description: 'base only: the FinanceOS table, exactly one of {"alias": "<table alias>"} or {"name": "<table name>"}, resolved in the company at ship, or {"id": <id>}, which ties the app to one company as metric_id does',
7831
+ properties: {
7832
+ alias: { type: "string", minLength: 1 },
7833
+ name: { type: "string", minLength: 1 },
7834
+ id: { type: "integer", minimum: 1 }
7835
+ }
7836
+ },
7837
+ value: { type: "string", minLength: 1, description: "base only: the field aggregated, by its alias, falling back to its name; resolved to the field's id in the company at ship" },
7838
+ date: { type: "string", minLength: 1, description: "base only: the field the metric's periods come from, by its alias, falling back to its name" },
7839
+ aggregate: { enum: ["sum", "count", "cumulative_sum", "cumulative_count"], description: "base only: how the value field aggregates in a period; the cumulative ones carry the total forward" },
7840
+ time_aggregation: { enum: ["sum", "eop", "avg"], description: "OPTIONAL, base only: how periods roll up into a longer one (a quarter of months). Absent: sum, or eop for a cumulative aggregate, which may not roll up with sum" },
7841
+ filters: {
7842
+ type: "array",
7843
+ description: 'OPTIONAL, base only: the rows the metric reads, each {"field": "<alias or name>", "values": [...], "exclude": false}; exclude: true reads every row but those values',
7844
+ items: {
7845
+ type: "object",
7846
+ required: ["field", "values"],
7847
+ additionalProperties: false,
7848
+ properties: {
7849
+ field: { type: "string", minLength: 1 },
7850
+ values: { type: "array", minItems: 1, items: { type: ["string", "number"] } },
7851
+ exclude: { type: "boolean" }
7852
+ }
7853
+ }
7854
+ },
7855
+ format: { type: "string", description: `OPTIONAL: the metric's display in Datarails (its Metric page, MCP, Excel), one of the 180 strings the metric editor writes: an optional currency prefix ("$" quoted, or \u20AC \xA3 \u20AA), #,### or ####, optional decimals .# to .####, an optional scale [kilo]k or [mega]M, and an optional % suffix (never with a currency): #,### "$"#,###.## ####.#%. A card keeps its own format` },
7856
+ description: { type: "string", description: "OPTIONAL: what the metric is, in words, for its page in Datarails" },
7857
+ reuse: { enum: ["definition", "name"], description: "OPTIONAL. definition (the default): bind only a company metric whose definition matches this one. name: bind the company's metric of this name whatever its definition, so a finance team's own Revenue wins; created from this definition when the company has none" },
7858
+ create: { type: "boolean", description: "OPTIONAL. true (the default): create the metric when the company has none to bind. false: bind only, never create; a declaration with no definition says false" }
7859
+ }
7860
+ }
7861
+ },
7801
7862
  library: {
7802
7863
  oneOf: [
7803
7864
  { type: "null" },
@@ -10469,14 +10530,14 @@ var PROP_ORDER = ["kind", "params", "title", "subtitle", "description", "source"
10469
10530
  function compile(contract, { kit, extra = {} } = {}) {
10470
10531
  if (!isPlainObject6(kit)) throw new TypeError("compile(contract, {kit}): the kit manifest (kit.json) says which shelf modules a page loads");
10471
10532
  const problems = [];
10472
- const refuse = (code, where2, message) => {
10533
+ const refuse2 = (code, where2, message) => {
10473
10534
  problems.push({ code, where: where2, lossy: false, message });
10474
10535
  return { files: {}, problems };
10475
10536
  };
10476
10537
  const source = isPlainObject6(contract) ? contract : {};
10477
10538
  const kind = source.app?.kind ?? null;
10478
10539
  if (kind !== "dashboard") {
10479
- return refuse(
10540
+ return refuse2(
10480
10541
  "kind_not_compilable",
10481
10542
  MANIFEST_FILE,
10482
10543
  `this compiler writes dashboards; app.kind is ${JSON.stringify(kind)}, and a kind: app contract is compiled by compileApp`
@@ -10484,23 +10545,23 @@ function compile(contract, { kit, extra = {} } = {}) {
10484
10545
  }
10485
10546
  if (source.app?.renderer === RUNTIME_RENDERER2) return compileApp(source, { kit, extra });
10486
10547
  if (source.app?.renderer !== void 0) {
10487
- return refuse(
10548
+ return refuse2(
10488
10549
  "renderer_not_compilable",
10489
10550
  MANIFEST_FILE,
10490
10551
  `app.renderer is ${JSON.stringify(source.app.renderer)}; the one renderer a dashboard may name is ${JSON.stringify(RUNTIME_RENDERER2)} (the app runtime, whose page compileApp writes), and absent means the dr-widgets page this compiler writes. Remove it, or set it to ${JSON.stringify(RUNTIME_RENDERER2)}`
10491
10552
  );
10492
10553
  }
10493
10554
  const described = describeTier(source.data);
10494
- if (described !== null) return refuse("describe_tier_not_compilable", described.where, described.message);
10555
+ if (described !== null) return refuse2("describe_tier_not_compilable", described.where, described.message);
10495
10556
  const { contract: composed, applied } = composeLayout(source);
10496
10557
  for (const fill of applied) {
10497
10558
  problems.push({ code: "layout_composed", where: fill.where, lossy: false, message: `${fill.message} (${fill.what})` });
10498
10559
  }
10499
10560
  const pages = [...Array.isArray(composed.pages) ? composed.pages : []].sort((a, b) => natural3(a.path ?? "", b.path ?? ""));
10500
- if (pages.length === 0) return refuse("no_pages", MANIFEST_FILE, "the contract declares no pages; a dashboard is one page of widgets or more");
10561
+ if (pages.length === 0) return refuse2("no_pages", MANIFEST_FILE, "the contract declares no pages; a dashboard is one page of widgets or more");
10501
10562
  const handlers = [...new Set(pages.map((page) => page.mount?.handler).filter((name) => typeof name === "string" && name !== ""))];
10502
10563
  if (handlers.length !== 1) {
10503
- return refuse(
10564
+ return refuse2(
10504
10565
  "one_handler_per_dashboard",
10505
10566
  MANIFEST_FILE,
10506
10567
  handlers.length === 0 ? "no page names a mount handler; every widget on a dashboard is answered by one handler" : `the pages name ${handlers.length} mount handlers (${handlers.join(", ")}); a dashboard has one, and a second source is a second ROW in its SPEC`
@@ -10508,7 +10569,7 @@ function compile(contract, { kit, extra = {} } = {}) {
10508
10569
  }
10509
10570
  const handlerName = handlers[0];
10510
10571
  const handler = dashboardHandler(composed, handlerName, extra);
10511
- if (handler.unwritable !== null) return refuse(handler.unwritable.code, handler.unwritable.where, handler.unwritable.message);
10572
+ if (handler.unwritable !== null) return refuse2(handler.unwritable.code, handler.unwritable.where, handler.unwritable.message);
10512
10573
  for (const note of handler.notes) problems.push(note);
10513
10574
  if (handler.refusals.length > 0) {
10514
10575
  for (const problem5 of handler.refusals) problems.push(problem5);
@@ -11045,7 +11106,7 @@ var DROPPED_REGIONS = /* @__PURE__ */ new Set(["controls", "band"]);
11045
11106
  var KEPT_CARD_PROPS = ["label", "format", "negative", "props"];
11046
11107
  function compileDashboard(contract, { kit = null, extra = {}, uiKit, charts, fmt, appComponents, appControls } = {}) {
11047
11108
  const problems = [];
11048
- const refuse = (code, pointer2, message) => problems.push({ code, pointer: pointer2, message });
11109
+ const refuse2 = (code, pointer2, message) => problems.push({ code, pointer: pointer2, message });
11049
11110
  const note = (code, where2, message) => problems.push({ code, where: where2, lossy: false, message });
11050
11111
  const refused = (restated2 = contract) => ({ files: {}, problems, contract: restated2, warnings: [] });
11051
11112
  const source = isPlainObject7(contract) ? contract : {};
@@ -11053,19 +11114,19 @@ function compileDashboard(contract, { kit = null, extra = {}, uiKit, charts, fmt
11053
11114
  const markdown = named5(kit?.markdown) ? kit.markdown : MARKDOWN;
11054
11115
  const described = describeTier(source.data);
11055
11116
  if (described !== null) {
11056
- refuse("describe_tier_not_compilable", described.where, described.message);
11117
+ refuse2("describe_tier_not_compilable", described.where, described.message);
11057
11118
  return refused();
11058
11119
  }
11059
11120
  const { contract: composed, applied } = composeLayout(source);
11060
11121
  for (const fill of applied) note("layout_composed", fill.where, `${fill.message} (${fill.what})`);
11061
11122
  const pages = [...Array.isArray(composed.pages) ? composed.pages : []].sort((a, b) => natural3(a.path ?? "", b.path ?? ""));
11062
11123
  if (pages.length === 0) {
11063
- refuse("no_pages", "/pages", "the contract declares no pages; a dashboard is one page of widgets or more");
11124
+ refuse2("no_pages", "/pages", "the contract declares no pages; a dashboard is one page of widgets or more");
11064
11125
  return refused();
11065
11126
  }
11066
11127
  const handlers = [...new Set(pages.map((page) => page.mount?.handler).filter((name) => typeof name === "string" && name !== ""))];
11067
11128
  if (handlers.length !== 1) {
11068
- refuse(
11129
+ refuse2(
11069
11130
  "one_handler_per_dashboard",
11070
11131
  "/pages",
11071
11132
  handlers.length === 0 ? "no page names a mount handler; every widget on a dashboard is answered by one handler" : `the pages name ${handlers.length} mount handlers (${handlers.join(", ")}); a dashboard has one, and a second source is a second ROW in its SPEC`
@@ -11075,15 +11136,15 @@ function compileDashboard(contract, { kit = null, extra = {}, uiKit, charts, fmt
11075
11136
  const handlerName = handlers[0];
11076
11137
  const handler = dashboardHandler(composed, handlerName, extra);
11077
11138
  if (handler.unwritable !== null) {
11078
- refuse(handler.unwritable.code, handler.unwritable.where, handler.unwritable.message);
11139
+ refuse2(handler.unwritable.code, handler.unwritable.where, handler.unwritable.message);
11079
11140
  return refused();
11080
11141
  }
11081
11142
  problems.push(...handler.notes);
11082
11143
  if (handler.refusals.length > 0) {
11083
- for (const problem5 of handler.refusals) refuse(problem5.code, problem5.where, problem5.message);
11144
+ for (const problem5 of handler.refusals) refuse2(problem5.code, problem5.where, problem5.message);
11084
11145
  return refused();
11085
11146
  }
11086
- const restated = restateDashboard(composed, pages, handlerName, handler, { refuse, note, stated: Array.isArray(source.pages) ? source.pages : [] });
11147
+ const restated = restateDashboard(composed, pages, handlerName, handler, { refuse: refuse2, note, stated: Array.isArray(source.pages) ? source.pages : [] });
11087
11148
  const whole = canonical({
11088
11149
  ...composed.v !== void 0 ? { v: composed.v } : {},
11089
11150
  ...composed.generated_by !== void 0 ? { generated_by: composed.generated_by } : {},
@@ -11120,14 +11181,14 @@ function compileDashboard(contract, { kit = null, extra = {}, uiKit, charts, fmt
11120
11181
  function isRefusal(problem5) {
11121
11182
  return problem5.lossy !== false;
11122
11183
  }
11123
- function restateDashboard(composed, pages, handlerName, handler, { refuse, note, stated: stated2 }) {
11184
+ function restateDashboard(composed, pages, handlerName, handler, { refuse: refuse2, note, stated: stated2 }) {
11124
11185
  const app = isPlainObject7(composed.app) ? composed.app : {};
11125
11186
  const typed = isPlainObject7(composed.elements) ? composed.elements : {};
11126
11187
  const declared = treeOf(typed);
11127
11188
  const conflicts = new Map(declared.problems.map((problem5) => [uidOfPointer(problem5.pointer), problem5]));
11128
11189
  const paths = new Set(pages.map((page) => page.path));
11129
11190
  const unplaced = Object.fromEntries(Object.keys(typed).filter((uid) => isPlainObject7(typed[uid]) && !paths.has(typed[uid].page)).map((uid) => [uid, typed[uid]]));
11130
- for (const problem5 of placementProblems(unplaced, pages, declared, [])) refuse(problem5.code, problem5.pointer, problem5.message);
11191
+ for (const problem5 of placementProblems(unplaced, pages, declared, [])) refuse2(problem5.code, problem5.pointer, problem5.message);
11131
11192
  const elements = {};
11132
11193
  const outPages = [];
11133
11194
  for (const page of pages) {
@@ -11163,7 +11224,7 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
11163
11224
  const onlyOne = (uids, what) => {
11164
11225
  for (const uid of uids.slice(1)) {
11165
11226
  duplicates.add(uid);
11166
- refuse(
11227
+ refuse2(
11167
11228
  "chrome_duplicate",
11168
11229
  `/elements/${escapePointer3(uid)}`,
11169
11230
  `"${uid}" is a second ${what} on ${path3}, after "${uids[0]}"; a dashboard page has one of each piece of chrome \u2014 remove it, or merge it into "${uids[0]}"`
@@ -11237,12 +11298,12 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
11237
11298
  const params = (Array.isArray(page.params) ? page.params : []).filter(isPlainObject7).map(canonical);
11238
11299
  const parts = [];
11239
11300
  for (const uid of own2.filter((uid2) => typed[uid2].type === "control")) {
11240
- const made = controlToPart(typed[uid], uid, path3, refuse);
11301
+ const made = controlToPart(typed[uid], uid, path3, refuse2);
11241
11302
  if (made === null) continue;
11242
11303
  const param = canonical(made.param);
11243
11304
  const taken = params.find((one) => one?.name === param.name);
11244
11305
  if (taken !== void 0 && JSON.stringify(taken) !== JSON.stringify(param)) {
11245
- refuse(
11306
+ refuse2(
11246
11307
  "control_conflict",
11247
11308
  `/elements/${escapePointer3(uid)}`,
11248
11309
  `control "${made.element.props.id}" declares page param ${JSON.stringify(param)}, and ${path3} already declares "${param.name}" as ${JSON.stringify(taken)}; one name is one declaration \u2014 make the two the same, or give the control its own param`
@@ -11259,14 +11320,14 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
11259
11320
  const handled = /* @__PURE__ */ new Set([header, ...chrome, filters, ...nav === void 0 ? [] : [nav, ...elements[nav].children], ...parts, ...cards]);
11260
11321
  const kept = own2.filter((uid) => !handled.has(uid) && !dropped.has(uid) && !["region", "nav", "tab", "control", "widget"].includes(typed[uid].type));
11261
11322
  for (const uid of kept) {
11262
- if (conflicts.has(uid)) refuse(conflicts.get(uid).code, conflicts.get(uid).pointer, conflicts.get(uid).message);
11323
+ if (conflicts.has(uid)) refuse2(conflicts.get(uid).code, conflicts.get(uid).pointer, conflicts.get(uid).message);
11263
11324
  elements[uid] = keptOf(typed[uid], uid, path3, typed, declared, dropped, kept, cards, sections);
11264
11325
  }
11265
11326
  for (const uid of cards) {
11266
11327
  const held = declared.parent.get(uid) ?? null;
11267
11328
  const parent = held !== null && kept.includes(held) ? held : null;
11268
- if (conflicts.has(uid) && (parent !== null || kept.includes(typed[uid].parent))) refuse(conflicts.get(uid).code, conflicts.get(uid).pointer, conflicts.get(uid).message);
11269
- elements[uid] = cardOf(typed[uid], uid, path3, handlerName, sections, refuse, parent);
11329
+ if (conflicts.has(uid) && (parent !== null || kept.includes(typed[uid].parent))) refuse2(conflicts.get(uid).code, conflicts.get(uid).pointer, conflicts.get(uid).message);
11330
+ elements[uid] = cardOf(typed[uid], uid, path3, handlerName, sections, refuse2, parent);
11270
11331
  }
11271
11332
  const otherRegions = own2.filter((uid) => typed[uid].type === "region" && !handled.has(uid) && !dropped.has(uid) && !duplicates.has(uid));
11272
11333
  for (const uid of otherRegions) elements[uid] = region(uid, typed[uid].role ?? "header", "section", []);
@@ -11279,7 +11340,7 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
11279
11340
  const authored = stated2.findIndex((one) => isPlainObject7(one) && one.path === path3);
11280
11341
  const statedRoute = authored === -1 ? null : stated2[authored].route ?? null;
11281
11342
  if (statedRoute !== null && statedRoute !== route) {
11282
- refuse(
11343
+ refuse2(
11283
11344
  "route_mismatch",
11284
11345
  `/pages/${authored}/route`,
11285
11346
  `${path3} states route ${JSON.stringify(statedRoute)}, and app.routes (${JSON.stringify(Array.isArray(app.routes) ? app.routes : [])}) ${route === null ? "serves it at no route" : `serves it at ${JSON.stringify(route)}`}; the manifest serves what app.routes lists \u2014 list the route there, or drop it from the page`
@@ -11295,11 +11356,11 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
11295
11356
  data: dataOf(composed, handler)
11296
11357
  };
11297
11358
  }
11298
- function cardOf(element, uid, path3, handlerName, sections, refuse, parent = null) {
11359
+ function cardOf(element, uid, path3, handlerName, sections, refuse2, parent = null) {
11299
11360
  const props = isPlainObject7(element.props) ? element.props : {};
11300
11361
  const spec = named5(element.id) ? element.id : named5(props.card) ? props.card : named5(props.id) ? props.id : null;
11301
11362
  if (spec === null) {
11302
- refuse(
11363
+ refuse2(
11303
11364
  "card_unnamed",
11304
11365
  `/elements/${escapePointer3(uid)}`,
11305
11366
  `widget "${uid}" names no SPEC card; a dashboard card is bound to a row of its handler's SPEC by its id \u2014 the element's id, or props.card`
@@ -11334,12 +11395,12 @@ function cardOf(element, uid, path3, handlerName, sections, refuse, parent = nul
11334
11395
  ...section === void 0 ? {} : { section }
11335
11396
  };
11336
11397
  }
11337
- function controlToPart(control, uid, path3, refuse) {
11398
+ function controlToPart(control, uid, path3, refuse2) {
11338
11399
  const props = isPlainObject7(control.props) ? control.props : {};
11339
11400
  const name = named5(props.param) ? props.param : named5(control.binding) ? control.binding : null;
11340
11401
  const id = named5(control.id) ? control.id : named5(props.id) ? props.id : name;
11341
11402
  if (name === null) {
11342
- refuse("control_unsupported", `/elements/${escapePointer3(uid)}`, `control "${id ?? uid}" names no param; a dashboard control parameterises its widgets by the param it writes (props.param)`);
11403
+ refuse2("control_unsupported", `/elements/${escapePointer3(uid)}`, `control "${id ?? uid}" names no param; a dashboard control parameterises its widgets by the param it writes (props.param)`);
11343
11404
  return null;
11344
11405
  }
11345
11406
  const kind = named5(props.control_kind) ? props.control_kind : props.type === "range" ? "slider" : props.type === "number" ? "number" : props.type === "checkbox" ? "toggle" : null;
@@ -11374,7 +11435,7 @@ function controlToPart(control, uid, path3, refuse) {
11374
11435
  if (value === void 0 || value === null || value === "") continue;
11375
11436
  const n = typeof value === "number" ? value : Number(value);
11376
11437
  if (!Number.isFinite(n)) {
11377
- refuse("control_unsupported", `/elements/${escapePointer3(uid)}/props/${key}`, `control "${id}" states ${key} ${JSON.stringify(value)}, which is not a number; a ${kind} page param takes numbers`);
11438
+ refuse2("control_unsupported", `/elements/${escapePointer3(uid)}/props/${key}`, `control "${id}" states ${key} ${JSON.stringify(value)}, which is not a number; a ${kind} page param takes numbers`);
11378
11439
  return null;
11379
11440
  }
11380
11441
  numbers[as] = n;
@@ -11386,7 +11447,7 @@ function controlToPart(control, uid, path3, refuse) {
11386
11447
  if (kind === "toggle") {
11387
11448
  return part("toggle", { name, kind: "toggle", ...label, default: props.checked !== void 0 && props.checked !== false && props.checked !== "false" });
11388
11449
  }
11389
- refuse(
11450
+ refuse2(
11390
11451
  "control_unsupported",
11391
11452
  `/elements/${escapePointer3(uid)}`,
11392
11453
  `control "${id}" is ${kind === null ? "of no kind this compiler knows" : `a ${kind}`}; on a dashboard the app runtime renders a control is a part over a page param, and the parts here are a slider (a range), a number input and a toggle (a checkbox)`
@@ -11889,6 +11950,234 @@ function formulaProblems(text3, operands) {
11889
11950
  return problems.length > 0 ? { tree: null, problems } : { tree, problems: [] };
11890
11951
  }
11891
11952
 
11953
+ // ../flows/insights-builder/kit/contract/metricFormula.mjs
11954
+ var SHIFT_UNITS = Object.freeze(["year", "quarter", "month", "day", "consumption_period"]);
11955
+ var AS_OF_ANCHORS = Object.freeze(["year_end", "quarter_end", "month_end"]);
11956
+ var WINDOW_FUNCTIONS = Object.freeze(["avg", "min", "max"]);
11957
+ var WINDOW_UNITS = Object.freeze(["year", "quarter", "month"]);
11958
+ var MAX_WINDOW_PERIODS = 120;
11959
+ var SYSTEM_VARIABLES = Object.freeze(["DAYS_IN_PERIOD", "PERIODS_IN_YEAR"]);
11960
+ var MAX_FORMULA_LENGTH = 5e3;
11961
+ var MAX_SHIFT_OFFSET_MAGNITUDE = 1200;
11962
+ var METRIC_KEY_RE = /^[a-z][a-z0-9_]*$/;
11963
+ var OPERATOR_RE = /\s*([+\-*/])\s*/g;
11964
+ var SENTINEL = "\uE000";
11965
+ var STASH_RE = /(\d+)/g;
11966
+ var REF_WITH_MOD_RE = /\[([^[\]]+)\](\.(?:SHIFT|AS_OF)\([^)]*\))?/g;
11967
+ var WINDOW_RE = new RegExp(String.raw`\b(${WINDOW_FUNCTIONS.join("|")})\(\s*\[([^[\]]+)\]\s*,\s*(\w+)\s*,\s*([+-]?\d+)\s*\)`, "g");
11968
+ var SYSTEM_VARIABLE_RE = new RegExp(`\\b(?:${SYSTEM_VARIABLES.join("|")})\\b`, "g");
11969
+ var SHIFT_RE = /^\.SHIFT\(\s*(\w+)\s*,\s*([+-]?\d+)\s*\)$/;
11970
+ var AS_OF_RE = /^\.AS_OF\(\s*(\w+)\s*\)$/;
11971
+ var MetricFormulaError = class extends Error {
11972
+ };
11973
+ function refuse(sentence2) {
11974
+ throw new MetricFormulaError(sentence2);
11975
+ }
11976
+ function modifierOnSystemVariable(token) {
11977
+ return `cannot attach a time modifier to the reserved token "${token}" (resolved at runtime, not a metric); drop the modifier or move it to a [@key] operand`;
11978
+ }
11979
+ function timeModifierProblem(mod) {
11980
+ const shift = SHIFT_RE.exec(mod);
11981
+ if (shift) {
11982
+ const unit = shift[1].toLowerCase();
11983
+ const offset = Number(shift[2]);
11984
+ if (!SHIFT_UNITS.includes(unit)) return `has an invalid SHIFT unit "${shift[1]}" (valid: ${SHIFT_UNITS.join(", ")})`;
11985
+ if (offset > 0) return `has a future SHIFT offset ${shift[2]}; the offset must be negative or zero (e.g. SHIFT(${unit}, -1))`;
11986
+ if (Math.abs(offset) > MAX_SHIFT_OFFSET_MAGNITUDE) {
11987
+ return `has a SHIFT offset ${shift[2]} whose magnitude exceeds the server maximum of ${MAX_SHIFT_OFFSET_MAGNITUDE}; use a smaller offset`;
11988
+ }
11989
+ return null;
11990
+ }
11991
+ const asOf = AS_OF_RE.exec(mod);
11992
+ if (asOf) {
11993
+ if (!AS_OF_ANCHORS.includes(asOf[1].toLowerCase())) return `has an invalid AS_OF anchor "${asOf[1]}" (valid: ${AS_OF_ANCHORS.join(", ")})`;
11994
+ return null;
11995
+ }
11996
+ return `has a malformed time modifier "${mod}" (expected .SHIFT(<unit>, <offset>) or .AS_OF(<anchor>))`;
11997
+ }
11998
+ function checkModifier(mod) {
11999
+ const problem5 = timeModifierProblem(mod);
12000
+ if (problem5 !== null) refuse(problem5);
12001
+ }
12002
+ function namedItself(spelled) {
12003
+ return `names the metric ${spelled} itself; an operand is [@key], a key data.metrics declares, so the contract names no company's metric \u2014 declare it there (with "reuse": "name" and "create": false to bind the company's own) and write [@<its key>]`;
12004
+ }
12005
+ function declaredKey(inner, declared) {
12006
+ if (!inner.startsWith("@")) refuse(namedItself(`[${inner}]`));
12007
+ const key = inner.slice(1);
12008
+ if (!METRIC_KEY_RE.test(key)) refuse(`writes [${inner}], and ${JSON.stringify(key)} is not a key: a key is a lowercase letter, then lowercase letters, digits or _`);
12009
+ if (!declared.includes(key)) {
12010
+ refuse(`reads [@${key}], which data.metrics does not declare (it declares ${declared.length > 0 ? declared.map((one) => `[@${one}]`).join(", ") : "none"})`);
12011
+ }
12012
+ return key;
12013
+ }
12014
+ function spelledEntry(entry) {
12015
+ if (entry.kind === "token") return entry.token;
12016
+ if (entry.kind === "window") return `${entry.fn}([@${entry.key}], ${entry.unit}, ${entry.periods})`;
12017
+ return `[@${entry.key}]${entry.mod}`;
12018
+ }
12019
+ function arithmeticProblem(skeleton, stash) {
12020
+ const tokens = [];
12021
+ for (let at = 0; at < skeleton.length; ) {
12022
+ const c = skeleton[at];
12023
+ if (/\s/.test(c)) {
12024
+ at += 1;
12025
+ } else if (c === SENTINEL) {
12026
+ const close = skeleton.indexOf(SENTINEL, at + 1);
12027
+ tokens.push({ kind: "value", text: spelledEntry(stash[Number(skeleton.slice(at + 1, close))]) });
12028
+ at = close + 1;
12029
+ } else if ("+-*/()".includes(c)) {
12030
+ tokens.push({ kind: c === "(" || c === ")" ? c : "op", text: c });
12031
+ at += 1;
12032
+ } else {
12033
+ const start = at;
12034
+ while (at < skeleton.length && /[0-9.]/.test(skeleton[at])) at += 1;
12035
+ const spelled = skeleton.slice(start, at);
12036
+ if (!/^(?:\d+\.?\d*|\.\d+)$/.test(spelled)) return `"${spelled || c}" is not a number`;
12037
+ tokens.push({ kind: "value", text: spelled });
12038
+ }
12039
+ }
12040
+ let expecting = "operand";
12041
+ let depth = 0;
12042
+ for (let at = 0; at <= tokens.length; at += 1) {
12043
+ const found = tokens[at];
12044
+ const before = tokens[at - 1];
12045
+ if (expecting === "operand") {
12046
+ if (found?.kind === "value") expecting = "operator";
12047
+ else if (found?.kind === "(") depth += 1;
12048
+ else if (found?.kind === "op" && found.text === "-") continue;
12049
+ else if (found?.kind === "op") return before?.kind === "op" ? `two operators in a row ("${before.text} ${found.text}")` : `an operand is missing before "${found.text}"`;
12050
+ else if (before?.kind === "(") return 'it has empty parentheses "()"';
12051
+ else if (before !== void 0) return `an operand is missing after "${before.text}"`;
12052
+ else return "it has no operand";
12053
+ } else if (found === void 0) {
12054
+ return depth > 0 ? 'a "(" is never closed' : null;
12055
+ } else if (found.kind === "op") {
12056
+ expecting = "operand";
12057
+ } else if (found.kind === ")") {
12058
+ if (depth === 0) return 'a ")" has no "(" before it';
12059
+ depth -= 1;
12060
+ } else {
12061
+ return `no operator stands between "${before.text}" and "${found.text}"`;
12062
+ }
12063
+ }
12064
+ return null;
12065
+ }
12066
+ function scan(formula, declared) {
12067
+ if (typeof formula !== "string") refuse(`is ${JSON.stringify(formula ?? null)}, not text`);
12068
+ if (formula.trim() === "") refuse("must be non-empty");
12069
+ if (formula.length > MAX_FORMULA_LENGTH) {
12070
+ refuse(`is ${formula.length} characters, over the server maximum of ${MAX_FORMULA_LENGTH}; shorten the formula`);
12071
+ }
12072
+ if (formula.includes(SENTINEL)) refuse("contains an unsupported character (U+E000)");
12073
+ if (formula.includes("|")) refuse("pins a scenario (|); a scenario pin is not accepted in a declared metric's formula \u2014 read the scenario on the source instead (from.financeos.scenario)");
12074
+ if (formula.includes("'")) {
12075
+ const quoted3 = /'[^']*'/.exec(formula);
12076
+ if (quoted3) refuse(namedItself(quoted3[0]));
12077
+ refuse("holds an apostrophe ('), which Datarails reads as the edge of a quoted metric name; an operand is [@key], and a metric name has no apostrophe");
12078
+ }
12079
+ const stash = [];
12080
+ const put = (entry) => {
12081
+ stash.push(entry);
12082
+ return `${SENTINEL}${stash.length - 1}${SENTINEL}`;
12083
+ };
12084
+ let working = formula.replace(WINDOW_RE, (_m, fn, inner, unit, periods) => {
12085
+ if (SYSTEM_VARIABLES.includes(inner)) refuse(`has a window over the reserved token "${inner}"; a window function reads a [@key] operand`);
12086
+ const key = declaredKey(inner, declared);
12087
+ if (!WINDOW_UNITS.includes(unit.toLowerCase())) refuse(`has an invalid window unit "${unit}" in ${fn}() (valid: ${WINDOW_UNITS.join(", ")})`);
12088
+ const count = Number(periods);
12089
+ if (!Number.isInteger(count) || count < 1 || count > MAX_WINDOW_PERIODS) {
12090
+ refuse(`has a window of ${periods} periods in ${fn}(); a window spans 1 to ${MAX_WINDOW_PERIODS} periods`);
12091
+ }
12092
+ return put({ kind: "window", fn, key, unit: unit.toLowerCase(), periods: String(count) });
12093
+ });
12094
+ working = working.replace(REF_WITH_MOD_RE, (_m, inner, mod) => {
12095
+ if (SYSTEM_VARIABLES.includes(inner)) {
12096
+ if (mod) refuse(modifierOnSystemVariable(inner));
12097
+ return put({ kind: "token", token: inner });
12098
+ }
12099
+ const key = declaredKey(inner, declared);
12100
+ if (mod) checkModifier(mod);
12101
+ return put({ kind: "ref", key, mod: mod ?? "" });
12102
+ });
12103
+ working = working.replace(SYSTEM_VARIABLE_RE, (token, offset, whole) => {
12104
+ if (/^\s*\.(?:SHIFT|AS_OF)\(/i.test(whole.slice(offset + token.length))) refuse(modifierOnSystemVariable(token));
12105
+ return put({ kind: "token", token });
12106
+ });
12107
+ if (working.includes("[") || working.includes("]")) refuse("has unbalanced bracket(s); each operand must be wrapped as [@key]");
12108
+ const unclosed = /\.(?:SHIFT|AS_OF)\([^)]*$/i.exec(working);
12109
+ if (unclosed) checkModifier(unclosed[0]);
12110
+ if (/\.(?:SHIFT|AS_OF)\(/i.test(working)) {
12111
+ refuse("time modifiers must use uppercase .SHIFT/.AS_OF, must be attached to a [@key] operand (e.g. [@revenue].SHIFT(year, -1)), and cannot be chained on the same operand");
12112
+ }
12113
+ const window = new RegExp(String.raw`\b(${WINDOW_FUNCTIONS.join("|")})\s*\(`, "i").exec(working);
12114
+ if (window) {
12115
+ refuse(
12116
+ `has a window function it cannot read (${window[1]}(...)); write it as ${window[1].toLowerCase()}([@key], unit, periods) \u2014 ${WINDOW_FUNCTIONS.join(", ")}, in lowercase, over one [@key] with no modifier \u2014 with unit ${WINDOW_UNITS.join(", ")} and periods from 1 to ${MAX_WINDOW_PERIODS}`
12117
+ );
12118
+ }
12119
+ let depth = 0;
12120
+ for (const ch of working) {
12121
+ if (ch === "(") depth += 1;
12122
+ else if (ch === ")" && --depth < 0) break;
12123
+ }
12124
+ if (depth !== 0) refuse('grouping is not balanced; every "(" must have a matching ")"');
12125
+ const padded = working.replace(OPERATOR_RE, " $1 ").trim();
12126
+ const residue = padded.replace(STASH_RE, "").replace(/[\s+\-*/()0-9.]/g, "");
12127
+ if (residue.includes("@")) refuse(`writes @ outside brackets (offending characters: "${residue}"); an operand is [@key]`);
12128
+ if (residue.length > 0) {
12129
+ refuse(`has unbracketed identifier(s) (offending characters: "${residue}"); an operand is [@key], or ${SYSTEM_VARIABLES.join(" or ")} written bare`);
12130
+ }
12131
+ const malformed = arithmeticProblem(padded, stash);
12132
+ if (malformed !== null) refuse(`is not a formula Datarails can evaluate: ${malformed}`);
12133
+ if (!stash.some((entry) => entry.kind !== "token")) refuse("must read at least one declared metric, e.g. [@revenue]");
12134
+ return { padded, stash };
12135
+ }
12136
+ function metricFormulaProblems(formula, declared) {
12137
+ try {
12138
+ const { stash } = scan(formula, Array.isArray(declared) ? declared.filter((one) => typeof one === "string") : []);
12139
+ const operands = [...new Set(stash.filter((entry) => entry.kind !== "token").map((entry) => entry.key))].sort();
12140
+ const windows = stash.filter((entry) => entry.kind === "window").map((entry) => `${entry.fn}([@${entry.key}], ${entry.unit}, ${entry.periods})`);
12141
+ return { problems: [], operands, windows };
12142
+ } catch (error) {
12143
+ if (!(error instanceof MetricFormulaError)) throw error;
12144
+ return { problems: [error.message], operands: [], windows: [] };
12145
+ }
12146
+ }
12147
+
12148
+ // ../flows/insights-builder/kit/contract/metricFormats.mjs
12149
+ var CURRENCY_PREFIXES = ['"$"', "\u20AC", "\xA3", "\u20AA"];
12150
+ var DIGITS = ["#,###", "####"];
12151
+ var DECIMALS = ["", ".#", ".##", ".###", ".####"];
12152
+ var SCALES = ["", "[kilo]k", "[mega]M"];
12153
+ var METRIC_FORMATS = Object.freeze(
12154
+ (() => {
12155
+ const signs = [{ prefix: "", suffix: "" }, ...CURRENCY_PREFIXES.map((prefix) => ({ prefix, suffix: "" })), { prefix: "", suffix: "%" }];
12156
+ const all = [];
12157
+ for (const { prefix, suffix } of signs) {
12158
+ for (const digits of DIGITS) {
12159
+ for (const decimals of DECIMALS) {
12160
+ for (const scale of SCALES) all.push(`${prefix}${digits}${decimals}${scale}${suffix}`);
12161
+ }
12162
+ }
12163
+ }
12164
+ return all;
12165
+ })()
12166
+ );
12167
+ var KNOWN = new Set(METRIC_FORMATS);
12168
+ function isMetricFormat(format) {
12169
+ return typeof format === "string" && KNOWN.has(format);
12170
+ }
12171
+ function metricFormatProblem(format) {
12172
+ if (isMetricFormat(format)) return null;
12173
+ if (typeof format !== "string") return `is ${JSON.stringify(format ?? null)}; a format is one of the strings the metric editor writes, such as "#,###", "\\"$\\"#,###.##" or "####.#%"`;
12174
+ const trimmed = format.trim();
12175
+ const quoted3 = trimmed.startsWith("$") ? `"$"${trimmed.slice(1)}` : trimmed;
12176
+ const hashed = quoted3.replace(/0/g, "#");
12177
+ const meant = [quoted3, hashed].find((one) => one !== format && KNOWN.has(one));
12178
+ return `is ${JSON.stringify(format)}, which the metric editor never writes${meant ? ` (did you mean ${JSON.stringify(meant)}?)` : ""}; build it from these parts, in this order: an optional currency prefix ("$" quoted, or \u20AC \xA3 \u20AA, never with %), #,### or ####, optional decimals .# to .####, an optional scale [kilo]k or [mega]M, and an optional % suffix`;
12179
+ }
12180
+
11892
12181
  // ../flows/insights-builder/kit/contract/compileApp.mjs
11893
12182
  var RUNTIME = "dr-app-runtime@1.11.0";
11894
12183
  var UI_KIT2 = "ui-kit/v4";
@@ -12178,6 +12467,9 @@ function originOf(row) {
12178
12467
  function sourcesOf(contract) {
12179
12468
  return isPlainObject8(contract?.data?.sources) ? contract.data.sources : {};
12180
12469
  }
12470
+ function readsMetric(read) {
12471
+ return isPlainObject8(read) && (read.metric_id !== void 0 && read.metric_id !== null || read.metric !== void 0 && read.metric !== null);
12472
+ }
12181
12473
  var FINANCEOS_SCOPES = {
12182
12474
  commands: {
12183
12475
  "status:read": ["status", "whoami"],
@@ -12222,7 +12514,7 @@ function financeosNeeds(row, origin) {
12222
12514
  }
12223
12515
  const read = isPlainObject8(row.from.financeos) ? row.from.financeos : {};
12224
12516
  const filterable = Array.isArray(row.filterable) && row.filterable.length > 0;
12225
- if (read.metric_id !== void 0 && read.metric_id !== null) {
12517
+ if (readsMetric(read)) {
12226
12518
  if (read.drill_rows) need("data_models", scopesGranting("namespaces", "data_models"));
12227
12519
  else need("metric_series", scopesGranting("commands", "metric_series"));
12228
12520
  if (filterable) need("data_models", scopesGranting("namespaces", "data_models"), true);
@@ -12271,7 +12563,7 @@ function opensRows(row) {
12271
12563
  if (row.transform !== void 0 && row.transform !== null) return false;
12272
12564
  if (!MENU_SHAPES.includes(row.shape ?? "rows")) return false;
12273
12565
  const read = isPlainObject8(row.from.financeos) ? row.from.financeos : {};
12274
- if (read.metric_id !== void 0 && read.metric_id !== null) return false;
12566
+ if (readsMetric(read)) return false;
12275
12567
  return Boolean(read.table_alias) || read.table_id !== void 0 && read.table_id !== null || read.address === "id" || read.address === "alias";
12276
12568
  }
12277
12569
  function fillMenuScope(app, sources, elements) {
@@ -12484,6 +12776,272 @@ function reaches2(sources, start, target, seen, isFormula) {
12484
12776
  if (!isFormula(row) || !Array.isArray(row.from)) return false;
12485
12777
  return row.from.filter(named5).some((one) => reaches2(sources, one, target, seen, isFormula));
12486
12778
  }
12779
+ var METRIC_DECL_KEYS = ["name", "kind", "formula", "table", "value", "date", "aggregate", "time_aggregation", "filters", "format", "description", "reuse", "create"];
12780
+ var METRIC_KINDS = ["base", "calc"];
12781
+ var METRIC_CALC_KEYS = ["formula"];
12782
+ var METRIC_BASE_KEYS = ["table", "value", "date", "aggregate", "time_aggregation", "filters"];
12783
+ var METRIC_AGGREGATES = ["sum", "count", "cumulative_sum", "cumulative_count"];
12784
+ var METRIC_TIME_AGGREGATIONS = ["sum", "eop", "avg"];
12785
+ var METRIC_REUSE = ["definition", "name"];
12786
+ var METRIC_TABLE_KEYS = ["alias", "name", "id"];
12787
+ var METRIC_FILTER_KEYS = ["field", "values", "exclude"];
12788
+ var METRIC_NAME_MAX = 128;
12789
+ function metricNameProblem(name) {
12790
+ if (typeof name !== "string" || name.trim() === "") {
12791
+ return `is ${JSON.stringify(name ?? null)}; every declaration names its metric in Datarails, as a finance team would ("Revenue MoM growth")`;
12792
+ }
12793
+ if (name.length > METRIC_NAME_MAX) return `is ${name.length} characters; Datarails takes at most ${METRIC_NAME_MAX}`;
12794
+ if (/['[\]]/.test(name)) return `${JSON.stringify(name)} holds ' [ or ], which a CALC formula reads as the edges of a metric reference`;
12795
+ if (name.startsWith("@")) return `${JSON.stringify(name)} starts with @, which marks a key of this contract, not a name in Datarails`;
12796
+ if (SYSTEM_VARIABLES.includes(name)) return `is ${name}, which Datarails reserves for its own system variable`;
12797
+ return null;
12798
+ }
12799
+ function metricTableProblem(table) {
12800
+ if (!isPlainObject8(table)) return true;
12801
+ const stated2 = Object.keys(table);
12802
+ if (stated2.length !== 1 || !METRIC_TABLE_KEYS.includes(stated2[0])) return true;
12803
+ const value = table[stated2[0]];
12804
+ return stated2[0] === "id" ? !(Number.isInteger(value) && value >= 1) : typeof value !== "string" || value.trim() === "";
12805
+ }
12806
+ function metricFilterProblem(filter) {
12807
+ if (!isPlainObject8(filter)) return `is ${JSON.stringify(filter ?? null)}`;
12808
+ const extra = Object.keys(filter).filter((key) => !METRIC_FILTER_KEYS.includes(key)).sort(natural3);
12809
+ if (extra.length > 0) return `states ${extra.join(", ")}, which a filter does not take`;
12810
+ if (typeof filter.field !== "string" || filter.field.trim() === "") return `names field ${JSON.stringify(filter.field ?? null)}, which is no field's alias or name`;
12811
+ if (!Array.isArray(filter.values) || filter.values.length === 0 || !filter.values.every((one) => typeof one === "string" || typeof one === "number" && Number.isFinite(one))) {
12812
+ return `states values ${JSON.stringify(filter.values ?? null)}; values is a non-empty list of the field's values, each a string or a number`;
12813
+ }
12814
+ if (filter.exclude !== void 0 && typeof filter.exclude !== "boolean") return `states exclude ${JSON.stringify(filter.exclude)}; exclude is true or false`;
12815
+ return null;
12816
+ }
12817
+ function metricReaches(operandsOf, start, target, seen) {
12818
+ if (start === target) return true;
12819
+ if (seen.has(start)) return false;
12820
+ seen.add(start);
12821
+ return (operandsOf.get(start) ?? []).some((one) => metricReaches(operandsOf, one, target, seen));
12822
+ }
12823
+ function calcShaped(node) {
12824
+ if (node[0] === "num" || node[0] === "name") return true;
12825
+ if (node[0] === "neg") return calcShaped(node[1]);
12826
+ if (node[0] === "bin") return calcShaped(node[2]) && calcShaped(node[3]);
12827
+ return false;
12828
+ }
12829
+ function metricProblems(metrics, sources) {
12830
+ const problems = [];
12831
+ const at = (key, field) => `/data/metrics/${escapePointer3(key)}${field ? `/${field}` : ""}`;
12832
+ const say = (code, pointer2, message) => problems.push({ code, pointer: pointer2, message });
12833
+ const advise = (code, pointer2, message) => problems.push({ code, pointer: pointer2, message, advisory: true });
12834
+ if (metrics !== void 0 && metrics !== null && !isPlainObject8(metrics)) {
12835
+ say("metric_decl", "/data/metrics", `data.metrics is ${JSON.stringify(metrics)}; it is an object of declarations keyed by a local name ({"revenue": {"name": "Revenue", "kind": "base", ...}})`);
12836
+ }
12837
+ const declared = isPlainObject8(metrics) ? metrics : {};
12838
+ const keys2 = Object.keys(declared).sort(natural3);
12839
+ const declaredKeys = keys2.filter((key) => METRIC_KEY_RE.test(key));
12840
+ const formulas = /* @__PURE__ */ new Map();
12841
+ for (const key of keys2) {
12842
+ const decl = declared[key];
12843
+ if (!METRIC_KEY_RE.test(key)) {
12844
+ say("metric_decl", at(key), `data.metrics declares ${JSON.stringify(key)}, which is not a key: a key is a lowercase letter, then lowercase letters, digits or _ (revenue_mom), the handle a source reads as "@<key>" and a formula as [@<key>]`);
12845
+ continue;
12846
+ }
12847
+ if (!isPlainObject8(decl)) {
12848
+ say("metric_decl", at(key), `metric "${key}" is ${JSON.stringify(decl ?? null)}; a declaration is an object: {"name": "<its name in Datarails>", "kind": "base" | "calc", ...}`);
12849
+ continue;
12850
+ }
12851
+ for (const field of Object.keys(decl).sort(natural3)) {
12852
+ if (!METRIC_DECL_KEYS.includes(field)) say("metric_decl", at(key, field), `metric "${key}" states ${field}, which a declaration does not take; it takes ${either3(METRIC_DECL_KEYS)}`);
12853
+ }
12854
+ const nameProblem = metricNameProblem(decl.name);
12855
+ if (nameProblem !== null) say("metric_decl", at(key, "name"), `metric "${key}"'s name ${nameProblem}`);
12856
+ if (decl.reuse !== void 0 && !METRIC_REUSE.includes(decl.reuse)) {
12857
+ say(
12858
+ "metric_decl",
12859
+ at(key, "reuse"),
12860
+ `metric "${key}" states reuse ${JSON.stringify(decl.reuse)}; reuse is "definition" (the default: bind only a company metric with this definition) or "name" (bind the company's metric of this name, whatever its definition)`
12861
+ );
12862
+ }
12863
+ if (decl.create !== void 0 && typeof decl.create !== "boolean") {
12864
+ say("metric_decl", at(key, "create"), `metric "${key}" states create ${JSON.stringify(decl.create)}; create is true (the default: create the metric where the company has none to bind) or false (bind only)`);
12865
+ }
12866
+ if (decl.description !== void 0 && typeof decl.description !== "string") {
12867
+ say("metric_decl", at(key, "description"), `metric "${key}" states description ${JSON.stringify(decl.description)}; a description is text for the metric's page in Datarails`);
12868
+ }
12869
+ if (decl.format !== void 0) {
12870
+ const formatProblem = metricFormatProblem(decl.format);
12871
+ if (formatProblem !== null) say("metric_format", at(key, "format"), `metric "${key}"'s format ${formatProblem}`);
12872
+ }
12873
+ const kind = decl.kind;
12874
+ if (kind === void 0) {
12875
+ const defining = [...METRIC_CALC_KEYS, ...METRIC_BASE_KEYS].filter((field) => decl[field] !== void 0);
12876
+ if (defining.length > 0) {
12877
+ say("metric_decl", at(key, "kind"), `metric "${key}" states ${defining.join(", ")} and no kind; state "kind": "base" (a table's field, aggregated) or "calc" (a formula over declared metrics)`);
12878
+ } else if (decl.create !== false) {
12879
+ say(
12880
+ "metric_decl",
12881
+ at(key),
12882
+ `metric "${key}" has no definition (no kind), so nothing could create it; bind the company's own metric by name with "reuse": "name" and "create": false, or give it a definition`
12883
+ );
12884
+ } else if (decl.reuse !== "name") {
12885
+ say(
12886
+ "metric_decl",
12887
+ at(key, "reuse"),
12888
+ `metric "${key}" has no definition, so no company metric can match it by definition (reuse "definition", the default); bind the company's own metric by name with "reuse": "name"`
12889
+ );
12890
+ }
12891
+ continue;
12892
+ }
12893
+ if (kind === "driver") {
12894
+ say(
12895
+ "metric_kind_later",
12896
+ at(key, "kind"),
12897
+ `metric "${key}" is kind "driver", which is reserved: driver declarations are not compiled yet. Declare a base or calc metric, or create the driver in Datarails and bind it with {"name": "<its name>", "reuse": "name", "create": false}`
12898
+ );
12899
+ continue;
12900
+ }
12901
+ if (!METRIC_KINDS.includes(kind)) {
12902
+ say("metric_decl", at(key, "kind"), `metric "${key}" is kind ${JSON.stringify(kind)}; a declared metric is "base" (a table's field, aggregated) or "calc" (a formula over declared metrics)`);
12903
+ continue;
12904
+ }
12905
+ for (const field of kind === "calc" ? METRIC_BASE_KEYS : METRIC_CALC_KEYS) {
12906
+ if (decl[field] !== void 0) say("metric_decl", at(key, field), `metric "${key}" is a ${kind} metric and states ${field}, which only a ${kind === "calc" ? "base" : "calc"} metric takes`);
12907
+ }
12908
+ if (kind === "calc") {
12909
+ if (decl.formula === void 0) {
12910
+ say("metric_decl", at(key, "formula"), `metric "${key}" is a calc metric with no formula; state one over declared metrics, each written [@<key>] (([@revenue] - [@cogs]) / [@revenue])`);
12911
+ } else {
12912
+ formulas.set(key, decl.formula);
12913
+ }
12914
+ continue;
12915
+ }
12916
+ if (decl.table === void 0) {
12917
+ say("metric_decl", at(key, "table"), `metric "${key}" is a base metric with no table; state {"alias": "<table alias>"} or {"name": "<table name>"}`);
12918
+ } else if (metricTableProblem(decl.table)) {
12919
+ say(
12920
+ "metric_decl",
12921
+ at(key, "table"),
12922
+ `metric "${key}" states table ${JSON.stringify(decl.table)}; a table is exactly one of {"alias": "<table alias>"}, {"name": "<table name>"} or {"id": <id>} (which ties the app to one company)`
12923
+ );
12924
+ }
12925
+ for (const [field, what] of [["value", "the field it aggregates"], ["date", "the field its periods come from"]]) {
12926
+ if (decl[field] === void 0) say("metric_decl", at(key, field), `metric "${key}" is a base metric with no ${field}; state ${what}, by alias or name`);
12927
+ else if (typeof decl[field] !== "string" || decl[field].trim() === "") say("metric_decl", at(key, field), `metric "${key}" states ${field} ${JSON.stringify(decl[field])}; ${field} is ${what}, by alias or name`);
12928
+ }
12929
+ if (decl.aggregate === void 0) {
12930
+ say("metric_decl", at(key, "aggregate"), `metric "${key}" is a base metric with no aggregate; state ${either3(METRIC_AGGREGATES)}`);
12931
+ } else if (!METRIC_AGGREGATES.includes(decl.aggregate)) {
12932
+ say("metric_base", at(key, "aggregate"), `metric "${key}" aggregates ${JSON.stringify(decl.aggregate)}; a metric aggregates ${either3(METRIC_AGGREGATES)} (avg, min and max are not metric aggregates)`);
12933
+ }
12934
+ if (decl.time_aggregation !== void 0 && !METRIC_TIME_AGGREGATIONS.includes(decl.time_aggregation)) {
12935
+ say("metric_base", at(key, "time_aggregation"), `metric "${key}" rolls up with ${JSON.stringify(decl.time_aggregation)}; time_aggregation is ${either3(METRIC_TIME_AGGREGATIONS)}`);
12936
+ } else if (decl.time_aggregation === "sum" && (decl.aggregate === "cumulative_sum" || decl.aggregate === "cumulative_count")) {
12937
+ say("metric_base", at(key, "time_aggregation"), `metric "${key}" aggregates ${decl.aggregate} and rolls up with sum, which Datarails refuses for a cumulative aggregate; use eop (the default) or avg`);
12938
+ }
12939
+ if (decl.filters !== void 0) {
12940
+ if (!Array.isArray(decl.filters)) {
12941
+ say("metric_base", at(key, "filters"), `metric "${key}" states filters ${JSON.stringify(decl.filters)}; filters is a list of {"field": "<alias or name>", "values": [...], "exclude": false}`);
12942
+ } else {
12943
+ decl.filters.forEach((filter, index) => {
12944
+ const filterProblem = metricFilterProblem(filter);
12945
+ if (filterProblem !== null) say("metric_base", at(key, `filters/${index}`), `metric "${key}"'s filter ${index} ${filterProblem}; a filter is {"field": "<alias or name>", "values": [...], "exclude": false}`);
12946
+ });
12947
+ }
12948
+ }
12949
+ }
12950
+ const operandsOf = /* @__PURE__ */ new Map();
12951
+ for (const key of [...formulas.keys()].sort(natural3)) {
12952
+ const { problems: wrong, operands, windows } = metricFormulaProblems(formulas.get(key), declaredKeys);
12953
+ for (const one of wrong) say("metric_formula", at(key, "formula"), `metric "${key}"'s formula ${one}`);
12954
+ if (wrong.length > 0) continue;
12955
+ operandsOf.set(key, operands);
12956
+ if (windows.length > 0) {
12957
+ advise(
12958
+ "metric_window",
12959
+ at(key, "formula"),
12960
+ `metric "${key}"'s formula calls ${windows.join(" and ")}, a window function, which Datarails evaluates only where the company has scenario_bound_calculations on, and refuses to create elsewhere`
12961
+ );
12962
+ }
12963
+ }
12964
+ for (const key of [...operandsOf.keys()].sort(natural3)) {
12965
+ for (const dep of operandsOf.get(key)) {
12966
+ if (metricReaches(operandsOf, dep, key, /* @__PURE__ */ new Set())) {
12967
+ say("metric_formula", at(key, "formula"), `metric "${key}"'s formula reads itself${dep === key ? "" : `, through [@${dep}]`}; a calc metric's operands are metrics that exist before it, so none may read it back`);
12968
+ break;
12969
+ }
12970
+ }
12971
+ }
12972
+ const read = /* @__PURE__ */ new Set();
12973
+ for (const name of Object.keys(sources ?? {}).sort(natural3)) {
12974
+ const row = sources[name];
12975
+ if (!isPlainObject8(row) || originOf(row) !== "financeos" || !isPlainObject8(row.from.financeos)) continue;
12976
+ const metric = row.from.financeos.metric;
12977
+ if (metric === void 0 || metric === null) continue;
12978
+ const pointer2 = `/data/sources/${escapePointer3(name)}/from/financeos/metric`;
12979
+ const metricId = row.from.financeos.metric_id;
12980
+ if (metricId !== void 0 && metricId !== null) {
12981
+ say(
12982
+ "source_metric",
12983
+ pointer2,
12984
+ `source "${name}" states metric ${JSON.stringify(metric)} and metric_id ${JSON.stringify(metricId)}; a source reads one metric \u2014 keep metric (a key data.metrics declares, bound in each company at ship) and drop metric_id, which ties the app to one company`
12985
+ );
12986
+ continue;
12987
+ }
12988
+ if (typeof metric !== "string" || !metric.startsWith("@")) {
12989
+ const meant = typeof metric === "string" && Object.prototype.hasOwnProperty.call(declared, metric) ? ` \u2014 write "@${metric}"` : "";
12990
+ say("source_metric", pointer2, `source "${name}" reads metric ${JSON.stringify(metric)}; a metric is named "@<key>", a key data.metrics declares ("@revenue_mom")${meant}`);
12991
+ continue;
12992
+ }
12993
+ const key = metric.slice(1);
12994
+ if (!declaredKeys.includes(key)) {
12995
+ say(
12996
+ "source_metric",
12997
+ pointer2,
12998
+ `source "${name}" reads metric "@${key}", which data.metrics does not declare (it declares ${declaredKeys.length > 0 ? declaredKeys.map((one) => `"@${one}"`).join(", ") : "none"}); declare it there, or read one it declares`
12999
+ );
13000
+ continue;
13001
+ }
13002
+ read.add(key);
13003
+ }
13004
+ if (!problems.some((one) => one.code === "metric_formula")) {
13005
+ const used = /* @__PURE__ */ new Set();
13006
+ const visit = (key) => {
13007
+ if (used.has(key)) return;
13008
+ used.add(key);
13009
+ for (const one of operandsOf.get(key) ?? []) visit(one);
13010
+ };
13011
+ for (const key of read) visit(key);
13012
+ for (const key of declaredKeys) {
13013
+ if (used.has(key)) continue;
13014
+ advise(
13015
+ "metric_unused",
13016
+ at(key),
13017
+ `metric "${key}" is declared and no source reads it, directly or through another declared metric's formula; \`app-space ship\` would still bind or create it in every company the app ships to \u2014 read it from a source (from.financeos.metric "@${key}"), or drop it`
13018
+ );
13019
+ }
13020
+ }
13021
+ for (const name of Object.keys(sources ?? {}).sort(natural3)) {
13022
+ const row = sources[name];
13023
+ if (!isPlainObject8(row) || typeof row.expr !== "string" || !Array.isArray(row.from)) continue;
13024
+ const operands = [...new Set(row.from.filter(named5))];
13025
+ if (operands.length === 0) continue;
13026
+ const reads = operands.map((dep) => Object.hasOwn(sources, dep) ? sources[dep] : void 0);
13027
+ const metricRead = (one) => isPlainObject8(one) && originOf(one) === "financeos" && (one.transform === void 0 || one.transform === null) && readsMetric(one.from.financeos);
13028
+ if (!reads.every(metricRead)) continue;
13029
+ if (new Set(reads.map((one) => JSON.stringify(one.from.financeos.scenario ?? null))).size !== 1) continue;
13030
+ let tree;
13031
+ try {
13032
+ tree = parseFormula(row.expr);
13033
+ } catch {
13034
+ continue;
13035
+ }
13036
+ if (!calcShaped(tree)) continue;
13037
+ advise(
13038
+ "expr_could_be_metric",
13039
+ `/data/sources/${escapePointer3(name)}/expr`,
13040
+ `source "${name}"'s formula is arithmetic over Datarails metrics only (${operands.join(", ")}), in one scenario; declare it in data.metrics as a calc metric (its operands declared there too, each [@<key>]) and read it with from.financeos.metric, so it lives in Datarails, where MCP and DR.METRIC read the same number`
13041
+ );
13042
+ }
13043
+ return problems;
13044
+ }
12487
13045
  var SOURCE_ORDERS = ["source", "declared"];
12488
13046
  function orderProblems(name, row, at) {
12489
13047
  const problems = [];
@@ -12662,22 +13220,22 @@ function themeProblems(app) {
12662
13220
  const theme = app?.theme;
12663
13221
  if (theme === void 0 || theme === null) return [];
12664
13222
  const at = (key) => `/app/theme${key ? `/${key}` : ""}`;
12665
- const refuse = (key, message) => ({ code: "theme_shape", pointer: at(key), message });
12666
- if (!isPlainObject8(theme)) return [refuse("", `app.theme is ${JSON.stringify(theme)}; it is {"file": "ui/theme.css", "tokens": {"--dr-\u2026": "\u2026"}}, or null for none`)];
13223
+ const refuse2 = (key, message) => ({ code: "theme_shape", pointer: at(key), message });
13224
+ if (!isPlainObject8(theme)) return [refuse2("", `app.theme is ${JSON.stringify(theme)}; it is {"file": "ui/theme.css", "tokens": {"--dr-\u2026": "\u2026"}}, or null for none`)];
12667
13225
  const out = [];
12668
- if (theme.file !== THEME_FILE4) out.push(refuse("file", `app.theme.file is ${JSON.stringify(theme.file ?? null)}; an app's theme is written to ${THEME_FILE4}, the one file its pages link after runtime.css`));
12669
- if (!isPlainObject8(theme.tokens)) out.push(refuse("tokens", `app.theme.tokens is ${JSON.stringify(theme.tokens ?? null)}; it is an object of --dr-* tokens to the values the app overrides them with ({} for none)`));
13226
+ if (theme.file !== THEME_FILE4) out.push(refuse2("file", `app.theme.file is ${JSON.stringify(theme.file ?? null)}; an app's theme is written to ${THEME_FILE4}, the one file its pages link after runtime.css`));
13227
+ if (!isPlainObject8(theme.tokens)) out.push(refuse2("tokens", `app.theme.tokens is ${JSON.stringify(theme.tokens ?? null)}; it is an object of --dr-* tokens to the values the app overrides them with ({} for none)`));
12670
13228
  else {
12671
13229
  for (const token of Object.keys(theme.tokens).sort(natural3)) {
12672
13230
  const value = theme.tokens[token];
12673
13231
  const pointer2 = `tokens/${escapePointer3(token)}`;
12674
- if (token === "--dr-app-theme") out.push(refuse(pointer2, "app.theme.tokens states --dr-app-theme, which is the compiler's own mark on the file it writes; drop it"));
12675
- else if (!TOKEN_RE.test(token)) out.push(refuse(pointer2, `app.theme.tokens states ${JSON.stringify(token)}; an app theme overrides --dr-* tokens only (the shadcn triples are computed from them)`));
13232
+ if (token === "--dr-app-theme") out.push(refuse2(pointer2, "app.theme.tokens states --dr-app-theme, which is the compiler's own mark on the file it writes; drop it"));
13233
+ else if (!TOKEN_RE.test(token)) out.push(refuse2(pointer2, `app.theme.tokens states ${JSON.stringify(token)}; an app theme overrides --dr-* tokens only (the shadcn triples are computed from them)`));
12676
13234
  else {
12677
13235
  const why = tokenValueProblem(value);
12678
13236
  if (why !== null) {
12679
13237
  out.push(
12680
- refuse(
13238
+ refuse2(
12681
13239
  pointer2,
12682
13240
  `app.theme.tokens["${token}"] is ${JSON.stringify(value)}, which ${why}; a token holds one CSS value, with no ; { } /* */ \\ < or url(, its quotes and brackets balanced`
12683
13241
  )
@@ -12687,9 +13245,9 @@ function themeProblems(app) {
12687
13245
  }
12688
13246
  }
12689
13247
  if (theme.preset !== void 0 && !THEME_PRESETS.includes(theme.preset)) {
12690
- out.push(refuse("preset", `app.theme.preset is ${JSON.stringify(theme.preset)}; the ui-kit this compiler pins (${UI_KIT2}) carries ${either3(THEME_PRESETS)}${theme.preset === "presentation" ? " \u2014 presentation was planned for ui-kit v4, which shipped without it" : ""}`));
13248
+ out.push(refuse2("preset", `app.theme.preset is ${JSON.stringify(theme.preset)}; the ui-kit this compiler pins (${UI_KIT2}) carries ${either3(THEME_PRESETS)}${theme.preset === "presentation" ? " \u2014 presentation was planned for ui-kit v4, which shipped without it" : ""}`));
12691
13249
  }
12692
- if (theme.density !== void 0 && !THEME_DENSITIES.includes(theme.density)) out.push(refuse("density", `app.theme.density is ${JSON.stringify(theme.density)}; it is ${either3(THEME_DENSITIES)}`));
13250
+ if (theme.density !== void 0 && !THEME_DENSITIES.includes(theme.density)) out.push(refuse2("density", `app.theme.density is ${JSON.stringify(theme.density)}; it is ${either3(THEME_DENSITIES)}`));
12693
13251
  return out;
12694
13252
  }
12695
13253
  function themeValue(token, tokens, seen = /* @__PURE__ */ new Set()) {
@@ -12887,51 +13445,51 @@ function atPointer(root, pointer2) {
12887
13445
  function paramValueProblems(param, said2, at) {
12888
13446
  const problems = [];
12889
13447
  const kind = param.kind;
12890
- const refuse = (key, why) => problems.push({ code: "param_kind_value", pointer: at(key), message: `${said2} (${kind}) ${why}` });
13448
+ const refuse2 = (key, why) => problems.push({ code: "param_kind_value", pointer: at(key), message: `${said2} (${kind}) ${why}` });
12891
13449
  const finite = (value2) => typeof value2 === "number" && Number.isFinite(value2);
12892
13450
  for (const key of ["min", "max", "step"]) {
12893
13451
  if (param[key] === void 0) continue;
12894
- if (!NUMERIC_PARAM_KINDS.has(kind)) refuse(key, `states ${key}, which only a slider or a number has`);
12895
- else if (!finite(param[key])) refuse(key, `states ${key} as ${JSON.stringify(param[key])}; it has to be a finite number \u2014 ${numberFix(param[key])}`);
13452
+ if (!NUMERIC_PARAM_KINDS.has(kind)) refuse2(key, `states ${key}, which only a slider or a number has`);
13453
+ else if (!finite(param[key])) refuse2(key, `states ${key} as ${JSON.stringify(param[key])}; it has to be a finite number \u2014 ${numberFix(param[key])}`);
12896
13454
  }
12897
13455
  if (param.values !== void 0) {
12898
- if (!STRING_PARAM_KINDS.has(kind)) refuse("values", `states values, which only a select, multi, period or month is held to`);
12899
- else if (!Array.isArray(param.values) || param.values.length === 0 || !param.values.every(named5)) refuse("values", `states values as ${JSON.stringify(param.values)}; it is a non-empty list of strings (leave it out to take the values from the source's catalog)`);
12900
- else if (new Set(param.values).size !== param.values.length) refuse("values", "lists one value twice");
13456
+ if (!STRING_PARAM_KINDS.has(kind)) refuse2("values", `states values, which only a select, multi, period or month is held to`);
13457
+ else if (!Array.isArray(param.values) || param.values.length === 0 || !param.values.every(named5)) refuse2("values", `states values as ${JSON.stringify(param.values)}; it is a non-empty list of strings (leave it out to take the values from the source's catalog)`);
13458
+ else if (new Set(param.values).size !== param.values.length) refuse2("values", "lists one value twice");
12901
13459
  }
12902
13460
  if (param.max_selected !== void 0) {
12903
- if (kind !== "multi") refuse("max_selected", "states max_selected, which only a multi has");
12904
- else if (!Number.isInteger(param.max_selected) || param.max_selected < 1) refuse("max_selected", `states max_selected as ${JSON.stringify(param.max_selected)}; it is a whole number of at least 1`);
12905
- else if (Array.isArray(param.values) && param.max_selected > param.values.length) refuse("max_selected", `allows ${param.max_selected} of only ${param.values.length} values`);
13461
+ if (kind !== "multi") refuse2("max_selected", "states max_selected, which only a multi has");
13462
+ else if (!Number.isInteger(param.max_selected) || param.max_selected < 1) refuse2("max_selected", `states max_selected as ${JSON.stringify(param.max_selected)}; it is a whole number of at least 1`);
13463
+ else if (Array.isArray(param.values) && param.max_selected > param.values.length) refuse2("max_selected", `allows ${param.max_selected} of only ${param.values.length} values`);
12906
13464
  }
12907
13465
  if (problems.length > 0) return problems;
12908
13466
  const { min, max, step } = param;
12909
13467
  if (NUMERIC_PARAM_KINDS.has(kind)) {
12910
- if (finite(min) && finite(max) && min > max) refuse("min", `has min ${min} above max ${max}`);
12911
- if (finite(step) && step <= 0) refuse("step", `steps by ${step}; a step is above 0`);
13468
+ if (finite(min) && finite(max) && min > max) refuse2("min", `has min ${min} above max ${max}`);
13469
+ if (finite(step) && step <= 0) refuse2("step", `steps by ${step}; a step is above 0`);
12912
13470
  }
12913
13471
  if (param.default === void 0) return problems;
12914
13472
  const value = param.default;
12915
13473
  const values = Array.isArray(param.values) ? param.values : null;
12916
13474
  if (NUMERIC_PARAM_KINDS.has(kind)) {
12917
- if (!finite(value)) refuse("default", `defaults to ${JSON.stringify(value)}; a ${kind} opens on a finite number`);
13475
+ if (!finite(value)) refuse2("default", `defaults to ${JSON.stringify(value)}; a ${kind} opens on a finite number`);
12918
13476
  else if (finite(min) && value < min || finite(max) && value > max) {
12919
- refuse("default", `defaults to ${value}, outside its own range [${finite(min) ? min : "\u2026"}, ${finite(max) ? max : "\u2026"}] \u2014 the page would open on no value at all`);
13477
+ refuse2("default", `defaults to ${value}, outside its own range [${finite(min) ? min : "\u2026"}, ${finite(max) ? max : "\u2026"}] \u2014 the page would open on no value at all`);
12920
13478
  }
12921
13479
  } else if (kind === "toggle") {
12922
- if (typeof value !== "boolean") refuse("default", `defaults to ${JSON.stringify(value)}; a toggle opens on true or false`);
13480
+ if (typeof value !== "boolean") refuse2("default", `defaults to ${JSON.stringify(value)}; a toggle opens on true or false`);
12923
13481
  } else if (kind === "multi") {
12924
- if (!Array.isArray(value) || !value.every((one) => typeof one === "string")) refuse("default", `defaults to ${JSON.stringify(value)}; a multi opens on a list of strings`);
12925
- else if (values !== null && value.some((one) => !values.includes(one))) refuse("default", `defaults to ${JSON.stringify(value)}, which is not among its values ${JSON.stringify(values)}`);
12926
- else if (Number.isInteger(param.max_selected) && value.length > param.max_selected) refuse("default", `opens on ${value.length} values and allows ${param.max_selected}`);
13482
+ if (!Array.isArray(value) || !value.every((one) => typeof one === "string")) refuse2("default", `defaults to ${JSON.stringify(value)}; a multi opens on a list of strings`);
13483
+ else if (values !== null && value.some((one) => !values.includes(one))) refuse2("default", `defaults to ${JSON.stringify(value)}, which is not among its values ${JSON.stringify(values)}`);
13484
+ else if (Number.isInteger(param.max_selected) && value.length > param.max_selected) refuse2("default", `opens on ${value.length} values and allows ${param.max_selected}`);
12927
13485
  } else if (typeof value !== "string") {
12928
- refuse("default", `defaults to ${JSON.stringify(value)}; a ${kind} opens on a string`);
13486
+ refuse2("default", `defaults to ${JSON.stringify(value)}; a ${kind} opens on a string`);
12929
13487
  } else if (kind === "month" && !MONTH_RE.test(value)) {
12930
- refuse("default", `defaults to ${JSON.stringify(value)}; a month is written YYYY-MM`);
13488
+ refuse2("default", `defaults to ${JSON.stringify(value)}; a month is written YYYY-MM`);
12931
13489
  } else if (kind === "date" && value !== "" && !isRealDay(value)) {
12932
- refuse("default", `defaults to ${JSON.stringify(value)}; a date is a real day written YYYY-MM-DD, or '' for none`);
13490
+ refuse2("default", `defaults to ${JSON.stringify(value)}; a date is a real day written YYYY-MM-DD, or '' for none`);
12933
13491
  } else if (values !== null && !values.includes(value)) {
12934
- refuse("default", `defaults to ${JSON.stringify(value)}, which is not among its values ${JSON.stringify(values)}`);
13492
+ refuse2("default", `defaults to ${JSON.stringify(value)}, which is not among its values ${JSON.stringify(values)}`);
12935
13493
  }
12936
13494
  return problems;
12937
13495
  }
@@ -13064,6 +13622,8 @@ function compileApp(contract, { kit = null, runtime = RUNTIME, extra = {} } = {}
13064
13622
  problems.push(...sourceFindings.filter((one) => !one.advisory));
13065
13623
  problems.push(...compareProblems(stated2, model));
13066
13624
  problems.push(...exprProblems(stated2));
13625
+ const metricFindings = metricProblems(contract?.data?.metrics, stated2);
13626
+ problems.push(...metricFindings.filter((one) => !one.advisory));
13067
13627
  problems.push(...paramProblems(contract, stated2));
13068
13628
  problems.push(...themeProblems(app));
13069
13629
  problems.push(...shellProblems(contract, typed, handlers, stated2));
@@ -13150,7 +13710,7 @@ function compileApp(contract, { kit = null, runtime = RUNTIME, extra = {} } = {}
13150
13710
  problems.push(...partPinProblems(declared, { "dr-components": appComponents, "dr-components-controls": appControls }));
13151
13711
  problems.push(...runtimePinProblems(declared, runtime));
13152
13712
  const widgetFindings = tree.problems.length === 0 ? widgetProblems(declared, tree, handlers, contract?.data?.sources, appWidgets) : [];
13153
- const warnings = [...sourceFindings, ...widgetFindings].filter((one) => one.advisory).map(({ advisory: _advisory, ...one }) => one);
13713
+ const warnings = [...sourceFindings, ...metricFindings, ...widgetFindings].filter((one) => one.advisory).map(({ advisory: _advisory, ...one }) => one);
13154
13714
  problems.push(...widgetFindings.filter((one) => !one.advisory));
13155
13715
  if (tree.problems.length === 0) problems.push(...partProblems(declared, tree, handlers, sources, contract?.pages, model, { "dr-components": appComponents }));
13156
13716
  const backendFacts = inferredBackendHandlers(contract?.backend?.handlers, handlers, extra);
@@ -13347,51 +13907,51 @@ function shellProblems(contract, elements, handlers, sources) {
13347
13907
  const pages = Array.isArray(contract?.pages) ? contract.pages : [];
13348
13908
  const paths = new Set(pages.map((page) => page?.path).filter(named5));
13349
13909
  const params = new Set(pages.flatMap((page) => (Array.isArray(page?.params) ? page.params : []).map((param) => param?.name)).filter(named5));
13350
- const refuse = (pointer2, message) => problems.push({ code: "shell_shape", pointer: pointer2, message });
13910
+ const refuse2 = (pointer2, message) => problems.push({ code: "shell_shape", pointer: pointer2, message });
13351
13911
  const roleNames = new Set(
13352
13912
  (Array.isArray(contract?.backend?.roles) ? contract.backend.roles : []).map((role) => typeof role === "string" ? role : role?.name).filter(named5)
13353
13913
  );
13354
13914
  if (app.shell !== void 0 && app.shell !== null) {
13355
13915
  const shell = app.shell;
13356
- if (!isPlainObject8(shell)) refuse("/app/shell", `app.shell is ${JSON.stringify(shell)}; it is {"nav": "tabs"|"rail", "topbar"?: true, "rail"?: {...}}`);
13916
+ if (!isPlainObject8(shell)) refuse2("/app/shell", `app.shell is ${JSON.stringify(shell)}; it is {"nav": "tabs"|"rail", "topbar"?: true, "rail"?: {...}}`);
13357
13917
  else {
13358
- if (shell.nav !== void 0 && !SHELL_NAVS.includes(shell.nav)) refuse("/app/shell/nav", `app.shell.nav is ${JSON.stringify(shell.nav)}; it is ${either3(SHELL_NAVS)}`);
13359
- if (shell.topbar !== void 0 && typeof shell.topbar !== "boolean") refuse("/app/shell/topbar", `app.shell.topbar is ${JSON.stringify(shell.topbar)}; it is true or false`);
13360
- if (shell.rail !== void 0 && !isPlainObject8(shell.rail)) refuse("/app/shell/rail", `app.shell.rail is ${JSON.stringify(shell.rail)}; it is {"title"?, "entries"?: [...], "footer"?: [...]}`);
13918
+ if (shell.nav !== void 0 && !SHELL_NAVS.includes(shell.nav)) refuse2("/app/shell/nav", `app.shell.nav is ${JSON.stringify(shell.nav)}; it is ${either3(SHELL_NAVS)}`);
13919
+ if (shell.topbar !== void 0 && typeof shell.topbar !== "boolean") refuse2("/app/shell/topbar", `app.shell.topbar is ${JSON.stringify(shell.topbar)}; it is true or false`);
13920
+ if (shell.rail !== void 0 && !isPlainObject8(shell.rail)) refuse2("/app/shell/rail", `app.shell.rail is ${JSON.stringify(shell.rail)}; it is {"title"?, "entries"?: [...], "footer"?: [...]}`);
13361
13921
  else if (isPlainObject8(shell.rail)) {
13362
13922
  for (const key of ["entries", "footer"]) {
13363
13923
  const list3 = shell.rail[key];
13364
13924
  if (list3 === void 0) continue;
13365
13925
  if (!Array.isArray(list3)) {
13366
- refuse(`/app/shell/rail/${key}`, `app.shell.rail.${key} is ${JSON.stringify(list3)}; it is a list of {"label", ...}`);
13926
+ refuse2(`/app/shell/rail/${key}`, `app.shell.rail.${key} is ${JSON.stringify(list3)}; it is a list of {"label", ...}`);
13367
13927
  continue;
13368
13928
  }
13369
13929
  for (const [index, entry] of list3.entries()) {
13370
13930
  const at = `/app/shell/rail/${key}/${index}`;
13371
13931
  if (!isPlainObject8(entry) || !named5(entry.label)) {
13372
- refuse(at, `app.shell.rail.${key}[${index}] is ${JSON.stringify(entry)}; every entry has a label (the runtime drops one without)`);
13932
+ refuse2(at, `app.shell.rail.${key}[${index}] is ${JSON.stringify(entry)}; every entry has a label (the runtime drops one without)`);
13373
13933
  continue;
13374
13934
  }
13375
13935
  if (entry.page !== void 0 && !(named5(entry.page) && paths.has(entry.page))) {
13376
- refuse(`${at}/page`, `rail entry "${entry.label}" links to ${JSON.stringify(entry.page)}, which pages[] does not declare; name a page of this app (${[...paths].sort(natural3).map((one) => JSON.stringify(one)).join(", ")})`);
13936
+ refuse2(`${at}/page`, `rail entry "${entry.label}" links to ${JSON.stringify(entry.page)}, which pages[] does not declare; name a page of this app (${[...paths].sort(natural3).map((one) => JSON.stringify(one)).join(", ")})`);
13377
13937
  }
13378
- if (key === "footer" && !named5(entry.page)) refuse(`${at}/page`, `rail footer entry "${entry.label}" links nowhere; a footer entry is {"label", "page"}`);
13938
+ if (key === "footer" && !named5(entry.page)) refuse2(`${at}/page`, `rail footer entry "${entry.label}" links nowhere; a footer entry is {"label", "page"}`);
13379
13939
  if (entry.param !== void 0) {
13380
13940
  if (!isPlainObject8(entry.param) || Object.keys(entry.param).length === 0) {
13381
- refuse(`${at}/param`, `rail entry "${entry.label}" states param as ${JSON.stringify(entry.param)}; it is {"<page param>": <value>}`);
13941
+ refuse2(`${at}/param`, `rail entry "${entry.label}" states param as ${JSON.stringify(entry.param)}; it is {"<page param>": <value>}`);
13382
13942
  } else {
13383
13943
  for (const name of Object.keys(entry.param).sort(natural3)) {
13384
- if (!params.has(name)) refuse(`${at}/param/${escapePointer3(name)}`, `rail entry "${entry.label}" sets the param "${name}", which no page declares in pages[].params; the param store keeps only declared params, so the entry would do nothing`);
13944
+ if (!params.has(name)) refuse2(`${at}/param/${escapePointer3(name)}`, `rail entry "${entry.label}" sets the param "${name}", which no page declares in pages[].params; the param store keeps only declared params, so the entry would do nothing`);
13385
13945
  }
13386
13946
  }
13387
13947
  }
13388
- if (entry.measure !== void 0 && !named5(entry.measure)) refuse(`${at}/measure`, `rail entry "${entry.label}" states measure as ${JSON.stringify(entry.measure)}; it is the name of one measure of the page's nav.counts.source`);
13948
+ if (entry.measure !== void 0 && !named5(entry.measure)) refuse2(`${at}/measure`, `rail entry "${entry.label}" states measure as ${JSON.stringify(entry.measure)}; it is the name of one measure of the page's nav.counts.source`);
13389
13949
  if (entry.role !== void 0) {
13390
13950
  const names = named5(entry.role) ? entry.role.split(/\s+/).filter(named5) : [];
13391
13951
  const unknown = names.filter((name) => !roleNames.has(name));
13392
- if (names.length === 0) refuse(`${at}/role`, `rail entry "${entry.label}" states role as ${JSON.stringify(entry.role)}; it is one role name, or several space-separated`);
13952
+ if (names.length === 0) refuse2(`${at}/role`, `rail entry "${entry.label}" states role as ${JSON.stringify(entry.role)}; it is one role name, or several space-separated`);
13393
13953
  else if (unknown.length > 0) {
13394
- refuse(
13954
+ refuse2(
13395
13955
  `${at}/role`,
13396
13956
  `rail entry "${entry.label}" is for ${unknown.map((one) => JSON.stringify(one)).join(", ")}, which ${roleNames.size === 0 ? "the app does not declare (it declares no roles)" : `backend.roles does not declare (${[...roleNames].map((one) => JSON.stringify(one)).join(", ")})`}; the runtime would draw it for nobody`
13397
13957
  );
@@ -13405,14 +13965,14 @@ function shellProblems(contract, elements, handlers, sources) {
13405
13965
  for (const [index, page] of pages.entries()) {
13406
13966
  if (!isPlainObject8(page) || page.owned === true) continue;
13407
13967
  if (isPlainObject8(page.header) && page.header.title_from !== void 0 && page.header.title_from !== "rail") {
13408
- refuse(`/pages/${index}/header/title_from`, `${page.path}'s header.title_from is ${JSON.stringify(page.header.title_from)}; the one this runtime reads is "rail" (the active rail entry's label)`);
13968
+ refuse2(`/pages/${index}/header/title_from`, `${page.path}'s header.title_from is ${JSON.stringify(page.header.title_from)}; the one this runtime reads is "rail" (the active rail entry's label)`);
13409
13969
  }
13410
13970
  if (page.nav !== void 0) {
13411
13971
  const source = page.nav?.counts?.source;
13412
13972
  if (!isPlainObject8(page.nav) || !isPlainObject8(page.nav.counts) || !named5(source)) {
13413
- refuse(`/pages/${index}/nav`, `${page.path}'s nav is ${JSON.stringify(page.nav)}; it is {"counts": {"source": "<summary handler>"}}`);
13973
+ refuse2(`/pages/${index}/nav`, `${page.path}'s nav is ${JSON.stringify(page.nav)}; it is {"counts": {"source": "<summary handler>"}}`);
13414
13974
  } else if (!Object.prototype.hasOwnProperty.call(handlers, source) && !Object.prototype.hasOwnProperty.call(sources ?? {}, source)) {
13415
- refuse(`/pages/${index}/nav/counts/source`, `${page.path}'s nav.counts.source is "${source}", which is not a handler or source of this app; name the summary handler whose measures are the rail's counts`);
13975
+ refuse2(`/pages/${index}/nav/counts/source`, `${page.path}'s nav.counts.source is "${source}", which is not a handler or source of this app; name the summary handler whose measures are the rail's counts`);
13416
13976
  }
13417
13977
  }
13418
13978
  }
@@ -13859,7 +14419,7 @@ function queryablePlatformColumns(table) {
13859
14419
  function platformQueryProblems(name, row, table) {
13860
14420
  const problems = [];
13861
14421
  const allowed = queryablePlatformColumns(table);
13862
- const refuse = (key, column) => {
14422
+ const refuse2 = (key, column) => {
13863
14423
  const why = column === "_created_by" ? `table "${table.name}" does not declare showAuthor, so no read returns the author and none may filter or sort by it \u2014 declare "showAuthor": true on the table, or drop it` : `no app read returns it, so none may filter or sort by it \u2014 use _created_at or _updated_at${table?.showAuthor ? ", or _created_by" : ""}`;
13864
14424
  problems.push({
13865
14425
  code: "platform_column_not_queryable",
@@ -13869,11 +14429,11 @@ function platformQueryProblems(name, row, table) {
13869
14429
  };
13870
14430
  if (named5(row.order_by)) {
13871
14431
  const column = row.order_by.replace(/^-+/, "");
13872
- if (column.startsWith("_") && !allowed.has(column)) refuse("order_by", column);
14432
+ if (column.startsWith("_") && !allowed.has(column)) refuse2("order_by", column);
13873
14433
  }
13874
14434
  for (const key of Object.keys(isPlainObject8(row.where) ? row.where : {}).sort()) {
13875
14435
  const column = key.split("__")[0];
13876
- if (column.startsWith("_") && !allowed.has(column)) refuse("where", column);
14436
+ if (column.startsWith("_") && !allowed.has(column)) refuse2("where", column);
13877
14437
  }
13878
14438
  return problems;
13879
14439
  }
@@ -15347,12 +15907,12 @@ function computedProblems(elements, tree, handlers, model) {
15347
15907
  const computed = computedIn(row.table);
15348
15908
  if (computed.size === 0) continue;
15349
15909
  const at = (...keys2) => `/data/app/handlers/${[name, ...keys2].map(escapePointer3).join("/")}`;
15350
- const refuse = (pointer2, column, how) => push(pointer2, `handler "${name}" ${how} "${column}", which is computed by the platform from its children; nothing writes it`);
15910
+ const refuse2 = (pointer2, column, how) => push(pointer2, `handler "${name}" ${how} "${column}", which is computed by the platform from its children; nothing writes it`);
15351
15911
  if (row.role === "submit" || row.role === "save") {
15352
15912
  for (const key of ["fields", "required"]) {
15353
- for (const hit of (Array.isArray(row[key]) ? row[key] : []).filter((column) => computed.has(column))) refuse(at(key), hit, key === "fields" ? "takes" : "requires");
15913
+ for (const hit of (Array.isArray(row[key]) ? row[key] : []).filter((column) => computed.has(column))) refuse2(at(key), hit, key === "fields" ? "takes" : "requires");
15354
15914
  }
15355
- for (const fixed of Object.keys(isPlainObject8(row.fixed) ? row.fixed : {}).filter((column) => computed.has(column))) refuse(at("fixed"), fixed, "fixes");
15915
+ for (const fixed of Object.keys(isPlainObject8(row.fixed) ? row.fixed : {}).filter((column) => computed.has(column))) refuse2(at("fixed"), fixed, "fixes");
15356
15916
  }
15357
15917
  }
15358
15918
  if (tree.problems.length > 0) return problems;
@@ -24976,6 +25536,15 @@ function rendererOf3(pages) {
24976
25536
  }
24977
25537
  return {};
24978
25538
  }
25539
+ function metricsOf2(pages) {
25540
+ for (const page of pages ?? []) {
25541
+ const embedded = embeddedContractOf2(page);
25542
+ if (embedded === null) continue;
25543
+ const metrics = embedded.data?.metrics;
25544
+ return isPlainObject12(metrics) ? { metrics } : {};
25545
+ }
25546
+ return {};
25547
+ }
24979
25548
  function cardSpecOf2(element) {
24980
25549
  const card = element?.props?.card;
24981
25550
  return element?.type === "widget" && typeof card === "string" && card !== "" ? card : null;
@@ -25562,7 +26131,9 @@ function dataSection2(app, manifest, spec, elements, describe7, problems) {
25562
26131
  ...application === null ? {} : { app: application },
25563
26132
  // Only when there are any: a contract written before sources existed
25564
26133
  // reads back byte for byte as it always did.
25565
- ...Object.keys(sources).length > 0 ? { sources } : {}
26134
+ ...Object.keys(sources).length > 0 ? { sources } : {},
26135
+ // The same for declared metrics, which only an app's pages carry.
26136
+ ...isApp ? metricsOf2(app.pages) : {}
25566
26137
  };
25567
26138
  if (describe7) {
25568
26139
  const widgets2 = isPlainObject12(describe7.widgets) ? describe7.widgets : {};
@@ -29092,6 +29663,14 @@ var REFUSALS = /* @__PURE__ */ new Set([
29092
29663
  "source_compare_field",
29093
29664
  // a source's formula, which the platform evaluates (lineage tier 3)
29094
29665
  "source_expr",
29666
+ // a declared Datarails metric (data.metrics, design 06), and a source that
29667
+ // reads one by key
29668
+ "metric_decl",
29669
+ "metric_kind_later",
29670
+ "metric_formula",
29671
+ "metric_base",
29672
+ "metric_format",
29673
+ "source_metric",
29095
29674
  // dr-components parts (DC-4)
29096
29675
  "part_props",
29097
29676
  "part_source_shape",
@@ -29178,6 +29757,14 @@ var KPI_REFUSALS = {
29178
29757
  var EXPR_REFUSALS = {
29179
29758
  source_expr: "a source's formula (its expr) has a from that is no list, or sits beside a transform; answers a shape other than kpi, series or breakdown; states compare (its compare is its formula over its operands' compares); cannot be evaluated (it is not text, a syntax error, a function other than abs, min, max, round and if, a wrong number of arguments, round decimals other than a whole number from 0 to 6, an if whose first argument is no comparison, a comparison anywhere but if's condition, a name not in from); can be, on some path, a number it states rather than an operand's (numbers alone such as 870000, a typed if() branch such as if(actual > 0, 870000, actual), or a typed min() or max() bound such as max(actual, 0): each branch of if() and each argument of min() and max() must come from an operand, and / already gives no figure for a zero divisor, so no fallback number is needed); names an operand it never reads; reads an operand that is not a FinanceOS read or metric or a formula over them (a transform, a composed source, a call, one of the app's own tables); reads an operand of a shape it cannot take (a kpi formula takes kpis; a series or breakdown formula its own shape and kpis), or, as a series or breakdown formula, has no operand of its own shape; reads a breakdown operand that does not state top: null; or reads itself \u2014 the message names which"
29180
29759
  };
29760
+ var METRIC_REFUSALS = {
29761
+ metric_decl: `a data.metrics key that is not a lowercase letter then lowercase letters, digits or _; a declaration that is no object, or states a key a declaration does not take; a name that is empty, over 128 characters, holds ' [ or ], starts with @, or is DAYS_IN_PERIOD or PERIODS_IN_YEAR; a kind other than base or calc; a calc with no formula; a base with no table, value, date or aggregate, or a table that is not exactly one of alias, name and id; one kind's keys on the other; a reuse other than definition or name, a create that is not true or false, a description that is not text; or a declaration with no definition that does not say "reuse": "name" and "create": false \u2014 the message names which`,
29762
+ metric_kind_later: 'a declaration of kind driver, which is reserved until driver declarations ship \u2014 declare a base or calc metric, or bind the driver by name ("reuse": "name", "create": false)',
29763
+ metric_formula: "a calc formula Datarails would refuse (dr-cli's checks: empty or over 5000 characters, unbalanced brackets or grouping, a time modifier that is lowercase, chained, on no operand, malformed, a future SHIFT or one past 1200, an invalid unit or anchor, a modifier on a system variable, an unbracketed word, no metric read); arithmetic out of order (two operators in a row, a missing operand or operator, empty parentheses, a number such as 1.2.3); an operand that is not a declared [@key] \u2014 a metric named itself, quoted or bracketed ('Revenue', [Revenue]), so the contract names no company's metric; a scenario pin (|); a window function it cannot read; or a declaration that reads itself, however far round",
29764
+ metric_base: "a base aggregate other than sum, count, cumulative_sum or cumulative_count, a time_aggregation other than sum, eop or avg, a cumulative aggregate rolled up with sum, or a filter that is not {field, values, exclude}",
29765
+ metric_format: 'a format the metric editor never writes (optional "$" quoted, \u20AC \xA3 \u20AA or a % suffix, #,### or ####, .# to .####, [kilo]k or [mega]M) \u2014 the message names the stored spelling where one was plainly meant',
29766
+ source_metric: `a source's from.financeos.metric that is not "@<key>" of a key data.metrics declares, or that stands beside metric_id`
29767
+ };
29181
29768
  var TRANSFORM_REFUSALS = {
29182
29769
  transform_row: 'a row names a transform and is not a dataset series row ("kind": "series", "data": "<dataset>", and no source, compute, depends, drill, window or by), or its dataset reads with the period pushed down (a period other than "all")',
29183
29770
  transform_missing: "a row or source names a transform not spelled transforms/<name>.py, or that file is not beside contract.json",
@@ -30286,6 +30873,9 @@ function registerCompileCommand(parent) {
30286
30873
  ...Object.entries(KPI_REFUSALS).map(([code, what]) => ` ${code.padEnd(22)}${what}`),
30287
30874
  "A source that states its figure as a formula (expr over from, evaluated by the platform) is refused with:",
30288
30875
  ...Object.entries(EXPR_REFUSALS).map(([code, what]) => ` ${code.padEnd(22)}${what}`),
30876
+ 'A declared Datarails metric (data.metrics, read by a source as from.financeos.metric "@<key>") is refused with:',
30877
+ ...Object.entries(METRIC_REFUSALS).map(([code, what]) => ` ${code.padEnd(22)}${what}`),
30878
+ " (warnings, never refusals: metric_window, a window function Datarails gates by company; metric_unused, a declaration no source reads; expr_could_be_metric, an expr a calc metric could say)",
30289
30879
  "",
30290
30880
  "A transform (a dashboard dataset series row's transforms/<name>.py, or an app source's) is refused with:",
30291
30881
  ...Object.entries(TRANSFORM_REFUSALS).map(([code, what]) => ` ${code.padEnd(22)}${what}`)
@@ -30336,29 +30926,29 @@ var ONTO_LIVE_MARK = ".onto-live";
30336
30926
  var ONTO_LIVE_OWNED = ["contract.json", "manifest.json", "data_model.json", "handlers", "ui", "transforms", ".appspace.json", ".appspace"];
30337
30927
  async function runShipOntoLive(dir, opts) {
30338
30928
  const appId = opts.appId;
30339
- const refuse = (why) => {
30929
+ const refuse2 = (why) => {
30340
30930
  process.stderr.write(`ship --onto-live: ${why}
30341
30931
  `);
30342
30932
  return 1;
30343
30933
  };
30344
- if (appId === void 0 || appId.trim() === "") return refuse("pass --app-id <id>: the app to land on.");
30345
- if ((opts.draft ?? []).length === 0) return refuse("pass --draft <file>: the cards to land.");
30934
+ if (appId === void 0 || appId.trim() === "") return refuse2("pass --app-id <id>: the app to land on.");
30935
+ if ((opts.draft ?? []).length === 0) return refuse2("pass --draft <file>: the cards to land.");
30346
30936
  if (opts.skeleton === void 0) {
30347
- return refuse("pass --skeleton <file>: the frame the app is created from if nothing has landed yet.");
30937
+ return refuse2("pass --skeleton <file>: the frame the app is created from if nothing has landed yet.");
30348
30938
  }
30349
30939
  if ((opts.expect ?? []).length > 0) {
30350
- return refuse("--expect holds the headline, which is the build's to confirm; a landing proves its own cards only.");
30940
+ return refuse2("--expect holds the headline, which is the build's to confirm; a landing proves its own cards only.");
30351
30941
  }
30352
30942
  if (opts.force === true) {
30353
- return refuse(
30943
+ return refuse2(
30354
30944
  "does not take --force: every landing builds on the cards already live, and --force would let this one replace them. Run the same command without it. If it refuses over files it did not write, the live app could not be read whole: run it again, and report the refusal if it repeats."
30355
30945
  );
30356
30946
  }
30357
30947
  const owned = await claimFolder(dir);
30358
- if (owned !== null) return refuse(owned);
30948
+ if (owned !== null) return refuse2(owned);
30359
30949
  await defaultServerFromSession(opts);
30360
30950
  if (!hasServer(opts)) {
30361
- return refuse("needs a server: pass -s <server> (or set DR_APP_SPACE_HOST), or log in so a stored session supplies one.");
30951
+ return refuse2("needs a server: pass -s <server> (or set DR_APP_SPACE_HOST), or log in so a stored session supplies one.");
30362
30952
  }
30363
30953
  return landWithRetries(async (createGuarded) => {
30364
30954
  for (const name of ONTO_LIVE_OWNED) await rm4(join15(dir, name), { recursive: true, force: true });
@@ -32788,7 +33378,7 @@ async function runUiSmoke(opts) {
32788
33378
  const { JSDOM, ResourceLoader, VirtualConsole } = await loadJsdom();
32789
33379
  let inFlight = 0;
32790
33380
  let lastActivity = Date.now();
32791
- async function record2(req, via, refuse) {
33381
+ async function record2(req, via, refuse2) {
32792
33382
  const call = { method: req.method, url: maskToken(req.url), via };
32793
33383
  if (via === "fetch" && req.body) {
32794
33384
  try {
@@ -32800,8 +33390,8 @@ async function runUiSmoke(opts) {
32800
33390
  const auth = req.headers["authorization"];
32801
33391
  if (auth !== void 0) call.authorization = summariseAuthorization(auth);
32802
33392
  calls.push(call);
32803
- if (refuse !== void 0) {
32804
- call.failure = refuse;
33393
+ if (refuse2 !== void 0) {
33394
+ call.failure = refuse2;
32805
33395
  return void 0;
32806
33396
  }
32807
33397
  if (!sameOrigin(req.url, target)) {