@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.
Files changed (71) hide show
  1. package/README.md +51 -15
  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/apply.d.ts +1 -1
  13. package/dist/migrate/apply.js +36 -3
  14. package/dist/migrate/plan.d.ts +1 -1
  15. package/dist/migrate/plan.js +38 -13
  16. package/dist/migrate/storage.d.ts +3 -2
  17. package/dist/migrate/storage.js +15 -5
  18. package/dist/names.d.ts +4 -2
  19. package/dist/names.js +4 -1
  20. package/dist/query.d.ts +5 -2
  21. package/dist/query.js +6 -2
  22. package/dist/relationship.d.ts +36 -5
  23. package/dist/relationship.js +5 -3
  24. package/dist/render.d.ts +47 -3
  25. package/dist/render.js +117 -11
  26. package/dist/replication/baseline.d.ts +19 -0
  27. package/dist/replication/baseline.js +97 -0
  28. package/dist/replication/bigquery.d.ts +23 -0
  29. package/dist/replication/bigquery.js +63 -0
  30. package/dist/replication/model.d.ts +38 -0
  31. package/dist/replication/model.js +99 -0
  32. package/dist/replication/schema.d.ts +7 -0
  33. package/dist/replication/schema.js +123 -0
  34. package/dist/replication/store.d.ts +35 -0
  35. package/dist/replication/store.js +88 -0
  36. package/dist/replication/transaction.d.ts +4 -0
  37. package/dist/replication/transaction.js +11 -0
  38. package/dist/runtime/pipeline.js +2 -1
  39. package/dist/runtime/principals.js +6 -0
  40. package/dist/runtime/service-binding.js +8 -0
  41. package/dist/runtime/sql-runtime.js +2 -1
  42. package/dist/service/aggregate-platform.d.ts +60 -0
  43. package/dist/service/aggregate-platform.js +189 -0
  44. package/dist/service/aggregate.d.ts +42 -0
  45. package/dist/service/aggregate.js +145 -0
  46. package/dist/service/api.d.ts +5 -3
  47. package/dist/service/api.js +52 -6
  48. package/dist/service/catalog.d.ts +10 -1
  49. package/dist/service/catalog.js +12 -3
  50. package/dist/service/config.d.ts +13 -1
  51. package/dist/service/config.js +11 -1
  52. package/dist/service/errors.d.ts +32 -0
  53. package/dist/service/errors.js +102 -0
  54. package/dist/service/handlers.d.ts +3 -1
  55. package/dist/service/handlers.js +67 -29
  56. package/dist/service/mcp/server.d.ts +5 -0
  57. package/dist/service/mcp/server.js +61 -9
  58. package/dist/service/mcp/tools.d.ts +13 -2
  59. package/dist/service/mcp/tools.js +165 -6
  60. package/dist/service/operations.d.ts +2 -1
  61. package/dist/service/operations.js +16 -6
  62. package/dist/service/plan.js +7 -1
  63. package/dist/service/replication.d.ts +5 -0
  64. package/dist/service/replication.js +25 -0
  65. package/dist/service/runtime-contract.d.ts +1 -0
  66. package/dist/service/schemas.d.ts +99 -1
  67. package/dist/service/schemas.js +150 -2
  68. package/dist/service/service.js +10 -2
  69. package/dist/user.d.ts +1 -1
  70. package/dist/warehouse-backed.js +222 -121
  71. package/package.json +1 -1
@@ -1,11 +1,14 @@
1
1
  import { Schema } from "effect";
2
+ import { AGGREGATE_NAME_PATTERN } from "../../aggregate-routes.js";
2
3
  import { InvalidOntology } from "../../diagnostics.js";
4
+ import { isValueMatched } from "../../manifest.js";
3
5
  import { GENERATED_UPSERT_PREFIX, humanize, mcpToolName } from "../../names.js";
4
6
  import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
5
7
  import { queryJsonSchema } from "../../query.js";
8
+ import { measuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
6
9
  import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
7
10
  import { actionDescription, requires } from "../descriptions.js";
8
- import { HistoryPage, Me } from "../schemas.js";
11
+ import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, HistoryPage, Me, } from "../schemas.js";
9
12
  const DEFS = "#/$defs/";
10
13
  /**
11
14
  * A JSON Schema whose root is an object schema, as MCP requires of tool inputs and outputs: a root
@@ -94,12 +97,30 @@ function relatedHint(planned, traversal) {
94
97
  `{"${traversal.opposite}": {"some": {"id": {"eq": "${id}"}}}}.`);
95
98
  }
96
99
  }
100
+ /**
101
+ * How an agent reads the target of a value-matched relationship, which the service cannot traverse:
102
+ * the reference is an ordinary property, and the target lives in the warehouse.
103
+ */
104
+ function matchedHints(planned) {
105
+ return planned.resolved.relationships.flatMap(({ name, relationship, target }) => {
106
+ if (relationship.cardinality !== "one" || !isValueMatched(relationship)) {
107
+ return [];
108
+ }
109
+ const property = Object.keys(relationship.on ?? {})[0] ?? "";
110
+ return [
111
+ `${name} is matched on ${property}; read ${target ?? "it"} through the workspace aggregates.`,
112
+ ];
113
+ });
114
+ }
97
115
  function readTools(planned, schemas, built) {
98
116
  const { manifest } = built.plan.ontology;
99
117
  const { definition } = planned.resolved.entity;
100
118
  const label = definition.label.toLowerCase();
101
119
  const read = permissionOf(planned.name, READ_VERB);
102
- const related = planned.traversals.map((traversal) => `- ${relatedHint(planned, traversal)}`);
120
+ const related = [
121
+ ...planned.traversals.map((traversal) => relatedHint(planned, traversal)),
122
+ ...matchedHints(planned),
123
+ ].map((hint) => `- ${hint}`);
103
124
  return [
104
125
  {
105
126
  name: mcpToolName(planned.name, "find"),
@@ -181,14 +202,140 @@ const GLOBAL_TOOLS = [
181
202
  target: { kind: "me" },
182
203
  },
183
204
  ];
205
+ const AGGREGATE_NAME = {
206
+ type: "string",
207
+ pattern: AGGREGATE_NAME_PATTERN,
208
+ description: "<entity>.<property>, or a route from a measure's entity when it reaches the property's " +
209
+ "entity in several ways: <entity>.<relationship>.<property>, at most two relationships.",
210
+ };
211
+ const AGGREGATE_VALUE = {
212
+ anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }],
213
+ };
214
+ /** One `anyOf` branch per measure: a JSON Schema `enum` cannot describe its values. */
215
+ function measureChoice({ name, entity, measure }) {
216
+ return {
217
+ const: name,
218
+ description: `${entity.label} · ${measure.label} (${measure.unit}): ${measure.description}`,
219
+ };
220
+ }
221
+ /** The aggregate tool's input for a caller: the request of `POST /api/ontology/aggregate`. */
222
+ function aggregateInput(measures) {
223
+ return {
224
+ type: "object",
225
+ properties: {
226
+ measures: {
227
+ type: "array",
228
+ minItems: 1,
229
+ maxItems: AGGREGATE_LIMITS.measures,
230
+ uniqueItems: true,
231
+ items: { anyOf: measures.map(measureChoice) },
232
+ description: "What to compute, as <entity>.<measure>. Measures of several entities are each " +
233
+ "computed over their own entity's rows, then combined on the dimensions.",
234
+ },
235
+ dimensions: {
236
+ type: "array",
237
+ maxItems: AGGREGATE_LIMITS.dimensions,
238
+ uniqueItems: true,
239
+ items: AGGREGATE_NAME,
240
+ description: "What to group by. Every dimension must be reachable from every measure's entity.",
241
+ },
242
+ filters: {
243
+ type: "array",
244
+ maxItems: AGGREGATE_LIMITS.filters,
245
+ description: "All must hold. A filter applies to each measure's entity that reaches it; the answer " +
246
+ "warns about the others.",
247
+ items: {
248
+ type: "object",
249
+ properties: {
250
+ dimension: AGGREGATE_NAME,
251
+ operator: { enum: [...AGGREGATE_FILTER_OPERATORS] },
252
+ value: { ...AGGREGATE_VALUE, description: "The value of equals." },
253
+ values: {
254
+ type: "array",
255
+ minItems: 1,
256
+ maxItems: AGGREGATE_LIMITS.values,
257
+ items: AGGREGATE_VALUE,
258
+ description: "The values of in, or the two inclusive bounds of between, for example " +
259
+ '["2026-09-01", "2026-09-30"].',
260
+ },
261
+ },
262
+ required: ["dimension", "operator"],
263
+ additionalProperties: false,
264
+ },
265
+ },
266
+ granularity: {
267
+ enum: [...AGGREGATE_GRANULARITIES],
268
+ description: "Buckets the date or timestamp dimension granularityOf names.",
269
+ },
270
+ granularityOf: { ...AGGREGATE_NAME, description: "A requested date or timestamp dimension." },
271
+ orderBy: {
272
+ type: "array",
273
+ maxItems: AGGREGATE_LIMITS.orderBy,
274
+ items: {
275
+ type: "object",
276
+ properties: {
277
+ measure: { type: "string", description: "A requested measure." },
278
+ dimension: { type: "string", description: "A requested dimension." },
279
+ direction: { enum: ["asc", "desc"] },
280
+ },
281
+ required: ["direction"],
282
+ additionalProperties: false,
283
+ },
284
+ },
285
+ limit: {
286
+ type: "integer",
287
+ minimum: 1,
288
+ maximum: AGGREGATE_LIMITS.rows,
289
+ description: "At most 1000 rows; 100 by default.",
290
+ },
291
+ },
292
+ required: ["measures"],
293
+ additionalProperties: false,
294
+ };
295
+ }
296
+ const AGGREGATE_DESCRIPTION = "Numbers about the model: measures from one or several entities, grouped and filtered by " +
297
+ `properties of related entities. The rule: ${RECORDS_OR_AGGREGATES}.\n\n` +
298
+ "Examples, with entities sales, stock and product:\n" +
299
+ '- September revenue by brand: {"measures": ["sales.revenue"], "dimensions": ["product.brand"], ' +
300
+ '"filters": [{"dimension": "sales.order_date", "operator": "between", ' +
301
+ '"values": ["2026-09-01", "2026-09-30"]}]}\n' +
302
+ '- September units sold beside units in stock, by status: {"measures": ["sales.units", ' +
303
+ '"stock.on_hand"], "dimensions": ["product.status"], "filters": [{"dimension": ' +
304
+ '"sales.order_date", "operator": "between", "values": ["2026-09-01", "2026-09-30"]}]}\n\n' +
305
+ "Partitioned warehouse entities require a between filter on their date property; the error " +
306
+ "names it. Currency measures need a single-currency filter. " +
307
+ "Answers come from the warehouse, and stored records from its replica: execution.sources says " +
308
+ "how fresh each is, and execution.warnings says when a filter did not apply to a measure. " +
309
+ "Requires reading every entity the request reads, path intermediates included. Call describe " +
310
+ "for the model's measures and relationships.";
311
+ function aggregateTool(manifest) {
312
+ return {
313
+ name: "aggregate",
314
+ title: "Aggregate measures",
315
+ description: AGGREGATE_DESCRIPTION,
316
+ inputSchema: aggregateInput(measuresFor(manifest)),
317
+ inputSchemaFor: (caller) => {
318
+ const measures = measuresFor(manifest, caller.permissions);
319
+ return measures.length === 0 ? undefined : aggregateInput(measures);
320
+ },
321
+ annotations: READ,
322
+ permissions: [],
323
+ target: { kind: "aggregate" },
324
+ };
325
+ }
184
326
  /**
185
- * The MCP tools of an ontology: `describe` and `me`, then for every stored entity `find`, `get`,
327
+ * The MCP tools of an ontology: `describe` and `me`, `aggregate` when the ontology has measures,
328
+ * then for every stored entity `find`, `get`,
186
329
  * `history` and a tool per generated and authored action. Entities with a warehouse source have none,
187
330
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
188
331
  * `check` already refuses.
189
332
  */
190
333
  export function mcpTools(built) {
191
- const tools = [...GLOBAL_TOOLS];
334
+ const { manifest } = built.plan.ontology;
335
+ const tools = [
336
+ ...GLOBAL_TOOLS,
337
+ ...(measuresFor(manifest).length > 0 ? [aggregateTool(manifest)] : []),
338
+ ];
192
339
  for (const planned of built.plan.entities) {
193
340
  if (planned.resolved.entity.definition.source !== undefined) {
194
341
  continue;
@@ -212,7 +359,19 @@ export function mcpTools(built) {
212
359
  }
213
360
  return tools;
214
361
  }
215
- /** The tools a caller may see: those whose every permission the caller's roles grant. */
362
+ /**
363
+ * The tools a caller may see: those whose every permission the caller's roles grant, each with the
364
+ * caller's input schema.
365
+ */
216
366
  export function visibleTools(tools, caller) {
217
- return tools.filter((tool) => tool.permissions.every((required) => isAllowed(required, caller)));
367
+ return tools.flatMap((tool) => {
368
+ if (!tool.permissions.every((required) => isAllowed(required, caller))) {
369
+ return [];
370
+ }
371
+ if (tool.inputSchemaFor === undefined) {
372
+ return [tool];
373
+ }
374
+ const inputSchema = tool.inputSchemaFor(caller);
375
+ return inputSchema === undefined ? [] : [{ ...tool, inputSchema }];
376
+ });
218
377
  }
@@ -4,6 +4,7 @@ import { PermissionDenied } from "../errors.js";
4
4
  import type { Query, QueryResult } from "../query.js";
5
5
  import { CurrentPrincipal } from "./authentication.js";
6
6
  import type { AfterCommitHook } from "./config.js";
7
+ import type { ReplicationKick } from "./replication.js";
7
8
  import { type ConnectionTransportFactory } from "./connections.js";
8
9
  import { InvalidActionInput, RequestId } from "./errors.js";
9
10
  import type { ApiPlan, PlannedAction, PlannedEntity, Traversal } from "./plan.js";
@@ -59,7 +60,7 @@ export interface Caller {
59
60
  }
60
61
  type Backend = OntologyBackend["Service"];
61
62
  /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
62
- export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory): {
63
+ export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick): {
63
64
  /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
64
65
  find: (planned: PlannedEntity, query: Query) => Effect.Effect<QueryResult<Readonly<Record<string, unknown>>>, PermissionDenied | import("../index.js").InvalidQuery, CurrentPrincipal>;
65
66
  /** One record. Requires `read` on the entity. */
@@ -145,7 +145,7 @@ function traverseOf(backend, targets, planned, traversal, id, query) {
145
145
  return page(yield* backend.find({ entity: traversal.target, query: { ...query, where } }));
146
146
  });
147
147
  }
148
- function runActionOf(backend, ontology, hooks, connections) {
148
+ function runActionOf(backend, ontology, hooks, connections, replicate) {
149
149
  return (planned, { name, generated }, call) => Effect.gen(function* () {
150
150
  const principal = yield* CurrentPrincipal;
151
151
  const requestId = yield* RequestId;
@@ -161,9 +161,19 @@ function runActionOf(backend, ontology, hooks, connections) {
161
161
  identityToken: call.identityToken,
162
162
  audit: { action: `${planned.name}.${name}`, principalId: principal.id, requestId },
163
163
  }),
164
- ...(hook === undefined
165
- ? {}
166
- : { afterCommit: afterCommitOf(hook, { requestId, generated }, ontology) }),
164
+ ...(hook || backend.replicationEnabled
165
+ ? {
166
+ afterCommit: (committed) => Effect.gen(function* () {
167
+ if (hook) {
168
+ yield* afterCommitOf(hook, { requestId, generated }, ontology)(committed);
169
+ }
170
+ if (committed.edits.length > 0 &&
171
+ (yield* backend.replicationEnabled ?? Effect.succeed(false))) {
172
+ yield* replicate(call.identityToken);
173
+ }
174
+ }).pipe(Effect.catchCause((cause) => Effect.logWarning("ontology.replication.deferred", cause))),
175
+ }
176
+ : {}),
167
177
  });
168
178
  const { issues } = outcome.validation;
169
179
  if (!outcome.validation.ok && !call.validateOnly) {
@@ -199,7 +209,7 @@ function callerOf(backend) {
199
209
  });
200
210
  }
201
211
  /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
202
- export function makeOperations(backend, plan, hooks, connections) {
212
+ export function makeOperations(backend, plan, hooks, connections, replicate) {
203
213
  const targets = relationshipTargets(plan);
204
214
  return {
205
215
  /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
@@ -211,7 +221,7 @@ export function makeOperations(backend, plan, hooks, connections) {
211
221
  /** The records a relationship reaches from one record, as a page of the target entity. */
212
222
  traverse: (planned, traversal, id, query) => traverseOf(backend, targets, planned, traversal, id, query),
213
223
  /** Runs an action as the caller, with the action's after-commit hook and connections. */
214
- runAction: runActionOf(backend, plan.ontology, hooks, connections),
224
+ runAction: runActionOf(backend, plan.ontology, hooks, connections, replicate),
215
225
  /** The caller's user, roles and permissions. It needs no permission. */
216
226
  caller: callerOf(backend),
217
227
  };
@@ -63,13 +63,19 @@ export function planApi(ontology, generatedActions) {
63
63
  const report = (path, message) => {
64
64
  diagnostics.push({ severity: "error", code: "reserved-name", path, message });
65
65
  };
66
+ // The service cannot read warehouse records, so a relationship to one has no route.
67
+ const warehouseBacked = new Set(ontology.entities
68
+ .filter((candidate) => candidate.entity.definition.source !== undefined)
69
+ .map((candidate) => candidate.name));
66
70
  const entities = ontology.entities.map((resolved) => ({
67
71
  name: resolved.name,
68
72
  resolved,
69
73
  traversals: resolved.entity.definition.source !== undefined
70
74
  ? []
71
75
  : [
72
- ...resolved.relationships.map(declaredTraversal),
76
+ ...resolved.relationships
77
+ .filter(({ target }) => target === undefined || !warehouseBacked.has(target))
78
+ .map(declaredTraversal),
73
79
  ...inverseTraversals(resolved.name, ontology.entities),
74
80
  ],
75
81
  actions: plannedActions(resolved, (entity) => generatedActions(entity, ontology), report),
@@ -0,0 +1,5 @@
1
+ import { Effect } from "effect";
2
+ import type { OntologyEnvironment } from "./config.js";
3
+ export type ReplicationKick = (identity: string | undefined) => Effect.Effect<void>;
4
+ /** A fast path only. The committed history, not this request, makes delivery durable. */
5
+ export declare function deployedReplication(environment: OntologyEnvironment, fetchImpl?: typeof fetch): ReplicationKick;
@@ -0,0 +1,25 @@
1
+ import { Effect } from "effect";
2
+ /** A fast path only. The committed history, not this request, makes delivery durable. */
3
+ export function deployedReplication(environment, fetchImpl = fetch) {
4
+ return (identity) => {
5
+ if (!identity) {
6
+ return Effect.void;
7
+ }
8
+ return Effect.tryPromise(async () => {
9
+ const response = await fetchImpl(`${environment.segmentstreamUrl}/api/internal/v1/workspaces/` +
10
+ `${encodeURIComponent(environment.workspaceId)}/ontology/replication/drain`, {
11
+ method: "POST",
12
+ redirect: "error",
13
+ signal: AbortSignal.timeout(5000),
14
+ headers: {
15
+ "x-segmentstream-runtime-identity": identity,
16
+ "x-segmentstream-organization-id": environment.organizationId,
17
+ },
18
+ });
19
+ await response.body?.cancel();
20
+ if (!response.ok) {
21
+ throw new Error(`Replication wake-up returned HTTP ${response.status}.`);
22
+ }
23
+ }).pipe(Effect.catchCause((cause) => Effect.logWarning("ontology.replication.deferred", cause)));
24
+ };
25
+ }
@@ -189,6 +189,7 @@ declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segm
189
189
  readonly resolveApiKey: (key: Redacted.Redacted<string>) => Effect.Effect<Principal | undefined>;
190
190
  /** The manifest hash recorded in the `_ontology` catalog table, or `undefined` before the first. */
191
191
  readonly appliedHash: Effect.Effect<string | undefined>;
192
+ readonly replicationEnabled?: Effect.Effect<boolean>;
192
193
  /** The OAuth tables, for members who connect agents; without it, agents use API keys. */
193
194
  readonly oauth?: OAuthStore;
194
195
  }>;
@@ -4,7 +4,7 @@ import type { ApiPlan } from "./plan.js";
4
4
  export declare const IdParam: Schema.String;
5
5
  /** The schemas of one entity's records and queries. */
6
6
  export interface EntitySchemas {
7
- /** System fields, properties and `<relationship>_id` per `rel.one`. */
7
+ /** System fields, properties and `<relationship>_id` per stored `rel.one`. */
8
8
  readonly record: Schema.Top;
9
9
  /** A record with the relationships a query may include. */
10
10
  readonly result: Schema.Top;
@@ -392,6 +392,13 @@ export declare const Graph: Schema.Struct<{
392
392
  readonly to: Schema.String;
393
393
  readonly inverse: Schema.String;
394
394
  readonly cardinality: Schema.Literals<readonly ["one", "many"]>;
395
+ /** The property pair of a relationship matched by value: one with a warehouse-backed end. */
396
+ readonly on: Schema.optionalKey<Schema.encodeKeys<Schema.Struct<{
397
+ readonly property: Schema.String;
398
+ readonly targetProperty: Schema.String;
399
+ }>, {
400
+ readonly targetProperty: "target_property";
401
+ }>>;
395
402
  readonly optional: Schema.Boolean;
396
403
  readonly phrase: Schema.Tuple<readonly [Schema.String, Schema.String]>;
397
404
  readonly forward: Schema.String;
@@ -414,6 +421,92 @@ export declare const SIGN_IN_CHECK_MAX_EMAIL_LENGTH = 320;
414
421
  export declare const SignInCheckRequest: Schema.Struct<{
415
422
  readonly email: Schema.String;
416
423
  }>;
424
+ /** The bounds of a model-level aggregate (docs/ontology-aggregates-spec.md, "Request"). */
425
+ export declare const AGGREGATE_LIMITS: {
426
+ readonly measures: 30;
427
+ readonly dimensions: 20;
428
+ readonly filters: 50;
429
+ readonly values: 100;
430
+ readonly orderBy: 50;
431
+ readonly rows: 1000;
432
+ };
433
+ export declare const AGGREGATE_FILTER_OPERATORS: readonly ["equals", "in", "isNull", "isNotNull", "between"];
434
+ /** The operators of the per-entity shorthand, which bounds dates with `dateRange` instead. */
435
+ export declare const ENTITY_AGGREGATE_FILTER_OPERATORS: readonly ["equals", "in", "isNull", "isNotNull"];
436
+ export declare const AGGREGATE_GRANULARITIES: readonly ["day", "week", "month"];
437
+ type AggregateValue = string | number | boolean;
438
+ interface AggregateFilter<Operator extends string> {
439
+ readonly dimension: string;
440
+ readonly operator: Operator;
441
+ readonly value?: AggregateValue;
442
+ readonly values?: ReadonlyArray<AggregateValue>;
443
+ }
444
+ declare const AggregateValue: Schema.Union<readonly [Schema.String, Schema.Number, Schema.Boolean]>;
445
+ interface AggregateOrdering {
446
+ readonly measure?: string;
447
+ readonly dimension?: string;
448
+ readonly direction: "asc" | "desc";
449
+ }
450
+ /**
451
+ * `POST /api/ontology/aggregate`. The service checks the shape and the bounds, resolves the names
452
+ * against its manifest and forwards the body unchanged; SegmentStream checks everything else.
453
+ */
454
+ export declare const AggregateRequest: Schema.Codec<AggregateRequest>;
455
+ export interface AggregateRequest {
456
+ readonly measures: ReadonlyArray<string>;
457
+ readonly dimensions?: ReadonlyArray<string>;
458
+ readonly filters?: ReadonlyArray<AggregateFilter<(typeof AGGREGATE_FILTER_OPERATORS)[number]>>;
459
+ readonly granularity?: (typeof AGGREGATE_GRANULARITIES)[number];
460
+ readonly granularityOf?: string;
461
+ readonly orderBy?: ReadonlyArray<AggregateOrdering>;
462
+ readonly limit?: number;
463
+ }
464
+ /**
465
+ * `POST /api/ontology/<entity>/aggregate`: SegmentStream's per-entity request, unchanged. Names are
466
+ * relative to the entity (`revenue`, `product.brand`), and `dateRange` bounds a partitioned entity.
467
+ */
468
+ export declare const EntityAggregateRequest: Schema.Codec<EntityAggregateRequest>;
469
+ export interface EntityAggregateRequest {
470
+ readonly dimensions: ReadonlyArray<string>;
471
+ readonly measures: ReadonlyArray<string>;
472
+ readonly dateRange?: {
473
+ readonly from: string;
474
+ readonly to: string;
475
+ };
476
+ readonly filters?: ReadonlyArray<AggregateFilter<(typeof ENTITY_AGGREGATE_FILTER_OPERATORS)[number]>>;
477
+ readonly granularity?: (typeof AGGREGATE_GRANULARITIES)[number];
478
+ readonly granularityOf?: string;
479
+ readonly orderBy?: ReadonlyArray<AggregateOrdering>;
480
+ readonly limit?: number;
481
+ }
482
+ /** SegmentStream's answer to an aggregate, which the service forwards unchanged. */
483
+ export declare const AggregateResult: Schema.Struct<{
484
+ readonly fields: Schema.$Array<Schema.Struct<{
485
+ readonly name: Schema.String;
486
+ readonly label: Schema.String;
487
+ readonly type: Schema.String;
488
+ readonly unit: Schema.optionalKey<Schema.String>;
489
+ readonly format: Schema.optionalKey<Schema.String>;
490
+ readonly scope: Schema.optionalKey<Schema.$Array<Schema.String>>;
491
+ readonly implicit: Schema.optionalKey<Schema.Boolean>;
492
+ readonly additive: Schema.optionalKey<Schema.Boolean>;
493
+ readonly filters: Schema.optionalKey<Schema.$Array<Schema.String>>;
494
+ }>>;
495
+ readonly rows: Schema.$Array<Schema.$Record<Schema.String, Schema.NullOr<Schema.Union<readonly [Schema.String, Schema.Boolean]>>>>;
496
+ readonly truncated: Schema.Boolean;
497
+ readonly execution: Schema.Struct<{
498
+ readonly elapsedMs: Schema.Number;
499
+ readonly bytesProcessed: Schema.optionalKey<Schema.String>;
500
+ readonly bytesBilled: Schema.optionalKey<Schema.String>;
501
+ readonly warnings: Schema.optionalKey<Schema.$Array<Schema.String>>;
502
+ readonly sources: Schema.optionalKey<Schema.$Array<Schema.Struct<{
503
+ readonly entity: Schema.String;
504
+ readonly asOf: Schema.String;
505
+ }>>>;
506
+ }>;
507
+ readonly deploymentId: Schema.NullOr<Schema.String>;
508
+ readonly definitionDigest: Schema.String;
509
+ }>;
417
510
  /** Whether the address may sign in, and with a code or a link. */
418
511
  export declare const SignInCheckResult: Schema.Union<readonly [Schema.Struct<{
419
512
  readonly allowed: Schema.Literal<false>;
@@ -448,7 +541,12 @@ export declare const ErrorSchemas: {
448
541
  rateLimited: Schema.Top;
449
542
  internal: Schema.Top;
450
543
  unavailable: Schema.Top;
544
+ invalidAggregateQuery: Schema.Top;
545
+ readForbidden: Schema.Top;
546
+ aggregateUnavailable: Schema.Top;
547
+ aggregateUpstreamUnavailable: Schema.Top;
451
548
  connectionRequired: Schema.Top;
452
549
  connectionResponseInvalid: Schema.Top;
453
550
  connectionUnavailable: Schema.Top;
454
551
  };
552
+ export {};
@@ -1,7 +1,7 @@
1
1
  import { BlobValue } from "../blob.js";
2
2
  import { Schema, SchemaTransformation } from "effect";
3
3
  import { wireStruct } from "../wire.js";
4
- import { OntologyManifestWire } from "../manifest.js";
4
+ import { isValueMatched, OntologyManifestWire } from "../manifest.js";
5
5
  import { entityQuerySchemas, queryLinks } from "../query.js";
6
6
  import { ValidationIssue as RuntimeValidationIssue } from "../runtime/errors.js";
7
7
  import { Edit, HistoryEntry as RuntimeHistoryEntry } from "../runtime/history.js";
@@ -96,7 +96,8 @@ function recordFields(entity) {
96
96
  fields[name] = definition.optional ? Schema.NullOr(value) : value;
97
97
  }
98
98
  for (const { name, relationship } of entity.relationships) {
99
- if (relationship.cardinality === "one") {
99
+ // A value-matched relationship's reference is the property its `on` names.
100
+ if (relationship.cardinality === "one" && !isValueMatched(relationship)) {
100
101
  const key = Id.annotate({ description: relationship.description });
101
102
  fields[`${name}_id`] = relationship.optional ? Schema.NullOr(key) : key;
102
103
  }
@@ -276,6 +277,8 @@ const GraphRelationship = Schema.Struct({
276
277
  to: Schema.String,
277
278
  inverse: Schema.String,
278
279
  cardinality: Schema.Literals(["one", "many"]),
280
+ /** The property pair of a relationship matched by value: one with a warehouse-backed end. */
281
+ on: Schema.optionalKey(Schema.Struct({ property: Schema.String, targetProperty: Schema.String }).pipe(Schema.encodeKeys({ targetProperty: "target_property" }))),
279
282
  optional: Schema.Boolean,
280
283
  phrase: Schema.Tuple([Schema.String, Schema.String]),
281
284
  forward: Schema.String.annotate({ examples: ["Lead is owned by one User"] }),
@@ -304,6 +307,143 @@ export const SignInCheckRequest = Schema.Struct({
304
307
  description: "The address the person typed, in any case.",
305
308
  }),
306
309
  }).annotate({ identifier: "SignInCheckRequest" });
310
+ /** The bounds of a model-level aggregate (docs/ontology-aggregates-spec.md, "Request"). */
311
+ export const AGGREGATE_LIMITS = {
312
+ measures: 30,
313
+ dimensions: 20,
314
+ filters: 50,
315
+ values: 100,
316
+ orderBy: 50,
317
+ rows: 1000,
318
+ };
319
+ export const AGGREGATE_FILTER_OPERATORS = [
320
+ "equals",
321
+ "in",
322
+ "isNull",
323
+ "isNotNull",
324
+ "between",
325
+ ];
326
+ /** The operators of the per-entity shorthand, which bounds dates with `dateRange` instead. */
327
+ export const ENTITY_AGGREGATE_FILTER_OPERATORS = ["equals", "in", "isNull", "isNotNull"];
328
+ export const AGGREGATE_GRANULARITIES = ["day", "week", "month"];
329
+ const AggregateValue = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]);
330
+ /** Which values each operator takes, with SegmentStream's own messages (`planner.ts`). */
331
+ function filterValuesIssue(filter) {
332
+ const { operator, value, values } = filter;
333
+ switch (operator) {
334
+ case "equals":
335
+ return value === undefined || values !== undefined
336
+ ? "Equality filters require one value."
337
+ : undefined;
338
+ case "in":
339
+ return values === undefined ||
340
+ values.length === 0 ||
341
+ values.length > AGGREGATE_LIMITS.values ||
342
+ value !== undefined
343
+ ? "Membership filters require between 1 and 100 values."
344
+ : undefined;
345
+ case "between":
346
+ return value !== undefined || values?.length !== 2
347
+ ? "between needs two values in order."
348
+ : undefined;
349
+ default:
350
+ return value !== undefined || values !== undefined
351
+ ? "Null filters do not accept values."
352
+ : undefined;
353
+ }
354
+ }
355
+ function aggregateFilter(operators) {
356
+ const filter = closedStruct({
357
+ dimension: Schema.String.annotate({
358
+ description: "A property, or a path of to-one relationships ending in one.",
359
+ }),
360
+ operator: Schema.Literals(operators),
361
+ value: Schema.optionalKey(AggregateValue).annotate({ description: "The value of equals." }),
362
+ values: Schema.optionalKey(Schema.Array(AggregateValue).check(Schema.isMaxLength(AGGREGATE_LIMITS.values))).annotate({ description: "The values of in, or the two inclusive bounds of between." }),
363
+ }, "Unknown key; use dimension, operator, value and values");
364
+ return filter.check(Schema.makeFilter((candidate) => filterValuesIssue(candidate) ?? true));
365
+ }
366
+ const AggregateOrder = closedStruct({
367
+ measure: Schema.optionalKey(Schema.String),
368
+ dimension: Schema.optionalKey(Schema.String),
369
+ direction: Schema.Literals(["asc", "desc"]),
370
+ }, "Unknown key; use measure or dimension, and direction");
371
+ /** The fields both aggregate requests share, with their bounds. */
372
+ const aggregateFields = {
373
+ granularity: Schema.optionalKey(Schema.Literals(AGGREGATE_GRANULARITIES)),
374
+ granularityOf: Schema.optionalKey(Schema.String).annotate({
375
+ description: "The requested date or timestamp dimension that granularity buckets.",
376
+ }),
377
+ orderBy: Schema.optionalKey(Schema.Array(AggregateOrder).check(Schema.isMaxLength(AGGREGATE_LIMITS.orderBy))),
378
+ limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: AGGREGATE_LIMITS.rows }))).annotate({ description: "At most 1000 rows; 100 by default." }),
379
+ };
380
+ /**
381
+ * `POST /api/ontology/aggregate`. The service checks the shape and the bounds, resolves the names
382
+ * against its manifest and forwards the body unchanged; SegmentStream checks everything else.
383
+ */
384
+ export const AggregateRequest = closedStruct({
385
+ measures: Schema.Array(Schema.String)
386
+ .check(Schema.isMaxLength(AGGREGATE_LIMITS.measures))
387
+ .annotate({ description: "<entity>.<measure>, at least one." }),
388
+ dimensions: Schema.optionalKey(Schema.Array(Schema.String).check(Schema.isMaxLength(AGGREGATE_LIMITS.dimensions))).annotate({
389
+ description: "<entity>.<property>, or a route from a measure's entity: " +
390
+ "<entity>.<relationship>[.<relationship>].<property>.",
391
+ }),
392
+ filters: Schema.optionalKey(Schema.Array(aggregateFilter(AGGREGATE_FILTER_OPERATORS)).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
393
+ ...aggregateFields,
394
+ }, "Unknown key; use measures, dimensions, filters, granularity, granularityOf, orderBy and limit").annotate({ identifier: "AggregateRequest" });
395
+ /**
396
+ * `POST /api/ontology/<entity>/aggregate`: SegmentStream's per-entity request, unchanged. Names are
397
+ * relative to the entity (`revenue`, `product.brand`), and `dateRange` bounds a partitioned entity.
398
+ */
399
+ export const EntityAggregateRequest = closedStruct({
400
+ dimensions: Schema.Array(Schema.String)
401
+ .check(Schema.isMaxLength(AGGREGATE_LIMITS.dimensions))
402
+ .annotate({
403
+ description: "A property, or a path of to-one relationships: product.brand.",
404
+ }),
405
+ measures: Schema.Array(Schema.String)
406
+ .check(Schema.isMaxLength(AGGREGATE_LIMITS.measures))
407
+ .annotate({ description: "The entity's measures, at least one." }),
408
+ dateRange: Schema.optionalKey(closedStruct({
409
+ from: Schema.String.annotate({ format: "date" }),
410
+ to: Schema.String.annotate({ format: "date" }),
411
+ }, "Unknown key; use from and to")).annotate({ description: "Inclusive days of the entity's partition property." }),
412
+ filters: Schema.optionalKey(Schema.Array(aggregateFilter(ENTITY_AGGREGATE_FILTER_OPERATORS)).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
413
+ ...aggregateFields,
414
+ }, "Unknown key; use dimensions, measures, dateRange, filters, granularity, granularityOf, " +
415
+ "orderBy and limit").annotate({ identifier: "EntityAggregateRequest" });
416
+ /** SegmentStream's answer to an aggregate, which the service forwards unchanged. */
417
+ export const AggregateResult = Schema.Struct({
418
+ fields: Schema.Array(Schema.Struct({
419
+ name: Schema.String.annotate({ description: "A dimension identity or <entity>.<measure>." }),
420
+ label: Schema.String,
421
+ type: Schema.String,
422
+ unit: Schema.optionalKey(Schema.String),
423
+ format: Schema.optionalKey(Schema.String),
424
+ scope: Schema.optionalKey(Schema.Array(Schema.String)),
425
+ implicit: Schema.optionalKey(Schema.Boolean),
426
+ additive: Schema.optionalKey(Schema.Boolean).annotate({
427
+ description: "false when the measure's rows do not add up to a total.",
428
+ }),
429
+ filters: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
430
+ description: "The filters the measure applied.",
431
+ }),
432
+ })),
433
+ rows: Schema.Array(Schema.Record(Schema.String, Schema.NullOr(Schema.Union([Schema.String, Schema.Boolean])))),
434
+ truncated: Schema.Boolean,
435
+ execution: Schema.Struct({
436
+ elapsedMs: Schema.Number,
437
+ bytesProcessed: Schema.optionalKey(Schema.String),
438
+ bytesBilled: Schema.optionalKey(Schema.String),
439
+ warnings: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
440
+ description: "For example, a filter that did not apply to some measure's entity.",
441
+ }),
442
+ sources: Schema.optionalKey(Schema.Array(Schema.Struct({ entity: Schema.String, asOf: Schema.String }))).annotate({ description: "How fresh the replica of each stored entity read is." }),
443
+ }),
444
+ deploymentId: Schema.NullOr(Schema.String),
445
+ definitionDigest: Schema.String,
446
+ }).annotate({ identifier: "AggregateResult" });
307
447
  /** Whether the address may sign in, and with a code or a link. */
308
448
  export const SignInCheckResult = Schema.Union([
309
449
  Schema.Struct({ allowed: Schema.Literal(false) }),
@@ -370,6 +510,14 @@ export const ErrorSchemas = {
370
510
  rateLimited: errorBody("RateLimited", 429, ["rate_limited"]),
371
511
  internal: errorBody("InternalError", 500, ["internal_error"]),
372
512
  unavailable: errorBody("ServiceUnavailable", 503, ["service_unavailable"]),
513
+ invalidAggregateQuery: errorBody("InvalidAggregateQuery", 400, ["invalid_aggregate_query"]),
514
+ readForbidden: errorBody("ReadForbidden", 403, ["forbidden"], {
515
+ permissions: Schema.optionalKey(Schema.Array(Schema.String)),
516
+ }),
517
+ aggregateUnavailable: errorBody("AggregateUnavailable", 503, ["aggregate_unavailable"], {
518
+ retryable: Schema.Boolean,
519
+ }),
520
+ aggregateUpstreamUnavailable: errorBody("AggregateUpstreamUnavailable", 502, ["aggregate_upstream_unavailable"], { retryable: Schema.Boolean }),
373
521
  connectionRequired: errorBody("ConnectionRequired", 409, ["connection_authorization_required", "connections_not_deployed"], { connection: Schema.String }),
374
522
  connectionResponseInvalid: errorBody("ConnectionResponseInvalid", 502, [
375
523
  "connection_response_invalid",