@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/dist/service/errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/service/errors.js
CHANGED
|
@@ -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>;
|
package/dist/service/handlers.js
CHANGED
|
@@ -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 {
|
|
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 `
|
|
28
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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) =>
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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`,
|
|
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
|
-
/**
|
|
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>;
|