@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.
@@ -1,10 +1,12 @@
1
1
  import { BlobValue } from "../blob.js";
2
2
  import { Schema, SchemaTransformation } from "effect";
3
3
  import { wireStruct } from "../wire.js";
4
+ import { AGGREGATE_FILTER_OPERATORS, ENTITY_AGGREGATE_FILTER_OPERATORS, filterValuesIssue, MAXIMUM_FILTER_VALUES, } from "../aggregate-filters.js";
4
5
  import { isValueMatched, OntologyManifestWire } from "../manifest.js";
5
6
  import { entityQuerySchemas, queryLinks } from "../query.js";
6
7
  import { ValidationIssue as RuntimeValidationIssue } from "../runtime/errors.js";
7
8
  import { Edit, HistoryEntry as RuntimeHistoryEntry } from "../runtime/history.js";
9
+ export { AGGREGATE_FILTER_OPERATORS, AGGREGATE_PERIOD_UNITS, AGGREGATE_PERIODS, ENTITY_AGGREGATE_FILTER_OPERATORS, } from "../aggregate-filters.js";
8
10
  /** `lead` → `Lead`, `pilot_request` → `PilotRequest`: component names in the OpenAPI document. */
9
11
  function pascalCase(name) {
10
12
  return name
@@ -312,54 +314,29 @@ export const AGGREGATE_LIMITS = {
312
314
  measures: 30,
313
315
  dimensions: 20,
314
316
  filters: 50,
315
- values: 100,
317
+ values: MAXIMUM_FILTER_VALUES,
316
318
  orderBy: 50,
317
319
  rows: 1000,
318
320
  };
319
- export const AGGREGATE_FILTER_OPERATORS = [
320
- "equals",
321
- "in",
322
- "isNull",
323
- "isNotNull",
324
- "between",
325
- ];
326
- /** The operators of the per-entity shorthand, which bounds dates with `dateRange` instead. */
327
- export const ENTITY_AGGREGATE_FILTER_OPERATORS = ["equals", "in", "isNull", "isNotNull"];
328
- export const AGGREGATE_GRANULARITIES = ["day", "week", "month"];
321
+ /**
322
+ * The granularities a date or timestamp dimension buckets by; `hour` applies to timestamps only.
323
+ */
324
+ export const AGGREGATE_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"];
325
+ /** The longest `timezone` a request names. */
326
+ export const MAXIMUM_TIMEZONE_LENGTH = 64;
329
327
  const AggregateValue = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]);
330
- /** Which values each operator takes, with SegmentStream's own messages (`planner.ts`). */
331
- function filterValuesIssue(filter) {
332
- const { operator, value, values } = filter;
333
- switch (operator) {
334
- case "equals":
335
- return value === undefined || values !== undefined
336
- ? "Equality filters require one value."
337
- : undefined;
338
- case "in":
339
- return values === undefined ||
340
- values.length === 0 ||
341
- values.length > AGGREGATE_LIMITS.values ||
342
- value !== undefined
343
- ? "Membership filters require between 1 and 100 values."
344
- : undefined;
345
- case "between":
346
- return value !== undefined || values?.length !== 2
347
- ? "between needs two values in order."
348
- : undefined;
349
- default:
350
- return value !== undefined || values !== undefined
351
- ? "Null filters do not accept values."
352
- : undefined;
353
- }
354
- }
355
- function aggregateFilter(operators) {
328
+ /**
329
+ * A filter of either request. Which values each operator takes is checked with SegmentStream's own
330
+ * messages (`filterValuesIssue`); whether they suit the dimension's type is SegmentStream's to check.
331
+ */
332
+ function aggregateFilter(operators, descriptions) {
356
333
  const filter = closedStruct({
357
334
  dimension: Schema.String.annotate({
358
335
  description: "A property, or a path of to-one relationships ending in one.",
359
336
  }),
360
337
  operator: Schema.Literals(operators),
361
- value: Schema.optionalKey(AggregateValue).annotate({ description: "The value of equals." }),
362
- values: Schema.optionalKey(Schema.Array(AggregateValue).check(Schema.isMaxLength(AGGREGATE_LIMITS.values))).annotate({ description: "The values of in, or the two inclusive bounds of between." }),
338
+ value: Schema.optionalKey(AggregateValue).annotate({ description: descriptions.value }),
339
+ values: Schema.optionalKey(Schema.Array(AggregateValue).check(Schema.isMaxLength(AGGREGATE_LIMITS.values))).annotate({ description: descriptions.values }),
363
340
  }, "Unknown key; use dimension, operator, value and values");
364
341
  return filter.check(Schema.makeFilter((candidate) => filterValuesIssue(candidate) ?? true));
365
342
  }
@@ -374,6 +351,11 @@ const aggregateFields = {
374
351
  granularityOf: Schema.optionalKey(Schema.String).annotate({
375
352
  description: "The requested date or timestamp dimension that granularity buckets.",
376
353
  }),
354
+ timezone: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAXIMUM_TIMEZONE_LENGTH))).annotate({
355
+ description: "An IANA timezone, such as Europe/Moscow, or UTC. Buckets of timestamps, date bounds on " +
356
+ "timestamps and inPeriod's today follow it instead of each entity's timezone; measure " +
357
+ "conditions never do.",
358
+ }),
377
359
  orderBy: Schema.optionalKey(Schema.Array(AggregateOrder).check(Schema.isMaxLength(AGGREGATE_LIMITS.orderBy))),
378
360
  limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: AGGREGATE_LIMITS.rows }))).annotate({ description: "At most 1000 rows; 100 by default." }),
379
361
  };
@@ -389,9 +371,15 @@ export const AggregateRequest = closedStruct({
389
371
  description: "<entity>.<property>, or a route from a measure's entity: " +
390
372
  "<entity>.<relationship>[.<relationship>].<property>.",
391
373
  }),
392
- filters: Schema.optionalKey(Schema.Array(aggregateFilter(AGGREGATE_FILTER_OPERATORS)).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
374
+ filters: Schema.optionalKey(Schema.Array(aggregateFilter(AGGREGATE_FILTER_OPERATORS, {
375
+ value: "The value of equals and notEquals; the bound of gt, gte, lt and lte; a non-empty " +
376
+ "string for contains, startsWith and endsWith; the period of inPeriod, such as " +
377
+ "last 7 days.",
378
+ values: "The values of in and notIn, or the two inclusive bounds of between.",
379
+ })).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
393
380
  ...aggregateFields,
394
- }, "Unknown key; use measures, dimensions, filters, granularity, granularityOf, orderBy and limit").annotate({ identifier: "AggregateRequest" });
381
+ }, "Unknown key; use measures, dimensions, filters, granularity, granularityOf, timezone, orderBy " +
382
+ "and limit").annotate({ identifier: "AggregateRequest" });
395
383
  /**
396
384
  * `POST /api/ontology/<entity>/aggregate`: SegmentStream's per-entity request, unchanged. Names are
397
385
  * relative to the entity (`revenue`, `product.brand`), and `dateRange` bounds a partitioned entity.
@@ -409,10 +397,14 @@ export const EntityAggregateRequest = closedStruct({
409
397
  from: Schema.String.annotate({ format: "date" }),
410
398
  to: Schema.String.annotate({ format: "date" }),
411
399
  }, "Unknown key; use from and to")).annotate({ description: "Inclusive days of the entity's partition property." }),
412
- filters: Schema.optionalKey(Schema.Array(aggregateFilter(ENTITY_AGGREGATE_FILTER_OPERATORS)).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
400
+ filters: Schema.optionalKey(Schema.Array(aggregateFilter(ENTITY_AGGREGATE_FILTER_OPERATORS, {
401
+ value: "The value of equals and notEquals; the bound of gt, gte, lt and lte; a non-empty " +
402
+ "string for contains, startsWith and endsWith.",
403
+ values: "The values of in and notIn.",
404
+ })).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
413
405
  ...aggregateFields,
414
406
  }, "Unknown key; use dimensions, measures, dateRange, filters, granularity, granularityOf, " +
415
- "orderBy and limit").annotate({ identifier: "EntityAggregateRequest" });
407
+ "timezone, orderBy and limit").annotate({ identifier: "EntityAggregateRequest" });
416
408
  /** SegmentStream's answer to an aggregate, which the service forwards unchanged. */
417
409
  export const AggregateResult = Schema.Struct({
418
410
  fields: Schema.Array(Schema.Struct({
@@ -429,6 +421,10 @@ export const AggregateResult = Schema.Struct({
429
421
  filters: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
430
422
  description: "The filters the measure applied.",
431
423
  }),
424
+ balanceOver: Schema.optionalKey(Schema.String).annotate({
425
+ description: "The date or timestamp dimension a balance measure was grouped by or filtered to one " +
426
+ "day of.",
427
+ }),
432
428
  })),
433
429
  rows: Schema.Array(Schema.Record(Schema.String, Schema.NullOr(Schema.Union([Schema.String, Schema.Boolean])))),
434
430
  truncated: Schema.Boolean,
@@ -440,6 +436,13 @@ export const AggregateResult = Schema.Struct({
440
436
  description: "For example, a filter that did not apply to some measure's entity.",
441
437
  }),
442
438
  sources: Schema.optionalKey(Schema.Array(Schema.Struct({ entity: Schema.String, asOf: Schema.String }))).annotate({ description: "How fresh the replica of each stored entity read is." }),
439
+ periods: Schema.optionalKey(Schema.Array(Schema.Struct({
440
+ dimension: Schema.String,
441
+ value: Schema.String,
442
+ from: Schema.String.annotate({ format: "date" }),
443
+ to: Schema.String.annotate({ format: "date" }),
444
+ timezone: Schema.String,
445
+ }))).annotate({ description: "The inclusive dates each inPeriod filter used, in request order." }),
443
446
  }),
444
447
  deploymentId: Schema.NullOr(Schema.String),
445
448
  definitionDigest: Schema.String,
@@ -1,4 +1,7 @@
1
- import { isSystemProperty, SNAKE_CASE_NAME } from "./names.js";
1
+ import { dimensionTypeOf, filterTypeIssue, filterValuesIssue, MEASURE_CONDITION_OPERATORS, } from "./aggregate-filters.js";
2
+ import { measuresManifest } from "./manifest.js";
3
+ import { balanceOf, conditionProperty, conditionsOf, MEASURE_AGGREGATES, } from "./measure.js";
4
+ import { humanize, isSystemProperty, SNAKE_CASE_NAME } from "./names.js";
2
5
  const columnName = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
6
  const types = new Set(["text", "integer", "number", "boolean", "date", "timestamp", "select"]);
4
7
  /**
@@ -92,23 +95,135 @@ export function checkWarehouseSource(entities, report) {
92
95
  }
93
96
  }
94
97
  }
95
- /** The aggregates a measure expression may contain. */
96
- const AGGREGATES = new Set(["sum", "countDistinct"]);
98
+ /**
99
+ * The aggregates of one property, which take one argument. `countDistinct` counts what compares
100
+ * exactly; `sum`, `avg`, `min` and `max` take numbers.
101
+ */
102
+ const AGGREGATES = new Set(["sum", "countDistinct", "avg", "min", "max"]);
97
103
  const OPERATORS = new Set([...AGGREGATES, "add", "subtract", "multiply", "divide"]);
104
+ /** The most conditions one aggregate takes. */
105
+ const MAXIMUM_CONDITIONS = 10;
106
+ const CONDITION_OPERATORS = new Set(MEASURE_CONDITION_OPERATORS);
107
+ const CONDITION_PROPERTIES = "Measure conditions name properties of the entity.";
98
108
  /**
99
109
  * The most dimensions and measures the platform accepts in one aggregate model. It also refuses a
100
- * model definition over 60 KiB, which the SDK cannot measure exactly.
110
+ * model definition over {@link MAXIMUM_MODEL_BYTES}.
101
111
  */
102
112
  const MAXIMUM_MODEL_MEMBERS = 100;
103
113
  /** Stored property types that are not dimensions: their values cannot be grouped by. */
104
114
  const UNGROUPABLE_TYPES = new Set(["json", "blob"]);
115
+ /** The most JSON the platform reads as one entity's model: its dimensions and measures. */
116
+ const MAXIMUM_MODEL_BYTES = 60 * 1024;
117
+ /**
118
+ * An entity's model as the platform decodes it (`warehouse-backed-entities.ts`): a warehouse-backed
119
+ * entity's properties as dimensions, or a stored entity's id and groupable properties over its
120
+ * replication view, and its measures as the manifest writes them.
121
+ */
122
+ function modelDefinition(entity) {
123
+ const { definition } = entity.entity;
124
+ const { source } = definition;
125
+ const label = (name, property) => property.label ?? humanize(name);
126
+ const identity = {
127
+ name: entity.name,
128
+ label: definition.label,
129
+ description: definition.description,
130
+ };
131
+ const measures = measuresManifest(entity);
132
+ if (source === undefined) {
133
+ const entityLabel = typeof definition.label === "string" ? definition.label.toLowerCase() : "";
134
+ const id = {
135
+ name: "id",
136
+ column: "id",
137
+ type: "string",
138
+ label: "ID",
139
+ description: `The ${entityLabel}'s id.`,
140
+ scope: [],
141
+ };
142
+ return {
143
+ ...identity,
144
+ source: `ontology/${entity.name}`,
145
+ dimensions: [
146
+ id,
147
+ ...entity.properties.flatMap(({ name, definition: property }) => {
148
+ const type = dimensionTypeOf(property.type);
149
+ return type === undefined
150
+ ? []
151
+ : [
152
+ {
153
+ name,
154
+ column: name,
155
+ type,
156
+ label: label(name, property),
157
+ description: property.description ?? "",
158
+ scope: [],
159
+ },
160
+ ];
161
+ }),
162
+ ],
163
+ measures,
164
+ };
165
+ }
166
+ return {
167
+ ...identity,
168
+ source: source.asset,
169
+ ...(source.currency ? { currencyDimension: source.currency } : {}),
170
+ dimensions: entity.properties.map(({ name, definition: property }) => ({
171
+ name,
172
+ label: label(name, property),
173
+ description: property.description ?? "",
174
+ type: dimensionTypeOf(property.type),
175
+ column: property.column,
176
+ scope: property.scope ?? [],
177
+ })),
178
+ measures,
179
+ };
180
+ }
181
+ function isRecord(value) {
182
+ return typeof value === "object" && value !== null && !Array.isArray(value);
183
+ }
184
+ /** Whether an expression is an aggregate: `count`, or an aggregate of one property. */
185
+ function isAggregate(expression) {
186
+ return MEASURE_AGGREGATES.includes(expression.op);
187
+ }
188
+ /**
189
+ * Why a measure condition is refused, or `undefined`. It names a property of the entity, never a
190
+ * path, with an operator and values a request filter on that property would take, and never a
191
+ * period relative to today.
192
+ */
193
+ function conditionIssue(condition, properties) {
194
+ if (!isRecord(condition)) {
195
+ return CONDITION_PROPERTIES;
196
+ }
197
+ const property = conditionProperty(condition);
198
+ const definition = typeof property === "string" ? properties.get(property) : undefined;
199
+ const type = definition === undefined ? undefined : dimensionTypeOf(definition.type);
200
+ const { operator, value, values } = condition;
201
+ if (type === undefined) {
202
+ return CONDITION_PROPERTIES;
203
+ }
204
+ if (operator === "inPeriod") {
205
+ return "Measure conditions cannot be relative to today.";
206
+ }
207
+ if (typeof operator !== "string" || !CONDITION_OPERATORS.has(operator)) {
208
+ return `Measure conditions use ${MEASURE_CONDITION_OPERATORS.join(", ")}.`;
209
+ }
210
+ // Values that are not a list are as wrong as an empty list, which no operator takes.
211
+ const listed = values === undefined || Array.isArray(values) ? values : [];
212
+ const filter = { operator, value, values: listed };
213
+ return filterValuesIssue(filter) ?? filterTypeIssue(filter, type);
214
+ }
105
215
  /**
106
216
  * The measures of a warehouse-backed or stored entity. A measure's columns name properties of the
107
217
  * entity. `hasCurrency` says whether the entity maps a text `source.currency`, which stored entities
108
218
  * never do, so their measures cannot be currency amounts.
109
219
  */
110
220
  function checkMeasures(input) {
111
- const { entity, measures, properties, hasCurrency, report } = input;
221
+ const { entity, measures, properties, hasCurrency } = input;
222
+ let refusals = 0;
223
+ const report = (code, path, message) => {
224
+ refusals++;
225
+ input.report(code, path, message);
226
+ };
112
227
  // A stored entity's rows carry its system fields, whose names a measure cannot take.
113
228
  const stored = entity.entity.definition.source === undefined;
114
229
  const measureCount = Object.keys(measures).length;
@@ -139,6 +254,35 @@ function checkMeasures(input) {
139
254
  report("invalid-measure", field, "Invalid measure format.");
140
255
  }
141
256
  let remaining = 10_000;
257
+ /** The balance properties of the measure's aggregates and of the measures it references. */
258
+ const balances = new Set();
259
+ /** An aggregate's balance property, which must be a date or timestamp property. */
260
+ const checkBalance = (balance) => {
261
+ const type = typeof balance === "string" ? properties.get(balance)?.type : undefined;
262
+ if (type !== "date" && type !== "timestamp") {
263
+ report("invalid-measure", field, "balanceOver names a date or timestamp property.");
264
+ }
265
+ if (typeof balance === "string") {
266
+ balances.add(balance);
267
+ }
268
+ };
269
+ /** An aggregate's conditions, from its `where` option or the `filtered` expression around it. */
270
+ const checkConditions = (where) => {
271
+ if (!Array.isArray(where) || where.length === 0) {
272
+ report("invalid-measure", field, "Measure conditions take at least one entry.");
273
+ return;
274
+ }
275
+ if (where.length > MAXIMUM_CONDITIONS) {
276
+ report("invalid-measure", field, "Measure conditions take at most 10 entries.");
277
+ return;
278
+ }
279
+ for (const condition of where) {
280
+ const issue = conditionIssue(condition, properties);
281
+ if (issue !== undefined) {
282
+ report("invalid-measure", field, issue);
283
+ }
284
+ }
285
+ };
142
286
  /** `aggregate` is the aggregate the expression is an argument of, if any. */
143
287
  const visit = (expression, references, depth, aggregate) => {
144
288
  if (--remaining < 0 ||
@@ -149,6 +293,24 @@ function checkMeasures(input) {
149
293
  report("invalid-measure", field, "Invalid or overly complex expression.");
150
294
  return false;
151
295
  }
296
+ const conditions = conditionsOf(expression);
297
+ const balance = balanceOf(expression);
298
+ if (conditions !== undefined && expression.op !== "filtered") {
299
+ if (isAggregate(expression)) {
300
+ checkConditions(conditions);
301
+ }
302
+ else {
303
+ report("invalid-measure", field, "Only count, sum, countDistinct, avg, min and max take where.");
304
+ }
305
+ }
306
+ if (balance !== undefined) {
307
+ if (isAggregate(expression)) {
308
+ checkBalance(balance);
309
+ }
310
+ else {
311
+ report("invalid-measure", field, "Only count, sum, countDistinct, avg, min and max take balanceOver.");
312
+ }
313
+ }
152
314
  switch (expression.op) {
153
315
  case "count":
154
316
  if (aggregate !== undefined) {
@@ -171,9 +333,12 @@ function checkMeasures(input) {
171
333
  "property.");
172
334
  }
173
335
  }
174
- else if (aggregate === undefined || !["integer", "number"].includes(type)) {
336
+ else if (aggregate === undefined) {
175
337
  report("invalid-measure", field, "Sums require a numeric property.");
176
338
  }
339
+ else if (!["integer", "number"].includes(type)) {
340
+ report("invalid-measure", field, "avg, min, max and sum require a numeric property.");
341
+ }
177
342
  return false;
178
343
  }
179
344
  case "measure": {
@@ -194,6 +359,22 @@ function checkMeasures(input) {
194
359
  }
195
360
  return visit(target, [...references, expression.name], depth + 1);
196
361
  }
362
+ case "filtered": {
363
+ // The wrapper counts as the aggregate it wraps, which must not declare conditions too.
364
+ const args = Array.isArray(expression.args)
365
+ ? expression.args
366
+ : [];
367
+ const [inner] = args;
368
+ if (args.length !== 1 ||
369
+ !isRecord(inner) ||
370
+ !isAggregate(inner) ||
371
+ conditionsOf(inner) !== undefined) {
372
+ report("invalid-measure", field, "Invalid expression operator or arity.");
373
+ return false;
374
+ }
375
+ checkConditions(expression.where);
376
+ return visit(inner, references, depth + 1, aggregate);
377
+ }
197
378
  default: {
198
379
  const aggregates = AGGREGATES.has(expression.op);
199
380
  if (!OPERATORS.has(expression.op) ||
@@ -221,6 +402,18 @@ function checkMeasures(input) {
221
402
  if (measure.unit === "currency" && !hasCurrency) {
222
403
  report("invalid-measure", field, "Currency measures require source.currency.");
223
404
  }
405
+ if (balances.size > 1) {
406
+ report("invalid-measure", field, "A measure reads balances over two properties.");
407
+ }
408
+ }
409
+ // The platform builds a model of every warehouse-backed entity, and of each stored entity with
410
+ // measures. Measured only once the measures are valid, which writing them requires.
411
+ if (refusals === 0 && (!stored || measureCount > 0)) {
412
+ const bytes = new TextEncoder().encode(JSON.stringify(modelDefinition(entity))).byteLength;
413
+ if (bytes > MAXIMUM_MODEL_BYTES) {
414
+ input.report("invalid-measure", entity.name, `${entity.name}'s model, its dimensions and measures as SegmentStream reads them, takes ` +
415
+ `${(bytes / 1024).toFixed(1)} KiB; the limit is ${MAXIMUM_MODEL_BYTES / 1024} KiB.`);
416
+ }
224
417
  }
225
418
  }
226
419
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",