@segmentstream-cloud/ontology 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +46 -16
  2. package/dist/aggregate-routes.d.ts +95 -0
  3. package/dist/aggregate-routes.js +332 -0
  4. package/dist/analyze.js +83 -16
  5. package/dist/entity.d.ts +31 -15
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/manifest.d.ts +57 -23
  9. package/dist/manifest.js +77 -30
  10. package/dist/measure.d.ts +11 -2
  11. package/dist/measure.js +14 -1
  12. package/dist/migrate/plan.d.ts +1 -1
  13. package/dist/migrate/plan.js +38 -13
  14. package/dist/migrate/storage.d.ts +3 -2
  15. package/dist/migrate/storage.js +15 -5
  16. package/dist/names.d.ts +4 -2
  17. package/dist/names.js +4 -1
  18. package/dist/query.d.ts +5 -2
  19. package/dist/query.js +6 -2
  20. package/dist/relationship.d.ts +36 -5
  21. package/dist/relationship.js +5 -3
  22. package/dist/render.d.ts +47 -3
  23. package/dist/render.js +117 -11
  24. package/dist/replication/model.d.ts +1 -1
  25. package/dist/replication/model.js +11 -1
  26. package/dist/service/aggregate-platform.d.ts +60 -0
  27. package/dist/service/aggregate-platform.js +189 -0
  28. package/dist/service/aggregate.d.ts +42 -0
  29. package/dist/service/aggregate.js +145 -0
  30. package/dist/service/api.d.ts +5 -3
  31. package/dist/service/api.js +52 -6
  32. package/dist/service/catalog.d.ts +10 -1
  33. package/dist/service/catalog.js +12 -3
  34. package/dist/service/config.d.ts +13 -1
  35. package/dist/service/config.js +11 -1
  36. package/dist/service/errors.d.ts +32 -0
  37. package/dist/service/errors.js +102 -0
  38. package/dist/service/handlers.d.ts +2 -1
  39. package/dist/service/handlers.js +66 -28
  40. package/dist/service/mcp/server.d.ts +3 -0
  41. package/dist/service/mcp/server.js +60 -8
  42. package/dist/service/mcp/tools.d.ts +13 -2
  43. package/dist/service/mcp/tools.js +165 -6
  44. package/dist/service/plan.js +7 -1
  45. package/dist/service/schemas.d.ts +99 -1
  46. package/dist/service/schemas.js +150 -2
  47. package/dist/service/service.js +8 -2
  48. package/dist/user.d.ts +1 -1
  49. package/dist/warehouse-backed.js +222 -121
  50. package/package.json +1 -1
@@ -0,0 +1,145 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { resolveAggregateRoutes } from "../aggregate-routes.js";
3
+ import { isAllowed, permissionOf, READ_VERB } from "../permissions.js";
4
+ import { CurrentPrincipal } from "./authentication.js";
5
+ import { AggregateLimited, AggregateUnavailable, InvalidAggregateQuery, InvalidRequestBody, ReadDenied, RequestId, schemaIssues, } from "./errors.js";
6
+ import { fixedWindowLimiter } from "./rate-limit.js";
7
+ import { AggregateRequest, EntityAggregateRequest } from "./schemas.js";
8
+ /**
9
+ * Aggregates one principal may run per minute, through both routes and the MCP tool together. Each
10
+ * runs a warehouse query with the deployment's credential, so one caller must not spend without
11
+ * bound.
12
+ */
13
+ export const AGGREGATE_LIMIT = { limit: 30, windowMs: 60_000, maxKeys: 10_000 };
14
+ function decode(schema, body) {
15
+ return Schema.decodeUnknownEffect(schema)(body, { errors: "all" }).pipe(Effect.mapError((error) => new InvalidRequestBody({ reason: "invalid_request", issues: schemaIssues(error.issue) })));
16
+ }
17
+ /**
18
+ * The first `orderBy` entry that names neither a requested measure nor a requested dimension, by
19
+ * its requested name or its identity. Ordering by anything else would read what the permission
20
+ * check never saw.
21
+ */
22
+ function unrequestedOrder(request, routes) {
23
+ const dimensions = new Set([
24
+ ...(request.dimensions ?? []),
25
+ ...(routes.entities[0]?.dimensions ?? []).map(({ identity }) => identity),
26
+ ]);
27
+ for (const order of request.orderBy ?? []) {
28
+ if (order.measure !== undefined && !request.measures.includes(order.measure)) {
29
+ return order.measure;
30
+ }
31
+ if (order.dimension !== undefined && !dimensions.has(order.dimension)) {
32
+ return order.dimension;
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+ /**
38
+ * A model-level request's entities, as the shared resolver finds them: each measure's entity,
39
+ * every entity on a dimension's or a filter's route, intermediates included, and every entity a
40
+ * filter names even where it does not apply.
41
+ */
42
+ function modelTouched(manifest, body) {
43
+ return Effect.gen(function* () {
44
+ const request = yield* decode(AggregateRequest, body);
45
+ const routes = resolveAggregateRoutes(manifest, {
46
+ measures: request.measures,
47
+ dimensions: request.dimensions ?? [],
48
+ filters: (request.filters ?? []).map((filter) => filter.dimension),
49
+ ...(request.granularityOf === undefined ? {} : { granularityOf: request.granularityOf }),
50
+ });
51
+ if (!routes.ok) {
52
+ return yield* new InvalidAggregateQuery({ reason: routes.reason });
53
+ }
54
+ const unordered = unrequestedOrder(request, routes);
55
+ if (unordered !== undefined) {
56
+ return yield* new InvalidAggregateQuery({
57
+ reason: `orderBy names ${unordered}, which the request does not ask for.`,
58
+ });
59
+ }
60
+ return routes.touched;
61
+ });
62
+ }
63
+ /**
64
+ * A per-entity request's entities: the entity, and every entity along each relative name it
65
+ * groups, filters, buckets or orders by, following the manifest's `rel.one` relationships. As on
66
+ * SegmentStream's per-entity route, a path may follow a self-relationship (`manager.name`).
67
+ */
68
+ function entityTouched(manifest, entity, body) {
69
+ return Effect.gen(function* () {
70
+ const request = yield* decode(EntityAggregateRequest, body);
71
+ const entities = new Map(manifest.entities.map((candidate) => [candidate.name, candidate]));
72
+ const touched = new Set([entity]);
73
+ const names = [
74
+ ...request.dimensions,
75
+ ...(request.filters ?? []).map((filter) => filter.dimension),
76
+ ...(request.granularityOf === undefined ? [] : [request.granularityOf]),
77
+ ...(request.orderBy ?? []).flatMap((order) => order.dimension === undefined ? [] : [order.dimension]),
78
+ ];
79
+ for (const name of names) {
80
+ const segments = name.split(".");
81
+ let current = entities.get(entity);
82
+ if (segments.some((segment) => segment === "")) {
83
+ return yield* new InvalidAggregateQuery({ reason: `${name} is not a valid name.` });
84
+ }
85
+ for (const relationshipName of segments.slice(0, -1)) {
86
+ const relationship = current?.relationships.find((candidate) => candidate.name === relationshipName);
87
+ if (current === undefined || relationship === undefined) {
88
+ return yield* new InvalidAggregateQuery({
89
+ reason: `${current?.name ?? entity} has no relationship ${relationshipName}.`,
90
+ });
91
+ }
92
+ if (relationship.cardinality !== "one") {
93
+ return yield* new InvalidAggregateQuery({
94
+ reason: `${relationshipName} is not a to-one relationship of ${current.name}.`,
95
+ });
96
+ }
97
+ current = entities.get(relationship.to);
98
+ touched.add(relationship.to);
99
+ }
100
+ }
101
+ return [...touched].sort();
102
+ });
103
+ }
104
+ /**
105
+ * The ontology service's aggregate facade (docs/ontology-aggregates-spec.md, "Surfaces"). Both
106
+ * routes and the MCP tool run as the caller:
107
+ * 0. the call counts against the caller's {@link AGGREGATE_LIMIT}, whichever way it came;
108
+ * 1. the request's shape and bounds are checked, and its names resolved against the service's own
109
+ * manifest into every entity it reads;
110
+ * 2. the caller needs `<entity>:read` on all of them;
111
+ * 3. the body goes to SegmentStream unchanged with the workspace app credential, naming the entities
112
+ * checked, or the answer is `aggregate_unavailable` where the deployment has no credential.
113
+ */
114
+ export function makeAggregates(input) {
115
+ const { manifest, transport } = input;
116
+ const limiter = input.limiter ?? fixedWindowLimiter(AGGREGATE_LIMIT);
117
+ const run = (request) => Effect.gen(function* () {
118
+ const principal = yield* CurrentPrincipal;
119
+ const requestId = yield* RequestId;
120
+ const retryAfterSeconds = limiter.take(principal.id);
121
+ if (retryAfterSeconds !== undefined) {
122
+ return yield* new AggregateLimited({ retryAfterSeconds });
123
+ }
124
+ const touched = yield* request.touchedBy(request.body);
125
+ const missing = touched
126
+ .map((entity) => permissionOf(entity, READ_VERB))
127
+ .filter((permission) => !isAllowed(permission, principal));
128
+ if (missing.length > 0) {
129
+ return yield* new ReadDenied({ permissions: missing });
130
+ }
131
+ if (transport === undefined) {
132
+ return yield* new AggregateUnavailable();
133
+ }
134
+ return yield* transport({
135
+ ...(request.entity === undefined ? {} : { entity: request.entity }),
136
+ body: request.body,
137
+ requestId,
138
+ touched,
139
+ });
140
+ });
141
+ return {
142
+ model: (body) => run({ body, touchedBy: (value) => modelTouched(manifest, value) }),
143
+ entity: (entity) => (body) => run({ entity, body, touchedBy: (value) => entityTouched(manifest, entity, value) }),
144
+ };
145
+ }
@@ -2,7 +2,7 @@ import { Layer, Schema } from "effect";
2
2
  import { HttpApi, HttpApiGroup, HttpApiMiddleware } from "effect/unstable/httpapi";
3
3
  import type { Ontology } from "../ontology.js";
4
4
  import { RequestId } from "./errors.js";
5
- import { type ApiPlan } from "./plan.js";
5
+ import { type ApiPlan, type PlannedEntity } from "./plan.js";
6
6
  import type { GeneratedActionsProvider } from "./runtime-contract.js";
7
7
  import { type EntitySchemas } from "./schemas.js";
8
8
  /** The path every ontology route lives under. */
@@ -26,6 +26,8 @@ declare const ErrorResponses_base: HttpApiMiddleware.ServiceClass<ErrorResponses
26
26
  export declare class ErrorResponses extends ErrorResponses_base {
27
27
  }
28
28
  export declare const ErrorResponsesLive: Layer.Layer<ErrorResponses, never, never>;
29
+ /** Whether an entity declares measures, and so has `POST /api/ontology/<entity>/aggregate`. */
30
+ export declare function hasMeasures(planned: PlannedEntity): boolean;
29
31
  export interface BuiltApi {
30
32
  readonly plan: ApiPlan;
31
33
  readonly api: OntologyApi;
@@ -39,8 +41,8 @@ export interface DeriveApiOptions {
39
41
  /**
40
42
  * The HttpApi of an ontology: per entity `find`, `query`, `get`, `history`, a traversal per
41
43
  * relationship and inverse, and a route per generated and authored action; and the catalog, graph,
42
- * OpenAPI, status, caller (`me`), sign-in check and prose routes. There is no PUT, PATCH or DELETE:
43
- * every change is an action.
44
+ * OpenAPI, status, caller (`me`), sign-in check, prose and aggregate routes. There is no PUT, PATCH
45
+ * or DELETE: every change is an action.
44
46
  */
45
47
  export declare function deriveApi(ontology: Ontology, options?: DeriveApiOptions): OntologyApi;
46
48
  export {};
@@ -1,5 +1,6 @@
1
1
  import { Effect, Layer, Schema } from "effect";
2
2
  import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi, } from "effect/unstable/httpapi";
3
+ import { AGGREGATE_ROUTE } from "../names.js";
3
4
  import { storageGroup } from "../storage/api.js";
4
5
  import { DownloadRequest, TransferGrant } from "../storage/sessions.js";
5
6
  import { permissionOf, READ_VERB } from "../permissions.js";
@@ -8,7 +9,7 @@ import { Authentication } from "./authentication.js";
8
9
  import { actionDescription, requires } from "./descriptions.js";
9
10
  import { InvalidRequestBody, RequestId, failureResponse } from "./errors.js";
10
11
  import { planApi, } from "./plan.js";
11
- import { ActionResponse, AsParams, Catalog, ErrorSchemas, Graph, HistoryPage, HistoryParams, IdParam, Me, SignInCheckRequest, SignInCheckResult, Status, actionRequest, entitySchemas, } from "./schemas.js";
12
+ import { ActionResponse, AggregateRequest, AggregateResult, EntityAggregateRequest, AsParams, Catalog, ErrorSchemas, Graph, HistoryPage, HistoryParams, IdParam, Me, SignInCheckRequest, SignInCheckResult, Status, actionRequest, entitySchemas, } from "./schemas.js";
12
13
  /** The path every ontology route lives under. */
13
14
  export const BASE_PATH = "/api/ontology";
14
15
  /** The group of the global routes. Entity names are snake_case, so it cannot clash with one. */
@@ -52,6 +53,20 @@ const actionErrors = [
52
53
  ErrorSchemas.unprocessable,
53
54
  ErrorSchemas.internal,
54
55
  ];
56
+ /**
57
+ * The facade's own refusals. SegmentStream's answers, success and errors alike, pass through as it
58
+ * sent them: its errors are `{ error, detail, requestId, retryable }`.
59
+ */
60
+ const aggregateErrors = [
61
+ ErrorSchemas.invalidRequest,
62
+ ErrorSchemas.invalidAggregateQuery,
63
+ ErrorSchemas.readForbidden,
64
+ ErrorSchemas.unsupportedMediaType,
65
+ ErrorSchemas.rateLimited,
66
+ ErrorSchemas.internal,
67
+ ErrorSchemas.aggregateUpstreamUnavailable,
68
+ ErrorSchemas.aggregateUnavailable,
69
+ ];
55
70
  /** An action that calls connections also answers for the connections it could not use. */
56
71
  const connectionActionErrors = [
57
72
  ...actionErrors,
@@ -62,6 +77,23 @@ const connectionActionErrors = [
62
77
  function readPermission(entity) {
63
78
  return permissionOf(entity, READ_VERB);
64
79
  }
80
+ /** Whether an entity declares measures, and so has `POST /api/ontology/<entity>/aggregate`. */
81
+ export function hasMeasures(planned) {
82
+ return Object.keys(planned.resolved.entity.definition.measures ?? {}).length > 0;
83
+ }
84
+ /** The per-entity shorthand, which runs as the caller like the model-level route. */
85
+ function entityAggregateEndpoint(planned) {
86
+ const { plural } = planned.resolved.entity.definition;
87
+ const endpoint = HttpApiEndpoint.post("aggregate", `${BASE_PATH}/${planned.name}/aggregate`, {
88
+ payload: EntityAggregateRequest,
89
+ success: AggregateResult,
90
+ error: aggregateErrors,
91
+ });
92
+ return documented(endpoint, `Aggregate ${plural.toLowerCase()}`, `The entity's measures, with names relative to it: revenue, product.brand. dateRange bounds a ` +
93
+ "partitioned entity. Requires reading the entity and every entity along each path it " +
94
+ "groups, filters or orders by. Answered as POST /api/ontology/aggregate is, and counted " +
95
+ "against the same limit.");
96
+ }
65
97
  function traversalEndpoint(planned, traversal, target, targetNames) {
66
98
  const label = planned.resolved.entity.definition.label;
67
99
  const reach = traversal.kind === "one" ? `one ${targetNames.label}` : `many ${targetNames.plural}`;
@@ -85,11 +117,12 @@ function entityGroup(planned, schemas, names) {
85
117
  const own = schemas.get(planned.name);
86
118
  const { definition } = planned.resolved.entity;
87
119
  const base = `${BASE_PATH}/${planned.name}`;
120
+ const aggregate = hasMeasures(planned) ? [authenticated(entityAggregateEndpoint(planned))] : [];
88
121
  if (definition.source !== undefined) {
89
122
  return HttpApiGroup.make(planned.name).add(authenticated(HttpApiEndpoint.get("sourceCollection", base, {
90
123
  success: Schema.Never,
91
124
  error: recordErrors,
92
- })));
125
+ })), ...aggregate);
93
126
  }
94
127
  const plural = definition.plural.toLowerCase();
95
128
  const read = requires([readPermission(planned.name)]);
@@ -127,7 +160,7 @@ function entityGroup(planned, schemas, names) {
127
160
  }), `Download ${property.name}`, `Requires ${planned.name}:read. Historical revisions must belong to this record and field.`)),
128
161
  ...planned.actions.map((action) => actionEndpoint(planned, action)),
129
162
  ];
130
- const [first, ...rest] = endpoints.map(authenticated);
163
+ const [first, ...rest] = [...endpoints.map(authenticated), ...aggregate];
131
164
  return HttpApiGroup.make(planned.name)
132
165
  .add(first, ...rest)
133
166
  .annotateMerge(OpenApi.annotations({ title: definition.plural, description: definition.description }));
@@ -165,7 +198,20 @@ function serviceGroup() {
165
198
  }).middleware(ErrorResponses), "Whether an address may sign in", "Public and rate limited per client address. The app asks before it emails a code or a " +
166
199
  "sign-in link. Under the invited policy, an active member's address is allowed: with " +
167
200
  "code once they have signed in with Neon Auth, with link before; any other address, " +
168
- "deactivated members included, is not. Under open, every address is allowed with link."))
201
+ "deactivated members included, is not. Under open, every address is allowed with link."), authenticated(documented(HttpApiEndpoint.post("aggregate", `${BASE_PATH}/${AGGREGATE_ROUTE}`, {
202
+ payload: AggregateRequest,
203
+ success: AggregateResult,
204
+ error: aggregateErrors,
205
+ }), "Aggregate measures across the ontology", "Numbers about the model: measures from several entities, grouped and filtered by " +
206
+ "properties of related entities. Look at or change records through find and actions; " +
207
+ "count, sum or compare through aggregate. Names are <entity>.<measure> and " +
208
+ "<entity>.<property>, or a route from a measure's entity: " +
209
+ "<entity>.<relationship>.<property>. Requires <entity>:read on every entity the " +
210
+ "request reads: each measure's entity, and every entity on a dimension's or a " +
211
+ "filter's route, and at most 30 aggregates a minute per caller, MCP included. The " +
212
+ "service then forwards the request to SegmentStream, which " +
213
+ "answers from the warehouse and the replica of stored records; its answer, errors " +
214
+ "included, is returned unchanged. Preview deployments answer aggregate_unavailable.")))
169
215
  .annotateMerge(OpenApi.annotations({ title: SERVICE_GROUP, description: "The ontology as a whole." }));
170
216
  }
171
217
  export function buildApi(plan) {
@@ -197,8 +243,8 @@ const runtimeGeneratedActions = (entity, ontology) => generatedActions(ontology,
197
243
  /**
198
244
  * The HttpApi of an ontology: per entity `find`, `query`, `get`, `history`, a traversal per
199
245
  * relationship and inverse, and a route per generated and authored action; and the catalog, graph,
200
- * OpenAPI, status, caller (`me`), sign-in check and prose routes. There is no PUT, PATCH or DELETE:
201
- * every change is an action.
246
+ * OpenAPI, status, caller (`me`), sign-in check, prose and aggregate routes. There is no PUT, PATCH
247
+ * or DELETE: every change is an action.
202
248
  */
203
249
  export function deriveApi(ontology, options = {}) {
204
250
  return buildApi(planApi(ontology, options.generatedActions ?? runtimeGeneratedActions)).api;
@@ -1,4 +1,4 @@
1
- import type { OntologyManifest } from "../manifest.js";
1
+ import { type OntologyManifest } from "../manifest.js";
2
2
  /**
3
3
  * The catalog for a reader: the manifest, with each entity's actions narrowed to the ones the given
4
4
  * permissions may run. Without permissions it is the manifest itself.
@@ -10,6 +10,15 @@ export interface GraphRelationship {
10
10
  readonly to: string;
11
11
  readonly inverse: string;
12
12
  readonly cardinality: "one" | "many";
13
+ /** The property pair of a relationship matched by value: one with a warehouse-backed end. */
14
+ readonly on?: {
15
+ readonly property: string;
16
+ readonly targetProperty: string;
17
+ };
18
+ /**
19
+ * Whether the relationship may lead to nothing. Only a stored → stored `rel.one` can promise a
20
+ * record; a value-matched reference is never checked against its target.
21
+ */
13
22
  readonly optional: boolean;
14
23
  readonly phrase: readonly [string, string];
15
24
  /** The relationship read from the declaring entity: "Lead is owned by one User". */
@@ -1,3 +1,4 @@
1
+ import { isValueMatched } from "../manifest.js";
1
2
  import { isAllowed } from "../permissions.js";
2
3
  import { actionPermission } from "../render.js";
3
4
  /**
@@ -33,8 +34,15 @@ export function graphOf(manifest) {
33
34
  relationships: manifest.entities.flatMap((source) => source.relationships.map((relationship) => {
34
35
  const target = entities.get(relationship.to);
35
36
  const targetLabel = target?.label ?? relationship.to;
37
+ // Manifests before version 5 wrote `optional` on warehouse → stored relationships too.
38
+ const valueMatched = isValueMatched(relationship) ||
39
+ source.source !== undefined ||
40
+ target?.source !== undefined;
41
+ // As describe phrases them: nothing promises a value-matched relationship a record.
42
+ const one = valueMatched ? "at most one" : "one";
43
+ const many = valueMatched ? "" : "many ";
36
44
  const forwardTarget = relationship.cardinality === "one"
37
- ? `one ${targetLabel}`
45
+ ? `${one} ${targetLabel}`
38
46
  : `many ${target?.plural ?? relationship.to}`;
39
47
  return {
40
48
  from: source.name,
@@ -42,10 +50,11 @@ export function graphOf(manifest) {
42
50
  to: relationship.to,
43
51
  inverse: relationship.inverse,
44
52
  cardinality: relationship.cardinality,
45
- optional: relationship.cardinality === "many" || relationship.optional === true,
53
+ ...(relationship.on !== undefined ? { on: relationship.on } : {}),
54
+ optional: relationship.cardinality === "many" || valueMatched || relationship.optional === true,
46
55
  phrase: relationship.phrase,
47
56
  forward: `${source.label} ${relationship.phrase[0]} ${forwardTarget}`,
48
- backward: `${targetLabel} ${relationship.phrase[1]} many ${source.plural}`,
57
+ backward: `${targetLabel} ${relationship.phrase[1]} ${many}${source.plural}`,
49
58
  description: relationship.description,
50
59
  };
51
60
  })),
@@ -11,6 +11,12 @@ export declare const ENVIRONMENT_VARIABLES: {
11
11
  readonly workspaceId: "SEGMENTSTREAM_WORKSPACE_ID";
12
12
  readonly organizationId: "SEGMENTSTREAM_ORGANIZATION_ID";
13
13
  };
14
+ /**
15
+ * The workspace app credential (`ssw_…`), which Vercel injects into every service of a production
16
+ * deployment. The service forwards aggregates to SegmentStream with it; without it, aggregates are
17
+ * unavailable and everything else works.
18
+ */
19
+ export declare const APP_CREDENTIAL_VARIABLE = "SEGMENTSTREAM_APP_CREDENTIAL";
14
20
  /**
15
21
  * The workspace's Neon Auth, which signs the app's users in. Provisioning sets both on workspaces
16
22
  * that have it; a service without them accepts SegmentStream app tokens and API keys only.
@@ -38,6 +44,11 @@ export interface OntologyEnvironment {
38
44
  readonly applicationId: string;
39
45
  readonly workspaceId: string;
40
46
  readonly organizationId: string;
47
+ /**
48
+ * The workspace app credential. With it, `/api/ontology/aggregate` forwards to SegmentStream at
49
+ * `segmentstreamUrl` for `workspaceId`; previews and local development have none.
50
+ */
51
+ readonly appCredential?: Redacted.Redacted<string>;
41
52
  /** Present once the workspace has Neon Auth: members then sign in with its tokens. */
42
53
  readonly neonAuth?: NeonAuthEnvironment;
43
54
  /**
@@ -88,7 +99,8 @@ export declare class OntologyServiceConfigError extends OntologyServiceConfigErr
88
99
  /**
89
100
  * Reads the service's configuration from the environment. It fails closed: a missing or invalid
90
101
  * variable throws {@link OntologyServiceConfigError} naming every missing variable, and never the
91
- * value of the database URL. The Neon Auth variables are optional, but only as a pair.
102
+ * value of the database URL. The Neon Auth variables are optional, but only as a pair. The app
103
+ * credential is optional and kept redacted.
92
104
  */
93
105
  export declare function configFromEnv(environment?: Readonly<Record<string, string | undefined>>): OntologyEnvironment;
94
106
  export {};
@@ -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.
@@ -70,7 +76,8 @@ function checkDatabaseUrl(name, value) {
70
76
  /**
71
77
  * Reads the service's configuration from the environment. It fails closed: a missing or invalid
72
78
  * 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.
79
+ * value of the database URL. The Neon Auth variables are optional, but only as a pair. The app
80
+ * credential is optional and kept redacted.
74
81
  */
75
82
  export function configFromEnv(environment = process.env) {
76
83
  const read = (name) => environment[name]?.trim() ?? "";
@@ -107,6 +114,9 @@ export function configFromEnv(environment = process.env) {
107
114
  storageMaintenanceKey: Redacted.make(read("SEGMENTSTREAM_ONTOLOGY_STORAGE_MAINTENANCE_KEY")),
108
115
  }
109
116
  : {}),
117
+ ...(read(APP_CREDENTIAL_VARIABLE)
118
+ ? { appCredential: Redacted.make(read(APP_CREDENTIAL_VARIABLE)) }
119
+ : {}),
110
120
  ...(neonAuth === undefined ? {} : { neonAuth }),
111
121
  ...(localConnections === undefined ? {} : { localConnections }),
112
122
  };
@@ -30,6 +30,38 @@ declare const SignInCheckLimited_base: Schema.Class<SignInCheckLimited, Schema.T
30
30
  /** A client asked `POST /api/ontology/sign-in/check` too often; it may ask again after this. */
31
31
  export declare class SignInCheckLimited extends SignInCheckLimited_base {
32
32
  }
33
+ declare const InvalidAggregateQuery_base: Schema.Class<InvalidAggregateQuery, Schema.TaggedStruct<"InvalidAggregateQuery", {
34
+ readonly reason: Schema.String;
35
+ }>, Cause.YieldableError>;
36
+ /** An aggregate request the ontology cannot answer; the reason is written for the caller. */
37
+ export declare class InvalidAggregateQuery extends InvalidAggregateQuery_base {
38
+ }
39
+ declare const ReadDenied_base: Schema.Class<ReadDenied, Schema.TaggedStruct<"ReadDenied", {
40
+ readonly permissions: Schema.$Array<Schema.String>;
41
+ }>, Cause.YieldableError>;
42
+ /** The caller lacks `<entity>:read` on entities an aggregate reads: every such permission. */
43
+ export declare class ReadDenied extends ReadDenied_base {
44
+ }
45
+ declare const AggregateUnavailable_base: Schema.Class<AggregateUnavailable, Schema.TaggedStruct<"AggregateUnavailable", {}>, Cause.YieldableError>;
46
+ /** The deployment has no workspace app credential: previews and local development. */
47
+ export declare class AggregateUnavailable extends AggregateUnavailable_base {
48
+ }
49
+ declare const AggregateLimited_base: Schema.Class<AggregateLimited, Schema.TaggedStruct<"AggregateLimited", {
50
+ readonly retryAfterSeconds: Schema.Number;
51
+ }>, Cause.YieldableError>;
52
+ /** A principal ran more aggregates than its window allows; it may run more after this. */
53
+ export declare class AggregateLimited extends AggregateLimited_base {
54
+ }
55
+ declare const AggregateUpstreamUnavailable_base: Schema.Class<AggregateUpstreamUnavailable, Schema.TaggedStruct<"AggregateUpstreamUnavailable", {
56
+ readonly reason: Schema.Literals<readonly ["unreachable", "timeout", "invalid_response", "credential_rejected", "upstream_misconfigured"]>;
57
+ readonly status: Schema.optionalKey<Schema.Number>;
58
+ }>, Cause.YieldableError>;
59
+ /**
60
+ * SegmentStream did not answer an aggregate usably, or its answer faults the deployment rather than
61
+ * the caller. The operator log keeps the details; the caller learns only the reason.
62
+ */
63
+ export declare class AggregateUpstreamUnavailable extends AggregateUpstreamUnavailable_base {
64
+ }
33
65
  export interface ErrorBody {
34
66
  readonly error: string;
35
67
  readonly detail: string;
@@ -20,6 +20,50 @@ export class InvalidRequestBody extends Schema.TaggedError()("InvalidRequestBody
20
20
  /** A client asked `POST /api/ontology/sign-in/check` too often; it may ask again after this. */
21
21
  export class SignInCheckLimited extends Schema.TaggedError()("SignInCheckLimited", { retryAfterSeconds: Schema.Number }) {
22
22
  }
23
+ /** An aggregate request the ontology cannot answer; the reason is written for the caller. */
24
+ export class InvalidAggregateQuery extends Schema.TaggedError()("InvalidAggregateQuery", { reason: Schema.String }) {
25
+ }
26
+ /** The caller lacks `<entity>:read` on entities an aggregate reads: every such permission. */
27
+ export class ReadDenied extends Schema.TaggedError()("ReadDenied", {
28
+ permissions: Schema.Array(Schema.String),
29
+ }) {
30
+ }
31
+ /** The deployment has no workspace app credential: previews and local development. */
32
+ export class AggregateUnavailable extends Schema.TaggedError()("AggregateUnavailable", {}) {
33
+ }
34
+ /** A principal ran more aggregates than its window allows; it may run more after this. */
35
+ export class AggregateLimited extends Schema.TaggedError()("AggregateLimited", {
36
+ retryAfterSeconds: Schema.Number,
37
+ }) {
38
+ }
39
+ /**
40
+ * SegmentStream did not answer an aggregate usably, or its answer faults the deployment rather than
41
+ * the caller. The operator log keeps the details; the caller learns only the reason.
42
+ */
43
+ export class AggregateUpstreamUnavailable extends Schema.TaggedError()("AggregateUpstreamUnavailable", {
44
+ reason: Schema.Literals([
45
+ "unreachable",
46
+ "timeout",
47
+ "invalid_response",
48
+ "credential_rejected",
49
+ "upstream_misconfigured",
50
+ ]),
51
+ status: Schema.optionalKey(Schema.Number),
52
+ }) {
53
+ }
54
+ const UPSTREAM_DETAILS = {
55
+ unreachable: "SegmentStream could not be reached. Try again shortly.",
56
+ timeout: "SegmentStream did not answer in time. Try again shortly.",
57
+ invalid_response: "SegmentStream answered with an unexpected response.",
58
+ credential_rejected: "SegmentStream refused the deployment's credential.",
59
+ upstream_misconfigured: "SegmentStream does not serve aggregates for this deployment.",
60
+ };
61
+ /** `a`, `a and b`, `a, b and c`. */
62
+ function listed(values) {
63
+ return values.length <= 1
64
+ ? values.join("")
65
+ : `${values.slice(0, -1).join(", ")} and ${values.at(-1)}`;
66
+ }
23
67
  export function toServerResponse(response) {
24
68
  return HttpServerResponse.jsonUnsafe(errorBodyForWire(response.body), {
25
69
  status: response.status,
@@ -279,6 +323,64 @@ export function expectedFailure(error, requestId) {
279
323
  },
280
324
  };
281
325
  }
326
+ if (Predicate.isTagged(error, "ReadDenied")) {
327
+ const { permissions } = error;
328
+ return {
329
+ status: 403,
330
+ body: {
331
+ error: "forbidden",
332
+ detail: `${listed(permissions)} ${permissions.length === 1 ? "is" : "are"} required.`,
333
+ requestId,
334
+ permissions,
335
+ },
336
+ };
337
+ }
338
+ if (Predicate.isTagged(error, "AggregateLimited")) {
339
+ const { retryAfterSeconds } = error;
340
+ return {
341
+ status: 429,
342
+ body: {
343
+ error: "rate_limited",
344
+ detail: `Too many aggregates. Wait ${retryAfterSeconds} seconds, then try again.`,
345
+ requestId,
346
+ retryable: true,
347
+ },
348
+ headers: { "retry-after": String(retryAfterSeconds) },
349
+ };
350
+ }
351
+ if (Predicate.isTagged(error, "InvalidAggregateQuery")) {
352
+ return {
353
+ status: 400,
354
+ body: {
355
+ error: "invalid_aggregate_query",
356
+ detail: error.reason,
357
+ requestId,
358
+ },
359
+ };
360
+ }
361
+ if (Predicate.isTagged(error, "AggregateUnavailable")) {
362
+ return {
363
+ status: 503,
364
+ body: {
365
+ error: "aggregate_unavailable",
366
+ detail: "Aggregates are available in production deployments.",
367
+ requestId,
368
+ retryable: false,
369
+ },
370
+ };
371
+ }
372
+ if (Predicate.isTagged(error, "AggregateUpstreamUnavailable")) {
373
+ const { reason } = error;
374
+ return {
375
+ status: 502,
376
+ body: {
377
+ error: "aggregate_upstream_unavailable",
378
+ detail: UPSTREAM_DETAILS[reason],
379
+ requestId,
380
+ retryable: reason === "unreachable" || reason === "timeout",
381
+ },
382
+ };
383
+ }
282
384
  if (Predicate.isTagged(error, "ConnectionError")) {
283
385
  return connectionFailure(error, requestId);
284
386
  }
@@ -1,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>;