@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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Data, Effect, Redacted } from "effect";
|
|
2
|
+
import { AggregateUpstreamUnavailable } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* How long one aggregate may take end to end, the response included. BigQuery answers most within
|
|
5
|
+
* seconds; the margin covers a cold warehouse and the hops to and from SegmentStream.
|
|
6
|
+
*/
|
|
7
|
+
export const AGGREGATE_TIMEOUT_MS = 60_000;
|
|
8
|
+
/** The header that carries the service's request id to SegmentStream, as connection calls do. */
|
|
9
|
+
const REQUEST_ID_HEADER = "x-segmentstream-request-id";
|
|
10
|
+
/**
|
|
11
|
+
* The hash of the manifest the service resolved and checked the request against. SegmentStream
|
|
12
|
+
* answers `409 ontology_version_mismatch` when its deployed manifest differs, so a request checked
|
|
13
|
+
* against one ontology never runs against another.
|
|
14
|
+
*/
|
|
15
|
+
export const MANIFEST_HASH_HEADER = "x-ontology-manifest-hash";
|
|
16
|
+
/**
|
|
17
|
+
* The entities the service checked `<entity>:read` on, sorted and comma-separated. SegmentStream
|
|
18
|
+
* answers `409 aggregate_scope_mismatch` when it would read any other, so the service's resolution
|
|
19
|
+
* and SegmentStream's can never drift apart unnoticed.
|
|
20
|
+
*/
|
|
21
|
+
export const TOUCHED_HEADER = "x-ontology-touched";
|
|
22
|
+
/**
|
|
23
|
+
* Statuses that refuse the deployment rather than the caller: its credential, its admission or the
|
|
24
|
+
* route itself. Passing them on would make the caller sign in again or look for a missing route.
|
|
25
|
+
*/
|
|
26
|
+
const DEPLOYMENT_FAULTS = {
|
|
27
|
+
401: "credential_rejected",
|
|
28
|
+
403: "credential_rejected",
|
|
29
|
+
404: "upstream_misconfigured",
|
|
30
|
+
};
|
|
31
|
+
/** SegmentStream would read beyond what the service checked: this deployment's SDK is behind. */
|
|
32
|
+
export const SCOPE_MISMATCH = "aggregate_scope_mismatch";
|
|
33
|
+
/** Statuses whose `Retry-After` the caller receives as SegmentStream sent it. */
|
|
34
|
+
const RETRY_AFTER_STATUSES = new Set([429, 503]);
|
|
35
|
+
/** A `Retry-After` value: seconds or an HTTP date. Anything else is not passed on. */
|
|
36
|
+
const RETRY_AFTER_VALUE = /^[0-9A-Za-z ,:+-]{1,64}$/;
|
|
37
|
+
/** The most of SegmentStream's body an operator log keeps. */
|
|
38
|
+
const SNIPPET_LENGTH = 500;
|
|
39
|
+
/** How many causes deep an operator log follows a failed call. */
|
|
40
|
+
const CAUSE_DEPTH = 3;
|
|
41
|
+
/** Hosts a deployment may reach over plain http: the machine itself, in development. */
|
|
42
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "[::1]"]);
|
|
43
|
+
/** The call ended without an answer; `aborted` when the caller went away first. */
|
|
44
|
+
class CallFailed extends Data.TaggedError("CallFailed") {
|
|
45
|
+
}
|
|
46
|
+
function isRecord(value) {
|
|
47
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
/** The credential travels only to SegmentStream over TLS, or over plain http to this machine. */
|
|
50
|
+
function securePlatform(origin) {
|
|
51
|
+
const { protocol, hostname } = new URL(origin);
|
|
52
|
+
if (protocol === "https:") {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return (protocol === "http:" && (LOOPBACK_HOSTS.has(hostname) || /^127(?:\.\d{1,3}){3}$/.test(hostname)));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A failed call for the operator: name, message and stack, then each cause, with every occurrence
|
|
59
|
+
* of the credential replaced.
|
|
60
|
+
*/
|
|
61
|
+
function describeError(error, redact, depth = 0) {
|
|
62
|
+
if (!(error instanceof Error)) {
|
|
63
|
+
return redact(String(error));
|
|
64
|
+
}
|
|
65
|
+
const code = error.code;
|
|
66
|
+
const head = error.stack ?? `${error.name}: ${error.message}`;
|
|
67
|
+
const own = typeof code === "string" ? `${head} [${code}]` : head;
|
|
68
|
+
const cause = error.cause !== undefined && depth < CAUSE_DEPTH
|
|
69
|
+
? `\ncaused by: ${describeError(error.cause, redact, depth + 1)}`
|
|
70
|
+
: "";
|
|
71
|
+
return redact(`${own}${cause}`);
|
|
72
|
+
}
|
|
73
|
+
function parseObject(text) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(text);
|
|
76
|
+
return isRecord(parsed) ? { body: parsed } : { error: new TypeError("Not a JSON object.") };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return { error };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* SegmentStream's `POST /api/v1/workspaces/{id}/aggregate`, and its per-entity
|
|
84
|
+
* `…/entities/{entity}/aggregate`, called with the workspace app credential. `undefined` when the deployment has none, or when SegmentStream's origin is plain
|
|
85
|
+
* http to another machine, which is logged once. Every request names the manifest it was checked
|
|
86
|
+
* against, and a redirect is refused, so the credential never leaves for another origin.
|
|
87
|
+
*
|
|
88
|
+
* Every answer but a 2xx is logged with both request ids, the status and the start of the body; a
|
|
89
|
+
* failed call with its message, stack and causes. The credential is redacted from all of them. A
|
|
90
|
+
* caller who goes away interrupts the call, which is not SegmentStream's failure.
|
|
91
|
+
*/
|
|
92
|
+
export function platformAggregates(environment, fetchImpl = fetch) {
|
|
93
|
+
const { appCredential, segmentstreamUrl } = environment;
|
|
94
|
+
if (appCredential === undefined) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (!securePlatform(segmentstreamUrl)) {
|
|
98
|
+
Effect.runSync(Effect.logError("ontology.aggregate.insecure_platform").pipe(Effect.annotateLogs({
|
|
99
|
+
diagnostic: "SEGMENTSTREAM_URL is not https; aggregates are unavailable.",
|
|
100
|
+
platform: segmentstreamUrl,
|
|
101
|
+
})));
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const credential = Redacted.value(appCredential);
|
|
105
|
+
const redact = (text) => text.split(credential).join("[redacted]");
|
|
106
|
+
const workspace = `${segmentstreamUrl}/api/v1/workspaces/` + encodeURIComponent(environment.workspaceId);
|
|
107
|
+
const urlOf = (entity) => entity === undefined
|
|
108
|
+
? `${workspace}/aggregate`
|
|
109
|
+
: `${workspace}/entities/${encodeURIComponent(entity)}/aggregate`;
|
|
110
|
+
const exchange = (input) => Effect.tryPromise({
|
|
111
|
+
try: async (signal) => {
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetchImpl(urlOf(input.entity), {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
authorization: `Bearer ${credential}`,
|
|
117
|
+
"content-type": "application/json",
|
|
118
|
+
accept: "application/json",
|
|
119
|
+
[REQUEST_ID_HEADER]: input.requestId,
|
|
120
|
+
[MANIFEST_HASH_HEADER]: environment.manifestHash,
|
|
121
|
+
[TOUCHED_HEADER]: input.touched.join(","),
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify(input.body),
|
|
124
|
+
redirect: "error",
|
|
125
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(AGGREGATE_TIMEOUT_MS)]),
|
|
126
|
+
});
|
|
127
|
+
const text = await response.text();
|
|
128
|
+
return {
|
|
129
|
+
status: response.status,
|
|
130
|
+
text,
|
|
131
|
+
retryAfter: response.headers.get("retry-after"),
|
|
132
|
+
platformRequestId: response.headers.get("x-request-id"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
throw new CallFailed({ error, aborted: signal.aborted });
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
catch: (error) => error instanceof CallFailed ? error : new CallFailed({ error, aborted: false }),
|
|
140
|
+
});
|
|
141
|
+
/** No answer: a timeout or an unreachable SegmentStream. */
|
|
142
|
+
const unanswered = (error, requestId) => {
|
|
143
|
+
const reason = error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "unreachable";
|
|
144
|
+
return Effect.logWarning("ontology.aggregate.upstream_failed").pipe(Effect.annotateLogs({ requestId, diagnostic: reason, error: describeError(error, redact) }), Effect.andThen(Effect.fail(new AggregateUpstreamUnavailable({ reason }))));
|
|
145
|
+
};
|
|
146
|
+
/** An answer: passed on, or a 502 when it faults the deployment or is not a JSON object. */
|
|
147
|
+
const judge = (answer, requestId) => Effect.gen(function* () {
|
|
148
|
+
const parsed = parseObject(answer.text);
|
|
149
|
+
const body = "body" in parsed ? parsed.body : undefined;
|
|
150
|
+
const fault = DEPLOYMENT_FAULTS[answer.status] ?? (body === undefined ? "invalid_response" : undefined);
|
|
151
|
+
const platformRequestId = typeof body?.requestId === "string" ? body.requestId : answer.platformRequestId;
|
|
152
|
+
if (fault !== undefined || answer.status < 200 || answer.status >= 300) {
|
|
153
|
+
const code = typeof body?.error === "string" ? body.error : undefined;
|
|
154
|
+
const level = fault !== undefined || code === SCOPE_MISMATCH
|
|
155
|
+
? Effect.logError
|
|
156
|
+
: answer.status >= 500
|
|
157
|
+
? Effect.logWarning
|
|
158
|
+
: Effect.logInfo;
|
|
159
|
+
yield* level(fault === undefined
|
|
160
|
+
? "ontology.aggregate.platform_refused"
|
|
161
|
+
: "ontology.aggregate.upstream_failed").pipe(Effect.annotateLogs({
|
|
162
|
+
requestId,
|
|
163
|
+
platformRequestId: platformRequestId ?? "none",
|
|
164
|
+
status: answer.status,
|
|
165
|
+
diagnostic: fault ?? code ?? "refused",
|
|
166
|
+
body: redact(answer.text.slice(0, SNIPPET_LENGTH)),
|
|
167
|
+
...("error" in parsed ? { error: describeError(parsed.error, redact) } : {}),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
if (fault !== undefined || body === undefined) {
|
|
171
|
+
return yield* new AggregateUpstreamUnavailable({
|
|
172
|
+
reason: fault ?? "invalid_response",
|
|
173
|
+
status: answer.status,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const retryAfter = RETRY_AFTER_STATUSES.has(answer.status) &&
|
|
177
|
+
answer.retryAfter !== null &&
|
|
178
|
+
RETRY_AFTER_VALUE.test(answer.retryAfter)
|
|
179
|
+
? answer.retryAfter
|
|
180
|
+
: undefined;
|
|
181
|
+
return {
|
|
182
|
+
status: answer.status,
|
|
183
|
+
text: answer.text,
|
|
184
|
+
body,
|
|
185
|
+
...(retryAfter === undefined ? {} : { retryAfter }),
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
return (input) => exchange(input).pipe(Effect.catchTag("CallFailed", (failed) => failed.aborted ? Effect.interrupt : unanswered(failed.error, input.requestId)), Effect.flatMap((answer) => judge(answer, input.requestId)));
|
|
189
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { OntologyManifest } from "../manifest.js";
|
|
3
|
+
import type { AggregateTransport, PlatformAnswer } from "./aggregate-platform.js";
|
|
4
|
+
import { CurrentPrincipal } from "./authentication.js";
|
|
5
|
+
import { AggregateLimited, AggregateUnavailable, AggregateUpstreamUnavailable, InvalidAggregateQuery, InvalidRequestBody, ReadDenied, RequestId } from "./errors.js";
|
|
6
|
+
import { type RateLimit, type RateLimiter } from "./rate-limit.js";
|
|
7
|
+
/**
|
|
8
|
+
* Aggregates one principal may run per minute, through both routes and the MCP tool together. Each
|
|
9
|
+
* runs a warehouse query with the deployment's credential, so one caller must not spend without
|
|
10
|
+
* bound.
|
|
11
|
+
*/
|
|
12
|
+
export declare const AGGREGATE_LIMIT: RateLimit;
|
|
13
|
+
/** Every way the facade refuses or fails an aggregate before SegmentStream answers it. */
|
|
14
|
+
export type AggregateFailure = AggregateLimited | InvalidRequestBody | InvalidAggregateQuery | ReadDenied | AggregateUnavailable | AggregateUpstreamUnavailable;
|
|
15
|
+
/**
|
|
16
|
+
* Runs an aggregate as the caller, whatever the transport: the HTTP route passes the parsed body,
|
|
17
|
+
* the MCP tool its arguments. Its answer is SegmentStream's, error statuses included.
|
|
18
|
+
*/
|
|
19
|
+
export type Aggregate = (body: unknown) => Effect.Effect<PlatformAnswer, AggregateFailure, CurrentPrincipal | RequestId>;
|
|
20
|
+
/** The service's aggregates: model-level, and the per-entity shorthand of each entity. */
|
|
21
|
+
export interface Aggregates {
|
|
22
|
+
/** `POST /api/ontology/aggregate` and the MCP `aggregate` tool. */
|
|
23
|
+
readonly model: Aggregate;
|
|
24
|
+
/** `POST /api/ontology/<entity>/aggregate`, the shorthand `segmentstream.ontology.<entity>`. */
|
|
25
|
+
readonly entity: (entity: string) => Aggregate;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The ontology service's aggregate facade (docs/ontology-aggregates-spec.md, "Surfaces"). Both
|
|
29
|
+
* routes and the MCP tool run as the caller:
|
|
30
|
+
* 0. the call counts against the caller's {@link AGGREGATE_LIMIT}, whichever way it came;
|
|
31
|
+
* 1. the request's shape and bounds are checked, and its names resolved against the service's own
|
|
32
|
+
* manifest into every entity it reads;
|
|
33
|
+
* 2. the caller needs `<entity>:read` on all of them;
|
|
34
|
+
* 3. the body goes to SegmentStream unchanged with the workspace app credential, naming the entities
|
|
35
|
+
* checked, or the answer is `aggregate_unavailable` where the deployment has no credential.
|
|
36
|
+
*/
|
|
37
|
+
export declare function makeAggregates(input: {
|
|
38
|
+
readonly manifest: OntologyManifest;
|
|
39
|
+
readonly transport: AggregateTransport | undefined;
|
|
40
|
+
/** One bucket per principal; {@link AGGREGATE_LIMIT} by default. */
|
|
41
|
+
readonly limiter?: RateLimiter;
|
|
42
|
+
}): Aggregates;
|
|
@@ -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
|
+
}
|
package/dist/service/api.d.ts
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/service/api.js
CHANGED
|
@@ -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
|
|
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
|
|
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". */
|
package/dist/service/catalog.js
CHANGED
|
@@ -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
|
-
?
|
|
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
|
-
|
|
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
|
|
57
|
+
backward: `${targetLabel} ${relationship.phrase[1]} ${many}${source.plural}`,
|
|
49
58
|
description: relationship.description,
|
|
50
59
|
};
|
|
51
60
|
})),
|
package/dist/service/config.d.ts
CHANGED
|
@@ -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 {};
|
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.
|
|
@@ -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
|
};
|