@segmentstream-cloud/ontology 0.22.0 → 0.23.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 CHANGED
@@ -11,7 +11,7 @@ action and is kept in history.
11
11
  TypeScript definitions, handler records and SDK calls use camelCase: `jobTitle`,
12
12
  `assignedOwnerId`, `createdAt`, and `changeStatus`. The SDK converts ontology names,
13
13
  select identifiers and declared action input fields to snake_case for the generated
14
- REST API, version 7 manifest and storage. TypeScript helpers and options stay
14
+ REST API, version 8 manifest and storage. TypeScript helpers and options stay
15
15
  camelCase. JSON property contents, provider payloads, opaque action results and
16
16
  historical change names remain data and are preserved. `formatManifest` serializes
17
17
  the canonical wire contract shared by REST, storage and future transports.
@@ -28,6 +28,31 @@ one Effect version.
28
28
  `ontology/` is a package of its own. The SDK is an ES module, so `ontology/package.json`
29
29
  must set `"type": "module"`.
30
30
 
31
+ SDK 0.23.0 writes manifest version 8. `defineOntology({ measures })` declares measures of the
32
+ model: arithmetic over measures of entities, `m.ref("acmeSales.ordered")`, and over other
33
+ model measures, `m.ref("planAttainment")`. `align` names the properties that stand for one another
34
+ across the entities a measure reads, or `null` for an entity without a counterpart, such as a
35
+ current snapshot without a date:
36
+
37
+ ```ts
38
+ defineOntology({
39
+ entities: [AcmeSales, AcmePlans],
40
+ measures: {
41
+ planAttainment: m.divide(m.ref("acmeSales.ordered"), m.ref("acmePlans.plannedUnits"), {
42
+ align: [{ acmeSales: "orderDate", acmePlans: "month" }],
43
+ unit: "ratio",
44
+ description: "Ordered units over planned units for the same months.",
45
+ }),
46
+ },
47
+ });
48
+ ```
49
+
50
+ Aggregate requests name a measure of the model by its bare name, `plan_attainment`, beside
51
+ `entity.measure`; a filter must reach every entity it reads unless its alignment says the entity has
52
+ no counterpart, and reading it requires reading each of those entities. `check` refuses aggregates
53
+ inside a measure of the model, unknown references, cycles and alignments that do not name every
54
+ entity once with properties of one type. Entity measures take no `align`.
55
+
31
56
  SDK 0.17.0 writes manifest version 7. It adds `m.avg`, `m.min` and `m.max`; a `where` option on
32
57
  every aggregate, so that a measure reads only the rows its conditions accept (`m.sum("amount",
33
58
  { where: [{ property: "status", operator: "equals", value: "won" }], description })`); and
@@ -72,7 +97,7 @@ SDK 0.13.0 also serves aggregates. Upgrade notes:
72
97
  - Under `signIn: { policy: "open" }`, anyone who signs in holds `member`, so `check` refuses a
73
98
  `member` whose wildcard (`*:read`, the default, or `*:*`) would let strangers read
74
99
  warehouse-backed entities through aggregates. Grant those to `member` by name
75
- (`trendtoys_sales:read`), or keep the `invited` policy.
100
+ (`acme_sales:read`), or keep the `invited` policy.
76
101
 
77
102
  See [ontology aggregates](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-aggregates.md)
78
103
  and [ontology analytics](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-aggregates-spec.md).
@@ -1,10 +1,10 @@
1
- import type { OntologyManifest } from "./manifest.js";
1
+ import type { ModelMeasureManifest, OntologyManifest } from "./manifest.js";
2
2
  /**
3
3
  * The names of a model-level aggregate request, as `/api/ontology/aggregate` and the platform's
4
4
  * `POST …/aggregate` receive them: wire snake_case, `entity.member`.
5
5
  */
6
6
  export interface AggregateNames {
7
- /** `entity.measure`. */
7
+ /** `entity.measure`, or the bare name of a measure of the model. */
8
8
  readonly measures: readonly string[];
9
9
  /** An identity, `entity.property`, or a route, `rootEntity.relationship[.relationship].property`. */
10
10
  readonly dimensions?: readonly string[];
@@ -16,29 +16,56 @@ export interface AggregateNames {
16
16
  /** How one measure entity reads every requested name. Paths are relative to the entity. */
17
17
  export interface EntityRoutes {
18
18
  readonly entity: string;
19
- /** The entity's requested measures, without the entity prefix, in request order. */
19
+ /**
20
+ * The measures the entity computes, without the entity prefix, each once: its requested measures
21
+ * in request order, then the hidden inputs of the requested model measures that read it, in their
22
+ * declaration order.
23
+ */
20
24
  readonly measures: readonly string[];
21
- /** Every requested dimension, in request order: `brand`, `product.brand`, `product.region.name`. */
25
+ /**
26
+ * Every requested dimension, in request order: `brand`, `product.brand`, `product.region.name`.
27
+ * A dimension that a requested model measure aligns reads the entity's own member of the group;
28
+ * `null` when the member is `null`, so the entity is broadcast across the dimension's values.
29
+ */
22
30
  readonly dimensions: readonly {
23
31
  readonly identity: string;
24
- readonly path: string;
32
+ readonly path: string | null;
25
33
  }[];
26
34
  /** The filters that apply to the entity, by their requested dimension name. */
27
35
  readonly filters: readonly {
28
36
  readonly name: string;
29
37
  readonly path: string;
30
38
  }[];
31
- /** The requested filter names that do not apply to the entity: it cannot reach them. */
39
+ /**
40
+ * The requested filter names that do not apply to the entity: it cannot reach them, or an
41
+ * alignment says it has no counterpart.
42
+ */
32
43
  readonly unappliedFilters: readonly string[];
33
- /** The path of the bucketed dimension, when the request names one. */
44
+ /** The path of the bucketed dimension, when the request names one and the entity groups by it. */
34
45
  readonly granularityOf?: string;
35
46
  }
47
+ /** A requested measure of the model: the entity measures it reads, and their entities. */
48
+ export interface ModelMeasureRoute {
49
+ readonly name: string;
50
+ /** `entity.measure`, in declaration order. */
51
+ readonly inputs: readonly string[];
52
+ /** The entities of the inputs, each once, in the order of the inputs. */
53
+ readonly entities: readonly string[];
54
+ }
36
55
  export type AggregateRoutes = {
37
56
  readonly ok: true;
38
- /** The measure entities, in the order their first measure was requested. */
57
+ /**
58
+ * The measure entities, in the order their first measure was requested: an entity measure's
59
+ * entity, or the entities of a model measure's inputs.
60
+ */
39
61
  readonly entities: readonly EntityRoutes[];
40
- /** Every entity the request reads, sorted: each needs `<entity>:read`. */
62
+ /**
63
+ * Every entity the request reads, the entities of model measures' inputs included, sorted:
64
+ * each needs `<entity>:read`.
65
+ */
41
66
  readonly touched: readonly string[];
67
+ /** The requested model measures, in request order. */
68
+ readonly model: readonly ModelMeasureRoute[];
42
69
  } | {
43
70
  readonly ok: false;
44
71
  readonly reason: string;
@@ -72,6 +99,14 @@ export declare function unknownPropertyMessage(input: {
72
99
  readonly entity: string;
73
100
  readonly property: string;
74
101
  }): string;
102
+ /** A bare measure name that is not a measure of the model. */
103
+ export declare function unknownModelMeasureMessage(name: string): string;
104
+ /** A filter that applies to some entities a model measure reads and cannot reach another. */
105
+ export declare function partialFilterMessage(input: {
106
+ readonly filter: string;
107
+ readonly entity: string;
108
+ readonly measure: string;
109
+ }): string;
75
110
  /**
76
111
  * Every entity one entity reaches, with the shortest routes there, in the order they are first
77
112
  * reached. Routes follow one to {@link MAXIMUM_HOPS} declared `rel.one`s, never inverses, never
@@ -84,6 +119,8 @@ export declare function shortestRoutes(manifest: OntologyManifest, input: {
84
119
  readonly from: string;
85
120
  readonly to: string;
86
121
  }): readonly AggregateRoute[];
122
+ /** The entities a model measure reads, each once, in the order of its inputs. */
123
+ export declare function inputEntities(measure: Pick<ModelMeasureManifest, "inputs">): readonly string[];
87
124
  /**
88
125
  * Resolves the names of a model-level aggregate against a manifest (docs/ontology-aggregates-spec.md,
89
126
  * "Resolution"). It is the one place names and routes resolve: backend-next plans each measure
@@ -29,6 +29,15 @@ export function toManySegmentMessage(input) {
29
29
  export function unknownPropertyMessage(input) {
30
30
  return `${input.entity} has no property ${input.property}.`;
31
31
  }
32
+ /** A bare measure name that is not a measure of the model. */
33
+ export function unknownModelMeasureMessage(name) {
34
+ return `The ontology has no measure named ${name}.`;
35
+ }
36
+ /** A filter that applies to some entities a model measure reads and cannot reach another. */
37
+ export function partialFilterMessage(input) {
38
+ return (`${input.filter} does not apply to ${input.entity}, which ${input.measure} reads; align the ` +
39
+ "properties in the measure, or request its inputs instead.");
40
+ }
32
41
  function entitiesByName(manifest) {
33
42
  return new Map(manifest.entities.map((entity) => [entity.name, entity]));
34
43
  }
@@ -86,16 +95,25 @@ class Refusal extends Error {
86
95
  function refuse(reason) {
87
96
  throw new Refusal(reason);
88
97
  }
89
- /** The ontology's entities by name, and the shortest routes from each measure entity. */
98
+ /**
99
+ * The ontology's entities and measures of the model by name, and the shortest routes from each
100
+ * measure entity.
101
+ */
90
102
  class RouteCatalog {
91
103
  entities;
104
+ measures;
92
105
  routes = new Map();
93
106
  constructor(manifest) {
94
107
  this.entities = entitiesByName(manifest);
108
+ this.measures = new Map((manifest.measures ?? []).map((measure) => [measure.name, measure]));
95
109
  }
96
110
  entity(name) {
97
111
  return this.entities.get(name) ?? refuse(`${name} is not an entity of this ontology.`);
98
112
  }
113
+ /** A measure of the model; a manifest before version 8 declares none. */
114
+ modelMeasure(name) {
115
+ return this.measures.get(name) ?? refuse(unknownModelMeasureMessage(name));
116
+ }
99
117
  /** Declared properties, and the `id` of a stored entity, which its replication view carries. */
100
118
  hasProperty(entity, property) {
101
119
  const stored = entity.source === undefined && !entity.builtIn;
@@ -113,10 +131,23 @@ class RouteCatalog {
113
131
  return reached.get(to) ?? [];
114
132
  }
115
133
  }
116
- /** The requested measures, grouped by entity in the order each entity's first measure appears. */
117
- function measureEntities(input) {
134
+ /**
135
+ * The requested measures, by entity. A bare name is a measure of the model, which reads the
136
+ * entities of its inputs; `entity.measure` is a measure of the entity.
137
+ */
138
+ function requestedMeasures(input) {
118
139
  const { catalog, measures } = input;
119
- const groups = new Map();
140
+ const entities = new Map();
141
+ const model = [];
142
+ const measureEntity = (name) => {
143
+ const existing = entities.get(name);
144
+ if (existing !== undefined) {
145
+ return existing;
146
+ }
147
+ const created = { entity: catalog.entity(name).name, requested: [], inputs: [], readers: [] };
148
+ entities.set(name, created);
149
+ return created;
150
+ };
120
151
  if (measures.length === 0) {
121
152
  refuse("Request at least one measure.");
122
153
  }
@@ -125,6 +156,18 @@ function measureEntities(input) {
125
156
  if (measures.indexOf(name) !== index) {
126
157
  refuse(`${name} is requested twice.`);
127
158
  }
159
+ if (segments.length === 1 && name.length > 0) {
160
+ const measure = catalog.modelMeasure(name);
161
+ model.push(measure);
162
+ for (const qualified of measure.inputs) {
163
+ const owner = measureEntity(qualified.slice(0, qualified.indexOf(SEPARATOR)));
164
+ owner.inputs.push(qualified.slice(qualified.indexOf(SEPARATOR) + 1));
165
+ if (!owner.readers.includes(measure)) {
166
+ owner.readers.push(measure);
167
+ }
168
+ }
169
+ continue;
170
+ }
128
171
  if (segments.length !== 2 || segments.some((segment) => segment.length === 0)) {
129
172
  refuse(`${name} is not a measure name; write <entity>.<measure>.`);
130
173
  }
@@ -133,9 +176,13 @@ function measureEntities(input) {
133
176
  if (!(entity.measures ?? []).some((candidate) => candidate.name === measure)) {
134
177
  refuse(`${entityName} has no measure ${measure}.`);
135
178
  }
136
- groups.set(entityName, [...(groups.get(entityName) ?? []), measure]);
179
+ measureEntity(entityName).requested.push(measure);
137
180
  }
138
- return groups;
181
+ return { entities, model };
182
+ }
183
+ /** The entities a model measure reads, each once, in the order of its inputs. */
184
+ export function inputEntities(measure) {
185
+ return [...new Set(measure.inputs.map((input) => input.slice(0, input.indexOf(SEPARATOR))))];
139
186
  }
140
187
  /**
141
188
  * Parses an identity, `entity.property`, or a route form, `root.relationship[.relationship].property`,
@@ -217,6 +264,79 @@ function resolveName(input) {
217
264
  }
218
265
  return { kind: "resolved", route: routes[0] };
219
266
  }
267
+ /**
268
+ * The member of an entity in the group of a model measure's alignment that an identity belongs to:
269
+ * a property, `null` when the entity has no counterpart, or `undefined` when no group has the
270
+ * identity.
271
+ */
272
+ function memberOf(input) {
273
+ const { measure, entity, identity } = input;
274
+ const group = measure.align.find((candidate) => Object.entries(candidate).some(([owner, property]) => property !== null && `${owner}${SEPARATOR}${property}` === identity));
275
+ if (group === undefined || !Object.hasOwn(group, entity)) {
276
+ return undefined;
277
+ }
278
+ return group[entity] ?? null;
279
+ }
280
+ /**
281
+ * How the requested model measures that read an entity align a requested name: the entity's own
282
+ * member of the group the name's identity belongs to, `null` when the entity has no counterpart, or
283
+ * `undefined` when none of them aligns it. Two that align it differently are refused: the entity
284
+ * computes its inputs once.
285
+ */
286
+ function alignedMember(input) {
287
+ const { entity, requested } = input;
288
+ let aligned;
289
+ for (const reader of entity.readers) {
290
+ const member = memberOf({
291
+ measure: reader,
292
+ entity: entity.entity,
293
+ identity: requested.identity,
294
+ });
295
+ if (member === undefined) {
296
+ continue;
297
+ }
298
+ if (aligned !== undefined && aligned.member !== member) {
299
+ refuse(`${requested.identity} is aligned differently by ${aligned.by} and ${reader.name}.`);
300
+ }
301
+ aligned ??= { by: reader.name, member };
302
+ }
303
+ return aligned?.member;
304
+ }
305
+ function resolveFor(input) {
306
+ const { catalog, entity, requested } = input;
307
+ const member = alignedMember({ entity, requested });
308
+ if (member !== undefined) {
309
+ return { kind: "aligned", path: member };
310
+ }
311
+ return resolveName({ catalog, from: entity.entity, requested });
312
+ }
313
+ /**
314
+ * Two requested dimensions that are members of one alignment group of a requested model measure
315
+ * would group each of its entities twice by the same property.
316
+ */
317
+ function refuseAlignedTwice(input) {
318
+ const { model, dimensions } = input;
319
+ for (const measure of model) {
320
+ for (const group of measure.align) {
321
+ const members = dimensions.filter(({ identity }) => Object.entries(group).some(([entity, property]) => property !== null && `${entity}${SEPARATOR}${property}` === identity));
322
+ const [first, second] = members;
323
+ if (first !== undefined && second !== undefined) {
324
+ refuse(`${second.identity} is aligned with ${first.identity}; request one of them.`);
325
+ }
326
+ }
327
+ }
328
+ }
329
+ /**
330
+ * A dimension that a measure entity cannot reach, named after its first requested measure, or after
331
+ * the first requested model measure that reads it.
332
+ */
333
+ function unreachableMessage(entity, identity) {
334
+ const [requested] = entity.requested;
335
+ if (requested !== undefined) {
336
+ return `${entity.entity}${SEPARATOR}${requested} cannot reach ${identity}.`;
337
+ }
338
+ return `${entity.readers[0]?.name} reads ${entity.entity}, which cannot reach ${identity}.`;
339
+ }
220
340
  function ambiguousRoute(input) {
221
341
  const { from, requested, routes } = input;
222
342
  const count = COUNT_WORDS[routes.length] ?? String(routes.length);
@@ -258,7 +378,8 @@ function resolve(catalog, names) {
258
378
  const measureNames = namesOf("measures", names.measures);
259
379
  const dimensionNames = namesOf("dimensions", names.dimensions ?? []);
260
380
  const filterNames = namesOf("filters", names.filters ?? []);
261
- const groups = measureEntities({ catalog, measures: measureNames });
381
+ const requested = requestedMeasures({ catalog, measures: measureNames });
382
+ const groups = requested.entities;
262
383
  const touched = new Set(groups.keys());
263
384
  const dimensions = dimensionNames.map((name) => parseName({ catalog, roots: groups, name }));
264
385
  for (const [index, dimension] of dimensions.entries()) {
@@ -266,6 +387,7 @@ function resolve(catalog, names) {
266
387
  refuse(`${dimension.identity} is requested twice.`);
267
388
  }
268
389
  }
390
+ refuseAlignedTwice({ model: requested.model, dimensions });
269
391
  const { granularityOf } = names;
270
392
  const bucketed = granularityOf === undefined
271
393
  ? undefined
@@ -278,15 +400,17 @@ function resolve(catalog, names) {
278
400
  touched.add(filter.target);
279
401
  filter.written?.route.entities.forEach((entity) => touched.add(entity));
280
402
  }
281
- const routed = [...groups].map(([entity, measures]) => {
282
- const firstMeasure = `${entity}${SEPARATOR}${measures[0]}`;
403
+ const routed = [...groups.values()].map((entity) => {
283
404
  const dimensionRoutes = dimensions.map((dimension) => {
284
- const resolution = resolveName({ catalog, from: entity, requested: dimension });
405
+ const resolution = resolveFor({ catalog, entity, requested: dimension });
406
+ if (resolution.kind === "aligned") {
407
+ return { identity: dimension.identity, path: resolution.path };
408
+ }
285
409
  if (resolution.kind === "unreachable") {
286
- refuse(`${firstMeasure} cannot reach ${dimension.identity}.`);
410
+ refuse(unreachableMessage(entity, dimension.identity));
287
411
  }
288
412
  if (resolution.kind === "ambiguous") {
289
- refuse(ambiguousRoute({ from: entity, requested: dimension, routes: resolution.routes }));
413
+ refuse(ambiguousRoute({ from: entity.entity, requested: dimension, routes: resolution.routes }));
290
414
  }
291
415
  resolution.route.entities.forEach((visited) => touched.add(visited));
292
416
  return {
@@ -294,39 +418,81 @@ function resolve(catalog, names) {
294
418
  path: pathOf(resolution.route, dimension.property),
295
419
  };
296
420
  });
297
- return { entity, measures, dimensionRoutes };
421
+ return { entity, dimensionRoutes };
298
422
  });
299
- const entities = routed.map(({ entity, measures, dimensionRoutes }) => {
423
+ const reach = new Map();
424
+ const entities = routed.map(({ entity, dimensionRoutes }) => {
300
425
  const applied = [];
301
426
  const unappliedFilters = [];
427
+ const reached = new Map();
302
428
  for (const filter of filters) {
303
- const resolution = resolveName({ catalog, from: entity, requested: filter });
429
+ const resolution = resolveFor({ catalog, entity, requested: filter });
430
+ if (resolution.kind === "aligned") {
431
+ if (resolution.path === null) {
432
+ unappliedFilters.push(filter.name);
433
+ reached.set(filter.name, "noCounterpart");
434
+ }
435
+ else {
436
+ applied.push({ name: filter.name, path: resolution.path });
437
+ reached.set(filter.name, "applied");
438
+ }
439
+ continue;
440
+ }
304
441
  if (resolution.kind === "ambiguous") {
305
- refuse(ambiguousRoute({ from: entity, requested: filter, routes: resolution.routes }));
442
+ refuse(ambiguousRoute({ from: entity.entity, requested: filter, routes: resolution.routes }));
306
443
  }
307
444
  if (resolution.kind === "unreachable") {
308
445
  unappliedFilters.push(filter.name);
446
+ reached.set(filter.name, "unreachable");
309
447
  continue;
310
448
  }
311
449
  resolution.route.entities.forEach((visited) => touched.add(visited));
312
450
  applied.push({ name: filter.name, path: pathOf(resolution.route, filter.property) });
451
+ reached.set(filter.name, "applied");
313
452
  }
453
+ reach.set(entity.entity, reached);
314
454
  const bucketedPath = bucketed === undefined
315
455
  ? undefined
316
456
  : dimensionRoutes.find((route) => route.identity === bucketed.identity)?.path;
317
457
  return {
318
- entity,
319
- measures,
458
+ entity: entity.entity,
459
+ measures: [...new Set([...entity.requested, ...entity.inputs])],
320
460
  dimensions: dimensionRoutes,
321
461
  filters: applied,
322
462
  unappliedFilters,
323
- ...(bucketedPath === undefined ? {} : { granularityOf: bucketedPath }),
463
+ ...(bucketedPath === undefined || bucketedPath === null
464
+ ? {}
465
+ : { granularityOf: bucketedPath }),
324
466
  };
325
467
  });
468
+ const model = requested.model.map((measure) => ({
469
+ name: measure.name,
470
+ inputs: measure.inputs,
471
+ entities: inputEntities(measure),
472
+ }));
326
473
  for (const filter of filters) {
474
+ // A model measure's inputs are filtered alike, or the filter is refused: a filtered numerator
475
+ // over an unfiltered denominator would answer another question. Only the measure's own
476
+ // alignment may say that an entity it reads has no counterpart.
477
+ for (const measure of requested.model) {
478
+ const states = inputEntities(measure).map((entity) => {
479
+ const state = reach.get(entity)?.get(filter.name);
480
+ const exempt = state === "noCounterpart" &&
481
+ memberOf({ measure, entity, identity: filter.identity }) === null;
482
+ return { entity, state: state === "noCounterpart" && !exempt ? "unreachable" : state };
483
+ });
484
+ const missed = states.find(({ state }) => state === "unreachable");
485
+ if (missed !== undefined && states.some(({ state }) => state === "applied")) {
486
+ refuse(partialFilterMessage({
487
+ filter: filter.name,
488
+ entity: missed.entity,
489
+ measure: measure.name,
490
+ }));
491
+ }
492
+ }
327
493
  if (entities.every((routes) => routes.unappliedFilters.includes(filter.name))) {
328
494
  refuse(`No measure can reach ${filter.name}.`);
329
495
  }
330
496
  }
331
- return { entities, touched: [...touched].sort() };
497
+ return { entities, touched: [...touched].sort(), model };
332
498
  }
package/dist/analyze.d.ts CHANGED
@@ -7,8 +7,10 @@ import { type PropertyDefinition } from "./property.js";
7
7
  import { type AnyRelationship } from "./relationship.js";
8
8
  import { type Role } from "./role.js";
9
9
  import { type SignInOptions } from "./sign-in.js";
10
+ import type { ModelMeasureManifest } from "./manifest.js";
11
+ import type { ModelMeasures } from "./measure.js";
10
12
  /** The input of `defineOntology` and `check`. */
11
- export interface OntologyConfig<Entities extends ReadonlyArray<AnyEntity> = ReadonlyArray<AnyEntity>> {
13
+ export interface OntologyConfig<Entities extends ReadonlyArray<AnyEntity> = ReadonlyArray<AnyEntity>, Measures extends ModelMeasures = ModelMeasures> {
12
14
  /**
13
15
  * The app's entities. The built-in `User` and `AgentChat` are always included; listing them is
14
16
  * optional.
@@ -28,6 +30,13 @@ export interface OntologyConfig<Entities extends ReadonlyArray<AnyEntity> = Read
28
30
  * handle an action lists must be here, and every handle here must be used by an action.
29
31
  */
30
32
  readonly connections?: ReadonlyArray<AnyConnectionHandle>;
33
+ /**
34
+ * Measures of the model, by camelCase name: each combines measures of entities, read with
35
+ * `m.ref("entity.measure")`, or other model measures, read with `m.ref("name")`, through
36
+ * `m.add`, `m.subtract`, `m.multiply`, `m.divide` and `m.literal`. `align` names the properties
37
+ * that stand for one another across the entities it reads.
38
+ */
39
+ readonly measures?: Measures;
31
40
  }
32
41
  /** What every active app user may do, beyond the roles they are given. */
33
42
  export interface MemberOptions {
@@ -115,6 +124,8 @@ export interface Analysis {
115
124
  readonly signIn: SignInOptions;
116
125
  /** The declared connections, in order. */
117
126
  readonly connections: ReadonlyArray<ResolvedConnection>;
127
+ /** The measures of the model that passed `check`, in their manifest form. */
128
+ readonly measures: ReadonlyArray<ModelMeasureManifest>;
118
129
  readonly diagnostics: ReadonlyArray<Diagnostic>;
119
130
  }
120
131
  /**
package/dist/analyze.js CHANGED
@@ -15,6 +15,7 @@ import { AgentChat } from "./agent-chat.js";
15
15
  import { User } from "./user.js";
16
16
  import { authoringToWire } from "./authoring.js";
17
17
  import { nameToWire } from "./authoring-names.js";
18
+ import { resolveModelMeasures } from "./model-measures.js";
18
19
  const GENERATED_UPSERT = new RegExp(`^${GENERATED_UPSERT_PREFIX}`);
19
20
  function errorMessage(error) {
20
21
  return error instanceof Error ? error.message : String(error);
@@ -60,12 +61,13 @@ export function analyze(config, options = {}) {
60
61
  checkMemberNames(resolved, report);
61
62
  checkToolNames(resolved, report);
62
63
  checkWarehouseSource(resolved, report);
64
+ const measures = resolveModelMeasures({ measures: config.measures, entities: resolved, report });
63
65
  checkConnectionsUsed(resolved, connections, config.connections ?? [], report);
64
66
  if (options.checkPermissions !== false) {
65
67
  checkPermissions(resolved, roles, report);
66
68
  checkOpenWarehouseReads({ entities: resolved, roles, signIn, report });
67
69
  }
68
- return { entities: resolved, roles, signIn, connections, diagnostics };
70
+ return { entities: resolved, roles, signIn, connections, measures, diagnostics };
69
71
  }
70
72
  /**
71
73
  * The operations of a stored entity that become MCP tools, each with the path a problem is reported
@@ -1,10 +1,10 @@
1
1
  import { Schema } from "effect";
2
- export declare const DIAGNOSTIC_CODES: readonly ["invalid-source", "missing-column", "invalid-column", "invalid-scope", "source-with-actions", "source-with-index", "invalid-measure", "unknown-measure", "circular-measure", "invalid-join", "missing-join", "invalid-entity", "invalid-property", "invalid-relationship", "invalid-actions", "invalid-action", "invalid-action-input", "invalid-role", "invalid-name", "reserved-name", "duplicate-name", "invalid-entity-id", "duplicate-entity-id", "missing-label", "missing-description", "invalid-title", "invalid-options", "invalid-default", "invalid-constraint", "unknown-relationship-target", "inverse-collision", "invalid-on-delete", "invalid-phrase", "invalid-index", "unknown-reference", "invalid-renamed-from", "invalid-guide", "managed-without-effect", "missing-permissions", "invalid-permission", "unknown-permission", "removed-allow", "unreachable-action", "wildcard-permission", "invalid-sign-in", "invalid-connection", "unknown-connection", "unused-connection", "invalid-prepare", "invalid-tool-name"];
2
+ export declare const DIAGNOSTIC_CODES: readonly ["invalid-source", "missing-column", "invalid-column", "invalid-scope", "source-with-actions", "source-with-index", "invalid-measure", "unknown-measure", "circular-measure", "invalid-join", "missing-join", "invalid-entity", "invalid-property", "invalid-relationship", "invalid-actions", "invalid-action", "invalid-action-input", "invalid-role", "invalid-name", "reserved-name", "duplicate-name", "invalid-entity-id", "duplicate-entity-id", "missing-label", "missing-description", "invalid-title", "invalid-options", "invalid-default", "invalid-constraint", "unknown-relationship-target", "inverse-collision", "invalid-on-delete", "invalid-phrase", "invalid-index", "unknown-reference", "invalid-renamed-from", "invalid-guide", "managed-without-effect", "missing-permissions", "invalid-permission", "unknown-permission", "removed-allow", "unreachable-action", "wildcard-permission", "invalid-sign-in", "invalid-connection", "unknown-connection", "unused-connection", "invalid-prepare", "invalid-tool-name", "invalid-alignment", "single-entity-model-measure"];
3
3
  export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[number];
4
4
  /** A problem `check` found in an ontology definition. */
5
5
  export declare const Diagnostic: Schema.Struct<{
6
6
  readonly severity: Schema.Literals<readonly ["error", "warning"]>;
7
- readonly code: Schema.Literals<readonly ["invalid-source", "missing-column", "invalid-column", "invalid-scope", "source-with-actions", "source-with-index", "invalid-measure", "unknown-measure", "circular-measure", "invalid-join", "missing-join", "invalid-entity", "invalid-property", "invalid-relationship", "invalid-actions", "invalid-action", "invalid-action-input", "invalid-role", "invalid-name", "reserved-name", "duplicate-name", "invalid-entity-id", "duplicate-entity-id", "missing-label", "missing-description", "invalid-title", "invalid-options", "invalid-default", "invalid-constraint", "unknown-relationship-target", "inverse-collision", "invalid-on-delete", "invalid-phrase", "invalid-index", "unknown-reference", "invalid-renamed-from", "invalid-guide", "managed-without-effect", "missing-permissions", "invalid-permission", "unknown-permission", "removed-allow", "unreachable-action", "wildcard-permission", "invalid-sign-in", "invalid-connection", "unknown-connection", "unused-connection", "invalid-prepare", "invalid-tool-name"]>;
7
+ readonly code: Schema.Literals<readonly ["invalid-source", "missing-column", "invalid-column", "invalid-scope", "source-with-actions", "source-with-index", "invalid-measure", "unknown-measure", "circular-measure", "invalid-join", "missing-join", "invalid-entity", "invalid-property", "invalid-relationship", "invalid-actions", "invalid-action", "invalid-action-input", "invalid-role", "invalid-name", "reserved-name", "duplicate-name", "invalid-entity-id", "duplicate-entity-id", "missing-label", "missing-description", "invalid-title", "invalid-options", "invalid-default", "invalid-constraint", "unknown-relationship-target", "inverse-collision", "invalid-on-delete", "invalid-phrase", "invalid-index", "unknown-reference", "invalid-renamed-from", "invalid-guide", "managed-without-effect", "missing-permissions", "invalid-permission", "unknown-permission", "removed-allow", "unreachable-action", "wildcard-permission", "invalid-sign-in", "invalid-connection", "unknown-connection", "unused-connection", "invalid-prepare", "invalid-tool-name", "invalid-alignment", "single-entity-model-measure"]>;
8
8
  /** Where the problem is, for example `lead.properties.status.options[1]`. */
9
9
  readonly path: Schema.String;
10
10
  readonly message: Schema.String;
@@ -14,7 +14,7 @@ declare const InvalidOntology_base: Schema.Class<InvalidOntology, Schema.TaggedS
14
14
  readonly message: Schema.String;
15
15
  readonly diagnostics: Schema.$Array<Schema.Struct<{
16
16
  readonly severity: Schema.Literals<readonly ["error", "warning"]>;
17
- readonly code: Schema.Literals<readonly ["invalid-source", "missing-column", "invalid-column", "invalid-scope", "source-with-actions", "source-with-index", "invalid-measure", "unknown-measure", "circular-measure", "invalid-join", "missing-join", "invalid-entity", "invalid-property", "invalid-relationship", "invalid-actions", "invalid-action", "invalid-action-input", "invalid-role", "invalid-name", "reserved-name", "duplicate-name", "invalid-entity-id", "duplicate-entity-id", "missing-label", "missing-description", "invalid-title", "invalid-options", "invalid-default", "invalid-constraint", "unknown-relationship-target", "inverse-collision", "invalid-on-delete", "invalid-phrase", "invalid-index", "unknown-reference", "invalid-renamed-from", "invalid-guide", "managed-without-effect", "missing-permissions", "invalid-permission", "unknown-permission", "removed-allow", "unreachable-action", "wildcard-permission", "invalid-sign-in", "invalid-connection", "unknown-connection", "unused-connection", "invalid-prepare", "invalid-tool-name"]>;
17
+ readonly code: Schema.Literals<readonly ["invalid-source", "missing-column", "invalid-column", "invalid-scope", "source-with-actions", "source-with-index", "invalid-measure", "unknown-measure", "circular-measure", "invalid-join", "missing-join", "invalid-entity", "invalid-property", "invalid-relationship", "invalid-actions", "invalid-action", "invalid-action-input", "invalid-role", "invalid-name", "reserved-name", "duplicate-name", "invalid-entity-id", "duplicate-entity-id", "missing-label", "missing-description", "invalid-title", "invalid-options", "invalid-default", "invalid-constraint", "unknown-relationship-target", "inverse-collision", "invalid-on-delete", "invalid-phrase", "invalid-index", "unknown-reference", "invalid-renamed-from", "invalid-guide", "managed-without-effect", "missing-permissions", "invalid-permission", "unknown-permission", "removed-allow", "unreachable-action", "wildcard-permission", "invalid-sign-in", "invalid-connection", "unknown-connection", "unused-connection", "invalid-prepare", "invalid-tool-name", "invalid-alignment", "single-entity-model-measure"]>;
18
18
  /** Where the problem is, for example `lead.properties.status.options[1]`. */
19
19
  readonly path: Schema.String;
20
20
  readonly message: Schema.String;
@@ -50,6 +50,8 @@ export const DIAGNOSTIC_CODES = [
50
50
  "unused-connection",
51
51
  "invalid-prepare",
52
52
  "invalid-tool-name",
53
+ "invalid-alignment",
54
+ "single-entity-model-measure",
53
55
  ];
54
56
  /** A problem `check` found in an ontology definition. */
55
57
  export const Diagnostic = Schema.Struct({
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { BlobValue, BlobUpload, uploads, BLOB_CONTENT_TYPES, MAX_BLOB_BYTES } fr
2
2
  export { warehouse, compatibleWarehouseColumn, warehouseColumnType, validateWarehouseSource, } from "./source.js";
3
3
  export type { WarehouseSource } from "./source.js";
4
4
  export { m } from "./measure.js";
5
- export type { AggregateDeclarations, AggregateOptions, Measure, MeasureColumnCondition, MeasureCondition, Measures, MeasureExpression, MeasureOptions, } from "./measure.js";
5
+ export type { AggregateDeclarations, AggregateOptions, AlignmentGroup, Measure, MeasureColumnCondition, MeasureCondition, Measures, MeasureExpression, MeasureOptions, ModelMeasure, ModelMeasureOptions, ModelMeasures, } from "./measure.js";
6
6
  export { MEASURE_CONDITION_OPERATORS } from "./aggregate-filters.js";
7
7
  export type { AggregateValue, MeasureConditionOperator } from "./aggregate-filters.js";
8
8
  export { action, isAction } from "./action.js";
@@ -16,7 +16,7 @@ export { entity, isEntity } from "./entity.js";
16
16
  export type { ActionsBuilder, AnyEntity, AnyStoredEntity, CreateValues, DraftEntity, Entity, EntityDefinition, EntityRecord, IndexDefinition, IndexField, NoActions, Properties, RecordRef, Relationships, SystemFields, UpdateValues, } from "./entity.js";
17
17
  export { ActionRejected, Conflict, NotFound, PermissionDenied } from "./errors.js";
18
18
  export type { ActionError } from "./errors.js";
19
- export { ActionManifest, actionConnectionKeys, canonicalJson, ConnectionManifest, EntityManifest, EntityPermissionsManifest, decodeManifestDocument, encodeManifest, formatManifest, IndexManifest, isValueMatched, MANIFEST_VERSION, manifestHash, OntologyManifest, OntologyManifestWire, PropertyManifest, RelationshipManifest, RoleManifest, SelectOptionManifest, SignInManifest, signInPolicyOf, } from "./manifest.js";
19
+ export { ActionManifest, actionConnectionKeys, canonicalJson, ConnectionManifest, EntityManifest, EntityPermissionsManifest, decodeManifestDocument, encodeManifest, formatManifest, IndexManifest, isValueMatched, MANIFEST_VERSION, manifestHash, ModelMeasureManifest, OntologyManifest, OntologyManifestWire, PropertyManifest, RelationshipManifest, RoleManifest, SelectOptionManifest, SignInManifest, signInPolicyOf, } from "./manifest.js";
20
20
  export { SYSTEM_PROPERTIES } from "./names.js";
21
21
  export type { SystemProperty } from "./names.js";
22
22
  export { check, defineOntology } from "./ontology.js";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { CONNECTION_ERROR_REASONS, CONNECTION_KEY, CONNECTION_METHODS, connectio
7
7
  export { Diagnostic, DIAGNOSTIC_CODES, formatDiagnostics, InvalidOntology } from "./diagnostics.js";
8
8
  export { entity, isEntity } from "./entity.js";
9
9
  export { ActionRejected, Conflict, NotFound, PermissionDenied } from "./errors.js";
10
- export { ActionManifest, actionConnectionKeys, canonicalJson, ConnectionManifest, EntityManifest, EntityPermissionsManifest, decodeManifestDocument, encodeManifest, formatManifest, IndexManifest, isValueMatched, MANIFEST_VERSION, manifestHash, OntologyManifest, OntologyManifestWire, PropertyManifest, RelationshipManifest, RoleManifest, SelectOptionManifest, SignInManifest, signInPolicyOf, } from "./manifest.js";
10
+ export { ActionManifest, actionConnectionKeys, canonicalJson, ConnectionManifest, EntityManifest, EntityPermissionsManifest, decodeManifestDocument, encodeManifest, formatManifest, IndexManifest, isValueMatched, MANIFEST_VERSION, manifestHash, ModelMeasureManifest, OntologyManifest, OntologyManifestWire, PropertyManifest, RelationshipManifest, RoleManifest, SelectOptionManifest, SignInManifest, signInPolicyOf, } from "./manifest.js";
11
11
  export { SYSTEM_PROPERTIES } from "./names.js";
12
12
  export { check, defineOntology } from "./ontology.js";
13
13
  export { DEFAULT_MEMBER_PERMISSIONS, generatedActionPermissions, grants, isAllowed, parsePermission, permissionsOf, READ_VERB, rolesGranting, WILDCARD, } from "./permissions.js";