@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 +89 -4
- package/dist/app.js +3 -3
- package/dist/data.d.ts +52 -8
- package/dist/data.js +1 -8
- package/dist/index.d.ts +2 -2
- package/dist/ontology-names.d.ts +7 -0
- package/dist/ontology-names.js +9 -0
- package/dist/ontology-wire.d.ts +2 -0
- package/dist/ontology-wire.js +14 -3
- package/dist/ontology.d.ts +80 -12
- package/dist/ontology.js +51 -14
- package/package.json +1 -1
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
|
|
338
|
-
|
|
339
|
-
`segmentstream.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/dist/ontology-names.d.ts
CHANGED
|
@@ -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;
|
package/dist/ontology-names.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/ontology-wire.d.ts
CHANGED
|
@@ -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;
|
package/dist/ontology-wire.js
CHANGED
|
@@ -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 &&
|
|
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" &&
|
|
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;
|
package/dist/ontology.d.ts
CHANGED
|
@@ -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 :
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
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
|
-
} ?
|
|
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`)
|
|
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
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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",
|