@segmentstream-cloud/ontology 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/aggregate-filters.d.ts +48 -0
- package/dist/aggregate-filters.js +252 -0
- package/dist/authoring-records.d.ts +5 -0
- package/dist/authoring-records.js +5 -1
- package/dist/authoring.js +32 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/manifest.d.ts +109 -60
- package/dist/manifest.js +43 -27
- package/dist/measure.d.ts +86 -7
- package/dist/measure.js +92 -15
- package/dist/migrate/model.d.ts +12 -8
- package/dist/render.d.ts +6 -3
- package/dist/render.js +158 -2
- package/dist/service/mcp/server.js +2 -2
- package/dist/service/mcp/tools.js +46 -11
- package/dist/service/schemas.d.ts +40 -22
- package/dist/service/schemas.js +46 -43
- package/dist/warehouse-backed.js +199 -6
- package/package.json +1 -1
package/dist/measure.js
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
|
+
/** The aggregates a measure expression may contain, `count` included. */
|
|
2
|
+
export const MEASURE_AGGREGATES = ["count", "sum", "countDistinct", "avg", "min", "max"];
|
|
1
3
|
function binary(op) {
|
|
2
4
|
return (left, right, options) => ({ op, args: [left, right], ...options });
|
|
3
5
|
}
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
op: "sum",
|
|
6
|
+
/** An aggregate of one property of the entity. */
|
|
7
|
+
function aggregate(op) {
|
|
8
|
+
return (property, options) => ({
|
|
9
|
+
op,
|
|
9
10
|
args: [{ op: "column", name: property }],
|
|
10
11
|
...options,
|
|
11
|
-
})
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Arithmetic combines aggregates; references name another measure of the same entity. Every
|
|
16
|
+
* aggregate takes `where`, conditions on properties of the entity that the rows it reads must meet,
|
|
17
|
+
* and `balanceOver`, the date its rows are snapshots over.
|
|
18
|
+
*/
|
|
19
|
+
export const m = {
|
|
20
|
+
count: (options) => ({ op: "count", ...options }),
|
|
21
|
+
/** The sum of an integer or number property. */
|
|
22
|
+
sum: aggregate("sum"),
|
|
12
23
|
/**
|
|
13
24
|
* The number of distinct non-null values of a text, email, url, select, integer, date or timestamp
|
|
14
25
|
* property. Distinct counts do not add up across groups.
|
|
15
26
|
*/
|
|
16
|
-
countDistinct: (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
countDistinct: aggregate("countDistinct"),
|
|
28
|
+
/** The average of an integer or number property, ignoring nulls. Averages do not add up. */
|
|
29
|
+
avg: aggregate("avg"),
|
|
30
|
+
/** The least value of an integer or number property. Minimums do not add up. */
|
|
31
|
+
min: aggregate("min"),
|
|
32
|
+
/** The greatest value of an integer or number property. Maximums do not add up. */
|
|
33
|
+
max: aggregate("max"),
|
|
21
34
|
ref: (name, options) => ({ op: "measure", name, ...options }),
|
|
22
35
|
literal: (value, options) => ({
|
|
23
36
|
op: "literal",
|
|
@@ -29,12 +42,52 @@ export const m = {
|
|
|
29
42
|
multiply: binary("multiply"),
|
|
30
43
|
divide: binary("divide"),
|
|
31
44
|
};
|
|
45
|
+
/** The conditions an expression declares: an aggregate's `where` option, or a `filtered` node's. */
|
|
46
|
+
export function conditionsOf(value) {
|
|
47
|
+
return "where" in value ? value.where : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** The balance property an expression node declares: an aggregate's `balanceOver` option. */
|
|
50
|
+
export function balanceOf(value) {
|
|
51
|
+
return "balanceOver" in value ? value.balanceOver : undefined;
|
|
52
|
+
}
|
|
32
53
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
54
|
+
* The balance properties a measure's own aggregates declare, top-level or inside arithmetic and
|
|
55
|
+
* `filtered` expressions, without duplicates. References are not followed: the platform inherits a
|
|
56
|
+
* referenced measure's balance itself.
|
|
36
57
|
*/
|
|
37
|
-
export function
|
|
58
|
+
export function balancesOf(value) {
|
|
59
|
+
const balances = new Set();
|
|
60
|
+
const visit = (expression, depth) => {
|
|
61
|
+
const balance = balanceOf(expression);
|
|
62
|
+
if (typeof balance === "string") {
|
|
63
|
+
balances.add(balance);
|
|
64
|
+
}
|
|
65
|
+
if ("args" in expression && Array.isArray(expression.args) && depth < 30) {
|
|
66
|
+
for (const arg of expression.args) {
|
|
67
|
+
visit(arg, depth + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
visit(value, 0);
|
|
72
|
+
return [...balances];
|
|
73
|
+
}
|
|
74
|
+
/** The property a condition names: `property` as authored, `column` in a `filtered` expression. */
|
|
75
|
+
export function conditionProperty(condition) {
|
|
76
|
+
if ("property" in condition) {
|
|
77
|
+
return condition.property;
|
|
78
|
+
}
|
|
79
|
+
return "column" in condition ? condition.column : undefined;
|
|
80
|
+
}
|
|
81
|
+
function columnCondition(condition, columns) {
|
|
82
|
+
const property = "property" in condition ? condition.property : condition.column;
|
|
83
|
+
return {
|
|
84
|
+
column: columns.get(property) ?? property,
|
|
85
|
+
operator: condition.operator,
|
|
86
|
+
...(condition.value === undefined ? {} : { value: condition.value }),
|
|
87
|
+
...(condition.values === undefined ? {} : { values: [...condition.values] }),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function bareExpression(value, columns) {
|
|
38
91
|
switch (value.op) {
|
|
39
92
|
case "count":
|
|
40
93
|
return { op: "count" };
|
|
@@ -44,7 +97,31 @@ export function measureExpression(value, columns) {
|
|
|
44
97
|
return { op: "measure", name: value.name };
|
|
45
98
|
case "literal":
|
|
46
99
|
return { op: "literal", value: value.value };
|
|
100
|
+
case "filtered":
|
|
101
|
+
return {
|
|
102
|
+
op: "filtered",
|
|
103
|
+
args: value.args.map((arg) => measureExpression(arg, columns)),
|
|
104
|
+
where: value.where.map((condition) => columnCondition(condition, columns)),
|
|
105
|
+
};
|
|
47
106
|
default:
|
|
48
107
|
return { op: value.op, args: value.args.map((arg) => measureExpression(arg, columns)) };
|
|
49
108
|
}
|
|
50
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Strip authoring options and resolve logical properties to physical columns for the manifest: a
|
|
112
|
+
* warehouse-backed property's `column`, or a stored property's replication view column, which is its
|
|
113
|
+
* wire name. An aggregate's `where` becomes the `filtered` expression that wraps it, its conditions
|
|
114
|
+
* naming columns too.
|
|
115
|
+
*/
|
|
116
|
+
export function measureExpression(value, columns) {
|
|
117
|
+
const expression = bareExpression(value, columns);
|
|
118
|
+
const where = conditionsOf(value);
|
|
119
|
+
if (where === undefined || value.op === "filtered") {
|
|
120
|
+
return expression;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
op: "filtered",
|
|
124
|
+
args: [expression],
|
|
125
|
+
where: where.map((condition) => columnCondition(condition, columns)),
|
|
126
|
+
};
|
|
127
|
+
}
|
package/dist/migrate/model.d.ts
CHANGED
|
@@ -44,6 +44,17 @@ export declare const MigrationPlan: Schema.Struct<{
|
|
|
44
44
|
readonly currentHash: Schema.optionalKey<Schema.String>;
|
|
45
45
|
readonly target: Schema.Union<readonly [Schema.encodeKeys<Schema.Struct<{
|
|
46
46
|
readonly entities: Schema.$Array<Schema.encodeKeys<Schema.Struct<{
|
|
47
|
+
readonly measures: Schema.optionalKey<Schema.$Array<Schema.encodeKeys<Schema.Struct<{
|
|
48
|
+
readonly name: Schema.String;
|
|
49
|
+
readonly label: Schema.String;
|
|
50
|
+
readonly description: Schema.String;
|
|
51
|
+
readonly unit: Schema.Literals<readonly ["number", "currency", "ratio", "percent"]>;
|
|
52
|
+
readonly format: Schema.Literals<readonly ["decimal", "currency", "percent"]>;
|
|
53
|
+
readonly expression: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
54
|
+
readonly balanceOver: Schema.optionalKey<Schema.String>;
|
|
55
|
+
}>, {
|
|
56
|
+
readonly balanceOver: "balance_over";
|
|
57
|
+
}>>>;
|
|
47
58
|
readonly properties: Schema.$Array<Schema.encodeKeys<Schema.Struct<{
|
|
48
59
|
readonly column: Schema.optionalKey<Schema.String>;
|
|
49
60
|
readonly scope: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
@@ -96,14 +107,6 @@ export declare const MigrationPlan: Schema.Struct<{
|
|
|
96
107
|
readonly key: Schema.$Array<Schema.String>;
|
|
97
108
|
readonly currency: Schema.optionalKey<Schema.String>;
|
|
98
109
|
}>>;
|
|
99
|
-
readonly measures: Schema.optionalKey<Schema.$Array<Schema.Struct<{
|
|
100
|
-
readonly name: Schema.String;
|
|
101
|
-
readonly label: Schema.String;
|
|
102
|
-
readonly description: Schema.String;
|
|
103
|
-
readonly unit: Schema.Literals<readonly ["number", "currency", "ratio", "percent"]>;
|
|
104
|
-
readonly format: Schema.Literals<readonly ["decimal", "currency", "percent"]>;
|
|
105
|
-
readonly expression: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
106
|
-
}>>>;
|
|
107
110
|
readonly id: Schema.String;
|
|
108
111
|
readonly name: Schema.String;
|
|
109
112
|
readonly label: Schema.String;
|
|
@@ -175,6 +178,7 @@ export declare const MigrationPlan: Schema.Struct<{
|
|
|
175
178
|
readonly unit: Schema.Literals<readonly ["number", "currency", "ratio", "percent"]>;
|
|
176
179
|
readonly format: Schema.Literals<readonly ["decimal", "currency", "percent"]>;
|
|
177
180
|
readonly expression: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
181
|
+
readonly balanceOver: Schema.optionalKey<Schema.String>;
|
|
178
182
|
}>>>;
|
|
179
183
|
readonly id: Schema.String;
|
|
180
184
|
readonly name: Schema.String;
|
package/dist/render.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ActionManifest, EntityManifest, OntologyManifest } from "./manifest.js";
|
|
1
|
+
import type { ActionManifest, EntityManifest, MeasureManifest, OntologyManifest } from "./manifest.js";
|
|
2
2
|
export interface RenderOptions {
|
|
3
3
|
/**
|
|
4
4
|
* The permissions of the reader. Only the actions these permissions may run are listed, and only
|
|
@@ -9,6 +9,11 @@ export interface RenderOptions {
|
|
|
9
9
|
}
|
|
10
10
|
/** What an action of the manifest requires; version 1 manifests leave it implicit. */
|
|
11
11
|
export declare function actionPermission(entity: string, action: ActionManifest): string;
|
|
12
|
+
/**
|
|
13
|
+
* A measure's description followed by its conditions and balance note, as `describe`, the MCP
|
|
14
|
+
* measure list and the console show it: `Revenue of won orders. Where status = "won".`
|
|
15
|
+
*/
|
|
16
|
+
export declare function measureDescription(entity: EntityManifest, measure: MeasureManifest): string;
|
|
12
17
|
/**
|
|
13
18
|
* Renders the catalog as prose for agents and people: each entity with its properties, both
|
|
14
19
|
* directions of its relationships and its actions, then the roles with their permissions. Without a
|
|
@@ -22,7 +27,6 @@ export declare function renderCatalog(manifest: OntologyManifest, options?: Rend
|
|
|
22
27
|
* or everything without them.
|
|
23
28
|
*/
|
|
24
29
|
export declare function describeCatalog(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): string;
|
|
25
|
-
type MeasureManifest = NonNullable<EntityManifest["measures"]>[number];
|
|
26
30
|
/** A measure as aggregates name it: `<entity>.<measure>`. */
|
|
27
31
|
export interface NamedMeasure {
|
|
28
32
|
readonly name: string;
|
|
@@ -59,4 +63,3 @@ export declare function reachesOf(manifest: OntologyManifest, from: string, perm
|
|
|
59
63
|
* per measure entity the entities it reaches. Empty when the reader may read no measure.
|
|
60
64
|
*/
|
|
61
65
|
export declare function renderAggregates(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): string;
|
|
62
|
-
export {};
|
package/dist/render.js
CHANGED
|
@@ -154,6 +154,162 @@ function section(title, lines) {
|
|
|
154
154
|
? []
|
|
155
155
|
: [`${INDENT}${title}:`, ...lines.map((line) => `${INDENT}${INDENT}${line}`)];
|
|
156
156
|
}
|
|
157
|
+
/** How a condition's operator reads between its property and its value. */
|
|
158
|
+
const CONDITION_OPERATORS = {
|
|
159
|
+
equals: "=",
|
|
160
|
+
notEquals: "!=",
|
|
161
|
+
gt: ">",
|
|
162
|
+
gte: ">=",
|
|
163
|
+
lt: "<",
|
|
164
|
+
lte: "<=",
|
|
165
|
+
contains: "contains",
|
|
166
|
+
startsWith: "starts with",
|
|
167
|
+
endsWith: "ends with",
|
|
168
|
+
};
|
|
169
|
+
function literal(value) {
|
|
170
|
+
return typeof value === "string" ? JSON.stringify(value) : String(value);
|
|
171
|
+
}
|
|
172
|
+
/** `status = "won"`, `status in ("won", "invoiced")`, `amount >= 100`, `name contains "x"`. */
|
|
173
|
+
function conditionText(condition, property) {
|
|
174
|
+
const { operator, value } = condition;
|
|
175
|
+
const values = Array.isArray(condition.values) ? condition.values : [];
|
|
176
|
+
const list = `(${values.map(literal).join(", ")})`;
|
|
177
|
+
switch (operator) {
|
|
178
|
+
case "in":
|
|
179
|
+
return `${property} in ${list}`;
|
|
180
|
+
case "notIn":
|
|
181
|
+
return `${property} not in ${list}`;
|
|
182
|
+
case "isNull":
|
|
183
|
+
return `${property} is null`;
|
|
184
|
+
case "isNotNull":
|
|
185
|
+
return `${property} is not null`;
|
|
186
|
+
case "between":
|
|
187
|
+
return `${property} between ${literal(values[0])} and ${literal(values[1])}`;
|
|
188
|
+
default: {
|
|
189
|
+
const symbol = CONDITION_OPERATORS[String(operator)] ?? String(operator);
|
|
190
|
+
return value === undefined
|
|
191
|
+
? `${property} ${symbol}`
|
|
192
|
+
: `${property} ${symbol} ${literal(value)}`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** How an aggregate reads in a sentence: `count`, `sum of amount`. */
|
|
197
|
+
const AGGREGATE_NAMES = {
|
|
198
|
+
count: "count",
|
|
199
|
+
sum: "sum",
|
|
200
|
+
countDistinct: "distinct count",
|
|
201
|
+
avg: "average",
|
|
202
|
+
min: "minimum",
|
|
203
|
+
max: "maximum",
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Every aggregate a measure reads: its own, top-level or inside arithmetic, and those of the
|
|
207
|
+
* measures it references, followed once each.
|
|
208
|
+
*/
|
|
209
|
+
function readAggregates(entity, measure) {
|
|
210
|
+
const found = [];
|
|
211
|
+
const visited = new Set();
|
|
212
|
+
const visit = (expression, where, depth) => {
|
|
213
|
+
if (!isRecord(expression) || depth > 30) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const args = Array.isArray(expression.args) ? expression.args : [];
|
|
217
|
+
const name = AGGREGATE_NAMES[String(expression.op)];
|
|
218
|
+
if (expression.op === "filtered" && Array.isArray(expression.where)) {
|
|
219
|
+
const conditions = expression.where.filter(isRecord);
|
|
220
|
+
args.forEach((arg) => visit(arg, conditions, depth + 1));
|
|
221
|
+
}
|
|
222
|
+
else if (expression.op === "measure") {
|
|
223
|
+
const referenced = entity.measures?.find((other) => other.name === expression.name);
|
|
224
|
+
if (referenced !== undefined && !visited.has(referenced.name)) {
|
|
225
|
+
visited.add(referenced.name);
|
|
226
|
+
visit(referenced.expression, undefined, depth + 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (name !== undefined) {
|
|
230
|
+
const [argument] = args;
|
|
231
|
+
const column = isRecord(argument) && argument.op === "column" ? argument.name : undefined;
|
|
232
|
+
found.push({ name, column, where });
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
args.forEach((arg) => visit(arg, where, depth + 1));
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
visited.add(measure.name);
|
|
239
|
+
visit(measure.expression, undefined, 0);
|
|
240
|
+
return found;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The columns a measure is a balance over: its own `balanceOver`, and that of every measure its
|
|
244
|
+
* expression references, since an expression is a balance when any measure it reads is.
|
|
245
|
+
*/
|
|
246
|
+
function balanceColumns(entity, measure) {
|
|
247
|
+
const columns = new Set();
|
|
248
|
+
const visited = new Set();
|
|
249
|
+
const visit = (candidate) => {
|
|
250
|
+
if (visited.has(candidate.name)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
visited.add(candidate.name);
|
|
254
|
+
if (candidate.balanceOver !== undefined) {
|
|
255
|
+
columns.add(candidate.balanceOver);
|
|
256
|
+
}
|
|
257
|
+
for (const name of referencedMeasures(candidate.expression)) {
|
|
258
|
+
const referenced = entity.measures?.find((other) => other.name === name);
|
|
259
|
+
if (referenced !== undefined) {
|
|
260
|
+
visit(referenced);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
visit(measure);
|
|
265
|
+
return [...columns];
|
|
266
|
+
}
|
|
267
|
+
function referencedMeasures(expression) {
|
|
268
|
+
if (!isRecord(expression)) {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
if (expression.op === "measure" && typeof expression.name === "string") {
|
|
272
|
+
return [expression.name];
|
|
273
|
+
}
|
|
274
|
+
return Array.isArray(expression.args) ? expression.args.flatMap(referencedMeasures) : [];
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* What a measure's definition adds to its description: the conditions its aggregates read rows by,
|
|
278
|
+
* and the property a balance must be grouped by or filtered to one day of. `Where …` only when every
|
|
279
|
+
* aggregate it reads, references followed, has the same conditions; otherwise a sentence per
|
|
280
|
+
* restricted aggregate. Columns read as the properties that map them, the names requests use.
|
|
281
|
+
*/
|
|
282
|
+
function measureNotes(entity, measure) {
|
|
283
|
+
const propertyOf = (column) => entity.properties.find((property) => (property.column ?? property.name) === column)?.name ??
|
|
284
|
+
String(column);
|
|
285
|
+
const aggregates = readAggregates(entity, measure).map(({ name, column, where }) => ({
|
|
286
|
+
name: column === undefined ? name : `${name} of ${propertyOf(column)}`,
|
|
287
|
+
conditions: where
|
|
288
|
+
?.map((condition) => conditionText(condition, propertyOf(condition.column)))
|
|
289
|
+
.join(" and "),
|
|
290
|
+
}));
|
|
291
|
+
const conditions = aggregates.map((aggregate) => aggregate.conditions);
|
|
292
|
+
const [first] = conditions;
|
|
293
|
+
const balances = balanceColumns(entity, measure).map((column) => `Balance over ${propertyOf(column)}: group by it or filter it to one day.`);
|
|
294
|
+
// "Where" holds only when every aggregate, references followed, reads by the same condition.
|
|
295
|
+
if (first !== undefined && conditions.every((candidate) => candidate === first)) {
|
|
296
|
+
return [`Where ${first}.`, ...balances];
|
|
297
|
+
}
|
|
298
|
+
const restricted = aggregates.flatMap(({ name, conditions: text }) => text === undefined ? [] : [`Its ${name} reads rows where ${text}.`]);
|
|
299
|
+
return [...new Set(restricted), ...balances];
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* A measure's description followed by its conditions and balance note, as `describe`, the MCP
|
|
303
|
+
* measure list and the console show it: `Revenue of won orders. Where status = "won".`
|
|
304
|
+
*/
|
|
305
|
+
export function measureDescription(entity, measure) {
|
|
306
|
+
const notes = measureNotes(entity, measure);
|
|
307
|
+
if (notes.length === 0) {
|
|
308
|
+
return measure.description;
|
|
309
|
+
}
|
|
310
|
+
const described = measure.description === "" ? [] : [sentence(measure.description)];
|
|
311
|
+
return [...described, ...notes].join(" ");
|
|
312
|
+
}
|
|
157
313
|
function renderEntity(entity, manifest, entities, options) {
|
|
158
314
|
const reader = options.as;
|
|
159
315
|
const actions = entity.actions.filter((action) => reader === undefined ||
|
|
@@ -170,7 +326,7 @@ function renderEntity(entity, manifest, entities, options) {
|
|
|
170
326
|
? [`${INDENT}Source: ${entity.source.asset}; key: ${entity.source.key.join(", ")}.`]
|
|
171
327
|
: []),
|
|
172
328
|
...section("Properties", entity.properties.flatMap((property) => propertyLines({ property, warehouseBacked: entity.source !== undefined }))),
|
|
173
|
-
...section("Measures", (entity.measures ?? []).map((measure) => `${measure.name}: ${measure
|
|
329
|
+
...section("Measures", (entity.measures ?? []).map((measure) => `${measure.name}: ${measureDescription(entity, measure)} (${measure.unit}).`)),
|
|
174
330
|
...section("Relationships", relationshipLines(entity, entities, manifest.entities)),
|
|
175
331
|
...section(reader === undefined ? "Actions" : "Actions you may run", actions.flatMap((action) => actionLines(action, reader === undefined
|
|
176
332
|
? requirement("Requires", actionPermission(entity.name, action), manifest.roles)
|
|
@@ -273,7 +429,7 @@ export function renderAggregates(manifest, permissions) {
|
|
|
273
429
|
const reaches = reachesOf(manifest, owner, permissions);
|
|
274
430
|
return reaches.length === 0 ? [] : [reachLine(owner, reaches)];
|
|
275
431
|
});
|
|
276
|
-
const measureLines = measures.map(({ name, measure }) => `${name} (${measure.unit}): ${sentence(measure
|
|
432
|
+
const measureLines = measures.map(({ name, entity, measure }) => `${name} (${measure.unit}): ${sentence(measureDescription(entity, measure))}`);
|
|
277
433
|
return [
|
|
278
434
|
`Aggregates: ${RECORDS_OR_AGGREGATES} (POST /api/ontology/aggregate, or the aggregate tool).`,
|
|
279
435
|
`${INDENT}Measures, as <entity>.<measure>:`,
|
|
@@ -6,7 +6,7 @@ import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
|
|
|
6
6
|
import { SCOPE_MISMATCH } from "../aggregate-platform.js";
|
|
7
7
|
import { CurrentPrincipal, withPrincipal } from "../authentication.js";
|
|
8
8
|
import { catalogFor } from "../catalog.js";
|
|
9
|
-
import { describeCatalog, measuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
|
|
9
|
+
import { describeCatalog, measureDescription, measuresFor, RECORDS_OR_AGGREGATES, } from "../../render.js";
|
|
10
10
|
import { RequestId, errorBodyForWire, expectedFailure, internalError, schemaIssues, } from "../errors.js";
|
|
11
11
|
import { publicOrigin } from "../oauth/protocol.js";
|
|
12
12
|
import { mcpChallenge, mcpResource } from "../oauth/server.js";
|
|
@@ -287,7 +287,7 @@ function instructions({ built }, permissions) {
|
|
|
287
287
|
return (definition.source === undefined && isAllowed(permissionOf(name, READ_VERB), { permissions }));
|
|
288
288
|
})
|
|
289
289
|
.map(({ name, resolved }) => `- ${name}: ${resolved.entity.definition.description}`);
|
|
290
|
-
const measures = measuresFor(manifest, permissions).map(({ name, measure }) => `- ${name}: ${measure
|
|
290
|
+
const measures = measuresFor(manifest, permissions).map(({ name, entity, measure }) => `- ${name}: ${measureDescription(entity, measure)}`);
|
|
291
291
|
return [
|
|
292
292
|
"This server is the app's ontology: the records the company works with and the actions " +
|
|
293
293
|
"that change them. Every change goes through an action, is checked against your " +
|
|
@@ -5,11 +5,11 @@ import { isValueMatched } from "../../manifest.js";
|
|
|
5
5
|
import { GENERATED_UPSERT_PREFIX, humanize, mcpToolName } from "../../names.js";
|
|
6
6
|
import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
|
|
7
7
|
import { queryJsonSchema } from "../../query.js";
|
|
8
|
-
import { measuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
|
|
8
|
+
import { measureDescription, measuresFor, RECORDS_OR_AGGREGATES, } from "../../render.js";
|
|
9
9
|
import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
|
|
10
10
|
import { uploadFields } from "../../storage/attachments.js";
|
|
11
11
|
import { actionDescription, requires } from "../descriptions.js";
|
|
12
|
-
import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, HistoryPage, Me, } from "../schemas.js";
|
|
12
|
+
import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, AGGREGATE_PERIOD_UNITS, AGGREGATE_PERIODS, HistoryPage, MAXIMUM_TIMEZONE_LENGTH, Me, } from "../schemas.js";
|
|
13
13
|
const DEFS = "#/$defs/";
|
|
14
14
|
/**
|
|
15
15
|
* A JSON Schema whose root is an object schema, as MCP requires of tool inputs and outputs: a root
|
|
@@ -243,11 +243,30 @@ const AGGREGATE_NAME = {
|
|
|
243
243
|
const AGGREGATE_VALUE = {
|
|
244
244
|
anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }],
|
|
245
245
|
};
|
|
246
|
+
/** `a, b or c`. */
|
|
247
|
+
function alternatives(values) {
|
|
248
|
+
return values.length < 2
|
|
249
|
+
? values.join("")
|
|
250
|
+
: `${values.slice(0, -1).join(", ")} or ${values[values.length - 1]}`;
|
|
251
|
+
}
|
|
252
|
+
const PERIODS = `${alternatives(AGGREGATE_PERIODS)}; or last N ${alternatives(AGGREGATE_PERIOD_UNITS)}, N at ` +
|
|
253
|
+
"least 1. Write a period exactly so: lowercase, single spaces, plural units even for 1 (last 1 " +
|
|
254
|
+
"days). last N days ends yesterday; last N weeks, months, quarters or years are whole units " +
|
|
255
|
+
"before the current one. Weeks start on Monday.";
|
|
256
|
+
const FILTER_OPERATOR_DESCRIPTION = "equals, notEquals, gt, gte, lt, lte, contains, startsWith, endsWith and inPeriod take value; " +
|
|
257
|
+
"in, notIn and between take values; isNull and isNotNull take neither. notEquals and notIn also " +
|
|
258
|
+
"match null values and rows a route finds nothing for, so equals and notEquals together cover " +
|
|
259
|
+
"every row.";
|
|
260
|
+
const FILTER_VALUE_DESCRIPTION = "equals, notEquals: the value. gt, gte, lt, lte: the bound, of a date, timestamp or number " +
|
|
261
|
+
"dimension; a date covers whole days on a timestamp. contains, startsWith, endsWith: a " +
|
|
262
|
+
"non-empty string, matched case-sensitively, without wildcards. inPeriod: a date or timestamp " +
|
|
263
|
+
`dimension's period, today being the date in the request's timezone: ${PERIODS}`;
|
|
246
264
|
/** One `anyOf` branch per measure: a JSON Schema `enum` cannot describe its values. */
|
|
247
265
|
function measureChoice({ name, entity, measure }) {
|
|
248
266
|
return {
|
|
249
267
|
const: name,
|
|
250
|
-
description: `${entity.label} · ${measure.label} (${measure.unit}):
|
|
268
|
+
description: `${entity.label} · ${measure.label} (${measure.unit}): ` +
|
|
269
|
+
measureDescription(entity, measure),
|
|
251
270
|
};
|
|
252
271
|
}
|
|
253
272
|
/** The aggregate tool's input for a caller: the request of `POST /api/ontology/aggregate`. */
|
|
@@ -280,15 +299,18 @@ function aggregateInput(measures) {
|
|
|
280
299
|
type: "object",
|
|
281
300
|
properties: {
|
|
282
301
|
dimension: AGGREGATE_NAME,
|
|
283
|
-
operator: {
|
|
284
|
-
|
|
302
|
+
operator: {
|
|
303
|
+
enum: [...AGGREGATE_FILTER_OPERATORS],
|
|
304
|
+
description: FILTER_OPERATOR_DESCRIPTION,
|
|
305
|
+
},
|
|
306
|
+
value: { ...AGGREGATE_VALUE, description: FILTER_VALUE_DESCRIPTION },
|
|
285
307
|
values: {
|
|
286
308
|
type: "array",
|
|
287
309
|
minItems: 1,
|
|
288
310
|
maxItems: AGGREGATE_LIMITS.values,
|
|
289
311
|
items: AGGREGATE_VALUE,
|
|
290
|
-
description: "The values of in, or the two inclusive bounds of between, for example " +
|
|
291
|
-
'["2026-09-01", "2026-09-30"].',
|
|
312
|
+
description: "The values of in and notIn, or the two inclusive bounds of between, for example " +
|
|
313
|
+
'["2026-09-01", "2026-09-30"]; dates cover whole days on a timestamp.',
|
|
292
314
|
},
|
|
293
315
|
},
|
|
294
316
|
required: ["dimension", "operator"],
|
|
@@ -297,9 +319,17 @@ function aggregateInput(measures) {
|
|
|
297
319
|
},
|
|
298
320
|
granularity: {
|
|
299
321
|
enum: [...AGGREGATE_GRANULARITIES],
|
|
300
|
-
description: "Buckets the date or timestamp dimension granularityOf names
|
|
322
|
+
description: "Buckets the date or timestamp dimension granularityOf names; hour applies to " +
|
|
323
|
+
"timestamps only. Weeks start on Monday.",
|
|
301
324
|
},
|
|
302
325
|
granularityOf: { ...AGGREGATE_NAME, description: "A requested date or timestamp dimension." },
|
|
326
|
+
timezone: {
|
|
327
|
+
type: "string",
|
|
328
|
+
maxLength: MAXIMUM_TIMEZONE_LENGTH,
|
|
329
|
+
description: "An IANA timezone, such as Europe/Moscow, or UTC. Buckets of timestamps, date bounds on " +
|
|
330
|
+
"timestamps and inPeriod's today follow it; without it, the timezone of the entity that " +
|
|
331
|
+
"owns the dimension.",
|
|
332
|
+
},
|
|
303
333
|
orderBy: {
|
|
304
334
|
type: "array",
|
|
305
335
|
maxItems: AGGREGATE_LIMITS.orderBy,
|
|
@@ -333,9 +363,14 @@ const AGGREGATE_DESCRIPTION = "Numbers about the model: measures from one or sev
|
|
|
333
363
|
'"values": ["2026-09-01", "2026-09-30"]}]}\n' +
|
|
334
364
|
'- September units sold beside units in stock, by status: {"measures": ["sales.units", ' +
|
|
335
365
|
'"stock.on_hand"], "dimensions": ["product.status"], "filters": [{"dimension": ' +
|
|
336
|
-
'"sales.order_date", "operator": "between", "values": ["2026-09-01", "2026-09-30"]}]}\n
|
|
337
|
-
|
|
338
|
-
"
|
|
366
|
+
'"sales.order_date", "operator": "between", "values": ["2026-09-01", "2026-09-30"]}]}\n' +
|
|
367
|
+
'- Won orders in the last 4 weeks by week: {"measures": ["sales.won_orders"], "dimensions": ' +
|
|
368
|
+
'["sales.order_date"], "granularity": "week", "granularityOf": "sales.order_date", "filters": ' +
|
|
369
|
+
'[{"dimension": "sales.order_date", "operator": "inPeriod", "value": "last 4 weeks"}]}\n\n' +
|
|
370
|
+
"Partitioned warehouse entities require a between or inPeriod filter on their date property; " +
|
|
371
|
+
"the error names it. Currency measures need a single-currency filter, unless the measure is " +
|
|
372
|
+
"pinned to one currency. Use inPeriod for relative dates; the answer's execution.periods says " +
|
|
373
|
+
"which dates were used. " +
|
|
339
374
|
"Answers come from the warehouse, and stored records from its replica: execution.sources says " +
|
|
340
375
|
"how fresh each is, and execution.warnings says when a filter did not apply to a measure. " +
|
|
341
376
|
"Requires reading every entity the request reads, path intermediates included. Call describe " +
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
+
import { AGGREGATE_FILTER_OPERATORS, ENTITY_AGGREGATE_FILTER_OPERATORS, type AggregateValue } from "../aggregate-filters.js";
|
|
2
3
|
import type { ApiPlan } from "./plan.js";
|
|
4
|
+
export { AGGREGATE_FILTER_OPERATORS, AGGREGATE_PERIOD_UNITS, AGGREGATE_PERIODS, ENTITY_AGGREGATE_FILTER_OPERATORS, } from "../aggregate-filters.js";
|
|
3
5
|
/** A record id in a path: a UUID. */
|
|
4
6
|
export declare const IdParam: Schema.String;
|
|
5
7
|
/** The schemas of one entity's records and queries. */
|
|
@@ -149,6 +151,17 @@ export declare const ActionResponse: Schema.Struct<{
|
|
|
149
151
|
}>;
|
|
150
152
|
export declare const Catalog: Schema.decodeTo<Schema.Struct<{
|
|
151
153
|
readonly entities: Schema.$Array<Schema.encodeKeys<Schema.Struct<{
|
|
154
|
+
readonly measures: Schema.optionalKey<Schema.$Array<Schema.encodeKeys<Schema.Struct<{
|
|
155
|
+
readonly name: Schema.String;
|
|
156
|
+
readonly label: Schema.String;
|
|
157
|
+
readonly description: Schema.String;
|
|
158
|
+
readonly unit: Schema.Literals<readonly ["number", "currency", "ratio", "percent"]>;
|
|
159
|
+
readonly format: Schema.Literals<readonly ["decimal", "currency", "percent"]>;
|
|
160
|
+
readonly expression: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
161
|
+
readonly balanceOver: Schema.optionalKey<Schema.String>;
|
|
162
|
+
}>, {
|
|
163
|
+
readonly balanceOver: "balance_over";
|
|
164
|
+
}>>>;
|
|
152
165
|
readonly properties: Schema.$Array<Schema.encodeKeys<Schema.Struct<{
|
|
153
166
|
readonly column: Schema.optionalKey<Schema.String>;
|
|
154
167
|
readonly scope: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
@@ -201,14 +214,6 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
|
|
|
201
214
|
readonly key: Schema.$Array<Schema.String>;
|
|
202
215
|
readonly currency: Schema.optionalKey<Schema.String>;
|
|
203
216
|
}>>;
|
|
204
|
-
readonly measures: Schema.optionalKey<Schema.$Array<Schema.Struct<{
|
|
205
|
-
readonly name: Schema.String;
|
|
206
|
-
readonly label: Schema.String;
|
|
207
|
-
readonly description: Schema.String;
|
|
208
|
-
readonly unit: Schema.Literals<readonly ["number", "currency", "ratio", "percent"]>;
|
|
209
|
-
readonly format: Schema.Literals<readonly ["decimal", "currency", "percent"]>;
|
|
210
|
-
readonly expression: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
211
|
-
}>>>;
|
|
212
217
|
readonly id: Schema.String;
|
|
213
218
|
readonly name: Schema.String;
|
|
214
219
|
readonly label: Schema.String;
|
|
@@ -264,6 +269,17 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
|
|
|
264
269
|
}>>>;
|
|
265
270
|
}>, Schema.Struct<{
|
|
266
271
|
readonly entities: Schema.toEncoded<Schema.$Array<Schema.encodeKeys<Schema.Struct<{
|
|
272
|
+
readonly measures: Schema.optionalKey<Schema.$Array<Schema.encodeKeys<Schema.Struct<{
|
|
273
|
+
readonly name: Schema.String;
|
|
274
|
+
readonly label: Schema.String;
|
|
275
|
+
readonly description: Schema.String;
|
|
276
|
+
readonly unit: Schema.Literals<readonly ["number", "currency", "ratio", "percent"]>;
|
|
277
|
+
readonly format: Schema.Literals<readonly ["decimal", "currency", "percent"]>;
|
|
278
|
+
readonly expression: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
279
|
+
readonly balanceOver: Schema.optionalKey<Schema.String>;
|
|
280
|
+
}>, {
|
|
281
|
+
readonly balanceOver: "balance_over";
|
|
282
|
+
}>>>;
|
|
267
283
|
readonly properties: Schema.$Array<Schema.encodeKeys<Schema.Struct<{
|
|
268
284
|
readonly column: Schema.optionalKey<Schema.String>;
|
|
269
285
|
readonly scope: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
@@ -316,14 +332,6 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
|
|
|
316
332
|
readonly key: Schema.$Array<Schema.String>;
|
|
317
333
|
readonly currency: Schema.optionalKey<Schema.String>;
|
|
318
334
|
}>>;
|
|
319
|
-
readonly measures: Schema.optionalKey<Schema.$Array<Schema.Struct<{
|
|
320
|
-
readonly name: Schema.String;
|
|
321
|
-
readonly label: Schema.String;
|
|
322
|
-
readonly description: Schema.String;
|
|
323
|
-
readonly unit: Schema.Literals<readonly ["number", "currency", "ratio", "percent"]>;
|
|
324
|
-
readonly format: Schema.Literals<readonly ["decimal", "currency", "percent"]>;
|
|
325
|
-
readonly expression: Schema.Codec<Schema.Json, Schema.Json, never, never>;
|
|
326
|
-
}>>>;
|
|
327
335
|
readonly id: Schema.String;
|
|
328
336
|
readonly name: Schema.String;
|
|
329
337
|
readonly label: Schema.String;
|
|
@@ -430,11 +438,12 @@ export declare const AGGREGATE_LIMITS: {
|
|
|
430
438
|
readonly orderBy: 50;
|
|
431
439
|
readonly rows: 1000;
|
|
432
440
|
};
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
export declare const AGGREGATE_GRANULARITIES: readonly ["day", "week", "month"];
|
|
437
|
-
|
|
441
|
+
/**
|
|
442
|
+
* The granularities a date or timestamp dimension buckets by; `hour` applies to timestamps only.
|
|
443
|
+
*/
|
|
444
|
+
export declare const AGGREGATE_GRANULARITIES: readonly ["hour", "day", "week", "month", "quarter", "year"];
|
|
445
|
+
/** The longest `timezone` a request names. */
|
|
446
|
+
export declare const MAXIMUM_TIMEZONE_LENGTH = 64;
|
|
438
447
|
interface AggregateFilter<Operator extends string> {
|
|
439
448
|
readonly dimension: string;
|
|
440
449
|
readonly operator: Operator;
|
|
@@ -458,6 +467,7 @@ export interface AggregateRequest {
|
|
|
458
467
|
readonly filters?: ReadonlyArray<AggregateFilter<(typeof AGGREGATE_FILTER_OPERATORS)[number]>>;
|
|
459
468
|
readonly granularity?: (typeof AGGREGATE_GRANULARITIES)[number];
|
|
460
469
|
readonly granularityOf?: string;
|
|
470
|
+
readonly timezone?: string;
|
|
461
471
|
readonly orderBy?: ReadonlyArray<AggregateOrdering>;
|
|
462
472
|
readonly limit?: number;
|
|
463
473
|
}
|
|
@@ -476,6 +486,7 @@ export interface EntityAggregateRequest {
|
|
|
476
486
|
readonly filters?: ReadonlyArray<AggregateFilter<(typeof ENTITY_AGGREGATE_FILTER_OPERATORS)[number]>>;
|
|
477
487
|
readonly granularity?: (typeof AGGREGATE_GRANULARITIES)[number];
|
|
478
488
|
readonly granularityOf?: string;
|
|
489
|
+
readonly timezone?: string;
|
|
479
490
|
readonly orderBy?: ReadonlyArray<AggregateOrdering>;
|
|
480
491
|
readonly limit?: number;
|
|
481
492
|
}
|
|
@@ -491,6 +502,7 @@ export declare const AggregateResult: Schema.Struct<{
|
|
|
491
502
|
readonly implicit: Schema.optionalKey<Schema.Boolean>;
|
|
492
503
|
readonly additive: Schema.optionalKey<Schema.Boolean>;
|
|
493
504
|
readonly filters: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
505
|
+
readonly balanceOver: Schema.optionalKey<Schema.String>;
|
|
494
506
|
}>>;
|
|
495
507
|
readonly rows: Schema.$Array<Schema.$Record<Schema.String, Schema.NullOr<Schema.Union<readonly [Schema.String, Schema.Boolean]>>>>;
|
|
496
508
|
readonly truncated: Schema.Boolean;
|
|
@@ -503,6 +515,13 @@ export declare const AggregateResult: Schema.Struct<{
|
|
|
503
515
|
readonly entity: Schema.String;
|
|
504
516
|
readonly asOf: Schema.String;
|
|
505
517
|
}>>>;
|
|
518
|
+
readonly periods: Schema.optionalKey<Schema.$Array<Schema.Struct<{
|
|
519
|
+
readonly dimension: Schema.String;
|
|
520
|
+
readonly value: Schema.String;
|
|
521
|
+
readonly from: Schema.String;
|
|
522
|
+
readonly to: Schema.String;
|
|
523
|
+
readonly timezone: Schema.String;
|
|
524
|
+
}>>>;
|
|
506
525
|
}>;
|
|
507
526
|
readonly deploymentId: Schema.NullOr<Schema.String>;
|
|
508
527
|
readonly definitionDigest: Schema.String;
|
|
@@ -549,4 +568,3 @@ export declare const ErrorSchemas: {
|
|
|
549
568
|
connectionResponseInvalid: Schema.Top;
|
|
550
569
|
connectionUnavailable: Schema.Top;
|
|
551
570
|
};
|
|
552
|
-
export {};
|