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