@segmentstream-cloud/app 0.4.0 → 0.6.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
@@ -1,5 +1,11 @@
1
1
  # @segmentstream-cloud/app
2
2
 
3
+ The ontology client uses snake_case entity fields and action names. Its TypeScript
4
+ options remain camelCase; it maps `orderBy`, `isNull`, `startsWith`, and
5
+ `validateOnly` to the generated REST contract and returns pagination/history
6
+ metadata as camelCase. Queries using multiword filter operators lazily read and
7
+ cache the ontology catalog so JSON property contents remain unchanged.
8
+
3
9
  Sign-in, users, permissions, the ontology client and warehouse data access for
4
10
  SegmentStream workspace apps built with Next.js (App Router, version 16 or later) and
5
11
  React 19. The app's users sign in with an email code, or a sign-in link the first time,
@@ -111,7 +117,7 @@ authUserId?, organizationId, workspaceId }`. `id` is the person's ontology `user
111
117
  or use another email.
112
118
  - `getUser()` returns the same, or `null` when signed out; it throws
113
119
  `SegmentStreamApiError` with `code: "not_invited"` for people without an invitation.
114
- - `can("lead:changeStatus")` is `true` when the user's permissions cover the
120
+ - `can("lead:change_status")` is `true` when the user's permissions cover the
115
121
  permission. Roles may hold wildcards: `lead:*`, `*:read`, `*:*`. `isAllowed` and
116
122
  `grants` apply the same rule to any list of permissions.
117
123
  - `signOut()` ends the session, clears its cookies and redirects to `/sign-in` (or
@@ -126,22 +132,22 @@ user in Server Functions and Route Handlers too; do not rely on the proxy alone.
126
132
  (no Tailwind). Every class starts with `ss-`; the `--ss-*` custom properties on
127
133
  `:root` set colours, radius and font.
128
134
 
129
- | Export | Does |
130
- | -------------------------------------- | ------------------------------------------------------------------------------- |
131
- | `<SignIn />` | Email, then a six-digit code or a sign-in link; resend after 30 seconds, errors |
132
- | `useSignIn()` | The same sign-in without markup, for the app's own form |
133
- | `<SignedIn>`, `<SignedOut>` | Render children by session state |
134
- | `<Can permission="lead:changeStatus">` | Renders children when the user holds the permission; `fallback` otherwise |
135
- | `<UserMenu />` | Name, email, roles and sign-out |
136
- | `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session |
137
- | `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }` |
138
- | `useSession()`, `signOut()` | The Neon Auth session in the browser; sign-out from the browser |
135
+ | Export | Does |
136
+ | --------------------------------------- | ------------------------------------------------------------------------------- |
137
+ | `<SignIn />` | Email, then a six-digit code or a sign-in link; resend after 30 seconds, errors |
138
+ | `useSignIn()` | The same sign-in without markup, for the app's own form |
139
+ | `<SignedIn>`, `<SignedOut>` | Render children by session state |
140
+ | `<Can permission="lead:change_status">` | Renders children when the user holds the permission; `fallback` otherwise |
141
+ | `<UserMenu />` | Name, email, roles and sign-out |
142
+ | `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session |
143
+ | `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }` |
144
+ | `useSession()`, `signOut()` | The Neon Auth session in the browser; sign-out from the browser |
139
145
 
140
146
  `<Can>` only hides controls: the ontology service checks every read and action. A
141
147
  Server Component may wrap server-rendered children in `<Can>`:
142
148
 
143
149
  ```tsx
144
- <Can permission="lead:changeStatus" fallback={lead.status}>
150
+ <Can permission="lead:change_status" fallback={lead.status}>
145
151
  <ChangeStatusForm lead={lead.id} />
146
152
  </Can>
147
153
  ```
@@ -254,7 +260,7 @@ Records, action inputs and action results are typed from the ontology:
254
260
  ```ts
255
261
  const { items, nextCursor } = await segmentstream.ontology.lead.find({
256
262
  where: { status: { in: ["new", "contacted"] } },
257
- orderBy: [{ field: "createdAt", direction: "desc" }],
263
+ orderBy: [{ field: "created_at", direction: "desc" }],
258
264
  include: { owner: true },
259
265
  limit: 20,
260
266
  });
@@ -263,8 +269,11 @@ const history = await segmentstream.ontology.lead.history(id, { limit: 50 });
263
269
  const page = await segmentstream.ontology.lead.query({ where: { email: { startsWith: "ada" } } });
264
270
 
265
271
  const input = { lead: id, status: "contacted" } as const;
266
- const check = await segmentstream.ontology.lead.actions.changeStatus(input, { validateOnly: true });
267
- const { result, validation, edits } = await segmentstream.ontology.lead.actions.changeStatus(input);
272
+ const check = await segmentstream.ontology.lead.actions.change_status(input, {
273
+ validateOnly: true,
274
+ });
275
+ const { result, validation, edits } =
276
+ await segmentstream.ontology.lead.actions.change_status(input);
268
277
  ```
269
278
 
270
279
  - `find` sends the query as URL parameters, `query` as a JSON body; both return
@@ -272,7 +281,7 @@ const { result, validation, edits } = await segmentstream.ontology.lead.actions.
272
281
  - `actions.<name>(input, { validateOnly })` posts `{ input, options }` and returns
273
282
  `{ result, validation, edits }`. Generated actions are there too
274
283
  (`actions.create`, `update`, `delete`), and `action(name, input)` runs any action by
275
- name, such as `upsertByEmail`.
284
+ name, such as `upsert_by_email`.
276
285
  - Failures throw `SegmentStreamApiError` with the service's `code` (`forbidden`,
277
286
  `not_found`, `invalid_input`, `action_rejected`, `conflict`, `not_invited`), `status`,
278
287
  `detail`, `requestId` and, for invalid input, `issues`.
@@ -325,10 +334,9 @@ export default async function Page() {
325
334
  - Values arrive as JSON: safe integers as numbers, larger integers and decimals as strings,
326
335
  timestamps as ISO strings, records and arrays as JSON strings.
327
336
 
328
- `segmentstream.semantic` (governed metrics from semantic models) and
329
- `segmentstream.fetch(path)` (any SegmentStream API path) act as the signed-in
330
- SegmentStream member, so they need the legacy sign-in; under Neon Auth they throw
331
- `app_credential_missing`.
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.
332
340
 
333
341
  ## How requests are handled
334
342
 
@@ -387,7 +395,7 @@ Without `NEON_AUTH_BASE_URL`, 0.3 behaves exactly as 0.2, so existing apps keep
387
395
  working: members of the workspace's organization sign in with their SegmentStream
388
396
  account through `/api/segmentstream/[...path]/route.ts`
389
397
  (`export const { GET, POST } = segmentstream.handlers();`), the proxy redirects to
390
- SegmentStream, and `fetch`, `warehouse` and `semantic` act as the member. In that mode
398
+ SegmentStream, and `fetch` acts as the member; warehouse queries and aggregates prefer the workspace app credential. In that mode
391
399
  `roles` and `permissions` are empty, `can()` is `false`, `authHandlers()` answers
392
400
  `404 neon_auth_not_configured`, and `segmentstream.ontology` sends the member's app
393
401
  token, which the ontology service accepts while workspaces move to Neon Auth. Keep the
@@ -410,3 +418,7 @@ app word messages in its own language without matching the English sentences.
410
418
  ## License
411
419
 
412
420
  Apache-2.0
421
+
422
+ ## Upgrading to 0.5
423
+
424
+ 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.
package/dist/app.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Environment, SegmentStreamAppConfig } from "./config.js";
2
- import type { SemanticClient, WarehouseClient } from "./data.js";
2
+ import type { WarehouseClient } from "./data.js";
3
3
  import type { SegmentStreamRouteHandlers } from "./handlers.js";
4
4
  import type { AuthRouteHandlers, CreateNeonAuth, SignOutOptions } from "./neon.js";
5
5
  import type { OntologyClient } from "./ontology.js";
@@ -44,8 +44,6 @@ export interface SegmentStreamApp<O = unknown> {
44
44
  * (`SEGMENTSTREAM_APP_CREDENTIAL`) when it is set, else the signed-in member's session.
45
45
  */
46
46
  readonly warehouse: WarehouseClient;
47
- /** Governed metrics from the workspace's deployed semantic models. Server-only. Legacy sign-in only. */
48
- readonly semantic: SemanticClient;
49
47
  /** The app's ontology service, `/api/ontology`, as the signed-in user. Server-only. */
50
48
  readonly ontology: OntologyClient<O>;
51
49
  }
package/dist/app.js CHANGED
@@ -48,13 +48,12 @@ export function createApp(config, dependencies = {}) {
48
48
  getAccessToken: legacy.getAccessToken,
49
49
  fetch: data.fetch,
50
50
  warehouse: data.warehouse,
51
- semantic: data.semantic,
52
51
  // The ontology service accepts SegmentStream app tokens while workspaces move to Neon Auth.
53
52
  ontology: createOntologyClient(createOntologyTransport({
54
53
  fetch: fetchImplementation,
55
54
  ontologyUrl: ontologyUrl === undefined ? undefined : new URL(ontologyUrl).origin,
56
55
  credentials: { token: legacy.getAccessToken },
57
- })),
56
+ }), data.aggregateTransport),
58
57
  };
59
58
  }
60
59
  const neon = createNeonSession({
@@ -82,8 +81,7 @@ export function createApp(config, dependencies = {}) {
82
81
  getAccessToken: async () => null,
83
82
  fetch: data.fetch,
84
83
  warehouse: data.warehouse,
85
- semantic: data.semantic,
86
- ontology: createOntologyClient(neon.ontologyTransport),
84
+ ontology: createOntologyClient(neon.ontologyTransport, data.aggregateTransport),
87
85
  };
88
86
  }
89
87
  /** Under Neon Auth the app has no SegmentStream session to call the API with. */
@@ -91,7 +89,7 @@ async function noSegmentStreamSession() {
91
89
  throw new SegmentStreamApiError({
92
90
  code: "app_credential_missing",
93
91
  status: 401,
94
- 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.",
92
+ 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.",
95
93
  });
96
94
  }
97
95
  function notConfiguredHandlers() {
package/dist/data.d.ts CHANGED
@@ -43,38 +43,39 @@ export interface WarehouseDryRun {
43
43
  readonly bytesProcessed: string | null;
44
44
  readonly maximumBytesBilled: string;
45
45
  }
46
- export interface SemanticQueryRequest {
47
- readonly dimensions: readonly string[];
48
- readonly measures: readonly string[];
49
- readonly dateRange: {
46
+ export interface AggregateRequest<Property extends string = string, Measure extends string = string, Temporal extends Property = Property> {
47
+ readonly dimensions: readonly Property[];
48
+ readonly measures: readonly Measure[];
49
+ readonly dateRange?: {
50
50
  readonly from: string;
51
51
  readonly to: string;
52
52
  };
53
+ readonly granularityOf?: Temporal;
53
54
  readonly granularity?: "day" | "week" | "month";
54
55
  readonly filters?: ReadonlyArray<{
55
- readonly dimension: string;
56
+ readonly dimension: Property;
56
57
  readonly operator: "equals" | "in" | "isNull" | "isNotNull";
57
58
  readonly value?: string | number | boolean;
58
59
  readonly values?: ReadonlyArray<string | number | boolean>;
59
60
  }>;
60
61
  readonly orderBy?: ReadonlyArray<{
61
- readonly measure?: string;
62
- readonly dimension?: string;
62
+ readonly measure?: Measure;
63
+ readonly dimension?: Property;
63
64
  readonly direction: "asc" | "desc";
64
65
  }>;
65
66
  readonly limit?: number;
66
67
  }
67
- export interface SemanticField {
68
+ export interface AggregateField {
68
69
  readonly name: string;
69
70
  readonly label: string;
70
71
  readonly type: string;
71
72
  readonly unit?: string;
72
73
  readonly format?: string;
73
74
  }
74
- export interface SemanticQueryResult<Row = Record<string, string | boolean | null>> {
75
+ export interface AggregateResult<Row = Record<string, string | boolean | null>> {
75
76
  readonly deploymentId: string | null;
76
77
  readonly definitionDigest: string;
77
- readonly fields: readonly SemanticField[];
78
+ readonly fields: readonly AggregateField[];
78
79
  readonly rows: Row[];
79
80
  readonly truncated: boolean;
80
81
  readonly execution: {
@@ -94,18 +95,6 @@ export interface WarehouseClient {
94
95
  /** Validates SQL and estimates bytes processed without running it. */
95
96
  readonly dryRun: (sql: string, options?: Pick<WarehouseQueryOptions, "params" | "maximumBytesBilled" | "signal">) => Promise<WarehouseDryRun>;
96
97
  }
97
- export interface SemanticClient {
98
- readonly list: () => Promise<{
99
- readonly deploymentId: string | null;
100
- readonly models: readonly unknown[];
101
- }>;
102
- readonly get: (model: string) => Promise<{
103
- readonly deploymentId: string | null;
104
- readonly definitionDigest: string;
105
- readonly model: unknown;
106
- }>;
107
- readonly query: <Row = Record<string, string | boolean | null>>(model: string, request: SemanticQueryRequest) => Promise<SemanticQueryResult<Row>>;
108
- }
109
98
  export interface DataClientDependencies {
110
99
  readonly config: ResolvedConfig;
111
100
  readonly fetch: Fetch;
@@ -122,5 +111,7 @@ export interface DataClientDependencies {
122
111
  export declare function createDataClient(dependencies: DataClientDependencies): {
123
112
  fetch: (path: string, init?: RequestInit) => Promise<Response>;
124
113
  warehouse: WarehouseClient;
125
- semantic: SemanticClient;
114
+ aggregateTransport: {
115
+ fetch: (path: string, init: RequestInit) => Promise<Response>;
116
+ };
126
117
  };
package/dist/data.js CHANGED
@@ -154,12 +154,14 @@ export function createDataClient(dependencies) {
154
154
  };
155
155
  },
156
156
  };
157
- const semantic = {
158
- list: () => call("GET", `${workspace}/semantic-models`),
159
- get: (model) => call("GET", `${workspace}/semantic-models/${encodeURIComponent(model)}`),
160
- query: (model, request) => call("POST", `${workspace}/semantic-models/${encodeURIComponent(model)}/query`, request),
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
+ },
161
163
  };
162
- return { fetch: apiFetch, warehouse, semantic };
164
+ return { fetch: apiFetch, warehouse, aggregateTransport };
163
165
  }
164
166
  function bytesLimit(value) {
165
167
  if (value === undefined) {
package/dist/errors.js CHANGED
@@ -35,7 +35,11 @@ export function apiErrorFrom(status, payload, fallbackDetail) {
35
35
  code: typeof body.error === "string" ? body.error : `http_${status}`,
36
36
  status,
37
37
  detail: typeof body.detail === "string" ? body.detail : fallbackDetail,
38
- requestId: typeof body.requestId === "string" ? body.requestId : undefined,
38
+ requestId: typeof body.request_id === "string"
39
+ ? body.request_id
40
+ : typeof body.requestId === "string"
41
+ ? body.requestId
42
+ : undefined,
39
43
  retryable: typeof body.retryable === "boolean" ? body.retryable : status >= 500 || status === 429,
40
44
  issues: issuesFrom(body.issues),
41
45
  });
package/dist/index.d.ts CHANGED
@@ -22,9 +22,9 @@ export type { AuthenticationMethod, SegmentStreamApp } from "./app.js";
22
22
  export type { ApiIssue } from "./errors.js";
23
23
  export type { CurrentUser } from "./me.js";
24
24
  export type { AuthRouteHandler, AuthRouteHandlers, SignOutOptions } from "./neon.js";
25
- export type { ActionCall, ActionOptions, ActionResponse, Change, Edit, EditKind, EntityClient, FieldFilter, GeneratedActions, HistoryEntry, HistoryOptions, HistoryPage, JsonRecord, JsonValue, ManyFilter, OntologyClient, OntologyPage, OntologyQuery, OrderBy, RequestOptions, UntypedOntologyClient, ValidationIssue, Where, } from "./ontology.js";
25
+ 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";
26
26
  export type { PermissionHolder } from "./permissions.js";
27
- export type { SemanticClient, SemanticField, SemanticQueryRequest, SemanticQueryResult, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
27
+ export type { AggregateField, AggregateRequest, AggregateResult, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
28
28
  export type { ParameterType, QueryParameterValue, QueryParameters, TypedParameter, } from "./parameters.js";
29
29
  export type { SegmentStreamAppConfig } from "./config.js";
30
30
  export type { SegmentStreamRouteHandlers, RouteHandler } from "./handlers.js";
@@ -0,0 +1,33 @@
1
+ import type { OntologyQuery, Where } from "./ontology.js";
2
+ interface CatalogProperty {
3
+ readonly name: string;
4
+ readonly type: string;
5
+ }
6
+ interface CatalogRelationship {
7
+ readonly name: string;
8
+ readonly to: string;
9
+ readonly inverse: string;
10
+ readonly cardinality: string;
11
+ }
12
+ interface CatalogEntity {
13
+ readonly name: string;
14
+ readonly properties: ReadonlyArray<CatalogProperty>;
15
+ readonly relationships: ReadonlyArray<CatalogRelationship>;
16
+ }
17
+ export interface WireCatalog {
18
+ readonly hash?: string;
19
+ readonly entities: ReadonlyArray<CatalogEntity>;
20
+ }
21
+ /** Catalog metadata distinguishes relationship filters from opaque JSON property values. */
22
+ export declare function encodeWhere(input: {
23
+ readonly where: Where | undefined;
24
+ readonly entity: string;
25
+ readonly getCatalog: () => Promise<WireCatalog>;
26
+ }): Promise<Where | undefined>;
27
+ export declare function queryToWire(query: OntologyQuery): unknown;
28
+ export declare function responseFromWire(input: {
29
+ readonly method: "GET" | "POST";
30
+ readonly path: string;
31
+ readonly payload: unknown;
32
+ }): unknown;
33
+ export {};
@@ -0,0 +1,161 @@
1
+ const FILTER_OPERATORS = [
2
+ "eq",
3
+ "ne",
4
+ "in",
5
+ "isNull",
6
+ "contains",
7
+ "startsWith",
8
+ "gt",
9
+ "gte",
10
+ "lt",
11
+ "lte",
12
+ "between",
13
+ ];
14
+ const SYSTEM_FIELDS = ["id", "created_at", "updated_at", "created_by", "updated_by"];
15
+ function isRecord(value) {
16
+ return value !== null && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+ function needsOperatorEncoding(value) {
19
+ if (value === null || typeof value !== "object") {
20
+ return false;
21
+ }
22
+ for (const [key, child] of Object.entries(value)) {
23
+ if (key === "isNull" || key === "startsWith") {
24
+ return true;
25
+ }
26
+ if (needsOperatorEncoding(child)) {
27
+ return true;
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+ /** Catalog metadata distinguishes relationship filters from opaque JSON property values. */
33
+ export async function encodeWhere(input) {
34
+ const { where, entity, getCatalog } = input;
35
+ if (where === undefined || !needsOperatorEncoding(where)) {
36
+ return where;
37
+ }
38
+ const catalog = await getCatalog();
39
+ return whereFromCatalog({ where, entity, catalog });
40
+ }
41
+ function whereFromCatalog(input) {
42
+ const { where, entity, catalog } = input;
43
+ const definition = catalog.entities.find((candidate) => candidate.name === entity);
44
+ const encodedWhere = Object.create(null);
45
+ for (const [field, condition] of Object.entries(where)) {
46
+ if (field === "and" || field === "or") {
47
+ const conditions = condition;
48
+ encodedWhere[field] = conditions.map((child) => whereFromCatalog({ where: child, entity, catalog }));
49
+ continue;
50
+ }
51
+ if (field === "not") {
52
+ encodedWhere[field] = whereFromCatalog({ where: condition, entity, catalog });
53
+ continue;
54
+ }
55
+ const relationship = relationshipTarget({ field, entity, definition, catalog });
56
+ if (relationship !== undefined && isRecord(condition)) {
57
+ encodedWhere[field] = relationshipFilterToWire({ condition, relationship, catalog });
58
+ continue;
59
+ }
60
+ encodedWhere[field] = fieldFilterToWire({ field, condition, definition });
61
+ }
62
+ return encodedWhere;
63
+ }
64
+ function relationshipTarget(input) {
65
+ const { field, entity, definition, catalog } = input;
66
+ const forward = definition?.relationships.find((candidate) => candidate.name === field);
67
+ if (forward !== undefined) {
68
+ return { entity: forward.to, many: forward.cardinality === "many" };
69
+ }
70
+ for (const candidate of catalog.entities) {
71
+ const inverse = candidate.relationships.find((relationship) => relationship.to === entity && relationship.inverse === field);
72
+ if (inverse !== undefined) {
73
+ return { entity: candidate.name, many: true };
74
+ }
75
+ }
76
+ return undefined;
77
+ }
78
+ function relationshipFilterToWire(input) {
79
+ const { condition, relationship, catalog } = input;
80
+ const entity = relationship.entity;
81
+ if (!relationship.many) {
82
+ return whereFromCatalog({ where: condition, entity, catalog });
83
+ }
84
+ const encodedQuantifiers = Object.create(null);
85
+ for (const [quantifier, child] of Object.entries(condition)) {
86
+ encodedQuantifiers[quantifier] = whereFromCatalog({ where: child, entity, catalog });
87
+ }
88
+ return encodedQuantifiers;
89
+ }
90
+ function isGeneratedField(field, definition) {
91
+ if (SYSTEM_FIELDS.includes(field)) {
92
+ return true;
93
+ }
94
+ return (definition?.relationships.some((relationship) => relationship.cardinality === "one" && `${relationship.name}_id` === field) ?? false);
95
+ }
96
+ function fieldFilterToWire(input) {
97
+ const { field, condition, definition } = input;
98
+ if (!isRecord(condition)) {
99
+ return condition;
100
+ }
101
+ const property = definition?.properties.find((candidate) => candidate.name === field);
102
+ if (property === undefined && !isGeneratedField(field, definition)) {
103
+ return condition;
104
+ }
105
+ const operatorFilter = Object.keys(condition).every((operator) => FILTER_OPERATORS.includes(operator));
106
+ if (property?.type === "json" && !operatorFilter) {
107
+ return condition;
108
+ }
109
+ const encodedFilter = Object.create(null);
110
+ for (const [operator, operand] of Object.entries(condition)) {
111
+ encodedFilter[operatorToWire(operator)] = operand;
112
+ }
113
+ return encodedFilter;
114
+ }
115
+ function operatorToWire(operator) {
116
+ if (operator === "isNull") {
117
+ return "is_null";
118
+ }
119
+ if (operator === "startsWith") {
120
+ return "starts_with";
121
+ }
122
+ return operator;
123
+ }
124
+ export function queryToWire(query) {
125
+ const { orderBy, where, ...rest } = query;
126
+ return {
127
+ ...rest,
128
+ ...(orderBy === undefined ? {} : { order_by: orderBy }),
129
+ ...(where === undefined ? {} : { where }),
130
+ };
131
+ }
132
+ function isPagedRoute(method, route) {
133
+ if (route.endsWith("/history")) {
134
+ return true;
135
+ }
136
+ if (method === "POST") {
137
+ return route.endsWith("/query");
138
+ }
139
+ const segments = route.split("/").filter(Boolean);
140
+ return segments.length === 1;
141
+ }
142
+ function historyEntryFromWire(item) {
143
+ const { record_id: recordId, action_id: actionId, principal_id: principalId, ...entry } = item;
144
+ return { ...entry, recordId, actionId, principalId };
145
+ }
146
+ export function responseFromWire(input) {
147
+ const { method, path, payload } = input;
148
+ if (!isRecord(payload)) {
149
+ return payload;
150
+ }
151
+ const route = path.split("?")[0] ?? "";
152
+ if (!isPagedRoute(method, route) || !Object.hasOwn(payload, "next_cursor")) {
153
+ return payload;
154
+ }
155
+ const { next_cursor: nextCursor, ...page } = payload;
156
+ if (!route.endsWith("/history") || !Array.isArray(page.items)) {
157
+ return { ...page, nextCursor };
158
+ }
159
+ const items = page.items.map(historyEntryFromWire);
160
+ return { ...page, nextCursor, items };
161
+ }
@@ -1,3 +1,4 @@
1
+ import type { AggregateRequest, AggregateResult } from "./data.js";
1
2
  /**
2
3
  * The typed client for the app's ontology service, `/api/ontology`. The app imports its ontology as
3
4
  * types only (`import type ontology from "../../ontology"`) and passes `typeof ontology` to
@@ -185,18 +186,30 @@ type ResultOf<A> = A extends {
185
186
  type ActionClients<A> = {
186
187
  readonly [K in keyof A & string]: ActionCall<InputOf<A[K]>, ResultOf<A[K]>>;
187
188
  };
189
+ type PropertiesOf<E> = E extends {
190
+ readonly properties: infer P;
191
+ } ? P : never;
192
+ type MeasuresOf<E> = E extends {
193
+ readonly measures: infer M;
194
+ } ? keyof M & string : never;
195
+ export interface AggregateEntityClient<Property extends string = string, Measure extends string = string, Temporal extends Property = Property> {
196
+ readonly aggregate: (request: AggregateRequest<Property, Measure, Temporal>, options?: RequestOptions) => Promise<AggregateResult>;
197
+ }
198
+ type ClientOf<E> = E extends {
199
+ readonly definition: {
200
+ readonly source: object;
201
+ };
202
+ } ? AggregateEntityClient<keyof PropertiesOf<E> & string, MeasuresOf<E>> : EntityClient<RecordOf<E>, ActionClients<ActionsOf<E>>>;
188
203
  type TypedOntologyClient<O> = {
189
- readonly [N in NameOf<EntitiesOf<O>>]: EntityClient<RecordOf<Extract<EntitiesOf<O>, {
190
- readonly name: N;
191
- }>>, ActionClients<ActionsOf<Extract<EntitiesOf<O>, {
204
+ readonly [N in NameOf<EntitiesOf<O>>]: ClientOf<Extract<EntitiesOf<O>, {
192
205
  readonly name: N;
193
- }>>>>;
206
+ }>>;
194
207
  };
195
208
  /** A client for any entity, when the app passes no ontology type. */
196
209
  export type UntypedOntologyClient = {
197
210
  readonly [entity: string]: EntityClient<JsonRecord, {
198
211
  readonly [action: string]: ActionCall<JsonRecord, JsonValue>;
199
- }>;
212
+ }> & AggregateEntityClient;
200
213
  };
201
214
  /**
202
215
  * `segmentstream.ontology`: one client per entity of `O`, the type of the app's ontology
@@ -209,9 +222,9 @@ export interface OntologyTransport {
209
222
  }
210
223
  /**
211
224
  * Entities and actions exist only in the app's types, so the client resolves them by name when
212
- * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/changeStatus`.
225
+ * they are used: `ontology.lead.actions.change_status(…)` posts to `lead/actions/change_status`.
213
226
  */
214
- export declare function createOntologyClient<O>(transport: OntologyTransport): OntologyClient<O>;
227
+ export declare function createOntologyClient<O>(transport: OntologyTransport, aggregateTransport?: OntologyTransport): OntologyClient<O>;
215
228
  /** `find` sends the query as URL parameters: `where` as JSON, `orderBy` as `field:direction`. */
216
229
  export declare function findParameters(query: OntologyQuery<JsonRecord>): URLSearchParams;
217
230
  export {};
package/dist/ontology.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SegmentStreamApiError, apiErrorFrom, readJson } from "./errors.js";
2
+ import { encodeWhere, queryToWire, responseFromWire } from "./ontology-wire.js";
2
3
  const ENTITY_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
3
- const ACTION_NAME = /^[a-z][a-zA-Z0-9]*$/;
4
+ const ACTION_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
4
5
  /** Names a caller may probe on any object; never an entity or an action. */
5
6
  const RESERVED = new Set([
6
7
  "then",
@@ -17,10 +18,11 @@ const RESERVED = new Set([
17
18
  ]);
18
19
  /**
19
20
  * Entities and actions exist only in the app's types, so the client resolves them by name when
20
- * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/changeStatus`.
21
+ * they are used: `ontology.lead.actions.change_status(…)` posts to `lead/actions/change_status`.
21
22
  */
22
- export function createOntologyClient(transport) {
23
+ export function createOntologyClient(transport, aggregateTransport) {
23
24
  const entities = new Map();
25
+ const catalogReader = createCatalogReader(transport);
24
26
  return new Proxy(Object.create(null), {
25
27
  get(_target, property) {
26
28
  if (typeof property !== "string" || RESERVED.has(property) || !ENTITY_NAME.test(property)) {
@@ -28,21 +30,60 @@ export function createOntologyClient(transport) {
28
30
  }
29
31
  let client = entities.get(property);
30
32
  if (client === undefined) {
31
- client = entityClient(property, transport);
33
+ client = {
34
+ ...entityClient(property, catalogReader.transport, catalogReader.getCatalog),
35
+ aggregate: (request, options = {}) => {
36
+ if (!aggregateTransport) {
37
+ throw new TypeError("An aggregate transport is required.");
38
+ }
39
+ return send(aggregateTransport, "POST", `/${property}/aggregate`, request, options.signal);
40
+ },
41
+ };
32
42
  entities.set(property, client);
33
43
  }
34
44
  return client;
35
45
  },
36
46
  });
37
47
  }
38
- function entityClient(entity, transport) {
48
+ function createCatalogReader(transport) {
49
+ let catalogPromise;
50
+ let catalogHash;
51
+ const observedTransport = {
52
+ async fetch(path, init) {
53
+ const response = await transport.fetch(path, init);
54
+ const hash = response.headers.get("x-ontology-hash");
55
+ if (catalogHash !== undefined && hash !== null && hash !== catalogHash) {
56
+ catalogPromise = undefined;
57
+ catalogHash = undefined;
58
+ }
59
+ return response;
60
+ },
61
+ };
62
+ async function readCatalog() {
63
+ try {
64
+ const catalog = await send(observedTransport, "GET", "", undefined, undefined);
65
+ catalogHash = catalog.hash;
66
+ return catalog;
67
+ }
68
+ catch (error) {
69
+ catalogPromise = undefined;
70
+ throw error;
71
+ }
72
+ }
73
+ function getCatalog() {
74
+ catalogPromise ??= readCatalog();
75
+ return catalogPromise;
76
+ }
77
+ return { transport: observedTransport, getCatalog };
78
+ }
79
+ function entityClient(entity, transport, getCatalog) {
39
80
  const base = `/${entity}`;
40
81
  const actionCalls = new Map();
41
82
  const runAction = (name, input, options = {}) => send(transport, "POST", `${base}/actions/${encodeURIComponent(name)}`, {
42
83
  input,
43
84
  ...(options.validateOnly === undefined
44
85
  ? {}
45
- : { options: { validateOnly: options.validateOnly } }),
86
+ : { options: { validate_only: options.validateOnly } }),
46
87
  }, options.signal);
47
88
  const actions = new Proxy(Object.create(null), {
48
89
  get(_target, property) {
@@ -58,11 +99,15 @@ function entityClient(entity, transport) {
58
99
  },
59
100
  });
60
101
  return {
61
- find: (query = {}, options = {}) => {
62
- const search = findParameters(query).toString();
102
+ find: async (query = {}, options = {}) => {
103
+ const where = await encodeWhere({ where: query.where, entity, getCatalog });
104
+ const search = findParameters({ ...query, where }).toString();
63
105
  return send(transport, "GET", search === "" ? base : `${base}?${search}`, undefined, options.signal);
64
106
  },
65
- query: (query = {}, options = {}) => send(transport, "POST", `${base}/query`, query, options.signal),
107
+ query: async (query = {}, options = {}) => {
108
+ const where = await encodeWhere({ where: query.where, entity, getCatalog });
109
+ return send(transport, "POST", `${base}/query`, queryToWire({ ...query, where }), options.signal);
110
+ },
66
111
  get: (id, options = {}) => send(transport, "GET", `${base}/${recordId(id)}`, undefined, options.signal),
67
112
  history: (id, options = {}) => {
68
113
  const search = new URLSearchParams();
@@ -98,7 +143,7 @@ export function findParameters(query) {
98
143
  search.set("where", JSON.stringify(query.where));
99
144
  }
100
145
  if (query.orderBy !== undefined && query.orderBy.length > 0) {
101
- search.set("orderBy", query.orderBy.map(({ field, direction }) => `${field}:${direction ?? "asc"}`).join(","));
146
+ search.set("order_by", query.orderBy.map(({ field, direction }) => `${field}:${direction ?? "asc"}`).join(","));
102
147
  }
103
148
  if (query.include !== undefined) {
104
149
  const relationships = Object.keys(query.include).filter((name) => query.include?.[name] === true);
@@ -137,5 +182,5 @@ async function send(transport, method, path, body, signal) {
137
182
  retryable: true,
138
183
  });
139
184
  }
140
- return payload;
185
+ return responseFromWire({ method, path, payload });
141
186
  }
@@ -8,8 +8,8 @@
8
8
  * ontology runtime. Keep the two in step.
9
9
  */
10
10
  const WILDCARD = "*";
11
- /** `<entity>:<verb>` with lowercase snake_case entities and lower camelCase verbs, or `*` for either. */
12
- const PERMISSION = /^(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-zA-Z0-9]*)$/;
11
+ /** `<entity>:<verb>` with lowercase snake_case entities and lowercase snake_case verbs, or `*` for either. */
12
+ const PERMISSION = /^(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*)$/;
13
13
  function parsePermission(value) {
14
14
  if (typeof value !== "string") {
15
15
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/app",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",