@datarails-ai/dr-appspace-cli 0.12.35 → 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.
- package/dist/cli.js +731 -104
- 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.
|
|
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-
|
|
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: [
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
|
@@ -10522,7 +10583,7 @@ function compile(contract, { kit, extra = {} } = {}) {
|
|
|
10522
10583
|
for (const page of pages) {
|
|
10523
10584
|
files[page.path] = pageFile(page, { contract: composed, pages, kit, problems });
|
|
10524
10585
|
}
|
|
10525
|
-
return { files, problems: problems.sort(byProblem2) };
|
|
10586
|
+
return { files, problems: problems.sort(byProblem2), warnings: narrativeOrderWarnings(composed, applied) };
|
|
10526
10587
|
}
|
|
10527
10588
|
var RUNTIME_RENDERER2 = "runtime";
|
|
10528
10589
|
function dashboardHandler(composed, handlerName, extra = {}) {
|
|
@@ -10598,6 +10659,43 @@ function missingExpressions(widgets) {
|
|
|
10598
10659
|
}
|
|
10599
10660
|
return problems;
|
|
10600
10661
|
}
|
|
10662
|
+
function narrativeOrderWarnings(composed, applied) {
|
|
10663
|
+
const picked = new Set(applied.filter((fill) => fill.what === "layout.order").map((fill) => fill.where));
|
|
10664
|
+
const elements = isPlainObject6(composed?.elements) ? composed.elements : {};
|
|
10665
|
+
const widgets = isPlainObject6(composed?.data?.widgets) ? composed.data.widgets : {};
|
|
10666
|
+
const pages = [...Array.isArray(composed?.pages) ? composed.pages : []].sort((a, b) => natural3(a.path ?? "", b.path ?? ""));
|
|
10667
|
+
const named13 = (value) => typeof value === "string" && value !== "";
|
|
10668
|
+
const quoted3 = (ids) => ids.map((id) => JSON.stringify(id)).join(", ");
|
|
10669
|
+
const warnings = [];
|
|
10670
|
+
for (const page of pages) {
|
|
10671
|
+
if (picked.has(page.path)) continue;
|
|
10672
|
+
const sections = Array.isArray(page.sections) ? page.sections : [];
|
|
10673
|
+
const sectionOf4 = (uid) => sections.findIndex((entry) => isPlainObject6(entry) && entry.id === elements[uid].section);
|
|
10674
|
+
const cards = Object.keys(elements).filter((uid) => elements[uid]?.page === page.path && elements[uid]?.type === "widget").sort((a, b) => sectionOf4(a) - sectionOf4(b) || order(elements[a]) - order(elements[b]) || natural3(a, b));
|
|
10675
|
+
const specOf2 = (uid) => {
|
|
10676
|
+
const element = elements[uid];
|
|
10677
|
+
const props = isPlainObject6(element.props) ? element.props : {};
|
|
10678
|
+
return named13(element.id) ? element.id : named13(props.card) ? props.card : named13(props.id) ? props.id : uid;
|
|
10679
|
+
};
|
|
10680
|
+
cards.forEach((uid, at) => {
|
|
10681
|
+
const own2 = specOf2(uid);
|
|
10682
|
+
const row = widgets[own2];
|
|
10683
|
+
const about = isPlainObject6(row) && row.kind === "narrative" && Array.isArray(row.about) ? row.about.filter((id) => named13(id) && id !== own2) : [];
|
|
10684
|
+
if (about.length === 0) return;
|
|
10685
|
+
const after = cards.slice(at + 1).filter((other) => sectionOf4(other) === sectionOf4(uid) && about.includes(specOf2(other)));
|
|
10686
|
+
if (after.length === 0) return;
|
|
10687
|
+
const later = [...new Set(after.map(specOf2))];
|
|
10688
|
+
const last = specOf2(after[after.length - 1]);
|
|
10689
|
+
const where2 = sectionOf4(uid) === -1 ? page.path : `section "${sections[sectionOf4(uid)].id}" of ${page.path}`;
|
|
10690
|
+
warnings.push({
|
|
10691
|
+
code: "narrative_before_about",
|
|
10692
|
+
pointer: `/elements/${escapePointer3(uid)}/order`,
|
|
10693
|
+
message: `narrative "${own2}" comes before ${quoted3(later)}, ${later.length === 1 ? "a card" : "cards"} it is about, on ${where2}: it reads the cards it is about as it loads, so its context gives ${quoted3(later)} as "(not loaded yet)". The page keeps the order you stated; move "${own2}" after "${last}"`
|
|
10694
|
+
});
|
|
10695
|
+
});
|
|
10696
|
+
}
|
|
10697
|
+
return warnings;
|
|
10698
|
+
}
|
|
10601
10699
|
var DEFAULT_CAPABILITIES = { financeos: ["metrics:read", "raw:read"] };
|
|
10602
10700
|
function manifestFile(contract, handlerName) {
|
|
10603
10701
|
const app = contract.app ?? {};
|
|
@@ -11008,7 +11106,7 @@ var DROPPED_REGIONS = /* @__PURE__ */ new Set(["controls", "band"]);
|
|
|
11008
11106
|
var KEPT_CARD_PROPS = ["label", "format", "negative", "props"];
|
|
11009
11107
|
function compileDashboard(contract, { kit = null, extra = {}, uiKit, charts, fmt, appComponents, appControls } = {}) {
|
|
11010
11108
|
const problems = [];
|
|
11011
|
-
const
|
|
11109
|
+
const refuse2 = (code, pointer2, message) => problems.push({ code, pointer: pointer2, message });
|
|
11012
11110
|
const note = (code, where2, message) => problems.push({ code, where: where2, lossy: false, message });
|
|
11013
11111
|
const refused = (restated2 = contract) => ({ files: {}, problems, contract: restated2, warnings: [] });
|
|
11014
11112
|
const source = isPlainObject7(contract) ? contract : {};
|
|
@@ -11016,19 +11114,19 @@ function compileDashboard(contract, { kit = null, extra = {}, uiKit, charts, fmt
|
|
|
11016
11114
|
const markdown = named5(kit?.markdown) ? kit.markdown : MARKDOWN;
|
|
11017
11115
|
const described = describeTier(source.data);
|
|
11018
11116
|
if (described !== null) {
|
|
11019
|
-
|
|
11117
|
+
refuse2("describe_tier_not_compilable", described.where, described.message);
|
|
11020
11118
|
return refused();
|
|
11021
11119
|
}
|
|
11022
11120
|
const { contract: composed, applied } = composeLayout(source);
|
|
11023
11121
|
for (const fill of applied) note("layout_composed", fill.where, `${fill.message} (${fill.what})`);
|
|
11024
11122
|
const pages = [...Array.isArray(composed.pages) ? composed.pages : []].sort((a, b) => natural3(a.path ?? "", b.path ?? ""));
|
|
11025
11123
|
if (pages.length === 0) {
|
|
11026
|
-
|
|
11124
|
+
refuse2("no_pages", "/pages", "the contract declares no pages; a dashboard is one page of widgets or more");
|
|
11027
11125
|
return refused();
|
|
11028
11126
|
}
|
|
11029
11127
|
const handlers = [...new Set(pages.map((page) => page.mount?.handler).filter((name) => typeof name === "string" && name !== ""))];
|
|
11030
11128
|
if (handlers.length !== 1) {
|
|
11031
|
-
|
|
11129
|
+
refuse2(
|
|
11032
11130
|
"one_handler_per_dashboard",
|
|
11033
11131
|
"/pages",
|
|
11034
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`
|
|
@@ -11038,15 +11136,15 @@ function compileDashboard(contract, { kit = null, extra = {}, uiKit, charts, fmt
|
|
|
11038
11136
|
const handlerName = handlers[0];
|
|
11039
11137
|
const handler = dashboardHandler(composed, handlerName, extra);
|
|
11040
11138
|
if (handler.unwritable !== null) {
|
|
11041
|
-
|
|
11139
|
+
refuse2(handler.unwritable.code, handler.unwritable.where, handler.unwritable.message);
|
|
11042
11140
|
return refused();
|
|
11043
11141
|
}
|
|
11044
11142
|
problems.push(...handler.notes);
|
|
11045
11143
|
if (handler.refusals.length > 0) {
|
|
11046
|
-
for (const problem5 of handler.refusals)
|
|
11144
|
+
for (const problem5 of handler.refusals) refuse2(problem5.code, problem5.where, problem5.message);
|
|
11047
11145
|
return refused();
|
|
11048
11146
|
}
|
|
11049
|
-
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 : [] });
|
|
11050
11148
|
const whole = canonical({
|
|
11051
11149
|
...composed.v !== void 0 ? { v: composed.v } : {},
|
|
11052
11150
|
...composed.generated_by !== void 0 ? { generated_by: composed.generated_by } : {},
|
|
@@ -11078,19 +11176,19 @@ function compileDashboard(contract, { kit = null, extra = {}, uiKit, charts, fmt
|
|
|
11078
11176
|
files[handlerPath(composed, handlerName)] = handler.text;
|
|
11079
11177
|
const shelf2 = { runtime: DASHBOARD_RUNTIME, widgets: DASHBOARD_WIDGETS, bar: DASHBOARD_BAR, assistant, markdown, uiKit, charts, fmt, appComponents, appControls };
|
|
11080
11178
|
for (const page of whole.pages) files[page.path] = pageHtml(whole, page, elements, tree, shelf2);
|
|
11081
|
-
return { files, problems, contract: whole, order: order2, warnings:
|
|
11179
|
+
return { files, problems, contract: whole, order: order2, warnings: narrativeOrderWarnings(composed, applied) };
|
|
11082
11180
|
}
|
|
11083
11181
|
function isRefusal(problem5) {
|
|
11084
11182
|
return problem5.lossy !== false;
|
|
11085
11183
|
}
|
|
11086
|
-
function restateDashboard(composed, pages, handlerName, handler, { refuse, note, stated: stated2 }) {
|
|
11184
|
+
function restateDashboard(composed, pages, handlerName, handler, { refuse: refuse2, note, stated: stated2 }) {
|
|
11087
11185
|
const app = isPlainObject7(composed.app) ? composed.app : {};
|
|
11088
11186
|
const typed = isPlainObject7(composed.elements) ? composed.elements : {};
|
|
11089
11187
|
const declared = treeOf(typed);
|
|
11090
11188
|
const conflicts = new Map(declared.problems.map((problem5) => [uidOfPointer(problem5.pointer), problem5]));
|
|
11091
11189
|
const paths = new Set(pages.map((page) => page.path));
|
|
11092
11190
|
const unplaced = Object.fromEntries(Object.keys(typed).filter((uid) => isPlainObject7(typed[uid]) && !paths.has(typed[uid].page)).map((uid) => [uid, typed[uid]]));
|
|
11093
|
-
for (const problem5 of placementProblems(unplaced, pages, declared, []))
|
|
11191
|
+
for (const problem5 of placementProblems(unplaced, pages, declared, [])) refuse2(problem5.code, problem5.pointer, problem5.message);
|
|
11094
11192
|
const elements = {};
|
|
11095
11193
|
const outPages = [];
|
|
11096
11194
|
for (const page of pages) {
|
|
@@ -11126,7 +11224,7 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
|
|
|
11126
11224
|
const onlyOne = (uids, what) => {
|
|
11127
11225
|
for (const uid of uids.slice(1)) {
|
|
11128
11226
|
duplicates.add(uid);
|
|
11129
|
-
|
|
11227
|
+
refuse2(
|
|
11130
11228
|
"chrome_duplicate",
|
|
11131
11229
|
`/elements/${escapePointer3(uid)}`,
|
|
11132
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]}"`
|
|
@@ -11200,12 +11298,12 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
|
|
|
11200
11298
|
const params = (Array.isArray(page.params) ? page.params : []).filter(isPlainObject7).map(canonical);
|
|
11201
11299
|
const parts = [];
|
|
11202
11300
|
for (const uid of own2.filter((uid2) => typed[uid2].type === "control")) {
|
|
11203
|
-
const made = controlToPart(typed[uid], uid, path3,
|
|
11301
|
+
const made = controlToPart(typed[uid], uid, path3, refuse2);
|
|
11204
11302
|
if (made === null) continue;
|
|
11205
11303
|
const param = canonical(made.param);
|
|
11206
11304
|
const taken = params.find((one) => one?.name === param.name);
|
|
11207
11305
|
if (taken !== void 0 && JSON.stringify(taken) !== JSON.stringify(param)) {
|
|
11208
|
-
|
|
11306
|
+
refuse2(
|
|
11209
11307
|
"control_conflict",
|
|
11210
11308
|
`/elements/${escapePointer3(uid)}`,
|
|
11211
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`
|
|
@@ -11222,14 +11320,14 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
|
|
|
11222
11320
|
const handled = /* @__PURE__ */ new Set([header, ...chrome, filters, ...nav === void 0 ? [] : [nav, ...elements[nav].children], ...parts, ...cards]);
|
|
11223
11321
|
const kept = own2.filter((uid) => !handled.has(uid) && !dropped.has(uid) && !["region", "nav", "tab", "control", "widget"].includes(typed[uid].type));
|
|
11224
11322
|
for (const uid of kept) {
|
|
11225
|
-
if (conflicts.has(uid))
|
|
11323
|
+
if (conflicts.has(uid)) refuse2(conflicts.get(uid).code, conflicts.get(uid).pointer, conflicts.get(uid).message);
|
|
11226
11324
|
elements[uid] = keptOf(typed[uid], uid, path3, typed, declared, dropped, kept, cards, sections);
|
|
11227
11325
|
}
|
|
11228
11326
|
for (const uid of cards) {
|
|
11229
11327
|
const held = declared.parent.get(uid) ?? null;
|
|
11230
11328
|
const parent = held !== null && kept.includes(held) ? held : null;
|
|
11231
|
-
if (conflicts.has(uid) && (parent !== null || kept.includes(typed[uid].parent)))
|
|
11232
|
-
elements[uid] = cardOf(typed[uid], uid, path3, handlerName, sections,
|
|
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);
|
|
11233
11331
|
}
|
|
11234
11332
|
const otherRegions = own2.filter((uid) => typed[uid].type === "region" && !handled.has(uid) && !dropped.has(uid) && !duplicates.has(uid));
|
|
11235
11333
|
for (const uid of otherRegions) elements[uid] = region(uid, typed[uid].role ?? "header", "section", []);
|
|
@@ -11242,7 +11340,7 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
|
|
|
11242
11340
|
const authored = stated2.findIndex((one) => isPlainObject7(one) && one.path === path3);
|
|
11243
11341
|
const statedRoute = authored === -1 ? null : stated2[authored].route ?? null;
|
|
11244
11342
|
if (statedRoute !== null && statedRoute !== route) {
|
|
11245
|
-
|
|
11343
|
+
refuse2(
|
|
11246
11344
|
"route_mismatch",
|
|
11247
11345
|
`/pages/${authored}/route`,
|
|
11248
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`
|
|
@@ -11258,11 +11356,11 @@ function restateDashboard(composed, pages, handlerName, handler, { refuse, note,
|
|
|
11258
11356
|
data: dataOf(composed, handler)
|
|
11259
11357
|
};
|
|
11260
11358
|
}
|
|
11261
|
-
function cardOf(element, uid, path3, handlerName, sections,
|
|
11359
|
+
function cardOf(element, uid, path3, handlerName, sections, refuse2, parent = null) {
|
|
11262
11360
|
const props = isPlainObject7(element.props) ? element.props : {};
|
|
11263
11361
|
const spec = named5(element.id) ? element.id : named5(props.card) ? props.card : named5(props.id) ? props.id : null;
|
|
11264
11362
|
if (spec === null) {
|
|
11265
|
-
|
|
11363
|
+
refuse2(
|
|
11266
11364
|
"card_unnamed",
|
|
11267
11365
|
`/elements/${escapePointer3(uid)}`,
|
|
11268
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`
|
|
@@ -11297,12 +11395,12 @@ function cardOf(element, uid, path3, handlerName, sections, refuse, parent = nul
|
|
|
11297
11395
|
...section === void 0 ? {} : { section }
|
|
11298
11396
|
};
|
|
11299
11397
|
}
|
|
11300
|
-
function controlToPart(control, uid, path3,
|
|
11398
|
+
function controlToPart(control, uid, path3, refuse2) {
|
|
11301
11399
|
const props = isPlainObject7(control.props) ? control.props : {};
|
|
11302
11400
|
const name = named5(props.param) ? props.param : named5(control.binding) ? control.binding : null;
|
|
11303
11401
|
const id = named5(control.id) ? control.id : named5(props.id) ? props.id : name;
|
|
11304
11402
|
if (name === null) {
|
|
11305
|
-
|
|
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)`);
|
|
11306
11404
|
return null;
|
|
11307
11405
|
}
|
|
11308
11406
|
const kind = named5(props.control_kind) ? props.control_kind : props.type === "range" ? "slider" : props.type === "number" ? "number" : props.type === "checkbox" ? "toggle" : null;
|
|
@@ -11337,7 +11435,7 @@ function controlToPart(control, uid, path3, refuse) {
|
|
|
11337
11435
|
if (value === void 0 || value === null || value === "") continue;
|
|
11338
11436
|
const n = typeof value === "number" ? value : Number(value);
|
|
11339
11437
|
if (!Number.isFinite(n)) {
|
|
11340
|
-
|
|
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`);
|
|
11341
11439
|
return null;
|
|
11342
11440
|
}
|
|
11343
11441
|
numbers[as] = n;
|
|
@@ -11349,7 +11447,7 @@ function controlToPart(control, uid, path3, refuse) {
|
|
|
11349
11447
|
if (kind === "toggle") {
|
|
11350
11448
|
return part("toggle", { name, kind: "toggle", ...label, default: props.checked !== void 0 && props.checked !== false && props.checked !== "false" });
|
|
11351
11449
|
}
|
|
11352
|
-
|
|
11450
|
+
refuse2(
|
|
11353
11451
|
"control_unsupported",
|
|
11354
11452
|
`/elements/${escapePointer3(uid)}`,
|
|
11355
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)`
|
|
@@ -11852,6 +11950,234 @@ function formulaProblems(text3, operands) {
|
|
|
11852
11950
|
return problems.length > 0 ? { tree: null, problems } : { tree, problems: [] };
|
|
11853
11951
|
}
|
|
11854
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
|
+
|
|
11855
12181
|
// ../flows/insights-builder/kit/contract/compileApp.mjs
|
|
11856
12182
|
var RUNTIME = "dr-app-runtime@1.11.0";
|
|
11857
12183
|
var UI_KIT2 = "ui-kit/v4";
|
|
@@ -12141,6 +12467,9 @@ function originOf(row) {
|
|
|
12141
12467
|
function sourcesOf(contract) {
|
|
12142
12468
|
return isPlainObject8(contract?.data?.sources) ? contract.data.sources : {};
|
|
12143
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
|
+
}
|
|
12144
12473
|
var FINANCEOS_SCOPES = {
|
|
12145
12474
|
commands: {
|
|
12146
12475
|
"status:read": ["status", "whoami"],
|
|
@@ -12185,7 +12514,7 @@ function financeosNeeds(row, origin) {
|
|
|
12185
12514
|
}
|
|
12186
12515
|
const read = isPlainObject8(row.from.financeos) ? row.from.financeos : {};
|
|
12187
12516
|
const filterable = Array.isArray(row.filterable) && row.filterable.length > 0;
|
|
12188
|
-
if (read
|
|
12517
|
+
if (readsMetric(read)) {
|
|
12189
12518
|
if (read.drill_rows) need("data_models", scopesGranting("namespaces", "data_models"));
|
|
12190
12519
|
else need("metric_series", scopesGranting("commands", "metric_series"));
|
|
12191
12520
|
if (filterable) need("data_models", scopesGranting("namespaces", "data_models"), true);
|
|
@@ -12234,7 +12563,7 @@ function opensRows(row) {
|
|
|
12234
12563
|
if (row.transform !== void 0 && row.transform !== null) return false;
|
|
12235
12564
|
if (!MENU_SHAPES.includes(row.shape ?? "rows")) return false;
|
|
12236
12565
|
const read = isPlainObject8(row.from.financeos) ? row.from.financeos : {};
|
|
12237
|
-
if (read
|
|
12566
|
+
if (readsMetric(read)) return false;
|
|
12238
12567
|
return Boolean(read.table_alias) || read.table_id !== void 0 && read.table_id !== null || read.address === "id" || read.address === "alias";
|
|
12239
12568
|
}
|
|
12240
12569
|
function fillMenuScope(app, sources, elements) {
|
|
@@ -12447,6 +12776,272 @@ function reaches2(sources, start, target, seen, isFormula) {
|
|
|
12447
12776
|
if (!isFormula(row) || !Array.isArray(row.from)) return false;
|
|
12448
12777
|
return row.from.filter(named5).some((one) => reaches2(sources, one, target, seen, isFormula));
|
|
12449
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
|
+
}
|
|
12450
13045
|
var SOURCE_ORDERS = ["source", "declared"];
|
|
12451
13046
|
function orderProblems(name, row, at) {
|
|
12452
13047
|
const problems = [];
|
|
@@ -12625,22 +13220,22 @@ function themeProblems(app) {
|
|
|
12625
13220
|
const theme = app?.theme;
|
|
12626
13221
|
if (theme === void 0 || theme === null) return [];
|
|
12627
13222
|
const at = (key) => `/app/theme${key ? `/${key}` : ""}`;
|
|
12628
|
-
const
|
|
12629
|
-
if (!isPlainObject8(theme)) return [
|
|
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`)];
|
|
12630
13225
|
const out = [];
|
|
12631
|
-
if (theme.file !== THEME_FILE4) out.push(
|
|
12632
|
-
if (!isPlainObject8(theme.tokens)) out.push(
|
|
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)`));
|
|
12633
13228
|
else {
|
|
12634
13229
|
for (const token of Object.keys(theme.tokens).sort(natural3)) {
|
|
12635
13230
|
const value = theme.tokens[token];
|
|
12636
13231
|
const pointer2 = `tokens/${escapePointer3(token)}`;
|
|
12637
|
-
if (token === "--dr-app-theme") out.push(
|
|
12638
|
-
else if (!TOKEN_RE.test(token)) out.push(
|
|
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)`));
|
|
12639
13234
|
else {
|
|
12640
13235
|
const why = tokenValueProblem(value);
|
|
12641
13236
|
if (why !== null) {
|
|
12642
13237
|
out.push(
|
|
12643
|
-
|
|
13238
|
+
refuse2(
|
|
12644
13239
|
pointer2,
|
|
12645
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`
|
|
12646
13241
|
)
|
|
@@ -12650,9 +13245,9 @@ function themeProblems(app) {
|
|
|
12650
13245
|
}
|
|
12651
13246
|
}
|
|
12652
13247
|
if (theme.preset !== void 0 && !THEME_PRESETS.includes(theme.preset)) {
|
|
12653
|
-
out.push(
|
|
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" : ""}`));
|
|
12654
13249
|
}
|
|
12655
|
-
if (theme.density !== void 0 && !THEME_DENSITIES.includes(theme.density)) out.push(
|
|
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)}`));
|
|
12656
13251
|
return out;
|
|
12657
13252
|
}
|
|
12658
13253
|
function themeValue(token, tokens, seen = /* @__PURE__ */ new Set()) {
|
|
@@ -12850,51 +13445,51 @@ function atPointer(root, pointer2) {
|
|
|
12850
13445
|
function paramValueProblems(param, said2, at) {
|
|
12851
13446
|
const problems = [];
|
|
12852
13447
|
const kind = param.kind;
|
|
12853
|
-
const
|
|
13448
|
+
const refuse2 = (key, why) => problems.push({ code: "param_kind_value", pointer: at(key), message: `${said2} (${kind}) ${why}` });
|
|
12854
13449
|
const finite = (value2) => typeof value2 === "number" && Number.isFinite(value2);
|
|
12855
13450
|
for (const key of ["min", "max", "step"]) {
|
|
12856
13451
|
if (param[key] === void 0) continue;
|
|
12857
|
-
if (!NUMERIC_PARAM_KINDS.has(kind))
|
|
12858
|
-
else if (!finite(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])}`);
|
|
12859
13454
|
}
|
|
12860
13455
|
if (param.values !== void 0) {
|
|
12861
|
-
if (!STRING_PARAM_KINDS.has(kind))
|
|
12862
|
-
else if (!Array.isArray(param.values) || param.values.length === 0 || !param.values.every(named5))
|
|
12863
|
-
else if (new Set(param.values).size !== param.values.length)
|
|
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");
|
|
12864
13459
|
}
|
|
12865
13460
|
if (param.max_selected !== void 0) {
|
|
12866
|
-
if (kind !== "multi")
|
|
12867
|
-
else if (!Number.isInteger(param.max_selected) || param.max_selected < 1)
|
|
12868
|
-
else if (Array.isArray(param.values) && param.max_selected > param.values.length)
|
|
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`);
|
|
12869
13464
|
}
|
|
12870
13465
|
if (problems.length > 0) return problems;
|
|
12871
13466
|
const { min, max, step } = param;
|
|
12872
13467
|
if (NUMERIC_PARAM_KINDS.has(kind)) {
|
|
12873
|
-
if (finite(min) && finite(max) && min > max)
|
|
12874
|
-
if (finite(step) && step <= 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`);
|
|
12875
13470
|
}
|
|
12876
13471
|
if (param.default === void 0) return problems;
|
|
12877
13472
|
const value = param.default;
|
|
12878
13473
|
const values = Array.isArray(param.values) ? param.values : null;
|
|
12879
13474
|
if (NUMERIC_PARAM_KINDS.has(kind)) {
|
|
12880
|
-
if (!finite(value))
|
|
13475
|
+
if (!finite(value)) refuse2("default", `defaults to ${JSON.stringify(value)}; a ${kind} opens on a finite number`);
|
|
12881
13476
|
else if (finite(min) && value < min || finite(max) && value > max) {
|
|
12882
|
-
|
|
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`);
|
|
12883
13478
|
}
|
|
12884
13479
|
} else if (kind === "toggle") {
|
|
12885
|
-
if (typeof value !== "boolean")
|
|
13480
|
+
if (typeof value !== "boolean") refuse2("default", `defaults to ${JSON.stringify(value)}; a toggle opens on true or false`);
|
|
12886
13481
|
} else if (kind === "multi") {
|
|
12887
|
-
if (!Array.isArray(value) || !value.every((one) => typeof one === "string"))
|
|
12888
|
-
else if (values !== null && value.some((one) => !values.includes(one)))
|
|
12889
|
-
else if (Number.isInteger(param.max_selected) && value.length > 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}`);
|
|
12890
13485
|
} else if (typeof value !== "string") {
|
|
12891
|
-
|
|
13486
|
+
refuse2("default", `defaults to ${JSON.stringify(value)}; a ${kind} opens on a string`);
|
|
12892
13487
|
} else if (kind === "month" && !MONTH_RE.test(value)) {
|
|
12893
|
-
|
|
13488
|
+
refuse2("default", `defaults to ${JSON.stringify(value)}; a month is written YYYY-MM`);
|
|
12894
13489
|
} else if (kind === "date" && value !== "" && !isRealDay(value)) {
|
|
12895
|
-
|
|
13490
|
+
refuse2("default", `defaults to ${JSON.stringify(value)}; a date is a real day written YYYY-MM-DD, or '' for none`);
|
|
12896
13491
|
} else if (values !== null && !values.includes(value)) {
|
|
12897
|
-
|
|
13492
|
+
refuse2("default", `defaults to ${JSON.stringify(value)}, which is not among its values ${JSON.stringify(values)}`);
|
|
12898
13493
|
}
|
|
12899
13494
|
return problems;
|
|
12900
13495
|
}
|
|
@@ -13027,6 +13622,8 @@ function compileApp(contract, { kit = null, runtime = RUNTIME, extra = {} } = {}
|
|
|
13027
13622
|
problems.push(...sourceFindings.filter((one) => !one.advisory));
|
|
13028
13623
|
problems.push(...compareProblems(stated2, model));
|
|
13029
13624
|
problems.push(...exprProblems(stated2));
|
|
13625
|
+
const metricFindings = metricProblems(contract?.data?.metrics, stated2);
|
|
13626
|
+
problems.push(...metricFindings.filter((one) => !one.advisory));
|
|
13030
13627
|
problems.push(...paramProblems(contract, stated2));
|
|
13031
13628
|
problems.push(...themeProblems(app));
|
|
13032
13629
|
problems.push(...shellProblems(contract, typed, handlers, stated2));
|
|
@@ -13113,7 +13710,7 @@ function compileApp(contract, { kit = null, runtime = RUNTIME, extra = {} } = {}
|
|
|
13113
13710
|
problems.push(...partPinProblems(declared, { "dr-components": appComponents, "dr-components-controls": appControls }));
|
|
13114
13711
|
problems.push(...runtimePinProblems(declared, runtime));
|
|
13115
13712
|
const widgetFindings = tree.problems.length === 0 ? widgetProblems(declared, tree, handlers, contract?.data?.sources, appWidgets) : [];
|
|
13116
|
-
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);
|
|
13117
13714
|
problems.push(...widgetFindings.filter((one) => !one.advisory));
|
|
13118
13715
|
if (tree.problems.length === 0) problems.push(...partProblems(declared, tree, handlers, sources, contract?.pages, model, { "dr-components": appComponents }));
|
|
13119
13716
|
const backendFacts = inferredBackendHandlers(contract?.backend?.handlers, handlers, extra);
|
|
@@ -13310,51 +13907,51 @@ function shellProblems(contract, elements, handlers, sources) {
|
|
|
13310
13907
|
const pages = Array.isArray(contract?.pages) ? contract.pages : [];
|
|
13311
13908
|
const paths = new Set(pages.map((page) => page?.path).filter(named5));
|
|
13312
13909
|
const params = new Set(pages.flatMap((page) => (Array.isArray(page?.params) ? page.params : []).map((param) => param?.name)).filter(named5));
|
|
13313
|
-
const
|
|
13910
|
+
const refuse2 = (pointer2, message) => problems.push({ code: "shell_shape", pointer: pointer2, message });
|
|
13314
13911
|
const roleNames = new Set(
|
|
13315
13912
|
(Array.isArray(contract?.backend?.roles) ? contract.backend.roles : []).map((role) => typeof role === "string" ? role : role?.name).filter(named5)
|
|
13316
13913
|
);
|
|
13317
13914
|
if (app.shell !== void 0 && app.shell !== null) {
|
|
13318
13915
|
const shell = app.shell;
|
|
13319
|
-
if (!isPlainObject8(shell))
|
|
13916
|
+
if (!isPlainObject8(shell)) refuse2("/app/shell", `app.shell is ${JSON.stringify(shell)}; it is {"nav": "tabs"|"rail", "topbar"?: true, "rail"?: {...}}`);
|
|
13320
13917
|
else {
|
|
13321
|
-
if (shell.nav !== void 0 && !SHELL_NAVS.includes(shell.nav))
|
|
13322
|
-
if (shell.topbar !== void 0 && typeof shell.topbar !== "boolean")
|
|
13323
|
-
if (shell.rail !== void 0 && !isPlainObject8(shell.rail))
|
|
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"?: [...]}`);
|
|
13324
13921
|
else if (isPlainObject8(shell.rail)) {
|
|
13325
13922
|
for (const key of ["entries", "footer"]) {
|
|
13326
13923
|
const list3 = shell.rail[key];
|
|
13327
13924
|
if (list3 === void 0) continue;
|
|
13328
13925
|
if (!Array.isArray(list3)) {
|
|
13329
|
-
|
|
13926
|
+
refuse2(`/app/shell/rail/${key}`, `app.shell.rail.${key} is ${JSON.stringify(list3)}; it is a list of {"label", ...}`);
|
|
13330
13927
|
continue;
|
|
13331
13928
|
}
|
|
13332
13929
|
for (const [index, entry] of list3.entries()) {
|
|
13333
13930
|
const at = `/app/shell/rail/${key}/${index}`;
|
|
13334
13931
|
if (!isPlainObject8(entry) || !named5(entry.label)) {
|
|
13335
|
-
|
|
13932
|
+
refuse2(at, `app.shell.rail.${key}[${index}] is ${JSON.stringify(entry)}; every entry has a label (the runtime drops one without)`);
|
|
13336
13933
|
continue;
|
|
13337
13934
|
}
|
|
13338
13935
|
if (entry.page !== void 0 && !(named5(entry.page) && paths.has(entry.page))) {
|
|
13339
|
-
|
|
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(", ")})`);
|
|
13340
13937
|
}
|
|
13341
|
-
if (key === "footer" && !named5(entry.page))
|
|
13938
|
+
if (key === "footer" && !named5(entry.page)) refuse2(`${at}/page`, `rail footer entry "${entry.label}" links nowhere; a footer entry is {"label", "page"}`);
|
|
13342
13939
|
if (entry.param !== void 0) {
|
|
13343
13940
|
if (!isPlainObject8(entry.param) || Object.keys(entry.param).length === 0) {
|
|
13344
|
-
|
|
13941
|
+
refuse2(`${at}/param`, `rail entry "${entry.label}" states param as ${JSON.stringify(entry.param)}; it is {"<page param>": <value>}`);
|
|
13345
13942
|
} else {
|
|
13346
13943
|
for (const name of Object.keys(entry.param).sort(natural3)) {
|
|
13347
|
-
if (!params.has(name))
|
|
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`);
|
|
13348
13945
|
}
|
|
13349
13946
|
}
|
|
13350
13947
|
}
|
|
13351
|
-
if (entry.measure !== void 0 && !named5(entry.measure))
|
|
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`);
|
|
13352
13949
|
if (entry.role !== void 0) {
|
|
13353
13950
|
const names = named5(entry.role) ? entry.role.split(/\s+/).filter(named5) : [];
|
|
13354
13951
|
const unknown = names.filter((name) => !roleNames.has(name));
|
|
13355
|
-
if (names.length === 0)
|
|
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`);
|
|
13356
13953
|
else if (unknown.length > 0) {
|
|
13357
|
-
|
|
13954
|
+
refuse2(
|
|
13358
13955
|
`${at}/role`,
|
|
13359
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`
|
|
13360
13957
|
);
|
|
@@ -13368,14 +13965,14 @@ function shellProblems(contract, elements, handlers, sources) {
|
|
|
13368
13965
|
for (const [index, page] of pages.entries()) {
|
|
13369
13966
|
if (!isPlainObject8(page) || page.owned === true) continue;
|
|
13370
13967
|
if (isPlainObject8(page.header) && page.header.title_from !== void 0 && page.header.title_from !== "rail") {
|
|
13371
|
-
|
|
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)`);
|
|
13372
13969
|
}
|
|
13373
13970
|
if (page.nav !== void 0) {
|
|
13374
13971
|
const source = page.nav?.counts?.source;
|
|
13375
13972
|
if (!isPlainObject8(page.nav) || !isPlainObject8(page.nav.counts) || !named5(source)) {
|
|
13376
|
-
|
|
13973
|
+
refuse2(`/pages/${index}/nav`, `${page.path}'s nav is ${JSON.stringify(page.nav)}; it is {"counts": {"source": "<summary handler>"}}`);
|
|
13377
13974
|
} else if (!Object.prototype.hasOwnProperty.call(handlers, source) && !Object.prototype.hasOwnProperty.call(sources ?? {}, source)) {
|
|
13378
|
-
|
|
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`);
|
|
13379
13976
|
}
|
|
13380
13977
|
}
|
|
13381
13978
|
}
|
|
@@ -13822,7 +14419,7 @@ function queryablePlatformColumns(table) {
|
|
|
13822
14419
|
function platformQueryProblems(name, row, table) {
|
|
13823
14420
|
const problems = [];
|
|
13824
14421
|
const allowed = queryablePlatformColumns(table);
|
|
13825
|
-
const
|
|
14422
|
+
const refuse2 = (key, column) => {
|
|
13826
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" : ""}`;
|
|
13827
14424
|
problems.push({
|
|
13828
14425
|
code: "platform_column_not_queryable",
|
|
@@ -13832,11 +14429,11 @@ function platformQueryProblems(name, row, table) {
|
|
|
13832
14429
|
};
|
|
13833
14430
|
if (named5(row.order_by)) {
|
|
13834
14431
|
const column = row.order_by.replace(/^-+/, "");
|
|
13835
|
-
if (column.startsWith("_") && !allowed.has(column))
|
|
14432
|
+
if (column.startsWith("_") && !allowed.has(column)) refuse2("order_by", column);
|
|
13836
14433
|
}
|
|
13837
14434
|
for (const key of Object.keys(isPlainObject8(row.where) ? row.where : {}).sort()) {
|
|
13838
14435
|
const column = key.split("__")[0];
|
|
13839
|
-
if (column.startsWith("_") && !allowed.has(column))
|
|
14436
|
+
if (column.startsWith("_") && !allowed.has(column)) refuse2("where", column);
|
|
13840
14437
|
}
|
|
13841
14438
|
return problems;
|
|
13842
14439
|
}
|
|
@@ -15310,12 +15907,12 @@ function computedProblems(elements, tree, handlers, model) {
|
|
|
15310
15907
|
const computed = computedIn(row.table);
|
|
15311
15908
|
if (computed.size === 0) continue;
|
|
15312
15909
|
const at = (...keys2) => `/data/app/handlers/${[name, ...keys2].map(escapePointer3).join("/")}`;
|
|
15313
|
-
const
|
|
15910
|
+
const refuse2 = (pointer2, column, how) => push(pointer2, `handler "${name}" ${how} "${column}", which is computed by the platform from its children; nothing writes it`);
|
|
15314
15911
|
if (row.role === "submit" || row.role === "save") {
|
|
15315
15912
|
for (const key of ["fields", "required"]) {
|
|
15316
|
-
for (const hit of (Array.isArray(row[key]) ? row[key] : []).filter((column) => computed.has(column)))
|
|
15913
|
+
for (const hit of (Array.isArray(row[key]) ? row[key] : []).filter((column) => computed.has(column))) refuse2(at(key), hit, key === "fields" ? "takes" : "requires");
|
|
15317
15914
|
}
|
|
15318
|
-
for (const fixed of Object.keys(isPlainObject8(row.fixed) ? row.fixed : {}).filter((column) => computed.has(column)))
|
|
15915
|
+
for (const fixed of Object.keys(isPlainObject8(row.fixed) ? row.fixed : {}).filter((column) => computed.has(column))) refuse2(at("fixed"), fixed, "fixes");
|
|
15319
15916
|
}
|
|
15320
15917
|
}
|
|
15321
15918
|
if (tree.problems.length > 0) return problems;
|
|
@@ -24939,6 +25536,15 @@ function rendererOf3(pages) {
|
|
|
24939
25536
|
}
|
|
24940
25537
|
return {};
|
|
24941
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
|
+
}
|
|
24942
25548
|
function cardSpecOf2(element) {
|
|
24943
25549
|
const card = element?.props?.card;
|
|
24944
25550
|
return element?.type === "widget" && typeof card === "string" && card !== "" ? card : null;
|
|
@@ -25525,7 +26131,9 @@ function dataSection2(app, manifest, spec, elements, describe7, problems) {
|
|
|
25525
26131
|
...application === null ? {} : { app: application },
|
|
25526
26132
|
// Only when there are any: a contract written before sources existed
|
|
25527
26133
|
// reads back byte for byte as it always did.
|
|
25528
|
-
...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) : {}
|
|
25529
26137
|
};
|
|
25530
26138
|
if (describe7) {
|
|
25531
26139
|
const widgets2 = isPlainObject12(describe7.widgets) ? describe7.widgets : {};
|
|
@@ -29055,6 +29663,14 @@ var REFUSALS = /* @__PURE__ */ new Set([
|
|
|
29055
29663
|
"source_compare_field",
|
|
29056
29664
|
// a source's formula, which the platform evaluates (lineage tier 3)
|
|
29057
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",
|
|
29058
29674
|
// dr-components parts (DC-4)
|
|
29059
29675
|
"part_props",
|
|
29060
29676
|
"part_source_shape",
|
|
@@ -29141,6 +29757,14 @@ var KPI_REFUSALS = {
|
|
|
29141
29757
|
var EXPR_REFUSALS = {
|
|
29142
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"
|
|
29143
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
|
+
};
|
|
29144
29768
|
var TRANSFORM_REFUSALS = {
|
|
29145
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")',
|
|
29146
29770
|
transform_missing: "a row or source names a transform not spelled transforms/<name>.py, or that file is not beside contract.json",
|
|
@@ -30249,6 +30873,9 @@ function registerCompileCommand(parent) {
|
|
|
30249
30873
|
...Object.entries(KPI_REFUSALS).map(([code, what]) => ` ${code.padEnd(22)}${what}`),
|
|
30250
30874
|
"A source that states its figure as a formula (expr over from, evaluated by the platform) is refused with:",
|
|
30251
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)",
|
|
30252
30879
|
"",
|
|
30253
30880
|
"A transform (a dashboard dataset series row's transforms/<name>.py, or an app source's) is refused with:",
|
|
30254
30881
|
...Object.entries(TRANSFORM_REFUSALS).map(([code, what]) => ` ${code.padEnd(22)}${what}`)
|
|
@@ -30299,29 +30926,29 @@ var ONTO_LIVE_MARK = ".onto-live";
|
|
|
30299
30926
|
var ONTO_LIVE_OWNED = ["contract.json", "manifest.json", "data_model.json", "handlers", "ui", "transforms", ".appspace.json", ".appspace"];
|
|
30300
30927
|
async function runShipOntoLive(dir, opts) {
|
|
30301
30928
|
const appId = opts.appId;
|
|
30302
|
-
const
|
|
30929
|
+
const refuse2 = (why) => {
|
|
30303
30930
|
process.stderr.write(`ship --onto-live: ${why}
|
|
30304
30931
|
`);
|
|
30305
30932
|
return 1;
|
|
30306
30933
|
};
|
|
30307
|
-
if (appId === void 0 || appId.trim() === "") return
|
|
30308
|
-
if ((opts.draft ?? []).length === 0) return
|
|
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.");
|
|
30309
30936
|
if (opts.skeleton === void 0) {
|
|
30310
|
-
return
|
|
30937
|
+
return refuse2("pass --skeleton <file>: the frame the app is created from if nothing has landed yet.");
|
|
30311
30938
|
}
|
|
30312
30939
|
if ((opts.expect ?? []).length > 0) {
|
|
30313
|
-
return
|
|
30940
|
+
return refuse2("--expect holds the headline, which is the build's to confirm; a landing proves its own cards only.");
|
|
30314
30941
|
}
|
|
30315
30942
|
if (opts.force === true) {
|
|
30316
|
-
return
|
|
30943
|
+
return refuse2(
|
|
30317
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."
|
|
30318
30945
|
);
|
|
30319
30946
|
}
|
|
30320
30947
|
const owned = await claimFolder(dir);
|
|
30321
|
-
if (owned !== null) return
|
|
30948
|
+
if (owned !== null) return refuse2(owned);
|
|
30322
30949
|
await defaultServerFromSession(opts);
|
|
30323
30950
|
if (!hasServer(opts)) {
|
|
30324
|
-
return
|
|
30951
|
+
return refuse2("needs a server: pass -s <server> (or set DR_APP_SPACE_HOST), or log in so a stored session supplies one.");
|
|
30325
30952
|
}
|
|
30326
30953
|
return landWithRetries(async (createGuarded) => {
|
|
30327
30954
|
for (const name of ONTO_LIVE_OWNED) await rm4(join15(dir, name), { recursive: true, force: true });
|
|
@@ -32751,7 +33378,7 @@ async function runUiSmoke(opts) {
|
|
|
32751
33378
|
const { JSDOM, ResourceLoader, VirtualConsole } = await loadJsdom();
|
|
32752
33379
|
let inFlight = 0;
|
|
32753
33380
|
let lastActivity = Date.now();
|
|
32754
|
-
async function record2(req, via,
|
|
33381
|
+
async function record2(req, via, refuse2) {
|
|
32755
33382
|
const call = { method: req.method, url: maskToken(req.url), via };
|
|
32756
33383
|
if (via === "fetch" && req.body) {
|
|
32757
33384
|
try {
|
|
@@ -32763,8 +33390,8 @@ async function runUiSmoke(opts) {
|
|
|
32763
33390
|
const auth = req.headers["authorization"];
|
|
32764
33391
|
if (auth !== void 0) call.authorization = summariseAuthorization(auth);
|
|
32765
33392
|
calls.push(call);
|
|
32766
|
-
if (
|
|
32767
|
-
call.failure =
|
|
33393
|
+
if (refuse2 !== void 0) {
|
|
33394
|
+
call.failure = refuse2;
|
|
32768
33395
|
return void 0;
|
|
32769
33396
|
}
|
|
32770
33397
|
if (!sameOrigin(req.url, target)) {
|