@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.
Files changed (71) hide show
  1. package/README.md +51 -15
  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/apply.d.ts +1 -1
  13. package/dist/migrate/apply.js +36 -3
  14. package/dist/migrate/plan.d.ts +1 -1
  15. package/dist/migrate/plan.js +38 -13
  16. package/dist/migrate/storage.d.ts +3 -2
  17. package/dist/migrate/storage.js +15 -5
  18. package/dist/names.d.ts +4 -2
  19. package/dist/names.js +4 -1
  20. package/dist/query.d.ts +5 -2
  21. package/dist/query.js +6 -2
  22. package/dist/relationship.d.ts +36 -5
  23. package/dist/relationship.js +5 -3
  24. package/dist/render.d.ts +47 -3
  25. package/dist/render.js +117 -11
  26. package/dist/replication/baseline.d.ts +19 -0
  27. package/dist/replication/baseline.js +97 -0
  28. package/dist/replication/bigquery.d.ts +23 -0
  29. package/dist/replication/bigquery.js +63 -0
  30. package/dist/replication/model.d.ts +38 -0
  31. package/dist/replication/model.js +99 -0
  32. package/dist/replication/schema.d.ts +7 -0
  33. package/dist/replication/schema.js +123 -0
  34. package/dist/replication/store.d.ts +35 -0
  35. package/dist/replication/store.js +88 -0
  36. package/dist/replication/transaction.d.ts +4 -0
  37. package/dist/replication/transaction.js +11 -0
  38. package/dist/runtime/pipeline.js +2 -1
  39. package/dist/runtime/principals.js +6 -0
  40. package/dist/runtime/service-binding.js +8 -0
  41. package/dist/runtime/sql-runtime.js +2 -1
  42. package/dist/service/aggregate-platform.d.ts +60 -0
  43. package/dist/service/aggregate-platform.js +189 -0
  44. package/dist/service/aggregate.d.ts +42 -0
  45. package/dist/service/aggregate.js +145 -0
  46. package/dist/service/api.d.ts +5 -3
  47. package/dist/service/api.js +52 -6
  48. package/dist/service/catalog.d.ts +10 -1
  49. package/dist/service/catalog.js +12 -3
  50. package/dist/service/config.d.ts +13 -1
  51. package/dist/service/config.js +11 -1
  52. package/dist/service/errors.d.ts +32 -0
  53. package/dist/service/errors.js +102 -0
  54. package/dist/service/handlers.d.ts +3 -1
  55. package/dist/service/handlers.js +67 -29
  56. package/dist/service/mcp/server.d.ts +5 -0
  57. package/dist/service/mcp/server.js +61 -9
  58. package/dist/service/mcp/tools.d.ts +13 -2
  59. package/dist/service/mcp/tools.js +165 -6
  60. package/dist/service/operations.d.ts +2 -1
  61. package/dist/service/operations.js +16 -6
  62. package/dist/service/plan.js +7 -1
  63. package/dist/service/replication.d.ts +5 -0
  64. package/dist/service/replication.js +25 -0
  65. package/dist/service/runtime-contract.d.ts +1 -0
  66. package/dist/service/schemas.d.ts +99 -1
  67. package/dist/service/schemas.js +150 -2
  68. package/dist/service/service.js +10 -2
  69. package/dist/user.d.ts +1 -1
  70. package/dist/warehouse-backed.js +222 -121
  71. package/package.json +1 -1
@@ -30,6 +30,38 @@ declare const SignInCheckLimited_base: Schema.Class<SignInCheckLimited, Schema.T
30
30
  /** A client asked `POST /api/ontology/sign-in/check` too often; it may ask again after this. */
31
31
  export declare class SignInCheckLimited extends SignInCheckLimited_base {
32
32
  }
33
+ declare const InvalidAggregateQuery_base: Schema.Class<InvalidAggregateQuery, Schema.TaggedStruct<"InvalidAggregateQuery", {
34
+ readonly reason: Schema.String;
35
+ }>, Cause.YieldableError>;
36
+ /** An aggregate request the ontology cannot answer; the reason is written for the caller. */
37
+ export declare class InvalidAggregateQuery extends InvalidAggregateQuery_base {
38
+ }
39
+ declare const ReadDenied_base: Schema.Class<ReadDenied, Schema.TaggedStruct<"ReadDenied", {
40
+ readonly permissions: Schema.$Array<Schema.String>;
41
+ }>, Cause.YieldableError>;
42
+ /** The caller lacks `<entity>:read` on entities an aggregate reads: every such permission. */
43
+ export declare class ReadDenied extends ReadDenied_base {
44
+ }
45
+ declare const AggregateUnavailable_base: Schema.Class<AggregateUnavailable, Schema.TaggedStruct<"AggregateUnavailable", {}>, Cause.YieldableError>;
46
+ /** The deployment has no workspace app credential: previews and local development. */
47
+ export declare class AggregateUnavailable extends AggregateUnavailable_base {
48
+ }
49
+ declare const AggregateLimited_base: Schema.Class<AggregateLimited, Schema.TaggedStruct<"AggregateLimited", {
50
+ readonly retryAfterSeconds: Schema.Number;
51
+ }>, Cause.YieldableError>;
52
+ /** A principal ran more aggregates than its window allows; it may run more after this. */
53
+ export declare class AggregateLimited extends AggregateLimited_base {
54
+ }
55
+ declare const AggregateUpstreamUnavailable_base: Schema.Class<AggregateUpstreamUnavailable, Schema.TaggedStruct<"AggregateUpstreamUnavailable", {
56
+ readonly reason: Schema.Literals<readonly ["unreachable", "timeout", "invalid_response", "credential_rejected", "upstream_misconfigured"]>;
57
+ readonly status: Schema.optionalKey<Schema.Number>;
58
+ }>, Cause.YieldableError>;
59
+ /**
60
+ * SegmentStream did not answer an aggregate usably, or its answer faults the deployment rather than
61
+ * the caller. The operator log keeps the details; the caller learns only the reason.
62
+ */
63
+ export declare class AggregateUpstreamUnavailable extends AggregateUpstreamUnavailable_base {
64
+ }
33
65
  export interface ErrorBody {
34
66
  readonly error: string;
35
67
  readonly detail: string;
@@ -20,6 +20,50 @@ export class InvalidRequestBody extends Schema.TaggedError()("InvalidRequestBody
20
20
  /** A client asked `POST /api/ontology/sign-in/check` too often; it may ask again after this. */
21
21
  export class SignInCheckLimited extends Schema.TaggedError()("SignInCheckLimited", { retryAfterSeconds: Schema.Number }) {
22
22
  }
23
+ /** An aggregate request the ontology cannot answer; the reason is written for the caller. */
24
+ export class InvalidAggregateQuery extends Schema.TaggedError()("InvalidAggregateQuery", { reason: Schema.String }) {
25
+ }
26
+ /** The caller lacks `<entity>:read` on entities an aggregate reads: every such permission. */
27
+ export class ReadDenied extends Schema.TaggedError()("ReadDenied", {
28
+ permissions: Schema.Array(Schema.String),
29
+ }) {
30
+ }
31
+ /** The deployment has no workspace app credential: previews and local development. */
32
+ export class AggregateUnavailable extends Schema.TaggedError()("AggregateUnavailable", {}) {
33
+ }
34
+ /** A principal ran more aggregates than its window allows; it may run more after this. */
35
+ export class AggregateLimited extends Schema.TaggedError()("AggregateLimited", {
36
+ retryAfterSeconds: Schema.Number,
37
+ }) {
38
+ }
39
+ /**
40
+ * SegmentStream did not answer an aggregate usably, or its answer faults the deployment rather than
41
+ * the caller. The operator log keeps the details; the caller learns only the reason.
42
+ */
43
+ export class AggregateUpstreamUnavailable extends Schema.TaggedError()("AggregateUpstreamUnavailable", {
44
+ reason: Schema.Literals([
45
+ "unreachable",
46
+ "timeout",
47
+ "invalid_response",
48
+ "credential_rejected",
49
+ "upstream_misconfigured",
50
+ ]),
51
+ status: Schema.optionalKey(Schema.Number),
52
+ }) {
53
+ }
54
+ const UPSTREAM_DETAILS = {
55
+ unreachable: "SegmentStream could not be reached. Try again shortly.",
56
+ timeout: "SegmentStream did not answer in time. Try again shortly.",
57
+ invalid_response: "SegmentStream answered with an unexpected response.",
58
+ credential_rejected: "SegmentStream refused the deployment's credential.",
59
+ upstream_misconfigured: "SegmentStream does not serve aggregates for this deployment.",
60
+ };
61
+ /** `a`, `a and b`, `a, b and c`. */
62
+ function listed(values) {
63
+ return values.length <= 1
64
+ ? values.join("")
65
+ : `${values.slice(0, -1).join(", ")} and ${values.at(-1)}`;
66
+ }
23
67
  export function toServerResponse(response) {
24
68
  return HttpServerResponse.jsonUnsafe(errorBodyForWire(response.body), {
25
69
  status: response.status,
@@ -279,6 +323,64 @@ export function expectedFailure(error, requestId) {
279
323
  },
280
324
  };
281
325
  }
326
+ if (Predicate.isTagged(error, "ReadDenied")) {
327
+ const { permissions } = error;
328
+ return {
329
+ status: 403,
330
+ body: {
331
+ error: "forbidden",
332
+ detail: `${listed(permissions)} ${permissions.length === 1 ? "is" : "are"} required.`,
333
+ requestId,
334
+ permissions,
335
+ },
336
+ };
337
+ }
338
+ if (Predicate.isTagged(error, "AggregateLimited")) {
339
+ const { retryAfterSeconds } = error;
340
+ return {
341
+ status: 429,
342
+ body: {
343
+ error: "rate_limited",
344
+ detail: `Too many aggregates. Wait ${retryAfterSeconds} seconds, then try again.`,
345
+ requestId,
346
+ retryable: true,
347
+ },
348
+ headers: { "retry-after": String(retryAfterSeconds) },
349
+ };
350
+ }
351
+ if (Predicate.isTagged(error, "InvalidAggregateQuery")) {
352
+ return {
353
+ status: 400,
354
+ body: {
355
+ error: "invalid_aggregate_query",
356
+ detail: error.reason,
357
+ requestId,
358
+ },
359
+ };
360
+ }
361
+ if (Predicate.isTagged(error, "AggregateUnavailable")) {
362
+ return {
363
+ status: 503,
364
+ body: {
365
+ error: "aggregate_unavailable",
366
+ detail: "Aggregates are available in production deployments.",
367
+ requestId,
368
+ retryable: false,
369
+ },
370
+ };
371
+ }
372
+ if (Predicate.isTagged(error, "AggregateUpstreamUnavailable")) {
373
+ const { reason } = error;
374
+ return {
375
+ status: 502,
376
+ body: {
377
+ error: "aggregate_upstream_unavailable",
378
+ detail: UPSTREAM_DETAILS[reason],
379
+ requestId,
380
+ retryable: reason === "unreachable" || reason === "timeout",
381
+ },
382
+ };
383
+ }
282
384
  if (Predicate.isTagged(error, "ConnectionError")) {
283
385
  return connectionFailure(error, requestId);
284
386
  }
@@ -1,7 +1,9 @@
1
1
  import { Layer } from "effect";
2
2
  import { type HttpApiGroup } from "effect/unstable/httpapi";
3
+ import type { Aggregates } from "./aggregate.js";
3
4
  import { type BuiltApi } from "./api.js";
4
5
  import type { AfterCommitHook } from "./config.js";
6
+ import type { ReplicationKick } from "./replication.js";
5
7
  import type { ConnectionTransportFactory } from "./connections.js";
6
8
  import { type RateLimit } from "./rate-limit.js";
7
9
  import { OntologyBackend } from "./runtime-contract.js";
@@ -11,4 +13,4 @@ import { OntologyBackend } from "./runtime-contract.js";
11
13
  */
12
14
  export declare const SIGN_IN_CHECK_LIMIT: RateLimit;
13
15
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
14
- export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
16
+ export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick, aggregates: Aggregates): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
@@ -3,11 +3,12 @@ import { Effect, Layer, Schema } from "effect";
3
3
  import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi";
4
4
  import { STORAGE_GROUP } from "../storage/api.js";
5
5
  import { Conflict } from "../errors.js";
6
+ import { isValueMatched } from "../manifest.js";
6
7
  import { EMAIL_PATTERN } from "../property.js";
7
- import { renderCatalog } from "../render.js";
8
- import { SERVICE_GROUP } from "./api.js";
8
+ import { SERVICE_GROUP, hasMeasures } from "./api.js";
9
9
  import { CurrentPrincipal } from "./authentication.js";
10
10
  import { catalogFor, graphOf } from "./catalog.js";
11
+ import { describeCatalog } from "../render.js";
11
12
  import { InvalidRequestBody, SignInCheckLimited, schemaIssues } from "./errors.js";
12
13
  import { identityToken, makeOperations, requireRead, toQuery, } from "./operations.js";
13
14
  import { clientAddress, fixedWindowLimiter, } from "./rate-limit.js";
@@ -24,34 +25,43 @@ function isJsonMediaType(contentType) {
24
25
  const mediaType = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
25
26
  return mediaType === "application/json" || mediaType.endsWith("+json");
26
27
  }
27
- /** Reads `{ input, options? }`. The input itself is validated by `runAction`. */
28
- function readEnvelope(request) {
28
+ /** Reads a JSON body: `application/json` or a `+json` type, or no content type at all. */
29
+ function readJson(request) {
29
30
  return Effect.gen(function* () {
30
31
  if (!isJsonMediaType(request.headers["content-type"])) {
31
32
  return yield* new InvalidRequestBody({ reason: "unsupported_media_type", issues: [] });
32
33
  }
33
34
  const text = yield* Effect.orDie(request.text);
34
- let body;
35
35
  try {
36
- body = JSON.parse(text);
36
+ return JSON.parse(text);
37
37
  }
38
38
  catch {
39
39
  return yield* new InvalidRequestBody({ reason: "invalid_json", issues: [] });
40
40
  }
41
- return yield* Schema.decodeUnknownEffect(ActionEnvelope)(body, { errors: "all" }).pipe(Effect.mapError((error) => new InvalidRequestBody({ reason: "invalid_request", issues: schemaIssues(error.issue) })));
42
41
  });
43
42
  }
44
- function entityHandlers(built, planned, operationsOf) {
43
+ /** Reads `{ input, options? }`. The input itself is validated by `runAction`. */
44
+ function readEnvelope(request) {
45
+ return readJson(request).pipe(Effect.flatMap((body) => Schema.decodeUnknownEffect(ActionEnvelope)(body, { errors: "all" }).pipe(Effect.mapError((error) => new InvalidRequestBody({
46
+ reason: "invalid_request",
47
+ issues: schemaIssues(error.issue),
48
+ })))));
49
+ }
50
+ function entityHandlers(built, planned, operationsOf, aggregates) {
51
+ // Registered beside the record routes; `aggregate` is a static segment, record ids are UUIDs.
52
+ const withAggregate = (handlers) => hasMeasures(planned)
53
+ ? handlers.handleRaw("aggregate", aggregateHandler(aggregates.entity(planned.name)))
54
+ : handlers;
45
55
  return dynamicGroup(built.api, planned.name, (handlers) => Effect.gen(function* () {
46
56
  if (planned.resolved.entity.definition.source !== undefined) {
47
- return handlers.handleRaw("sourceCollection", () => requireRead([planned.name]).pipe(Effect.andThen(() => HttpServerResponse.json({
57
+ return withAggregate(handlers).handleRaw("sourceCollection", () => requireRead([planned.name]).pipe(Effect.andThen(() => HttpServerResponse.json({
48
58
  error: "not_found",
49
59
  detail: `${planned.name} uses a warehouse source; query it through the workspace API.`,
50
60
  }, { status: 404 }))));
51
61
  }
52
62
  const backend = yield* OntologyBackend;
53
63
  const operations = operationsOf(backend);
54
- let registered = handlers
64
+ let registered = withAggregate(handlers)
55
65
  .handle("find", (request) => operations.find(planned, toQuery(request.query)))
56
66
  .handle("query", (request) => operations.find(planned, toQuery(request.payload)))
57
67
  .handle("get", (request) => operations.get(planned, request.params.id))
@@ -108,7 +118,21 @@ function checkSignIn(backend, limiter) {
108
118
  return yield* backend.checkSignIn(email);
109
119
  });
110
120
  }
111
- function serviceHandlers(built, operationsOf) {
121
+ /**
122
+ * `POST /api/ontology/aggregate` and `POST /api/ontology/<entity>/aggregate`: the facade's answer is
123
+ * SegmentStream's, so its status and JSON body pass through as they came, with its `Retry-After` on
124
+ * a 429 or a 503.
125
+ */
126
+ function aggregateHandler(aggregate) {
127
+ return ({ request }) => readJson(request).pipe(Effect.flatMap(aggregate), Effect.map((answer) => HttpServerResponse.text(answer.text, {
128
+ status: answer.status,
129
+ contentType: "application/json",
130
+ ...(answer.retryAfter === undefined
131
+ ? {}
132
+ : { headers: { "retry-after": answer.retryAfter } }),
133
+ })));
134
+ }
135
+ function serviceHandlers(built, operationsOf, aggregate) {
112
136
  const { manifest } = built.plan.ontology;
113
137
  const graph = graphOf(manifest);
114
138
  const signInLimiter = fixedWindowLimiter(SIGN_IN_CHECK_LIMIT);
@@ -130,29 +154,43 @@ function serviceHandlers(built, operationsOf) {
130
154
  .handle("openapi", () => Effect.sync(() => {
131
155
  if (openApi === undefined) {
132
156
  openApi = OpenApi.fromApi(built.api);
133
- for (const entity of manifest.entities.filter((entity) => entity.source !== undefined)) {
134
- const document = Schema.toJsonSchemaDocument(built.schemas.get(entity.name).record);
135
- openApi.components.schemas ??= {};
136
- openApi.components.schemas[entity.name] = {
137
- ...(document.definitions[`${entity.name
138
- .split("_")
139
- .map((part) => part[0].toUpperCase() + part.slice(1))
140
- .join("")}Record`] ?? document.schema),
141
- "x-ontology-source": entity.source,
142
- "x-ontology-measures": entity.measures,
143
- "x-ontology-relationships": entity.relationships,
144
- };
157
+ openApi.components.schemas ??= {};
158
+ for (const entity of manifest.entities) {
159
+ const recordName = `${entity.name
160
+ .split("_")
161
+ .map((part) => part[0].toUpperCase() + part.slice(1))
162
+ .join("")}Record`;
163
+ if (entity.source !== undefined) {
164
+ const document = Schema.toJsonSchemaDocument(built.schemas.get(entity.name).record);
165
+ openApi.components.schemas[entity.name] = {
166
+ ...(document.definitions[recordName] ?? document.schema),
167
+ "x-ontology-source": entity.source,
168
+ "x-ontology-measures": entity.measures,
169
+ "x-ontology-relationships": entity.relationships,
170
+ };
171
+ continue;
172
+ }
173
+ // A value-matched relationship has no key field or route; only this names it.
174
+ const matched = entity.relationships.filter(isValueMatched);
175
+ const record = openApi.components.schemas[recordName];
176
+ if (matched.length > 0 && record !== undefined) {
177
+ openApi.components.schemas[recordName] = {
178
+ ...record,
179
+ "x-ontology-relationships": matched,
180
+ };
181
+ }
145
182
  }
146
183
  }
147
184
  return openApi;
148
185
  }))
149
- .handle("describe", (request) => Effect.map(permissionsFor(request.query.as), (permissions) => renderCatalog(manifest, permissions === undefined ? {} : { as: permissions })))
186
+ .handle("describe", (request) => Effect.map(permissionsFor(request.query.as), (permissions) => describeCatalog(manifest, permissions)))
150
187
  .handle("status", () => Effect.map(backend.appliedHash, (appliedHash) => ({
151
188
  hash: manifest.hash,
152
189
  appliedHash: appliedHash ?? null,
153
190
  upToDate: appliedHash === manifest.hash,
154
191
  })))
155
- .handle("signInCheck", checkSignIn(backend, signInLimiter));
192
+ .handle("signInCheck", checkSignIn(backend, signInLimiter))
193
+ .handleRaw("aggregate", aggregateHandler(aggregate));
156
194
  }));
157
195
  }
158
196
  function storageHandlers(built) {
@@ -178,14 +216,14 @@ function storageHandlers(built) {
178
216
  }));
179
217
  }
180
218
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
181
- export function handlersLayer(built, hooks, connections) {
182
- const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections);
219
+ export function handlersLayer(built, hooks, connections, replicate, aggregates) {
220
+ const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate);
183
221
  const layers = [
184
- serviceHandlers(built, operationsOf),
222
+ serviceHandlers(built, operationsOf, aggregates.model),
185
223
  ...(built.plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
186
224
  ? [storageHandlers(built)]
187
225
  : []),
188
- ...built.plan.entities.map((planned) => entityHandlers(built, planned, operationsOf)),
226
+ ...built.plan.entities.map((planned) => entityHandlers(built, planned, operationsOf, aggregates)),
189
227
  ];
190
228
  const [first, ...rest] = layers;
191
229
  return Layer.mergeAll(first, ...rest);
@@ -1,10 +1,12 @@
1
1
  import { Layer } from "effect";
2
2
  import { HttpRouter } from "effect/unstable/http";
3
+ import type { Aggregates } from "../aggregate.js";
3
4
  import type { BuiltApi } from "../api.js";
4
5
  import { type TokenVerifier } from "../authentication.js";
5
6
  import type { AfterCommitHook } from "../config.js";
6
7
  import type { ConnectionTransportFactory } from "../connections.js";
7
8
  import { RequestId } from "../errors.js";
9
+ import type { ReplicationKick } from "../replication.js";
8
10
  import { OntologyBackend } from "../runtime-contract.js";
9
11
  /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
10
12
  export declare const MCP_PATH = "/mcp";
@@ -14,7 +16,10 @@ export interface McpServerOptions {
14
16
  readonly built: BuiltApi;
15
17
  readonly hooks: ReadonlyMap<string, AfterCommitHook>;
16
18
  readonly connections: ConnectionTransportFactory;
19
+ readonly replicate: ReplicationKick;
17
20
  readonly verifyToken: TokenVerifier;
21
+ /** The facade the aggregate routes run; the aggregate tool runs its model-level aggregate. */
22
+ readonly aggregates: Aggregates;
18
23
  }
19
24
  /**
20
25
  * The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
@@ -3,9 +3,10 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
3
3
  import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
4
  import { encodeManifest } from "../../manifest.js";
5
5
  import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
6
- import { renderCatalog } from "../../render.js";
6
+ import { SCOPE_MISMATCH } from "../aggregate-platform.js";
7
7
  import { CurrentPrincipal, withPrincipal } from "../authentication.js";
8
8
  import { catalogFor } from "../catalog.js";
9
+ import { describeCatalog, measuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
9
10
  import { RequestId, errorBodyForWire, expectedFailure, internalError, schemaIssues, } from "../errors.js";
10
11
  import { publicOrigin } from "../oauth/protocol.js";
11
12
  import { mcpChallenge, mcpResource } from "../oauth/server.js";
@@ -79,6 +80,41 @@ function errorText(body) {
79
80
  : [];
80
81
  return `${body.detail} (${body.error}, request ${body.requestId})${issues.join("")}`;
81
82
  }
83
+ /** SegmentStream's refusal when its deployed manifest is not the one the service checked against. */
84
+ const VERSION_MISMATCH = "ontology_version_mismatch";
85
+ /** What the agent reads instead of SegmentStream's detail, for refusals it must act on itself. */
86
+ const AGENT_DETAILS = {
87
+ [VERSION_MISMATCH]: "The ontology changed; reload and try again: call describe for the current measures and " +
88
+ "names, then call aggregate with them.",
89
+ [SCOPE_MISMATCH]: "SegmentStream would read entities this app's ontology service did not check, so it refused. " +
90
+ "Retrying will not help: ask the app's operator to update @segmentstream-cloud/ontology and " +
91
+ "redeploy.",
92
+ };
93
+ /**
94
+ * SegmentStream's answer to an aggregate as the model reads it: the body as structured content, its
95
+ * warnings first in the text so they are not missed, and an error status as a tool error.
96
+ */
97
+ function aggregateOutput(answer) {
98
+ const { body } = answer;
99
+ if (answer.status >= 400) {
100
+ const code = typeof body.error === "string" ? body.error : `HTTP ${answer.status}`;
101
+ const reference = typeof body.requestId === "string" ? `, request ${body.requestId}` : "";
102
+ const detail = AGENT_DETAILS[code] ??
103
+ (typeof body.detail === "string" ? body.detail : "The aggregate failed.");
104
+ return { text: `${detail} (${code}${reference})`, structured: body, isError: true };
105
+ }
106
+ const execution = body.execution;
107
+ const warnings = Array.isArray(execution?.warnings)
108
+ ? execution.warnings.filter((warning) => typeof warning === "string")
109
+ : [];
110
+ const json = JSON.stringify(body);
111
+ return {
112
+ text: warnings.length === 0
113
+ ? json
114
+ : `Warnings:\n${warnings.map((warning) => `- ${warning}`).join("\n")}\n\n${json}`,
115
+ structured: body,
116
+ };
117
+ }
82
118
  /**
83
119
  * A failed tool call as the model reads it: `isError`, the REST route's error body as structured
84
120
  * content, and the same in a sentence. Unexpected failures are logged with their cause and answered
@@ -108,16 +144,21 @@ function toolFailure(cause, requestId) {
108
144
  isError: true,
109
145
  }));
110
146
  }
111
- function runTool({ built, operations }, tool, args, request) {
147
+ function runTool({ built, operations, aggregate }, tool, args, request) {
112
148
  return Effect.gen(function* () {
113
149
  const { target } = tool;
114
150
  switch (target.kind) {
115
151
  case "describe": {
116
152
  const principal = yield* CurrentPrincipal;
117
153
  return {
118
- text: renderCatalog(built.plan.ontology.manifest, { as: principal.permissions }),
154
+ text: describeCatalog(built.plan.ontology.manifest, principal.permissions),
119
155
  };
120
156
  }
157
+ case "aggregate": {
158
+ // The route's facade, as the caller: the arguments are the request body.
159
+ const answer = yield* aggregate(args).pipe(Effect.catchTag("InvalidRequestBody", (error) => Effect.fail(new InvalidArguments({ issues: error.issues }))));
160
+ return aggregateOutput(answer);
161
+ }
121
162
  case "me":
122
163
  return structured(yield* encoded(Me, yield* operations.caller));
123
164
  case "find": {
@@ -157,9 +198,10 @@ function callTool(server, params, request) {
157
198
  if (!isObject(args)) {
158
199
  return { kind: "invalid", message: "The tool's arguments must be an object." };
159
200
  }
160
- const result = yield* runTool(server, tool, args, request).pipe(Effect.map(({ text, structured: content }) => ({
201
+ const result = yield* runTool(server, tool, args, request).pipe(Effect.map(({ text, structured: content, isError }) => ({
161
202
  content: [{ type: "text", text }],
162
203
  ...(content === undefined ? {} : { structuredContent: content }),
204
+ ...(isError === true ? { isError } : {}),
163
205
  })), Effect.catchCause((cause) => toolFailure(cause, requestId)));
164
206
  return { kind: "called", result };
165
207
  });
@@ -201,7 +243,7 @@ function readResource({ built }, uri) {
201
243
  {
202
244
  uri,
203
245
  mimeType: "text/plain",
204
- text: renderCatalog(manifest, { as: principal.permissions }),
246
+ text: describeCatalog(manifest, principal.permissions),
205
247
  },
206
248
  ];
207
249
  default:
@@ -210,16 +252,18 @@ function readResource({ built }, uri) {
210
252
  });
211
253
  }
212
254
  /**
213
- * What an agent learns when it connects: what the server is, the entities the caller may read and
214
- * where to find the rest.
255
+ * What an agent learns when it connects: what the server is, the entities the caller may read, the
256
+ * measures of every entity they may read, stored or warehouse-backed, and where to find the rest.
215
257
  */
216
258
  function instructions({ built }, permissions) {
259
+ const { manifest } = built.plan.ontology;
217
260
  const readable = built.plan.entities
218
261
  .filter(({ name, resolved }) => {
219
262
  const definition = resolved.entity.definition;
220
263
  return (definition.source === undefined && isAllowed(permissionOf(name, READ_VERB), { permissions }));
221
264
  })
222
265
  .map(({ name, resolved }) => `- ${name}: ${resolved.entity.definition.description}`);
266
+ const measures = measuresFor(manifest, permissions).map(({ name, measure }) => `- ${name}: ${measure.description}`);
223
267
  return [
224
268
  "This server is the app's ontology: the records the company works with and the actions " +
225
269
  "that change them. Every change goes through an action, is checked against your " +
@@ -227,7 +271,14 @@ function instructions({ built }, permissions) {
227
271
  readable.length > 0
228
272
  ? `Entities you may read, each with <entity>_find, <entity>_get and <entity>_history:\n${readable.join("\n")}`
229
273
  : "You may read no entities.",
230
- "Call describe for the full catalog: properties, relationships and the actions you may run.",
274
+ ...(measures.length > 0
275
+ ? [
276
+ `Measures you may aggregate with the aggregate tool; ${RECORDS_OR_AGGREGATES}:\n` +
277
+ measures.join("\n"),
278
+ ]
279
+ : []),
280
+ "Call describe for the full catalog: properties, relationships, measures and the actions " +
281
+ "you may run.",
231
282
  ].join("\n\n");
232
283
  }
233
284
  function negotiate(requested) {
@@ -361,7 +412,8 @@ export function mcpLayer(options) {
361
412
  const server = {
362
413
  built: options.built,
363
414
  tools,
364
- operations: makeOperations(backend, options.built.plan, options.hooks, options.connections),
415
+ operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate),
416
+ aggregate: options.aggregates.model,
365
417
  };
366
418
  yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, backend));
367
419
  yield* router.add("GET", MCP_PATH, METHOD_NOT_ALLOWED);
@@ -22,6 +22,8 @@ export type McpToolTarget = {
22
22
  readonly kind: "describe";
23
23
  } | {
24
24
  readonly kind: "me";
25
+ } | {
26
+ readonly kind: "aggregate";
25
27
  };
26
28
  /** A tool of the ontology's MCP server, derived from the same plan as the HTTP routes. */
27
29
  export interface McpTool {
@@ -34,14 +36,23 @@ export interface McpTool {
34
36
  readonly annotations: McpToolAnnotations;
35
37
  /** Every permission a caller needs to see and run the tool; none for the global tools. */
36
38
  readonly permissions: ReadonlyArray<string>;
39
+ /**
40
+ * For a tool whose input depends on what the caller may read: the caller's input schema, or
41
+ * `undefined` when the tool can do nothing for them and is not listed. Calls are checked anyway.
42
+ */
43
+ readonly inputSchemaFor?: (caller: PermissionHolder) => JsonSchemaObject | undefined;
37
44
  readonly target: McpToolTarget;
38
45
  }
39
46
  /**
40
- * The MCP tools of an ontology: `describe` and `me`, then for every stored entity `find`, `get`,
47
+ * The MCP tools of an ontology: `describe` and `me`, `aggregate` when the ontology has measures,
48
+ * then for every stored entity `find`, `get`,
41
49
  * `history` and a tool per generated and authored action. Entities with a warehouse source have none,
42
50
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
43
51
  * `check` already refuses.
44
52
  */
45
53
  export declare function mcpTools(built: BuiltApi): ReadonlyArray<McpTool>;
46
- /** The tools a caller may see: those whose every permission the caller's roles grant. */
54
+ /**
55
+ * The tools a caller may see: those whose every permission the caller's roles grant, each with the
56
+ * caller's input schema.
57
+ */
47
58
  export declare function visibleTools(tools: ReadonlyArray<McpTool>, caller: PermissionHolder): ReadonlyArray<McpTool>;