@segmentstream-cloud/ontology 0.12.0 → 0.14.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 (64) hide show
  1. package/README.md +84 -17
  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 +6 -3
  7. package/dist/index.js +5 -2
  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.js +2 -1
  13. package/dist/migrate/plan.d.ts +1 -1
  14. package/dist/migrate/plan.js +59 -22
  15. package/dist/migrate/storage.d.ts +15 -2
  16. package/dist/migrate/storage.js +63 -5
  17. package/dist/names.d.ts +4 -2
  18. package/dist/names.js +5 -1
  19. package/dist/query.d.ts +5 -2
  20. package/dist/query.js +6 -2
  21. package/dist/relationship.d.ts +36 -5
  22. package/dist/relationship.js +5 -3
  23. package/dist/render.d.ts +47 -3
  24. package/dist/render.js +117 -11
  25. package/dist/replication/model.d.ts +1 -1
  26. package/dist/replication/model.js +11 -1
  27. package/dist/runtime/agents.d.ts +9 -0
  28. package/dist/runtime/agents.js +145 -0
  29. package/dist/runtime/service-binding.js +2 -0
  30. package/dist/service/agent/protocol.d.ts +47 -0
  31. package/dist/service/agent/protocol.js +59 -0
  32. package/dist/service/agent/server.d.ts +27 -0
  33. package/dist/service/agent/server.js +235 -0
  34. package/dist/service/aggregate-platform.d.ts +60 -0
  35. package/dist/service/aggregate-platform.js +189 -0
  36. package/dist/service/aggregate.d.ts +42 -0
  37. package/dist/service/aggregate.js +145 -0
  38. package/dist/service/api.d.ts +5 -3
  39. package/dist/service/api.js +52 -6
  40. package/dist/service/authentication.d.ts +16 -5
  41. package/dist/service/authentication.js +51 -6
  42. package/dist/service/catalog.d.ts +10 -1
  43. package/dist/service/catalog.js +12 -3
  44. package/dist/service/config.d.ts +30 -1
  45. package/dist/service/config.js +53 -1
  46. package/dist/service/errors.d.ts +38 -0
  47. package/dist/service/errors.js +118 -0
  48. package/dist/service/handlers.d.ts +2 -1
  49. package/dist/service/handlers.js +66 -28
  50. package/dist/service/mcp/server.d.ts +6 -0
  51. package/dist/service/mcp/server.js +74 -17
  52. package/dist/service/mcp/tools.d.ts +13 -2
  53. package/dist/service/mcp/tools.js +165 -6
  54. package/dist/service/plan.js +7 -1
  55. package/dist/service/runtime-contract.d.ts +68 -0
  56. package/dist/service/runtime-identity.d.ts +59 -0
  57. package/dist/service/runtime-identity.js +102 -0
  58. package/dist/service/schemas.d.ts +99 -1
  59. package/dist/service/schemas.js +150 -2
  60. package/dist/service/service.d.ts +2 -2
  61. package/dist/service/service.js +24 -6
  62. package/dist/user.d.ts +1 -1
  63. package/dist/warehouse-backed.js +222 -121
  64. package/package.json +1 -1
@@ -9,6 +9,12 @@ export const ENVIRONMENT_VARIABLES = {
9
9
  workspaceId: "SEGMENTSTREAM_WORKSPACE_ID",
10
10
  organizationId: "SEGMENTSTREAM_ORGANIZATION_ID",
11
11
  };
12
+ /**
13
+ * The workspace app credential (`ssw_…`), which Vercel injects into every service of a production
14
+ * deployment. The service forwards aggregates to SegmentStream with it; without it, aggregates are
15
+ * unavailable and everything else works.
16
+ */
17
+ export const APP_CREDENTIAL_VARIABLE = "SEGMENTSTREAM_APP_CREDENTIAL";
12
18
  /**
13
19
  * The workspace's Neon Auth, which signs the app's users in. Provisioning sets both on workspaces
14
20
  * that have it; a service without them accepts SegmentStream app tokens and API keys only.
@@ -17,6 +23,17 @@ export const NEON_AUTH_ENVIRONMENT_VARIABLES = {
17
23
  baseUrl: "NEON_AUTH_BASE_URL",
18
24
  jwksUrl: "NEON_AUTH_JWKS_URL",
19
25
  };
26
+ /**
27
+ * The deployment's own runtime callers, such as the app's agent: the issuer and the audience of the
28
+ * team's Vercel OIDC tokens, set together by the platform on deployments that have an agent. Without
29
+ * them a runtime token is an invalid token. Tokens must also name the project in Vercel's
30
+ * `VERCEL_PROJECT_ID`, as the agent runs in the same Vercel project as the ontology service.
31
+ */
32
+ export const RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES = {
33
+ issuer: "SEGMENTSTREAM_RUNTIME_OIDC_ISSUER",
34
+ audience: "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE",
35
+ projectId: "VERCEL_PROJECT_ID",
36
+ };
20
37
  /** The service cannot start: its configuration is incomplete or invalid. */
21
38
  export class OntologyServiceConfigError extends Schema.TaggedError()("OntologyServiceConfigError", { message: Schema.String }) {
22
39
  }
@@ -54,6 +71,35 @@ function neonAuthFromEnv(read) {
54
71
  jwksUrl: httpUrl(NEON_AUTH_ENVIRONMENT_VARIABLES.jwksUrl, read(NEON_AUTH_ENVIRONMENT_VARIABLES.jwksUrl)).href,
55
72
  };
56
73
  }
74
+ /**
75
+ * The runtime issuer and audience, both or neither, and the project the tokens must name. A partial
76
+ * configuration is a broken deployment, so the service refuses to start rather than run without the
77
+ * agent's identity, or trust tokens of every project of the team.
78
+ */
79
+ function runtimeIdentityFromEnv(read) {
80
+ const { issuer, audience, projectId } = RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES;
81
+ const pair = [issuer, audience];
82
+ const missingPair = pair.filter((name) => read(name) === "");
83
+ if (missingPair.length === pair.length) {
84
+ return undefined;
85
+ }
86
+ if (missingPair.length > 0) {
87
+ throw new OntologyServiceConfigError({
88
+ message: `The ontology service cannot start: ${pair.join(" and ")} are set together; ` +
89
+ `${missingPair.join(", ")} is not set.`,
90
+ });
91
+ }
92
+ if (read(projectId) === "") {
93
+ throw new OntologyServiceConfigError({
94
+ message: `The ontology service cannot start: ${projectId} is not set, which runtime tokens must name.`,
95
+ });
96
+ }
97
+ return {
98
+ issuer: httpUrl(issuer, read(issuer)).href.replace(/\/+$/, ""),
99
+ audience: read(audience),
100
+ projectId: read(projectId),
101
+ };
102
+ }
57
103
  function checkDatabaseUrl(name, value) {
58
104
  // The value is a credential: errors name the variable, never the value.
59
105
  if (!URL.canParse(value)) {
@@ -70,7 +116,8 @@ function checkDatabaseUrl(name, value) {
70
116
  /**
71
117
  * Reads the service's configuration from the environment. It fails closed: a missing or invalid
72
118
  * variable throws {@link OntologyServiceConfigError} naming every missing variable, and never the
73
- * value of the database URL. The Neon Auth variables are optional, but only as a pair.
119
+ * value of the database URL. The Neon Auth and runtime identity variables are optional, but only as
120
+ * pairs. The app credential is optional and kept redacted.
74
121
  */
75
122
  export function configFromEnv(environment = process.env) {
76
123
  const read = (name) => environment[name]?.trim() ?? "";
@@ -83,6 +130,7 @@ export function configFromEnv(environment = process.env) {
83
130
  });
84
131
  }
85
132
  const neonAuth = neonAuthFromEnv(read);
133
+ const runtimeIdentity = runtimeIdentityFromEnv(read);
86
134
  const localConnections = localConnectionsFromEnv(environment);
87
135
  let blobStorage;
88
136
  if (read("SEGMENTSTREAM_ONTOLOGY_STORAGE")) {
@@ -107,7 +155,11 @@ export function configFromEnv(environment = process.env) {
107
155
  storageMaintenanceKey: Redacted.make(read("SEGMENTSTREAM_ONTOLOGY_STORAGE_MAINTENANCE_KEY")),
108
156
  }
109
157
  : {}),
158
+ ...(read(APP_CREDENTIAL_VARIABLE)
159
+ ? { appCredential: Redacted.make(read(APP_CREDENTIAL_VARIABLE)) }
160
+ : {}),
110
161
  ...(neonAuth === undefined ? {} : { neonAuth }),
162
+ ...(runtimeIdentity === undefined ? {} : { runtimeIdentity }),
111
163
  ...(localConnections === undefined ? {} : { localConnections }),
112
164
  };
113
165
  }
@@ -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;
@@ -60,6 +92,12 @@ export declare function notInvited(requestId: string): ErrorResponse;
60
92
  * The cookie rides along with any request to the app's domain, so only its origin can vouch for it.
61
93
  */
62
94
  export declare function crossOriginSession(requestId: string): ErrorResponse;
95
+ /** The caller is authenticated but may not do this; the detail says why. */
96
+ export declare function forbidden(requestId: string, detail: string): ErrorResponse;
97
+ /** A client asked too often; it may ask again after `retryAfterSeconds`. */
98
+ export declare function rateLimited(requestId: string, detail: string, retryAfterSeconds: number): ErrorResponse;
99
+ /** The resource does not exist, or not for this caller; the detail says what to do. */
100
+ export declare function notFound(requestId: string, detail: string): ErrorResponse;
63
101
  export declare function unavailable(requestId: string): ErrorResponse;
64
102
  export declare function internalError(requestId: string): ErrorResponse;
65
103
  export declare function routeNotFound(requestId: string): ErrorResponse;
@@ -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,
@@ -85,6 +129,22 @@ export function crossOriginSession(requestId) {
85
129
  },
86
130
  };
87
131
  }
132
+ /** The caller is authenticated but may not do this; the detail says why. */
133
+ export function forbidden(requestId, detail) {
134
+ return { status: 403, body: { error: "forbidden", detail, requestId } };
135
+ }
136
+ /** A client asked too often; it may ask again after `retryAfterSeconds`. */
137
+ export function rateLimited(requestId, detail, retryAfterSeconds) {
138
+ return {
139
+ status: 429,
140
+ body: { error: "rate_limited", detail, requestId },
141
+ headers: { "retry-after": String(retryAfterSeconds) },
142
+ };
143
+ }
144
+ /** The resource does not exist, or not for this caller; the detail says what to do. */
145
+ export function notFound(requestId, detail) {
146
+ return { status: 404, body: { error: "not_found", detail, requestId } };
147
+ }
88
148
  export function unavailable(requestId) {
89
149
  return {
90
150
  status: 503,
@@ -279,6 +339,64 @@ export function expectedFailure(error, requestId) {
279
339
  },
280
340
  };
281
341
  }
342
+ if (Predicate.isTagged(error, "ReadDenied")) {
343
+ const { permissions } = error;
344
+ return {
345
+ status: 403,
346
+ body: {
347
+ error: "forbidden",
348
+ detail: `${listed(permissions)} ${permissions.length === 1 ? "is" : "are"} required.`,
349
+ requestId,
350
+ permissions,
351
+ },
352
+ };
353
+ }
354
+ if (Predicate.isTagged(error, "AggregateLimited")) {
355
+ const { retryAfterSeconds } = error;
356
+ return {
357
+ status: 429,
358
+ body: {
359
+ error: "rate_limited",
360
+ detail: `Too many aggregates. Wait ${retryAfterSeconds} seconds, then try again.`,
361
+ requestId,
362
+ retryable: true,
363
+ },
364
+ headers: { "retry-after": String(retryAfterSeconds) },
365
+ };
366
+ }
367
+ if (Predicate.isTagged(error, "InvalidAggregateQuery")) {
368
+ return {
369
+ status: 400,
370
+ body: {
371
+ error: "invalid_aggregate_query",
372
+ detail: error.reason,
373
+ requestId,
374
+ },
375
+ };
376
+ }
377
+ if (Predicate.isTagged(error, "AggregateUnavailable")) {
378
+ return {
379
+ status: 503,
380
+ body: {
381
+ error: "aggregate_unavailable",
382
+ detail: "Aggregates are available in production deployments.",
383
+ requestId,
384
+ retryable: false,
385
+ },
386
+ };
387
+ }
388
+ if (Predicate.isTagged(error, "AggregateUpstreamUnavailable")) {
389
+ const { reason } = error;
390
+ return {
391
+ status: 502,
392
+ body: {
393
+ error: "aggregate_upstream_unavailable",
394
+ detail: UPSTREAM_DETAILS[reason],
395
+ requestId,
396
+ retryable: reason === "unreachable" || reason === "timeout",
397
+ },
398
+ };
399
+ }
282
400
  if (Predicate.isTagged(error, "ConnectionError")) {
283
401
  return connectionFailure(error, requestId);
284
402
  }
@@ -1,5 +1,6 @@
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";
5
6
  import type { ReplicationKick } from "./replication.js";
@@ -12,4 +13,4 @@ import { OntologyBackend } from "./runtime-contract.js";
12
13
  */
13
14
  export declare const SIGN_IN_CHECK_LIMIT: RateLimit;
14
15
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
15
- export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick): 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, replicate) {
219
+ export function handlersLayer(built, hooks, connections, replicate, aggregates) {
182
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,5 +1,6 @@
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";
@@ -7,6 +8,7 @@ import type { ConnectionTransportFactory } from "../connections.js";
7
8
  import { RequestId } from "../errors.js";
8
9
  import type { ReplicationKick } from "../replication.js";
9
10
  import { OntologyBackend } from "../runtime-contract.js";
11
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
10
12
  /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
11
13
  export declare const MCP_PATH = "/mcp";
12
14
  /** The MCP revisions the server speaks, newest first. */
@@ -17,6 +19,10 @@ export interface McpServerOptions {
17
19
  readonly connections: ConnectionTransportFactory;
18
20
  readonly replicate: ReplicationKick;
19
21
  readonly verifyToken: TokenVerifier;
22
+ /** The deployment's runtime tokens, acting as a member; `undefined` while that is disabled. */
23
+ readonly verifyRuntime?: RuntimeTokenVerifier | undefined;
24
+ /** The facade the aggregate routes run; the aggregate tool runs its model-level aggregate. */
25
+ readonly aggregates: Aggregates;
20
26
  }
21
27
  /**
22
28
  * The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can