@segmentstream-cloud/ontology 0.11.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.
- package/README.md +51 -15
- package/dist/aggregate-routes.d.ts +95 -0
- package/dist/aggregate-routes.js +332 -0
- package/dist/analyze.js +83 -16
- package/dist/entity.d.ts +31 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +57 -23
- package/dist/manifest.js +77 -30
- package/dist/measure.d.ts +11 -2
- package/dist/measure.js +14 -1
- package/dist/migrate/apply.d.ts +1 -1
- package/dist/migrate/apply.js +36 -3
- package/dist/migrate/plan.d.ts +1 -1
- package/dist/migrate/plan.js +38 -13
- package/dist/migrate/storage.d.ts +3 -2
- package/dist/migrate/storage.js +15 -5
- package/dist/names.d.ts +4 -2
- package/dist/names.js +4 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.js +6 -2
- package/dist/relationship.d.ts +36 -5
- package/dist/relationship.js +5 -3
- package/dist/render.d.ts +47 -3
- package/dist/render.js +117 -11
- package/dist/replication/baseline.d.ts +19 -0
- package/dist/replication/baseline.js +97 -0
- package/dist/replication/bigquery.d.ts +23 -0
- package/dist/replication/bigquery.js +63 -0
- package/dist/replication/model.d.ts +38 -0
- package/dist/replication/model.js +99 -0
- package/dist/replication/schema.d.ts +7 -0
- package/dist/replication/schema.js +123 -0
- package/dist/replication/store.d.ts +35 -0
- package/dist/replication/store.js +88 -0
- package/dist/replication/transaction.d.ts +4 -0
- package/dist/replication/transaction.js +11 -0
- package/dist/runtime/pipeline.js +2 -1
- package/dist/runtime/principals.js +6 -0
- package/dist/runtime/service-binding.js +8 -0
- package/dist/runtime/sql-runtime.js +2 -1
- package/dist/service/aggregate-platform.d.ts +60 -0
- package/dist/service/aggregate-platform.js +189 -0
- package/dist/service/aggregate.d.ts +42 -0
- package/dist/service/aggregate.js +145 -0
- package/dist/service/api.d.ts +5 -3
- package/dist/service/api.js +52 -6
- package/dist/service/catalog.d.ts +10 -1
- package/dist/service/catalog.js +12 -3
- package/dist/service/config.d.ts +13 -1
- package/dist/service/config.js +11 -1
- package/dist/service/errors.d.ts +32 -0
- package/dist/service/errors.js +102 -0
- package/dist/service/handlers.d.ts +3 -1
- package/dist/service/handlers.js +67 -29
- package/dist/service/mcp/server.d.ts +5 -0
- package/dist/service/mcp/server.js +61 -9
- package/dist/service/mcp/tools.d.ts +13 -2
- package/dist/service/mcp/tools.js +165 -6
- package/dist/service/operations.d.ts +2 -1
- package/dist/service/operations.js +16 -6
- package/dist/service/plan.js +7 -1
- package/dist/service/replication.d.ts +5 -0
- package/dist/service/replication.js +25 -0
- package/dist/service/runtime-contract.d.ts +1 -0
- package/dist/service/schemas.d.ts +99 -1
- package/dist/service/schemas.js +150 -2
- package/dist/service/service.js +10 -2
- package/dist/user.d.ts +1 -1
- package/dist/warehouse-backed.js +222 -121
- 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
|
|
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.
|
|
@@ -28,6 +28,39 @@ 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.12.0 adds warehouse replication enabled by default for all stored business entities,
|
|
32
|
+
fields and business relationships. Existing history captures immutable snapshots and the
|
|
33
|
+
service wakes the publisher after commits. Upgrade and redeploy to activate it; workspace
|
|
34
|
+
configuration can disable replication or exclude individual entities, fields and relationships. See
|
|
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).
|
|
63
|
+
|
|
31
64
|
## Define the ontology
|
|
32
65
|
|
|
33
66
|
```ts
|
|
@@ -104,8 +137,10 @@ export default defineOntology({
|
|
|
104
137
|
- `p.text`, `p.email`, `p.url`, `p.integer`, `p.number`, `p.boolean`, `p.date`,
|
|
105
138
|
`p.timestamp`, `p.select` and `p.json` declare properties. `managed: true` keeps a
|
|
106
139
|
property out of the generated `update` action.
|
|
107
|
-
- `rel.one` and `rel.many` declare relationships.
|
|
108
|
-
|
|
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.
|
|
109
144
|
- `role(name, { description, permissions })` declares a role: a named set of permissions.
|
|
110
145
|
`member` is built in: every active app user holds it, with the permissions of
|
|
111
146
|
`defineOntology({ member })`.
|
|
@@ -126,8 +161,8 @@ A permission is `<entity>:<verb>`. The verbs are `read`, the generated `create`,
|
|
|
126
161
|
`delete`, `link` and `unlink`, and the names of authored actions. A role may use `*` for the
|
|
127
162
|
entity, the verb or both: `lead:*`, `*:read`, `*:*`.
|
|
128
163
|
|
|
129
|
-
- Reads require `<entity>:read`: lists, queries, records, history,
|
|
130
|
-
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
|
|
131
166
|
the related entity. A missing permission answers 403 with `<permission> is required.`
|
|
132
167
|
- Each action requires one permission. The generated `upsert_by_<property>` requires both
|
|
133
168
|
`<entity>:create` and `<entity>:update`.
|
|
@@ -166,15 +201,16 @@ database pool. `configFromEnv()` reads these variables (`ENVIRONMENT_VARIABLES`,
|
|
|
166
201
|
optional `NEON_AUTH_ENVIRONMENT_VARIABLES`). It throws `OntologyServiceConfigError` when
|
|
167
202
|
one is missing or invalid, and never repeats the database URL.
|
|
168
203
|
|
|
169
|
-
| Variable | Purpose
|
|
170
|
-
| ------------------------------------- |
|
|
171
|
-
| `SEGMENTSTREAM_ONTOLOGY_DATABASE_URL` | Pooled connection string of the `ontology_app` role.
|
|
172
|
-
| `SEGMENTSTREAM_URL` | SegmentStream origin. It issues and verifies app tokens.
|
|
173
|
-
| `SEGMENTSTREAM_APPLICATION_ID` | App tokens must be issued for this application.
|
|
174
|
-
| `SEGMENTSTREAM_WORKSPACE_ID` | The workspace of the app.
|
|
175
|
-
| `SEGMENTSTREAM_ORGANIZATION_ID` | The organization of the workspace.
|
|
176
|
-
| `NEON_AUTH_BASE_URL` | Optional. The workspace's Neon Auth URL; its origin issues users' tokens.
|
|
177
|
-
| `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. |
|
|
178
214
|
|
|
179
215
|
The platform sets these variables in the workspace deployment, the Neon Auth pair once the
|
|
180
216
|
workspace has Neon Auth.
|
|
@@ -251,4 +287,4 @@ Apache-2.0
|
|
|
251
287
|
|
|
252
288
|
## Entity sources and aggregates
|
|
253
289
|
|
|
254
|
-
Entities with a warehouse source declare mapped properties and
|
|
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
|
+
}
|