@segmentstream-cloud/ontology 0.12.0 → 0.13.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.
Files changed (50) hide show
  1. package/README.md +46 -16
  2. package/dist/aggregate-routes.d.ts +95 -0
  3. package/dist/aggregate-routes.js +332 -0
  4. package/dist/analyze.js +83 -16
  5. package/dist/entity.d.ts +31 -15
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/manifest.d.ts +57 -23
  9. package/dist/manifest.js +77 -30
  10. package/dist/measure.d.ts +11 -2
  11. package/dist/measure.js +14 -1
  12. package/dist/migrate/plan.d.ts +1 -1
  13. package/dist/migrate/plan.js +38 -13
  14. package/dist/migrate/storage.d.ts +3 -2
  15. package/dist/migrate/storage.js +15 -5
  16. package/dist/names.d.ts +4 -2
  17. package/dist/names.js +4 -1
  18. package/dist/query.d.ts +5 -2
  19. package/dist/query.js +6 -2
  20. package/dist/relationship.d.ts +36 -5
  21. package/dist/relationship.js +5 -3
  22. package/dist/render.d.ts +47 -3
  23. package/dist/render.js +117 -11
  24. package/dist/replication/model.d.ts +1 -1
  25. package/dist/replication/model.js +11 -1
  26. package/dist/service/aggregate-platform.d.ts +60 -0
  27. package/dist/service/aggregate-platform.js +189 -0
  28. package/dist/service/aggregate.d.ts +42 -0
  29. package/dist/service/aggregate.js +145 -0
  30. package/dist/service/api.d.ts +5 -3
  31. package/dist/service/api.js +52 -6
  32. package/dist/service/catalog.d.ts +10 -1
  33. package/dist/service/catalog.js +12 -3
  34. package/dist/service/config.d.ts +13 -1
  35. package/dist/service/config.js +11 -1
  36. package/dist/service/errors.d.ts +32 -0
  37. package/dist/service/errors.js +102 -0
  38. package/dist/service/handlers.d.ts +2 -1
  39. package/dist/service/handlers.js +66 -28
  40. package/dist/service/mcp/server.d.ts +3 -0
  41. package/dist/service/mcp/server.js +60 -8
  42. package/dist/service/mcp/tools.d.ts +13 -2
  43. package/dist/service/mcp/tools.js +165 -6
  44. package/dist/service/plan.js +7 -1
  45. package/dist/service/schemas.d.ts +99 -1
  46. package/dist/service/schemas.js +150 -2
  47. package/dist/service/service.js +8 -2
  48. package/dist/user.d.ts +1 -1
  49. package/dist/warehouse-backed.js +222 -121
  50. package/package.json +1 -1
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 4 manifest and storage. TypeScript helpers and options stay
14
+ REST API, version 6 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.
@@ -32,7 +32,34 @@ SDK 0.12.0 adds warehouse replication enabled by default for all stored business
32
32
  fields and business relationships. Existing history captures immutable snapshots and the
33
33
  service wakes the publisher after commits. Upgrade and redeploy to activate it; workspace
34
34
  configuration can disable replication or exclude individual entities, fields and relationships. See
35
- [setup and recovery](../../docs/ontology-replication.md).
35
+ [setup and recovery](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-replication.md).
36
+
37
+ SDK 0.13.0 writes manifest version 6. Relationships may target warehouse-backed entities, from
38
+ stored or warehouse-backed entities, matched by value through `on`, and `unique` is allowed on
39
+ warehouse-backed properties as an unenforced assertion. Upgrading: 0.12 accepted an explicit `onDelete`, or `optional: true`, on a
40
+ warehouse → stored relationship; 0.13.0 `check` refuses both, so remove them. See
41
+ [ontology aggregates](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-aggregates.md#relationships).
42
+
43
+ SDK 0.13.0 also serves aggregates. Upgrade notes:
44
+
45
+ - `POST /api/ontology/aggregate`, the MCP tool `aggregate` and, for each entity with measures,
46
+ `POST /api/ontology/<entity>/aggregate` answer measures of the model as the caller. The
47
+ service checks `<entity>:read` on every entity a request touches, then forwards it to
48
+ SegmentStream with `SEGMENTSTREAM_APP_CREDENTIAL`. Previews and local development have no
49
+ credential and answer `503 aggregate_unavailable`.
50
+ - `m.countDistinct(property)` counts the distinct values of a text, email, url, select,
51
+ integer, date or timestamp property.
52
+ - Stored entities may declare `measures`, computed over their replicated records; the built-in
53
+ `User` cannot.
54
+ - `aggregate` is reserved for the route and the MCP tool: rename an entity with that name
55
+ before upgrading.
56
+ - Under `signIn: { policy: "open" }`, anyone who signs in holds `member`, so `check` refuses a
57
+ `member` whose wildcard (`*:read`, the default, or `*:*`) would let strangers read
58
+ warehouse-backed entities through aggregates. Grant those to `member` by name
59
+ (`trendtoys_sales:read`), or keep the `invited` policy.
60
+
61
+ See [ontology aggregates](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-aggregates.md)
62
+ and [ontology analytics](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-aggregates-spec.md).
36
63
 
37
64
  ## Define the ontology
38
65
 
@@ -110,8 +137,10 @@ export default defineOntology({
110
137
  - `p.text`, `p.email`, `p.url`, `p.integer`, `p.number`, `p.boolean`, `p.date`,
111
138
  `p.timestamp`, `p.select` and `p.json` declare properties. `managed: true` keeps a
112
139
  property out of the generated `update` action.
113
- - `rel.one` and `rel.many` declare relationships. `User` is the built-in entity for app
114
- users and service principals.
140
+ - `rel.one` and `rel.many` declare relationships. Between stored entities, `rel.one` adds a
141
+ key and `rel.many` a join table. With a warehouse-backed end, `rel.one` declares one `on`
142
+ pair and is matched by value. `User` is the built-in entity for app users and service
143
+ principals.
115
144
  - `role(name, { description, permissions })` declares a role: a named set of permissions.
116
145
  `member` is built in: every active app user holds it, with the permissions of
117
146
  `defineOntology({ member })`.
@@ -132,8 +161,8 @@ A permission is `<entity>:<verb>`. The verbs are `read`, the generated `create`,
132
161
  `delete`, `link` and `unlink`, and the names of authored actions. A role may use `*` for the
133
162
  entity, the verb or both: `lead:*`, `*:read`, `*:*`.
134
163
 
135
- - Reads require `<entity>:read`: lists, queries, records, history, and both entities of a
136
- relationship traversal. `include` and a filter through a relationship require `read` on
164
+ - Reads require `<entity>:read`: lists, queries, records, history, both entities of a
165
+ relationship traversal, and every entity an aggregate touches. `include` and a filter through a relationship require `read` on
137
166
  the related entity. A missing permission answers 403 with `<permission> is required.`
138
167
  - Each action requires one permission. The generated `upsert_by_<property>` requires both
139
168
  `<entity>:create` and `<entity>:update`.
@@ -172,15 +201,16 @@ database pool. `configFromEnv()` reads these variables (`ENVIRONMENT_VARIABLES`,
172
201
  optional `NEON_AUTH_ENVIRONMENT_VARIABLES`). It throws `OntologyServiceConfigError` when
173
202
  one is missing or invalid, and never repeats the database URL.
174
203
 
175
- | Variable | Purpose |
176
- | ------------------------------------- | ------------------------------------------------------------------------- |
177
- | `SEGMENTSTREAM_ONTOLOGY_DATABASE_URL` | Pooled connection string of the `ontology_app` role. |
178
- | `SEGMENTSTREAM_URL` | SegmentStream origin. It issues and verifies app tokens. |
179
- | `SEGMENTSTREAM_APPLICATION_ID` | App tokens must be issued for this application. |
180
- | `SEGMENTSTREAM_WORKSPACE_ID` | The workspace of the app. |
181
- | `SEGMENTSTREAM_ORGANIZATION_ID` | The organization of the workspace. |
182
- | `NEON_AUTH_BASE_URL` | Optional. The workspace's Neon Auth URL; its origin issues users' tokens. |
183
- | `NEON_AUTH_JWKS_URL` | Optional, set with the base URL. The keys that verify those tokens. |
204
+ | Variable | Purpose |
205
+ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
206
+ | `SEGMENTSTREAM_ONTOLOGY_DATABASE_URL` | Pooled connection string of the `ontology_app` role. |
207
+ | `SEGMENTSTREAM_URL` | SegmentStream origin. It issues and verifies app tokens, and receives aggregates over https. |
208
+ | `SEGMENTSTREAM_APPLICATION_ID` | App tokens must be issued for this application. |
209
+ | `SEGMENTSTREAM_WORKSPACE_ID` | The workspace of the app. |
210
+ | `SEGMENTSTREAM_ORGANIZATION_ID` | The organization of the workspace. |
211
+ | `NEON_AUTH_BASE_URL` | Optional. The workspace's Neon Auth URL; its origin issues users' tokens. |
212
+ | `NEON_AUTH_JWKS_URL` | Optional, set with the base URL. The keys that verify those tokens. |
213
+ | `SEGMENTSTREAM_APP_CREDENTIAL` | Optional. The workspace app credential; without it, aggregates answer 503. |
184
214
 
185
215
  The platform sets these variables in the workspace deployment, the Neon Auth pair once the
186
216
  workspace has Neon Auth.
@@ -257,4 +287,4 @@ Apache-2.0
257
287
 
258
288
  ## Entity sources and aggregates
259
289
 
260
- Entities with a warehouse source declare mapped properties and measures without storage tables or record operations. See [ontology aggregates](../../docs/ontology-aggregates.md) for declaration rules, source verification, workspace authorization and the overlap release.
290
+ Entities with a warehouse source declare mapped properties, measures and relationships without storage tables or record operations. Stored entities may declare measures too; they are computed over the entity's replication view, whose columns are the properties' snake_case names. See [ontology aggregates](https://github.com/segmentstream/segmentstream/blob/main/docs/ontology-aggregates.md) for declaration rules, relationship paths, source verification, workspace authorization and the overlap release.
@@ -0,0 +1,95 @@
1
+ import type { OntologyManifest } from "./manifest.js";
2
+ /**
3
+ * The names of a model-level aggregate request, as `/api/ontology/aggregate` and the platform's
4
+ * `POST …/aggregate` receive them: wire snake_case, `entity.member`.
5
+ */
6
+ export interface AggregateNames {
7
+ /** `entity.measure`. */
8
+ readonly measures: readonly string[];
9
+ /** An identity, `entity.property`, or a route, `rootEntity.relationship[.relationship].property`. */
10
+ readonly dimensions?: readonly string[];
11
+ /** The dimension name of every filter, in the same forms as dimensions. */
12
+ readonly filters?: readonly string[];
13
+ /** A requested dimension, by its requested name or its identity. */
14
+ readonly granularityOf?: string;
15
+ }
16
+ /** How one measure entity reads every requested name. Paths are relative to the entity. */
17
+ export interface EntityRoutes {
18
+ readonly entity: string;
19
+ /** The entity's requested measures, without the entity prefix, in request order. */
20
+ readonly measures: readonly string[];
21
+ /** Every requested dimension, in request order: `brand`, `product.brand`, `product.region.name`. */
22
+ readonly dimensions: readonly {
23
+ readonly identity: string;
24
+ readonly path: string;
25
+ }[];
26
+ /** The filters that apply to the entity, by their requested dimension name. */
27
+ readonly filters: readonly {
28
+ readonly name: string;
29
+ readonly path: string;
30
+ }[];
31
+ /** The requested filter names that do not apply to the entity: it cannot reach them. */
32
+ readonly unappliedFilters: readonly string[];
33
+ /** The path of the bucketed dimension, when the request names one. */
34
+ readonly granularityOf?: string;
35
+ }
36
+ export type AggregateRoutes = {
37
+ readonly ok: true;
38
+ /** The measure entities, in the order their first measure was requested. */
39
+ readonly entities: readonly EntityRoutes[];
40
+ /** Every entity the request reads, sorted: each needs `<entity>:read`. */
41
+ readonly touched: readonly string[];
42
+ } | {
43
+ readonly ok: false;
44
+ readonly reason: string;
45
+ };
46
+ /** A route follows at most this many to-one relationships. */
47
+ export declare const MAXIMUM_HOPS = 2;
48
+ /**
49
+ * The JSON Schema pattern of an aggregate name: `entity.member`, or a route of at most
50
+ * {@link MAXIMUM_HOPS} relationships from a measure entity, in snake_case segments.
51
+ */
52
+ export declare const AGGREGATE_NAME_PATTERN = "^[a-z][a-z0-9_]*(?:\\.[a-z][a-z0-9_]*){1,3}$";
53
+ /** To-one relationships followed from an entity, and every entity visited, both ends included. */
54
+ export interface AggregateRoute {
55
+ readonly relationships: readonly string[];
56
+ readonly entities: readonly string[];
57
+ }
58
+ /** The built-in User is never replicated, so no route passes through it or ends at it. */
59
+ export declare function isBuiltInUser(entity: string): boolean;
60
+ export declare function hopLimitMessage(): string;
61
+ export declare function userPathMessage(): string;
62
+ export declare function unknownRelationshipMessage(input: {
63
+ readonly entity: string;
64
+ readonly relationship: string;
65
+ }): string;
66
+ /** A `rel.many`, or an inverse, which is to-many from the side that names it. */
67
+ export declare function toManySegmentMessage(input: {
68
+ readonly entity: string;
69
+ readonly relationship: string;
70
+ }): string;
71
+ export declare function unknownPropertyMessage(input: {
72
+ readonly entity: string;
73
+ readonly property: string;
74
+ }): string;
75
+ /**
76
+ * Every entity one entity reaches, with the shortest routes there, in the order they are first
77
+ * reached. Routes follow one to {@link MAXIMUM_HOPS} declared `rel.one`s, never inverses, never
78
+ * through User, and never visit an entity twice; several routes to one target are listed in
79
+ * declaration order. These are the routes aggregate resolution weighs.
80
+ */
81
+ export declare function shortestRoutesFrom(manifest: OntologyManifest, from: string): ReadonlyMap<string, readonly AggregateRoute[]>;
82
+ /** The shortest routes from one entity to another, under {@link shortestRoutesFrom}'s rules. */
83
+ export declare function shortestRoutes(manifest: OntologyManifest, input: {
84
+ readonly from: string;
85
+ readonly to: string;
86
+ }): readonly AggregateRoute[];
87
+ /**
88
+ * Resolves the names of a model-level aggregate against a manifest (docs/ontology-aggregates-spec.md,
89
+ * "Resolution"). It is the one place names and routes resolve: backend-next plans each measure
90
+ * entity from it, and the ontology service checks `<entity>:read` on every entity it touches.
91
+ *
92
+ * Pure: it reads only the manifest. It does not check partitions, currency, replication or measure
93
+ * operators, nor add scope parents, which the planner adds along the same relative path.
94
+ */
95
+ export declare function resolveAggregateRoutes(manifest: OntologyManifest, names: AggregateNames): AggregateRoutes;
@@ -0,0 +1,332 @@
1
+ import { User } from "./user.js";
2
+ /** A route follows at most this many to-one relationships. */
3
+ export const MAXIMUM_HOPS = 2;
4
+ const SEPARATOR = ".";
5
+ const COUNT_WORDS = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];
6
+ /**
7
+ * The JSON Schema pattern of an aggregate name: `entity.member`, or a route of at most
8
+ * {@link MAXIMUM_HOPS} relationships from a measure entity, in snake_case segments.
9
+ */
10
+ export const AGGREGATE_NAME_PATTERN = `^[a-z][a-z0-9_]*(?:\\.[a-z][a-z0-9_]*){1,${MAXIMUM_HOPS + 1}}$`;
11
+ /** The built-in User is never replicated, so no route passes through it or ends at it. */
12
+ export function isBuiltInUser(entity) {
13
+ return entity === User.name;
14
+ }
15
+ // The refusals that route resolution shares with backend-next's planner, word for word.
16
+ export function hopLimitMessage() {
17
+ return `A path follows at most ${COUNT_WORDS[MAXIMUM_HOPS]} relationships.`;
18
+ }
19
+ export function userPathMessage() {
20
+ return "Paths cannot follow relationships to User.";
21
+ }
22
+ export function unknownRelationshipMessage(input) {
23
+ return `${input.entity} has no relationship ${input.relationship}.`;
24
+ }
25
+ /** A `rel.many`, or an inverse, which is to-many from the side that names it. */
26
+ export function toManySegmentMessage(input) {
27
+ return `${input.relationship} is not a to-one relationship of ${input.entity}.`;
28
+ }
29
+ export function unknownPropertyMessage(input) {
30
+ return `${input.entity} has no property ${input.property}.`;
31
+ }
32
+ function entitiesByName(manifest) {
33
+ return new Map(manifest.entities.map((entity) => [entity.name, entity]));
34
+ }
35
+ /**
36
+ * The relationships a route may follow from an entity: its declared `rel.one`s, never an inverse,
37
+ * to entities of the manifest other than User.
38
+ */
39
+ function edgesOf(entities, name) {
40
+ return (entities.get(name)?.relationships ?? [])
41
+ .filter((relationship) => relationship.cardinality === "one")
42
+ .filter((relationship) => entities.has(relationship.to) && !isBuiltInUser(relationship.to))
43
+ .map((relationship) => ({ relationship: relationship.name, target: relationship.to }));
44
+ }
45
+ function routesFrom(entities, from) {
46
+ const shortest = new Map();
47
+ let frontier = [{ relationships: [], entities: [from] }];
48
+ for (let hops = 1; hops <= MAXIMUM_HOPS; hops++) {
49
+ frontier = frontier.flatMap((route) => edgesOf(entities, route.entities.at(-1))
50
+ .filter((edge) => !route.entities.includes(edge.target))
51
+ .map((edge) => ({
52
+ relationships: [...route.relationships, edge.relationship],
53
+ entities: [...route.entities, edge.target],
54
+ })));
55
+ const reachedSooner = new Set(shortest.keys());
56
+ for (const route of frontier) {
57
+ const target = route.entities.at(-1);
58
+ if (!reachedSooner.has(target)) {
59
+ shortest.set(target, [...(shortest.get(target) ?? []), route]);
60
+ }
61
+ }
62
+ }
63
+ return shortest;
64
+ }
65
+ /**
66
+ * Every entity one entity reaches, with the shortest routes there, in the order they are first
67
+ * reached. Routes follow one to {@link MAXIMUM_HOPS} declared `rel.one`s, never inverses, never
68
+ * through User, and never visit an entity twice; several routes to one target are listed in
69
+ * declaration order. These are the routes aggregate resolution weighs.
70
+ */
71
+ export function shortestRoutesFrom(manifest, from) {
72
+ return routesFrom(entitiesByName(manifest), from);
73
+ }
74
+ /** The shortest routes from one entity to another, under {@link shortestRoutesFrom}'s rules. */
75
+ export function shortestRoutes(manifest, input) {
76
+ return shortestRoutesFrom(manifest, input.from).get(input.to) ?? [];
77
+ }
78
+ /** Ends resolution with the reason the request is refused. It never leaves this module. */
79
+ class Refusal extends Error {
80
+ reason;
81
+ constructor(reason) {
82
+ super(reason);
83
+ this.reason = reason;
84
+ }
85
+ }
86
+ function refuse(reason) {
87
+ throw new Refusal(reason);
88
+ }
89
+ /** The ontology's entities by name, and the shortest routes from each measure entity. */
90
+ class RouteCatalog {
91
+ entities;
92
+ routes = new Map();
93
+ constructor(manifest) {
94
+ this.entities = entitiesByName(manifest);
95
+ }
96
+ entity(name) {
97
+ return this.entities.get(name) ?? refuse(`${name} is not an entity of this ontology.`);
98
+ }
99
+ /** Declared properties, and the `id` of a stored entity, which its replication view carries. */
100
+ hasProperty(entity, property) {
101
+ const stored = entity.source === undefined && !entity.builtIn;
102
+ return (entity.properties.some((candidate) => candidate.name === property) ||
103
+ (stored && property === "id"));
104
+ }
105
+ /** Whether `name` is the inverse of a relationship that another entity declares to this one. */
106
+ hasInverse(entity, name) {
107
+ return [...this.entities.values()].some((owner) => owner.relationships.some((relationship) => relationship.to === entity.name && relationship.inverse === name));
108
+ }
109
+ shortestRoutes(input) {
110
+ const { from, to } = input;
111
+ const reached = this.routes.get(from) ?? routesFrom(this.entities, from);
112
+ this.routes.set(from, reached);
113
+ return reached.get(to) ?? [];
114
+ }
115
+ }
116
+ /** The requested measures, grouped by entity in the order each entity's first measure appears. */
117
+ function measureEntities(input) {
118
+ const { catalog, measures } = input;
119
+ const groups = new Map();
120
+ if (measures.length === 0) {
121
+ refuse("Request at least one measure.");
122
+ }
123
+ for (const [index, name] of measures.entries()) {
124
+ const segments = name.split(SEPARATOR);
125
+ if (measures.indexOf(name) !== index) {
126
+ refuse(`${name} is requested twice.`);
127
+ }
128
+ if (segments.length !== 2 || segments.some((segment) => segment.length === 0)) {
129
+ refuse(`${name} is not a measure name; write <entity>.<measure>.`);
130
+ }
131
+ const [entityName, measure] = segments;
132
+ const entity = catalog.entity(entityName);
133
+ if (!(entity.measures ?? []).some((candidate) => candidate.name === measure)) {
134
+ refuse(`${entityName} has no measure ${measure}.`);
135
+ }
136
+ groups.set(entityName, [...(groups.get(entityName) ?? []), measure]);
137
+ }
138
+ return groups;
139
+ }
140
+ /**
141
+ * Parses an identity, `entity.property`, or a route form, `root.relationship[.relationship].property`,
142
+ * whose root must own a requested measure. A written route follows the same relationships as a
143
+ * resolved one: declared `rel.one`s, never through User, never visiting an entity twice.
144
+ */
145
+ function parseName(input) {
146
+ const { catalog, roots, name } = input;
147
+ const segments = name.split(SEPARATOR);
148
+ if (segments.length < 2 || segments.some((segment) => segment.length === 0)) {
149
+ refuse(`${name} is not a valid name; write <entity>.<property> or a route from a measure entity.`);
150
+ }
151
+ if (segments.length > MAXIMUM_HOPS + 2) {
152
+ refuse(hopLimitMessage());
153
+ }
154
+ const [rootName, ...rest] = segments;
155
+ const relationships = rest.slice(0, -1);
156
+ const property = rest.at(-1);
157
+ let entity = catalog.entity(rootName);
158
+ if (relationships.length > 0 && !roots.has(rootName)) {
159
+ refuse(`${name} is written as a route from ${rootName}, which owns no requested measure.`);
160
+ }
161
+ const entities = [entity.name];
162
+ for (const relationshipName of relationships) {
163
+ const relationship = entity.relationships.find((candidate) => candidate.name === relationshipName);
164
+ const segment = { entity: entity.name, relationship: relationshipName };
165
+ if (relationship === undefined) {
166
+ refuse(catalog.hasInverse(entity, relationshipName)
167
+ ? toManySegmentMessage(segment)
168
+ : unknownRelationshipMessage(segment));
169
+ }
170
+ if (relationship.cardinality !== "one") {
171
+ refuse(toManySegmentMessage(segment));
172
+ }
173
+ if (isBuiltInUser(relationship.to)) {
174
+ refuse(userPathMessage());
175
+ }
176
+ const target = catalog.entity(relationship.to);
177
+ if (entities.includes(target.name)) {
178
+ refuse(`${name} visits ${target.name} twice; a route never revisits an entity.`);
179
+ }
180
+ entities.push(target.name);
181
+ entity = target;
182
+ }
183
+ if (!catalog.hasProperty(entity, property)) {
184
+ refuse(unknownPropertyMessage({ entity: entity.name, property }));
185
+ }
186
+ const identity = `${entity.name}${SEPARATOR}${property}`;
187
+ if (relationships.length === 0) {
188
+ return { name, target: entity.name, property, identity };
189
+ }
190
+ return {
191
+ name,
192
+ target: entity.name,
193
+ property,
194
+ identity,
195
+ written: { root: rootName, route: { relationships, entities } },
196
+ };
197
+ }
198
+ /**
199
+ * The route one measure entity takes to a requested name: the written route when the name is a route
200
+ * form rooted at the entity; no relationship when the name is the entity's own property, which
201
+ * always wins; otherwise the shortest routes.
202
+ */
203
+ function resolveName(input) {
204
+ const { catalog, from, requested } = input;
205
+ if (requested.written?.root === from) {
206
+ return { kind: "resolved", route: requested.written.route };
207
+ }
208
+ if (requested.target === from) {
209
+ return { kind: "resolved", route: { relationships: [], entities: [from] } };
210
+ }
211
+ const routes = catalog.shortestRoutes({ from, to: requested.target });
212
+ if (routes.length === 0) {
213
+ return { kind: "unreachable" };
214
+ }
215
+ if (routes.length > 1) {
216
+ return { kind: "ambiguous", routes };
217
+ }
218
+ return { kind: "resolved", route: routes[0] };
219
+ }
220
+ function ambiguousRoute(input) {
221
+ const { from, requested, routes } = input;
222
+ const count = COUNT_WORDS[routes.length] ?? String(routes.length);
223
+ const listed = routes.map((route) => route.relationships.join(SEPARATOR)).join(", ");
224
+ const placeholder = routes[0].relationships.map(() => "<relationship>").join(SEPARATOR);
225
+ return (`${from} reaches ${requested.target} in ${count} ways (${listed}); ` +
226
+ `write the route as ${from}.${placeholder}.${requested.property}.`);
227
+ }
228
+ /** A list of names. Callers validate requests first; this keeps any other value from throwing. */
229
+ function namesOf(field, value) {
230
+ if (!Array.isArray(value) || value.some((name) => typeof name !== "string")) {
231
+ refuse(`${field} must contain names written as entity.member.`);
232
+ }
233
+ return value;
234
+ }
235
+ function pathOf(route, property) {
236
+ return [...route.relationships, property].join(SEPARATOR);
237
+ }
238
+ /**
239
+ * Resolves the names of a model-level aggregate against a manifest (docs/ontology-aggregates-spec.md,
240
+ * "Resolution"). It is the one place names and routes resolve: backend-next plans each measure
241
+ * entity from it, and the ontology service checks `<entity>:read` on every entity it touches.
242
+ *
243
+ * Pure: it reads only the manifest. It does not check partitions, currency, replication or measure
244
+ * operators, nor add scope parents, which the planner adds along the same relative path.
245
+ */
246
+ export function resolveAggregateRoutes(manifest, names) {
247
+ try {
248
+ return { ok: true, ...resolve(new RouteCatalog(manifest), names) };
249
+ }
250
+ catch (error) {
251
+ if (error instanceof Refusal) {
252
+ return { ok: false, reason: error.reason };
253
+ }
254
+ throw error;
255
+ }
256
+ }
257
+ function resolve(catalog, names) {
258
+ const measureNames = namesOf("measures", names.measures);
259
+ const dimensionNames = namesOf("dimensions", names.dimensions ?? []);
260
+ const filterNames = namesOf("filters", names.filters ?? []);
261
+ const groups = measureEntities({ catalog, measures: measureNames });
262
+ const touched = new Set(groups.keys());
263
+ const dimensions = dimensionNames.map((name) => parseName({ catalog, roots: groups, name }));
264
+ for (const [index, dimension] of dimensions.entries()) {
265
+ if (dimensions.findIndex((other) => other.identity === dimension.identity) !== index) {
266
+ refuse(`${dimension.identity} is requested twice.`);
267
+ }
268
+ }
269
+ const { granularityOf } = names;
270
+ const bucketed = granularityOf === undefined
271
+ ? undefined
272
+ : (dimensions.find((dimension) => dimension.name === granularityOf || dimension.identity === granularityOf) ?? refuse("granularityOf must name a requested dimension."));
273
+ // Several filters may share a dimension; each distinct name resolves once.
274
+ const filters = [...new Set(filterNames)].map((name) => parseName({ catalog, roots: groups, name }));
275
+ // The entity a filter names is touched even where the filter does not apply, and so is every
276
+ // entity of a written route.
277
+ for (const filter of filters) {
278
+ touched.add(filter.target);
279
+ filter.written?.route.entities.forEach((entity) => touched.add(entity));
280
+ }
281
+ const routed = [...groups].map(([entity, measures]) => {
282
+ const firstMeasure = `${entity}${SEPARATOR}${measures[0]}`;
283
+ const dimensionRoutes = dimensions.map((dimension) => {
284
+ const resolution = resolveName({ catalog, from: entity, requested: dimension });
285
+ if (resolution.kind === "unreachable") {
286
+ refuse(`${firstMeasure} cannot reach ${dimension.identity}.`);
287
+ }
288
+ if (resolution.kind === "ambiguous") {
289
+ refuse(ambiguousRoute({ from: entity, requested: dimension, routes: resolution.routes }));
290
+ }
291
+ resolution.route.entities.forEach((visited) => touched.add(visited));
292
+ return {
293
+ identity: dimension.identity,
294
+ path: pathOf(resolution.route, dimension.property),
295
+ };
296
+ });
297
+ return { entity, measures, dimensionRoutes };
298
+ });
299
+ const entities = routed.map(({ entity, measures, dimensionRoutes }) => {
300
+ const applied = [];
301
+ const unappliedFilters = [];
302
+ for (const filter of filters) {
303
+ const resolution = resolveName({ catalog, from: entity, requested: filter });
304
+ if (resolution.kind === "ambiguous") {
305
+ refuse(ambiguousRoute({ from: entity, requested: filter, routes: resolution.routes }));
306
+ }
307
+ if (resolution.kind === "unreachable") {
308
+ unappliedFilters.push(filter.name);
309
+ continue;
310
+ }
311
+ resolution.route.entities.forEach((visited) => touched.add(visited));
312
+ applied.push({ name: filter.name, path: pathOf(resolution.route, filter.property) });
313
+ }
314
+ const bucketedPath = bucketed === undefined
315
+ ? undefined
316
+ : dimensionRoutes.find((route) => route.identity === bucketed.identity)?.path;
317
+ return {
318
+ entity,
319
+ measures,
320
+ dimensions: dimensionRoutes,
321
+ filters: applied,
322
+ unappliedFilters,
323
+ ...(bucketedPath === undefined ? {} : { granularityOf: bucketedPath }),
324
+ };
325
+ });
326
+ for (const filter of filters) {
327
+ if (entities.every((routes) => routes.unappliedFilters.includes(filter.name))) {
328
+ refuse(`No measure can reach ${filter.name}.`);
329
+ }
330
+ }
331
+ return { entities, touched: [...touched].sort() };
332
+ }