@segmentstream-cloud/app 0.9.0 → 0.10.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 CHANGED
@@ -334,9 +334,79 @@ export default async function Page() {
334
334
  - Values arrive as JSON: safe integers as numbers, larger integers and decimals as strings,
335
335
  timestamps as ISO strings, records and arrays as JSON strings.
336
336
 
337
- `segmentstream.ontology.<entity>.aggregate(request)` uses the server-held workspace app
338
- credential and supports Neon Auth apps. Stored record operations use the ontology user token.
339
- `segmentstream.fetch(path)` still requires the legacy platform session.
337
+ One authorization model covers the ontology: every `segmentstream.ontology` call, aggregates
338
+ included, runs as the signed-in user through the ontology service, which checks their
339
+ permissions. `segmentstream.ontology.<entity>.aggregate(request)` posts to
340
+ `/api/ontology/<entity>/aggregate` and needs read permission on the entities it reaches.
341
+ `segmentstream.warehouse` is the only call that uses the workspace app credential, and it
342
+ reaches the whole workspace warehouse. `segmentstream.fetch(path)` still requires the legacy
343
+ platform session. Aggregates through the service need the workspace's ontology deployed with
344
+ `@segmentstream-cloud/ontology` 0.13.0 or later.
345
+
346
+ Since 0.10.0, dimensions, filters and `orderBy` accept relationship paths, typed from the
347
+ ontology's relationships:
348
+
349
+ ```ts
350
+ const { rows, execution } = await segmentstream.ontology.order.aggregate({
351
+ dimensions: ["customer.segment"],
352
+ measures: ["orders"],
353
+ dateRange: { from: "2026-09-01", to: "2026-09-30" },
354
+ });
355
+ ```
356
+
357
+ - A path is `<relationship>.<property>`, through one or two to-one relationships. Paths into
358
+ a stored target include `id`; json and blob properties are left out. A thunk target types
359
+ its paths as `` `customer.${string}` ``. Each segment converts to snake_case on the wire,
360
+ and rows come back keyed by the camelCase path. These names are relative to the entity and
361
+ convert as record fields do: `line_2` reads as `line2`.
362
+ - `execution.warnings` reports keys that matched more than one related row; totals are
363
+ unaffected. `execution.sources` gives, for each stored entity read through replication,
364
+ the time its data is current to, once known. Both are absent when the query produced none.
365
+ - 0.10.0 requires the platform release that joins stored targets through replication.
366
+ - See [ontology aggregates](../../docs/ontology-aggregates.md#relationship-paths).
367
+
368
+ Since 0.10.0, `segmentstream.ontology.aggregate(request)` asks for numbers about the whole
369
+ model: measures of several entities, warehouse-backed or stored, grouped by properties they all
370
+ reach. Look at or change records through `find` and actions; count, sum or compare through
371
+ `aggregate`.
372
+
373
+ ```ts
374
+ const { fields, rows, execution } = await segmentstream.ontology.aggregate({
375
+ measures: ["trendtoysSales.revenue", "trendtoysStock.onHand"],
376
+ dimensions: ["trendtoysProduct.assortmentStatus"],
377
+ filters: [
378
+ {
379
+ dimension: "trendtoysSales.orderDate",
380
+ operator: "between",
381
+ values: ["2026-09-01", "2026-09-30"],
382
+ },
383
+ { dimension: "trendtoysSales.currency", operator: "equals", value: "RUB" },
384
+ ],
385
+ orderBy: [{ measure: "trendtoysSales.revenue", direction: "desc" }],
386
+ });
387
+ ```
388
+
389
+ - Names are `entity.measure` and `entity.property`, each segment in camelCase, typed from the
390
+ ontology. An `_` before a digit stays, so an entity's declared name converts back:
391
+ `sales_2025.revenue`. This differs from the per-entity call and records, which drop it. Fields
392
+ and rows are keyed by these names.
393
+ - When an entity reaches a property's entity in more than one way, write a route from it:
394
+ `trendtoysSales.returnedProduct.brand`. Its column is still keyed by the property's own name,
395
+ `trendtoysProduct.brand`.
396
+ - It posts to the ontology service's `/api/ontology/aggregate` as the signed-in user, who needs
397
+ read permission on every entity the request reaches. Previews and local development answer
398
+ `aggregate_unavailable`.
399
+ - Date bounds are `between` filters, inclusive. Each partitioned entity needs one on its own
400
+ partition property. Currency measures need their entity filtered to one currency, the same
401
+ for every entity.
402
+ - A measure field's `filters` lists the filters applied to it, and `execution.warnings` names
403
+ each filter that did not reach a measure's entity. `additive: false` marks a measure whose
404
+ rows don't add up to a total.
405
+ - Failures throw `SegmentStreamApiError` with the service's `code`: `invalid_request`,
406
+ `invalid_aggregate_query`, `forbidden`, `rate_limited` (30 aggregates a minute per user),
407
+ `aggregate_unavailable`, `aggregate_upstream_unavailable`, `ontology_version_mismatch` or
408
+ `aggregate_scope_mismatch`. See
409
+ [ontology aggregates](../../docs/ontology-aggregates.md#errors).
340
410
 
341
411
  ## How requests are handled
342
412
 
@@ -395,7 +465,7 @@ Without `NEON_AUTH_BASE_URL`, 0.3 behaves exactly as 0.2, so existing apps keep
395
465
  working: members of the workspace's organization sign in with their SegmentStream
396
466
  account through `/api/segmentstream/[...path]/route.ts`
397
467
  (`export const { GET, POST } = segmentstream.handlers();`), the proxy redirects to
398
- SegmentStream, and `fetch` acts as the member; warehouse queries and aggregates prefer the workspace app credential. In that mode
468
+ SegmentStream, and `fetch` acts as the member; warehouse queries prefer the workspace app credential. In that mode
399
469
  `roles` and `permissions` are empty, `can()` is `false`, `authHandlers()` answers
400
470
  `404 neon_auth_not_configured`, and `segmentstream.ontology` sends the member's app
401
471
  token, which the ontology service accepts while workspaces move to Neon Auth. Keep the
@@ -422,3 +492,18 @@ Apache-2.0
422
492
  ## Upgrading to 0.5
423
493
 
424
494
  Replace segmentstream.semantic with typed warehouse-backed entity aggregates. See [ontology aggregates](../../docs/ontology-aggregates.md). Warehouse-backed clients expose no record CRUD or actions.
495
+
496
+ ## Upgrading to 0.10
497
+
498
+ - Shorthand aggregates accept relationship paths. Their responses may carry
499
+ `execution.warnings` and `execution.sources`.
500
+ - `segmentstream.ontology.aggregate()` is new, and `aggregate` is a reserved entity name.
501
+ - `segmentstream.ontology.<entity>.aggregate()` now runs as the signed-in user through the
502
+ ontology service, like record reads, and no longer uses `SEGMENTSTREAM_APP_CREDENTIAL`. The
503
+ user needs read permission on the entities it reaches.
504
+ - Server code without a signed-in user, such as a scheduled Route Handler, can no longer
505
+ aggregate with the workspace credential. Use `segmentstream.warehouse` there, or call the
506
+ ontology service with an API key.
507
+ - Deploy the workspace's ontology with `@segmentstream-cloud/ontology` 0.13.0 or later. It
508
+ serves `/api/ontology/<entity>/aggregate` and `/api/ontology/aggregate`; earlier versions
509
+ don't.
package/dist/app.js CHANGED
@@ -55,7 +55,7 @@ export function createApp(config, dependencies = {}) {
55
55
  getAccessToken: legacy.getAccessToken,
56
56
  fetch: data.fetch,
57
57
  warehouse: data.warehouse,
58
- ontology: createOntologyClient(ontologyTransport, data.aggregateTransport),
58
+ ontology: createOntologyClient(ontologyTransport),
59
59
  oauth: createAgentAuthorizations(ontologyTransport),
60
60
  };
61
61
  }
@@ -84,7 +84,7 @@ export function createApp(config, dependencies = {}) {
84
84
  getAccessToken: async () => null,
85
85
  fetch: data.fetch,
86
86
  warehouse: data.warehouse,
87
- ontology: createOntologyClient(neon.ontologyTransport, data.aggregateTransport),
87
+ ontology: createOntologyClient(neon.ontologyTransport),
88
88
  oauth: createAgentAuthorizations(neon.ontologyTransport),
89
89
  };
90
90
  }
@@ -93,7 +93,7 @@ async function noSegmentStreamSession() {
93
93
  throw new SegmentStreamApiError({
94
94
  code: "app_credential_missing",
95
95
  status: 401,
96
- detail: "Apps that sign in with Neon Auth have no SegmentStream session. Warehouse queries and ontology aggregates use the workspace app credential, SEGMENTSTREAM_APP_CREDENTIAL, which is not set; SegmentStream sets it on deploy. Other SegmentStream API calls are not available to these apps yet.",
96
+ detail: "Apps that sign in with Neon Auth have no SegmentStream session. Warehouse queries use the workspace app credential, SEGMENTSTREAM_APP_CREDENTIAL, which is not set; SegmentStream sets it on deploy. Other SegmentStream API calls are not available to these apps yet.",
97
97
  });
98
98
  }
99
99
  function notConfiguredHandlers() {
package/dist/data.d.ts CHANGED
@@ -43,34 +43,71 @@ export interface WarehouseDryRun {
43
43
  readonly bytesProcessed: string | null;
44
44
  readonly maximumBytesBilled: string;
45
45
  }
46
- export interface AggregateRequest<Property extends string = string, Measure extends string = string, Temporal extends Property = Property> {
47
- readonly dimensions: readonly Property[];
46
+ /**
47
+ * An aggregate query. Dimensions, filters and ordering name the entity's own properties, or a
48
+ * related entity's property through to-one relationships: `Path`, such as `product.brand`.
49
+ */
50
+ export interface AggregateRequest<Property extends string = string, Measure extends string = string, Temporal extends Property = Property, Path extends string = never> {
51
+ readonly dimensions: readonly (Property | Path)[];
48
52
  readonly measures: readonly Measure[];
49
53
  readonly dateRange?: {
50
54
  readonly from: string;
51
55
  readonly to: string;
52
56
  };
53
- readonly granularityOf?: Temporal;
57
+ /** The selected date or timestamp dimension to bucket, which may be a related one. */
58
+ readonly granularityOf?: Temporal | Path;
54
59
  readonly granularity?: "day" | "week" | "month";
55
60
  readonly filters?: ReadonlyArray<{
56
- readonly dimension: Property;
61
+ readonly dimension: Property | Path;
57
62
  readonly operator: "equals" | "in" | "isNull" | "isNotNull";
58
63
  readonly value?: string | number | boolean;
59
64
  readonly values?: ReadonlyArray<string | number | boolean>;
60
65
  }>;
61
66
  readonly orderBy?: ReadonlyArray<{
62
67
  readonly measure?: Measure;
63
- readonly dimension?: Property;
68
+ readonly dimension?: Property | Path;
64
69
  readonly direction: "asc" | "desc";
65
70
  }>;
66
71
  readonly limit?: number;
67
72
  }
73
+ /** A filter of a model-level aggregate. `between` takes two inclusive `values`, the lower first. */
74
+ export interface ModelAggregateFilter<Dimension extends string = string> {
75
+ readonly dimension: Dimension;
76
+ readonly operator: "equals" | "in" | "isNull" | "isNotNull" | "between";
77
+ readonly value?: string | number | boolean;
78
+ readonly values?: ReadonlyArray<string | number | boolean>;
79
+ }
80
+ /**
81
+ * A model-level aggregate: measures of one or more entities, as `entity.measure`, grouped by
82
+ * `entity.property` dimensions. A dimension may instead be a route from a measure's entity,
83
+ * `entity.relationship.property`, which picks the way that entity reaches the property. Date bounds
84
+ * are `between` filters.
85
+ */
86
+ export interface ModelAggregateRequest<Measure extends string = string, Dimension extends string = string> {
87
+ readonly measures: readonly Measure[];
88
+ readonly dimensions?: readonly Dimension[];
89
+ readonly filters?: ReadonlyArray<ModelAggregateFilter<Dimension>>;
90
+ readonly granularity?: "day" | "week" | "month";
91
+ /** The requested date or timestamp dimension to bucket by `granularity`. */
92
+ readonly granularityOf?: Dimension;
93
+ readonly orderBy?: ReadonlyArray<{
94
+ readonly measure?: Measure;
95
+ readonly dimension?: Dimension;
96
+ readonly direction: "asc" | "desc";
97
+ }>;
98
+ /** Rows to return, 1–1000; 100 by default. */
99
+ readonly limit?: number;
100
+ }
68
101
  export interface AggregateField {
69
102
  readonly name: string;
70
103
  readonly label: string;
71
104
  readonly type: string;
72
105
  readonly unit?: string;
73
106
  readonly format?: string;
107
+ /** On a measure: the filters applied to it. A filter its entity cannot reach is not listed. */
108
+ readonly filters?: readonly string[];
109
+ /** `false` on a measure whose rows don't add up to a total, such as a distinct count or a ratio. */
110
+ readonly additive?: boolean;
74
111
  }
75
112
  export interface AggregateResult<Row = Record<string, string | boolean | null>> {
76
113
  readonly deploymentId: string | null;
@@ -82,6 +119,16 @@ export interface AggregateResult<Row = Record<string, string | boolean | null>>
82
119
  readonly elapsedMs: number;
83
120
  readonly bytesProcessed?: string;
84
121
  readonly bytesBilled?: string;
122
+ /**
123
+ * Data problems the query met, for example a join key that matched more than one record, and
124
+ * filters that did not apply to some measure's entity.
125
+ */
126
+ readonly warnings?: readonly string[];
127
+ /** The stored entities a query read, each with the time up to which its copy is complete. */
128
+ readonly sources?: ReadonlyArray<{
129
+ readonly entity: string;
130
+ readonly asOf: string;
131
+ }>;
85
132
  };
86
133
  }
87
134
  export interface WarehouseClient {
@@ -111,7 +158,4 @@ export interface DataClientDependencies {
111
158
  export declare function createDataClient(dependencies: DataClientDependencies): {
112
159
  fetch: (path: string, init?: RequestInit) => Promise<Response>;
113
160
  warehouse: WarehouseClient;
114
- aggregateTransport: {
115
- fetch: (path: string, init: RequestInit) => Promise<Response>;
116
- };
117
161
  };
package/dist/data.js CHANGED
@@ -154,14 +154,7 @@ export function createDataClient(dependencies) {
154
154
  };
155
155
  },
156
156
  };
157
- const aggregateTransport = {
158
- fetch: (path, init) => {
159
- if (!/^\/[a-z][a-z0-9_]*\/aggregate$/.test(path))
160
- throw new TypeError("Invalid aggregate path.");
161
- return authorizedFetch(`${workspace}/entities${path}`, init, warehouseToken);
162
- },
163
- };
164
- return { fetch: apiFetch, warehouse, aggregateTransport };
157
+ return { fetch: apiFetch, warehouse };
165
158
  }
166
159
  function bytesLimit(value) {
167
160
  if (value === undefined) {
package/dist/index.d.ts CHANGED
@@ -24,9 +24,9 @@ export type { AgentAuthorizationRequest, AgentAuthorizations, AgentScope } from
24
24
  export type { ApiIssue } from "./errors.js";
25
25
  export type { CurrentUser } from "./me.js";
26
26
  export type { AuthRouteHandler, AuthRouteHandlers, SignOutOptions } from "./neon.js";
27
- export type { ActionCall, ActionOptions, ActionResponse, Change, Edit, EditKind, EntityClient, FieldFilter, GeneratedActions, HistoryEntry, HistoryOptions, HistoryPage, JsonRecord, JsonValue, ManyFilter, OntologyClient, AggregateEntityClient, OntologyPage, OntologyQuery, OrderBy, RequestOptions, UntypedOntologyClient, ValidationIssue, Where, } from "./ontology.js";
27
+ export type { ActionCall, ActionOptions, ActionResponse, Change, Edit, EditKind, EntityClient, FieldFilter, GeneratedActions, HistoryEntry, HistoryOptions, HistoryPage, JsonRecord, JsonValue, ManyFilter, ModelAggregateClient, OntologyClient, AggregateEntityClient, OntologyPage, OntologyQuery, OrderBy, RequestOptions, UntypedOntologyClient, ValidationIssue, Where, } from "./ontology.js";
28
28
  export type { PermissionHolder } from "./permissions.js";
29
- export type { AggregateField, AggregateRequest, AggregateResult, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
29
+ export type { AggregateField, AggregateRequest, AggregateResult, ModelAggregateFilter, ModelAggregateRequest, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
30
30
  export type { ParameterType, QueryParameterValue, QueryParameters, TypedParameter, } from "./parameters.js";
31
31
  export type { SegmentStreamAppConfig } from "./config.js";
32
32
  export type { SegmentStreamRouteHandlers, RouteHandler } from "./handlers.js";
@@ -1,3 +1,10 @@
1
1
  /** Convert each character once, including acronym boundaries such as HTTPClient. */
2
2
  export declare function toSnakeCase(name: string): string;
3
3
  export declare function toCamelCase(name: string): string;
4
+ /**
5
+ * camelCase that keeps an `_` before a digit, so `toSnakeCase` restores the name: `sales_2025`
6
+ * stays `sales_2025` and `wb2_sales` becomes `wb2Sales`. Model-level aggregate names, whose first
7
+ * segment is a declared snake_case entity name, use it; records and per-entity aggregates keep
8
+ * `toCamelCase`.
9
+ */
10
+ export declare function toReversibleCamelCase(name: string): string;
@@ -24,3 +24,12 @@ export function toSnakeCase(name) {
24
24
  export function toCamelCase(name) {
25
25
  return name.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
26
26
  }
27
+ /**
28
+ * camelCase that keeps an `_` before a digit, so `toSnakeCase` restores the name: `sales_2025`
29
+ * stays `sales_2025` and `wb2_sales` becomes `wb2Sales`. Model-level aggregate names, whose first
30
+ * segment is a declared snake_case entity name, use it; records and per-entity aggregates keep
31
+ * `toCamelCase`.
32
+ */
33
+ export function toReversibleCamelCase(name) {
34
+ return name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
35
+ }
@@ -11,6 +11,8 @@ interface CatalogRelationship {
11
11
  readonly to: string;
12
12
  readonly inverse: string;
13
13
  readonly cardinality: string;
14
+ /** Present when either end is warehouse-backed: the relationship is matched by value. */
15
+ readonly on?: unknown;
14
16
  }
15
17
  interface CatalogEntity {
16
18
  readonly name: string;
@@ -16,6 +16,13 @@ const SYSTEM_FIELDS = ["id", "created_at", "updated_at", "created_by", "updated_
16
16
  function isRecord(value) {
17
17
  return value !== null && typeof value === "object" && !Array.isArray(value);
18
18
  }
19
+ /**
20
+ * A relationship with a warehouse-backed end is matched by value through `on`: it has no
21
+ * `<name>_id` field, and the ontology service cannot traverse or filter through it.
22
+ */
23
+ function isValueMatched(relationship) {
24
+ return relationship.on !== undefined && relationship.on !== null;
25
+ }
19
26
  /** Catalog metadata distinguishes relationship filters from opaque JSON property values. */
20
27
  export async function encodeWhere(input) {
21
28
  const { where, entity, getCatalog } = input;
@@ -51,12 +58,14 @@ function whereFromCatalog(input) {
51
58
  }
52
59
  function relationshipTarget(input) {
53
60
  const { field, entity, definition, catalog } = input;
54
- const forward = definition?.relationships.find((candidate) => candidate.name === field);
61
+ const forward = definition?.relationships.find((candidate) => candidate.name === field && !isValueMatched(candidate));
55
62
  if (forward !== undefined) {
56
63
  return { entity: forward.to, many: forward.cardinality === "many" };
57
64
  }
58
65
  for (const candidate of catalog.entities) {
59
- const inverse = candidate.relationships.find((relationship) => relationship.to === entity && relationship.inverse === field);
66
+ const inverse = candidate.relationships.find((relationship) => relationship.to === entity &&
67
+ relationship.inverse === field &&
68
+ !isValueMatched(relationship));
60
69
  if (inverse !== undefined) {
61
70
  return { entity: candidate.name, many: true };
62
71
  }
@@ -79,7 +88,9 @@ function isGeneratedField(field, definition) {
79
88
  if (SYSTEM_FIELDS.includes(field)) {
80
89
  return true;
81
90
  }
82
- return (definition?.relationships.some((relationship) => relationship.cardinality === "one" && `${relationship.name}_id` === field) ?? false);
91
+ return (definition?.relationships.some((relationship) => relationship.cardinality === "one" &&
92
+ !isValueMatched(relationship) &&
93
+ `${relationship.name}_id` === field) ?? false);
83
94
  }
84
95
  function fieldFilterToWire(input) {
85
96
  const { field, condition, definition } = input;
@@ -1,4 +1,4 @@
1
- import type { AggregateRequest, AggregateResult } from "./data.js";
1
+ import type { AggregateRequest, AggregateResult, ModelAggregateRequest } from "./data.js";
2
2
  /**
3
3
  * The typed client for the app's ontology service, `/api/ontology`. The app imports its ontology as
4
4
  * types only (`import type ontology from "../../ontology"`) and passes `typeof ontology` to
@@ -191,31 +191,99 @@ type ActionClients<A> = {
191
191
  type PropertiesOf<E> = E extends {
192
192
  readonly properties: infer P;
193
193
  } ? P : never;
194
+ /** Declared names only: a definition typed as any `Measures` names none the client can check. */
195
+ type DeclaredNames<M> = string extends keyof M ? never : keyof M & string;
196
+ /** A warehouse-backed entity's `measures`, or the measures of a stored entity's definition. */
194
197
  type MeasuresOf<E> = E extends {
195
198
  readonly measures: infer M;
196
- } ? keyof M & string : never;
197
- export interface AggregateEntityClient<Property extends string = string, Measure extends string = string, Temporal extends Property = Property> {
198
- readonly aggregate: (request: AggregateRequest<Property, Measure, Temporal>, options?: RequestOptions) => Promise<AggregateResult>;
199
- }
200
- type ClientOf<E> = E extends {
199
+ } ? keyof M & string : E extends {
200
+ readonly definition: {
201
+ readonly measures?: infer M;
202
+ };
203
+ } ? DeclaredNames<NonNullable<M>> : never;
204
+ type RelationshipsOf<E> = E extends {
205
+ readonly relationships: infer R;
206
+ } ? R : never;
207
+ type IsWarehouseBacked<E> = E extends {
201
208
  readonly definition: {
202
209
  readonly source: object;
203
210
  };
204
- } ? AggregateEntityClient<keyof PropertiesOf<E> & string, MeasuresOf<E>> : EntityClient<RecordOf<E>, ActionClients<ActionsOf<E>>>;
211
+ } ? true : false;
212
+ /**
213
+ * A `rel.one`, which aggregates may follow. Every relationship of a warehouse-backed entity is
214
+ * matched by value; a stored entity's may also be keyed (`<relationship>_id` joined to `id`).
215
+ */
216
+ interface ToOne {
217
+ readonly cardinality: "one";
218
+ }
219
+ /** The target's name, or `string` when a thunk erased it or the ontology SDK predates the name. */
220
+ type TargetNameOf<R> = R extends {
221
+ readonly "~ontology.target": infer Name extends string;
222
+ } ? Name : string;
223
+ /** The entity of `O` named `Name`. `User` is not replicated, so aggregates never reach it. */
224
+ type EntityNamed<O, Name extends string> = Name extends "user" ? never : Extract<EntitiesOf<O>, {
225
+ readonly name: Name;
226
+ }>;
227
+ /** Properties whose values are scalars. json and blob values are objects, which aggregates skip. */
228
+ type ScalarPropertiesOf<T> = {
229
+ [K in keyof PropertiesOf<T>]-?: PropertiesOf<T>[K] extends {
230
+ readonly Type: infer V;
231
+ } ? [Extract<V, object>] extends [never] ? K : never : K;
232
+ }[keyof PropertiesOf<T>] & string;
233
+ /** What a path may end on: the target's scalar properties, and `id` when the target is stored. */
234
+ type FieldsOf<T> = ScalarPropertiesOf<T> | (IsWarehouseBacked<T> extends true ? never : "id");
235
+ /** The rest of a path after reaching `T`: a field, or one more relationship when `Again`. */
236
+ type PathEnds<T, O, Again extends boolean> = [T] extends [never] ? never : FieldsOf<T> | (Again extends true ? PathsFrom<T, O, false> : never);
237
+ type PathsFrom<E, O, Again extends boolean> = {
238
+ [K in keyof RelationshipsOf<E> & string]: RelationshipsOf<E>[K] extends ToOne ? string extends TargetNameOf<RelationshipsOf<E>[K]> ? `${K}.${string}` : `${K}.${PathEnds<EntityNamed<O, TargetNameOf<RelationshipsOf<E>[K]>>, O, Again>}` : never;
239
+ }[keyof RelationshipsOf<E> & string];
240
+ /**
241
+ * Related properties of `E` as aggregate paths through at most two `rel.one` relationships:
242
+ * `product.brand`, `product.brand.name`. A thunk target erases its entity, so any path may follow
243
+ * its relationship: `` `${relationship}.${string}` ``.
244
+ */
245
+ type PathsOf<E, O> = PathsFrom<E, O, true>;
246
+ type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
247
+ /**
248
+ * A snake_case entity name in camelCase, keeping an `_` before a digit so the name converts back:
249
+ * `sales_2025` stays, `wb2_sales` is `wb2Sales`. The client decodes model-level names the same way.
250
+ */
251
+ type CamelCase<S extends string> = S extends `${infer Head}_${infer Tail}` ? Tail extends `${Digit}${string}` ? `${Head}_${CamelCase<Tail>}` : `${Head}${Capitalize<CamelCase<Tail>>}` : S;
252
+ /** `entity.measure` for every measure of every entity, warehouse-backed or stored. */
253
+ type ModelMeasuresOf<E> = E extends unknown ? `${CamelCase<NameOf<E>>}.${MeasuresOf<E>}` : never;
254
+ /** A route picks how an entity with measures reaches a property, so only such entities start one. */
255
+ type RoutesOf<E, O> = [MeasuresOf<E>] extends [never] ? never : PathsOf<E, O>;
256
+ /**
257
+ * `entity.property` for every entity's scalar properties, and `id` for stored entities, plus the
258
+ * routes `entity.relationship.property` from each entity with measures.
259
+ */
260
+ type ModelDimensionsOf<E, O> = E extends unknown ? `${CamelCase<NameOf<E>>}.${FieldsOf<E> | RoutesOf<E, O>}` : never;
261
+ /**
262
+ * `segmentstream.ontology.aggregate`: `POST /api/ontology/aggregate`, as the signed-in user, who
263
+ * needs read permission on every entity the request reaches.
264
+ */
265
+ export interface ModelAggregateClient<Measure extends string = string, Dimension extends string = string> {
266
+ readonly aggregate: (request: ModelAggregateRequest<Measure, Dimension>, options?: RequestOptions) => Promise<AggregateResult>;
267
+ }
268
+ export interface AggregateEntityClient<Property extends string = string, Measure extends string = string, Temporal extends Property = Property, Path extends string = never> {
269
+ readonly aggregate: (request: AggregateRequest<Property, Measure, Temporal, Path>, options?: RequestOptions) => Promise<AggregateResult>;
270
+ }
271
+ type ClientOf<E, O> = IsWarehouseBacked<E> extends true ? AggregateEntityClient<keyof PropertiesOf<E> & string, MeasuresOf<E>, keyof PropertiesOf<E> & string, PathsOf<E, O>> : EntityClient<RecordOf<E>, ActionClients<ActionsOf<E>>>;
205
272
  type TypedOntologyClient<O> = {
206
273
  readonly [N in NameOf<EntitiesOf<O>>]: ClientOf<Extract<EntitiesOf<O>, {
207
274
  readonly name: N;
208
- }>>;
209
- };
275
+ }>, O>;
276
+ } & ModelAggregateClient<ModelMeasuresOf<EntitiesOf<O>>, ModelDimensionsOf<EntitiesOf<O>, O>>;
210
277
  /** A client for any entity, when the app passes no ontology type. */
211
- export type UntypedOntologyClient = {
278
+ export type UntypedOntologyClient = ModelAggregateClient & {
212
279
  readonly [entity: string]: EntityClient<JsonRecord, {
213
280
  readonly [action: string]: ActionCall<JsonRecord, JsonValue>;
214
281
  }> & AggregateEntityClient;
215
282
  };
216
283
  /**
217
284
  * `segmentstream.ontology`: one client per entity of `O`, the type of the app's ontology
218
- * (`typeof ontology`). Without it, any entity name is accepted and records are untyped.
285
+ * (`typeof ontology`), and `aggregate` across them. `aggregate` is a reserved entity name. Without
286
+ * `O`, any entity name is accepted and records are untyped.
219
287
  */
220
288
  export type OntologyClient<O = unknown> = unknown extends O ? UntypedOntologyClient : TypedOntologyClient<O>;
221
289
  /** Sends one ontology request with the caller's credentials. Paths are below `/api/ontology`. */
@@ -226,7 +294,7 @@ export interface OntologyTransport {
226
294
  * Entities and actions exist only in the app's types, so the client resolves them by name when
227
295
  * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/change_status`.
228
296
  */
229
- export declare function createOntologyClient<O>(transport: OntologyTransport, aggregateTransport?: OntologyTransport): OntologyClient<O>;
297
+ export declare function createOntologyClient<O>(transport: OntologyTransport): OntologyClient<O>;
230
298
  /** `find` sends the query as URL parameters: `where` as JSON, `orderBy` as `field:direction`. */
231
299
  export declare function findParameters(query: OntologyQuery<JsonRecord>): URLSearchParams;
232
300
  export {};
package/dist/ontology.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { SegmentStreamApiError, apiErrorFrom, readJson } from "./errors.js";
2
2
  import { actionInputToWire, encodeWhere, editsFromWire, queryToWire, recordFromWire, responseFromWire, } from "./ontology-wire.js";
3
- import { toCamelCase, toSnakeCase } from "./ontology-names.js";
3
+ import { toCamelCase, toReversibleCamelCase, toSnakeCase } from "./ontology-names.js";
4
4
  const ENTITY_NAME = /^[a-z][a-zA-Z0-9]*(?:_[a-z0-9]+)*$/;
5
5
  const ACTION_NAME = ENTITY_NAME;
6
+ /** The ontology service's model-level aggregate, `/api/ontology/aggregate`; never an entity. */
7
+ const MODEL_AGGREGATE = "aggregate";
6
8
  /** Names a caller may probe on any object; never an entity or an action. */
7
9
  const RESERVED = new Set([
8
10
  "then",
@@ -21,11 +23,16 @@ const RESERVED = new Set([
21
23
  * Entities and actions exist only in the app's types, so the client resolves them by name when
22
24
  * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/change_status`.
23
25
  */
24
- export function createOntologyClient(transport, aggregateTransport) {
26
+ export function createOntologyClient(transport) {
25
27
  const entities = new Map();
26
28
  const catalogReader = createCatalogReader(transport);
29
+ // Every call, aggregates included, carries the user's session: the service checks permissions.
30
+ const aggregate = (request, options = {}) => send(catalogReader.transport, "POST", `/${MODEL_AGGREGATE}`, aggregateToWire(request), options.signal).then(modelAggregateResultFromWire);
27
31
  return new Proxy(Object.create(null), {
28
32
  get(_target, property) {
33
+ if (property === MODEL_AGGREGATE) {
34
+ return aggregate;
35
+ }
29
36
  if (typeof property !== "string" || RESERVED.has(property) || !ENTITY_NAME.test(property)) {
30
37
  return undefined;
31
38
  }
@@ -33,16 +40,8 @@ export function createOntologyClient(transport, aggregateTransport) {
33
40
  if (client === undefined) {
34
41
  client = {
35
42
  ...entityClient(toSnakeCase(property), catalogReader.transport, catalogReader.getCatalog),
36
- aggregate: (request, options = {}) => {
37
- if (!aggregateTransport) {
38
- throw new TypeError("An aggregate transport is required.");
39
- }
40
- return send(aggregateTransport, "POST", `/${toSnakeCase(property)}/aggregate`, aggregateToWire(request), options.signal).then((result) => ({
41
- ...result,
42
- fields: result.fields.map((field) => ({ ...field, name: toCamelCase(field.name) })),
43
- rows: result.rows.map((row) => Object.fromEntries(Object.entries(row).map(([name, value]) => [toCamelCase(name), value]))),
44
- }));
45
- },
43
+ // Names are relative to the entity and convert as record fields do.
44
+ aggregate: (request, options = {}) => send(catalogReader.transport, "POST", `/${toSnakeCase(property)}/aggregate`, aggregateToWire(request), options.signal).then((result) => aggregateResultFromWire(result, toCamelCase)),
46
45
  };
47
46
  entities.set(property, client);
48
47
  }
@@ -212,11 +211,16 @@ export function findParameters(query) {
212
211
  }
213
212
  return search;
214
213
  }
215
- /** The platform aggregate API keeps its existing protocol keys; ontology identifiers are canonical. */
214
+ /**
215
+ * Aggregate requests keep their camelCase protocol keys and filter values; the names in them convert
216
+ * to snake_case one segment at a time.
217
+ */
216
218
  function aggregateToWire(request) {
217
219
  return {
218
220
  ...request,
219
- dimensions: request.dimensions.map(toSnakeCase),
221
+ ...(request.dimensions === undefined
222
+ ? {}
223
+ : { dimensions: request.dimensions.map(toSnakeCase) }),
220
224
  measures: request.measures.map(toSnakeCase),
221
225
  ...(request.granularityOf === undefined
222
226
  ? {}
@@ -240,6 +244,39 @@ function aggregateToWire(request) {
240
244
  }),
241
245
  };
242
246
  }
247
+ /**
248
+ * Field names, the filters each measure applied and row keys come back camelCase per segment;
249
+ * `execution` is passed on as sent.
250
+ */
251
+ function aggregateResultFromWire(result, decodeName) {
252
+ return {
253
+ ...result,
254
+ fields: result.fields.map((field) => ({
255
+ ...field,
256
+ name: decodeName(field.name),
257
+ ...(field.filters === undefined ? {} : { filters: field.filters.map(decodeName) }),
258
+ })),
259
+ rows: result.rows.map((row) => Object.fromEntries(Object.entries(row).map(([name, value]) => [decodeName(name), value]))),
260
+ };
261
+ }
262
+ /** Model-level names keep an `_` before a digit, and the entities in `sources` convert too. */
263
+ function modelAggregateResultFromWire(wire) {
264
+ const result = aggregateResultFromWire(wire, toReversibleCamelCase);
265
+ const sources = result.execution.sources;
266
+ if (sources === undefined) {
267
+ return result;
268
+ }
269
+ return {
270
+ ...result,
271
+ execution: {
272
+ ...result.execution,
273
+ sources: sources.map((source) => ({
274
+ ...source,
275
+ entity: toReversibleCamelCase(source.entity),
276
+ })),
277
+ },
278
+ };
279
+ }
243
280
  async function send(transport, method, path, body, signal) {
244
281
  const headers = new Headers({ accept: "application/json" });
245
282
  if (body !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/app",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Sign-in, users, permissions, the ontology client and warehouse data access for SegmentStream workspace apps built with Next.js.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",