@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.
- package/README.md +84 -17
- 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 +6 -3
- package/dist/index.js +5 -2
- 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.js +2 -1
- package/dist/migrate/plan.d.ts +1 -1
- package/dist/migrate/plan.js +59 -22
- package/dist/migrate/storage.d.ts +15 -2
- package/dist/migrate/storage.js +63 -5
- package/dist/names.d.ts +4 -2
- package/dist/names.js +5 -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/model.d.ts +1 -1
- package/dist/replication/model.js +11 -1
- package/dist/runtime/agents.d.ts +9 -0
- package/dist/runtime/agents.js +145 -0
- package/dist/runtime/service-binding.js +2 -0
- package/dist/service/agent/protocol.d.ts +47 -0
- package/dist/service/agent/protocol.js +59 -0
- package/dist/service/agent/server.d.ts +27 -0
- package/dist/service/agent/server.js +235 -0
- 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/authentication.d.ts +16 -5
- package/dist/service/authentication.js +51 -6
- package/dist/service/catalog.d.ts +10 -1
- package/dist/service/catalog.js +12 -3
- package/dist/service/config.d.ts +30 -1
- package/dist/service/config.js +53 -1
- package/dist/service/errors.d.ts +38 -0
- package/dist/service/errors.js +118 -0
- package/dist/service/handlers.d.ts +2 -1
- package/dist/service/handlers.js +66 -28
- package/dist/service/mcp/server.d.ts +6 -0
- package/dist/service/mcp/server.js +74 -17
- package/dist/service/mcp/tools.d.ts +13 -2
- package/dist/service/mcp/tools.js +165 -6
- package/dist/service/plan.js +7 -1
- package/dist/service/runtime-contract.d.ts +68 -0
- package/dist/service/runtime-identity.d.ts +59 -0
- package/dist/service/runtime-identity.js +102 -0
- package/dist/service/schemas.d.ts +99 -1
- package/dist/service/schemas.js +150 -2
- package/dist/service/service.d.ts +2 -2
- package/dist/service/service.js +24 -6
- package/dist/user.d.ts +1 -1
- package/dist/warehouse-backed.js +222 -121
- package/package.json +1 -1
package/dist/service/config.js
CHANGED
|
@@ -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
|
|
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
|
}
|
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;
|
|
@@ -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;
|
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,
|
|
@@ -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>;
|
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, 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
|