@segmentstream-cloud/app 0.5.0 → 0.7.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 +11 -2
- package/dist/errors.js +5 -1
- package/dist/me.d.ts +0 -1
- package/dist/me.js +3 -2
- package/dist/ontology-names.d.ts +3 -0
- package/dist/ontology-names.js +26 -0
- package/dist/ontology-wire.d.ts +57 -0
- package/dist/ontology-wire.js +288 -0
- package/dist/ontology.d.ts +1 -1
- package/dist/ontology.js +128 -21
- package/dist/permissions.js +6 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @segmentstream-cloud/app
|
|
2
2
|
|
|
3
|
+
The ontology client uses camelCase entities, records and actions, including
|
|
4
|
+
`createdAt`, `assignedOwnerId`, and `actions.changeStatus(...)`. It translates
|
|
5
|
+
queries and declared action inputs to snake_case REST and decodes records back to
|
|
6
|
+
camelCase. The catalog is read lazily and cached by ontology hash so nested
|
|
7
|
+
relationships and select identifiers convert while JSON property contents,
|
|
8
|
+
provider payloads and opaque action results remain unchanged.
|
|
9
|
+
|
|
3
10
|
Sign-in, users, permissions, the ontology client and warehouse data access for
|
|
4
11
|
SegmentStream workspace apps built with Next.js (App Router, version 16 or later) and
|
|
5
12
|
React 19. The app's users sign in with an email code, or a sign-in link the first time,
|
|
@@ -263,7 +270,9 @@ const history = await segmentstream.ontology.lead.history(id, { limit: 50 });
|
|
|
263
270
|
const page = await segmentstream.ontology.lead.query({ where: { email: { startsWith: "ada" } } });
|
|
264
271
|
|
|
265
272
|
const input = { lead: id, status: "contacted" } as const;
|
|
266
|
-
const check = await segmentstream.ontology.lead.actions.changeStatus(input, {
|
|
273
|
+
const check = await segmentstream.ontology.lead.actions.changeStatus(input, {
|
|
274
|
+
validateOnly: true,
|
|
275
|
+
});
|
|
267
276
|
const { result, validation, edits } = await segmentstream.ontology.lead.actions.changeStatus(input);
|
|
268
277
|
```
|
|
269
278
|
|
|
@@ -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 `
|
|
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`.
|
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.
|
|
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/me.d.ts
CHANGED
package/dist/me.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** The ontology service's account of the caller: `GET /api/ontology/me`. */
|
|
2
|
+
import { toCamelCase } from "./ontology-names.js";
|
|
2
3
|
export const ME_PATH = "/api/ontology/me";
|
|
3
4
|
/**
|
|
4
5
|
* Reads `{ user: { id, name, email, kind, active }, roles, permissions }` tolerantly: unknown
|
|
@@ -22,8 +23,8 @@ export function parseMe(payload) {
|
|
|
22
23
|
email: typeof email === "string" ? email : null,
|
|
23
24
|
kind: typeof kind === "string" ? kind : "member",
|
|
24
25
|
active: typeof active === "boolean" ? active : true,
|
|
25
|
-
roles: strings(roles),
|
|
26
|
-
permissions: strings(permissions),
|
|
26
|
+
roles: strings(roles).map(toCamelCase),
|
|
27
|
+
permissions: strings(permissions).map(toCamelCase),
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
function strings(value) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Ontology identifiers in TypeScript and their canonical transport spelling. */
|
|
2
|
+
function isUppercase(letter) {
|
|
3
|
+
return letter !== undefined && letter >= "A" && letter <= "Z";
|
|
4
|
+
}
|
|
5
|
+
function isLowercase(letter) {
|
|
6
|
+
return letter !== undefined && letter >= "a" && letter <= "z";
|
|
7
|
+
}
|
|
8
|
+
/** Convert each character once, including acronym boundaries such as HTTPClient. */
|
|
9
|
+
export function toSnakeCase(name) {
|
|
10
|
+
const characters = [];
|
|
11
|
+
for (let index = 0; index < name.length; index++) {
|
|
12
|
+
const current = name[index];
|
|
13
|
+
const previous = name[index - 1];
|
|
14
|
+
const next = name[index + 1];
|
|
15
|
+
const followsWord = isLowercase(previous) || (previous !== undefined && previous >= "0" && previous <= "9");
|
|
16
|
+
const endsAcronym = isUppercase(previous) && isLowercase(next);
|
|
17
|
+
if (isUppercase(current) && (followsWord || endsAcronym)) {
|
|
18
|
+
characters.push("_");
|
|
19
|
+
}
|
|
20
|
+
characters.push(current);
|
|
21
|
+
}
|
|
22
|
+
return characters.join("").toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
export function toCamelCase(name) {
|
|
25
|
+
return name.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
|
|
26
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Edit, OntologyQuery, Where } from "./ontology.js";
|
|
2
|
+
interface CatalogProperty {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly type: string;
|
|
5
|
+
readonly options?: ReadonlyArray<{
|
|
6
|
+
readonly value: string;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
interface CatalogRelationship {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly to: string;
|
|
12
|
+
readonly inverse: string;
|
|
13
|
+
readonly cardinality: string;
|
|
14
|
+
}
|
|
15
|
+
interface CatalogEntity {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly properties: ReadonlyArray<CatalogProperty>;
|
|
18
|
+
readonly relationships: ReadonlyArray<CatalogRelationship>;
|
|
19
|
+
readonly actions?: ReadonlyArray<{
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly input: JsonSchema;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
interface JsonSchema {
|
|
25
|
+
readonly $ref?: string;
|
|
26
|
+
readonly $defs?: Readonly<Record<string, JsonSchema>>;
|
|
27
|
+
readonly properties?: Readonly<Record<string, JsonSchema>>;
|
|
28
|
+
readonly items?: JsonSchema;
|
|
29
|
+
readonly anyOf?: ReadonlyArray<JsonSchema>;
|
|
30
|
+
readonly oneOf?: ReadonlyArray<JsonSchema>;
|
|
31
|
+
readonly enum?: ReadonlyArray<unknown>;
|
|
32
|
+
readonly const?: unknown;
|
|
33
|
+
readonly additionalProperties?: boolean | JsonSchema;
|
|
34
|
+
readonly required?: ReadonlyArray<string>;
|
|
35
|
+
}
|
|
36
|
+
export interface WireCatalog {
|
|
37
|
+
readonly hash?: string;
|
|
38
|
+
readonly entities: ReadonlyArray<CatalogEntity>;
|
|
39
|
+
}
|
|
40
|
+
/** Catalog metadata distinguishes relationship filters from opaque JSON property values. */
|
|
41
|
+
export declare function encodeWhere(input: {
|
|
42
|
+
readonly where: Where | undefined;
|
|
43
|
+
readonly entity: string;
|
|
44
|
+
readonly getCatalog: () => Promise<WireCatalog>;
|
|
45
|
+
}): Promise<Where | undefined>;
|
|
46
|
+
export declare function queryToWire(query: OntologyQuery): unknown;
|
|
47
|
+
/** Decode record fields and included relationships without descending into JSON properties. */
|
|
48
|
+
export declare function recordFromWire<A = unknown>(value: unknown, entity: string, catalog: WireCatalog): A;
|
|
49
|
+
export declare function actionInputToWire(input: unknown, entity: string, action: string, catalog: WireCatalog): unknown;
|
|
50
|
+
export declare function responseFromWire(input: {
|
|
51
|
+
readonly method: "GET" | "POST";
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly payload: unknown;
|
|
54
|
+
}): unknown;
|
|
55
|
+
/** Decode current edits using the current catalog; stored historical changes retain their names. */
|
|
56
|
+
export declare function editsFromWire(edits: ReadonlyArray<Edit>, catalog: WireCatalog): ReadonlyArray<Edit>;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { toCamelCase, toSnakeCase } from "./ontology-names.js";
|
|
2
|
+
const FILTER_OPERATORS = [
|
|
3
|
+
"eq",
|
|
4
|
+
"ne",
|
|
5
|
+
"in",
|
|
6
|
+
"isNull",
|
|
7
|
+
"contains",
|
|
8
|
+
"startsWith",
|
|
9
|
+
"gt",
|
|
10
|
+
"gte",
|
|
11
|
+
"lt",
|
|
12
|
+
"lte",
|
|
13
|
+
"between",
|
|
14
|
+
];
|
|
15
|
+
const SYSTEM_FIELDS = ["id", "created_at", "updated_at", "created_by", "updated_by"];
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
/** Catalog metadata distinguishes relationship filters from opaque JSON property values. */
|
|
20
|
+
export async function encodeWhere(input) {
|
|
21
|
+
const { where, entity, getCatalog } = input;
|
|
22
|
+
if (where === undefined) {
|
|
23
|
+
return where;
|
|
24
|
+
}
|
|
25
|
+
const catalog = await getCatalog();
|
|
26
|
+
return whereFromCatalog({ where, entity, catalog });
|
|
27
|
+
}
|
|
28
|
+
function whereFromCatalog(input) {
|
|
29
|
+
const { where, entity, catalog } = input;
|
|
30
|
+
const definition = catalog.entities.find((candidate) => candidate.name === entity);
|
|
31
|
+
const encodedWhere = Object.create(null);
|
|
32
|
+
for (const [field, condition] of Object.entries(where)) {
|
|
33
|
+
if (field === "and" || field === "or") {
|
|
34
|
+
const conditions = condition;
|
|
35
|
+
encodedWhere[field] = conditions.map((child) => whereFromCatalog({ where: child, entity, catalog }));
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (field === "not") {
|
|
39
|
+
encodedWhere[field] = whereFromCatalog({ where: condition, entity, catalog });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const wireField = toSnakeCase(field);
|
|
43
|
+
const relationship = relationshipTarget({ field: wireField, entity, definition, catalog });
|
|
44
|
+
if (relationship !== undefined && isRecord(condition)) {
|
|
45
|
+
encodedWhere[wireField] = relationshipFilterToWire({ condition, relationship, catalog });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
encodedWhere[wireField] = fieldFilterToWire({ field: wireField, condition, definition });
|
|
49
|
+
}
|
|
50
|
+
return encodedWhere;
|
|
51
|
+
}
|
|
52
|
+
function relationshipTarget(input) {
|
|
53
|
+
const { field, entity, definition, catalog } = input;
|
|
54
|
+
const forward = definition?.relationships.find((candidate) => candidate.name === field);
|
|
55
|
+
if (forward !== undefined) {
|
|
56
|
+
return { entity: forward.to, many: forward.cardinality === "many" };
|
|
57
|
+
}
|
|
58
|
+
for (const candidate of catalog.entities) {
|
|
59
|
+
const inverse = candidate.relationships.find((relationship) => relationship.to === entity && relationship.inverse === field);
|
|
60
|
+
if (inverse !== undefined) {
|
|
61
|
+
return { entity: candidate.name, many: true };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
function relationshipFilterToWire(input) {
|
|
67
|
+
const { condition, relationship, catalog } = input;
|
|
68
|
+
const entity = relationship.entity;
|
|
69
|
+
if (!relationship.many) {
|
|
70
|
+
return whereFromCatalog({ where: condition, entity, catalog });
|
|
71
|
+
}
|
|
72
|
+
const encodedQuantifiers = Object.create(null);
|
|
73
|
+
for (const [quantifier, child] of Object.entries(condition)) {
|
|
74
|
+
encodedQuantifiers[quantifier] = whereFromCatalog({ where: child, entity, catalog });
|
|
75
|
+
}
|
|
76
|
+
return encodedQuantifiers;
|
|
77
|
+
}
|
|
78
|
+
function isGeneratedField(field, definition) {
|
|
79
|
+
if (SYSTEM_FIELDS.includes(field)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return (definition?.relationships.some((relationship) => relationship.cardinality === "one" && `${relationship.name}_id` === field) ?? false);
|
|
83
|
+
}
|
|
84
|
+
function fieldFilterToWire(input) {
|
|
85
|
+
const { field, condition, definition } = input;
|
|
86
|
+
const property = definition?.properties.find((candidate) => candidate.name === field);
|
|
87
|
+
if (!isRecord(condition)) {
|
|
88
|
+
return selectToWire(condition, property);
|
|
89
|
+
}
|
|
90
|
+
if (property === undefined && !isGeneratedField(field, definition)) {
|
|
91
|
+
return condition;
|
|
92
|
+
}
|
|
93
|
+
const operatorFilter = Object.keys(condition).every((operator) => FILTER_OPERATORS.includes(operator));
|
|
94
|
+
if (property?.type === "json" && !operatorFilter) {
|
|
95
|
+
return condition;
|
|
96
|
+
}
|
|
97
|
+
const encodedFilter = Object.create(null);
|
|
98
|
+
for (const [operator, operand] of Object.entries(condition)) {
|
|
99
|
+
encodedFilter[operatorToWire(operator)] = selectToWire(operand, property);
|
|
100
|
+
}
|
|
101
|
+
return encodedFilter;
|
|
102
|
+
}
|
|
103
|
+
function operatorToWire(operator) {
|
|
104
|
+
if (operator === "isNull") {
|
|
105
|
+
return "is_null";
|
|
106
|
+
}
|
|
107
|
+
if (operator === "startsWith") {
|
|
108
|
+
return "starts_with";
|
|
109
|
+
}
|
|
110
|
+
return operator;
|
|
111
|
+
}
|
|
112
|
+
export function queryToWire(query) {
|
|
113
|
+
const { orderBy, where, ...rest } = query;
|
|
114
|
+
return {
|
|
115
|
+
...rest,
|
|
116
|
+
...(orderBy === undefined
|
|
117
|
+
? {}
|
|
118
|
+
: { order_by: orderBy.map((order) => ({ ...order, field: toSnakeCase(order.field) })) }),
|
|
119
|
+
...(query.include === undefined
|
|
120
|
+
? {}
|
|
121
|
+
: {
|
|
122
|
+
include: Object.fromEntries(Object.entries(query.include).map(([name, value]) => [toSnakeCase(name), value])),
|
|
123
|
+
}),
|
|
124
|
+
...(where === undefined ? {} : { where }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function selectToWire(value, property) {
|
|
128
|
+
if (property?.type !== "select") {
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
const convert = (child) => typeof child === "string" &&
|
|
132
|
+
property.options?.some((option) => option.value === toSnakeCase(child))
|
|
133
|
+
? toSnakeCase(child)
|
|
134
|
+
: child;
|
|
135
|
+
return Array.isArray(value) ? value.map(convert) : convert(value);
|
|
136
|
+
}
|
|
137
|
+
/** Decode record fields and included relationships without descending into JSON properties. */
|
|
138
|
+
export function recordFromWire(value, entity, catalog) {
|
|
139
|
+
if (!isRecord(value)) {
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
const definition = catalog.entities.find((candidate) => candidate.name === entity);
|
|
143
|
+
return Object.fromEntries(Object.entries(value).map(([field, child]) => {
|
|
144
|
+
const property = definition?.properties.find((candidate) => candidate.name === field);
|
|
145
|
+
if (property?.type === "select" && typeof child === "string") {
|
|
146
|
+
return [toCamelCase(field), toCamelCase(child)];
|
|
147
|
+
}
|
|
148
|
+
const relationship = relationshipTarget({ field, entity, definition, catalog });
|
|
149
|
+
if (relationship !== undefined) {
|
|
150
|
+
const decoded = Array.isArray(child)
|
|
151
|
+
? child.map((record) => recordFromWire(record, relationship.entity, catalog))
|
|
152
|
+
: recordFromWire(child, relationship.entity, catalog);
|
|
153
|
+
return [toCamelCase(field), decoded];
|
|
154
|
+
}
|
|
155
|
+
return [toCamelCase(field), child];
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
function schemaInputToWire(value, schema, definitions) {
|
|
159
|
+
if (schema.$ref?.startsWith("#/$defs/")) {
|
|
160
|
+
const target = definitions[schema.$ref.slice("#/$defs/".length)];
|
|
161
|
+
return target === undefined ? value : schemaInputToWire(value, target, definitions);
|
|
162
|
+
}
|
|
163
|
+
const alternatives = schema.anyOf ?? schema.oneOf;
|
|
164
|
+
if (alternatives !== undefined) {
|
|
165
|
+
const object = alternatives.find((candidate) => matchesObjectSchema(value, candidate, definitions));
|
|
166
|
+
const array = alternatives.find((candidate) => candidate.items !== undefined && Array.isArray(value));
|
|
167
|
+
const literals = alternatives.flatMap((candidate) => candidate.enum ?? (candidate.const === undefined ? [] : [candidate.const]));
|
|
168
|
+
if (typeof value === "string" && literals.includes(toSnakeCase(value))) {
|
|
169
|
+
return toSnakeCase(value);
|
|
170
|
+
}
|
|
171
|
+
const target = object ?? array;
|
|
172
|
+
return target === undefined ? value : schemaInputToWire(value, target, definitions);
|
|
173
|
+
}
|
|
174
|
+
if (typeof value === "string" &&
|
|
175
|
+
(schema.enum?.includes(toSnakeCase(value)) || schema.const === toSnakeCase(value))) {
|
|
176
|
+
return toSnakeCase(value);
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(value) && schema.items !== undefined) {
|
|
179
|
+
return value.map((child) => schemaInputToWire(child, schema.items, definitions));
|
|
180
|
+
}
|
|
181
|
+
if (!isRecord(value)) {
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
if (schema.properties === undefined) {
|
|
185
|
+
const childSchema = schema.additionalProperties;
|
|
186
|
+
return typeof childSchema === "object"
|
|
187
|
+
? Object.fromEntries(Object.entries(value).map(([key, child]) => [
|
|
188
|
+
key,
|
|
189
|
+
schemaInputToWire(child, childSchema, definitions),
|
|
190
|
+
]))
|
|
191
|
+
: value;
|
|
192
|
+
}
|
|
193
|
+
return Object.fromEntries(Object.entries(value).map(([field, child]) => {
|
|
194
|
+
const wireField = toSnakeCase(field);
|
|
195
|
+
const childSchema = schema.properties?.[wireField];
|
|
196
|
+
return [
|
|
197
|
+
wireField,
|
|
198
|
+
childSchema === undefined ? child : schemaInputToWire(child, childSchema, definitions),
|
|
199
|
+
];
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
function matchesObjectSchema(value, schema, definitions) {
|
|
203
|
+
if (schema.$ref?.startsWith("#/$defs/")) {
|
|
204
|
+
const target = definitions[schema.$ref.slice("#/$defs/".length)];
|
|
205
|
+
return (target !== undefined && target !== schema && matchesObjectSchema(value, target, definitions));
|
|
206
|
+
}
|
|
207
|
+
if (!isRecord(value) || schema.properties === undefined) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
if (schema.required?.some((key) => !Object.hasOwn(value, key) && !Object.hasOwn(value, toCamelCase(key)))) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
return Object.entries(schema.properties).every(([key, childSchema]) => {
|
|
214
|
+
if (childSchema.const === undefined) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
const child = value[key] ?? value[toCamelCase(key)];
|
|
218
|
+
return (child === childSchema.const ||
|
|
219
|
+
(typeof child === "string" && toSnakeCase(child) === childSchema.const));
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
export function actionInputToWire(input, entity, action, catalog) {
|
|
223
|
+
const definition = catalog.entities.find((candidate) => candidate.name === entity);
|
|
224
|
+
const authored = definition?.actions?.find((candidate) => candidate.name === action);
|
|
225
|
+
if (authored !== undefined) {
|
|
226
|
+
return schemaInputToWire(input, authored.input, authored.input.$defs ?? {});
|
|
227
|
+
}
|
|
228
|
+
if (!isRecord(input)) {
|
|
229
|
+
return input;
|
|
230
|
+
}
|
|
231
|
+
return Object.fromEntries(Object.entries(input).map(([field, child]) => {
|
|
232
|
+
const wireField = toSnakeCase(field);
|
|
233
|
+
const property = definition?.properties.find((candidate) => candidate.name === wireField);
|
|
234
|
+
return [
|
|
235
|
+
wireField,
|
|
236
|
+
(action === "link" || action === "unlink") &&
|
|
237
|
+
field === "relationship" &&
|
|
238
|
+
typeof child === "string"
|
|
239
|
+
? toSnakeCase(child)
|
|
240
|
+
: selectToWire(child, property),
|
|
241
|
+
];
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
function isPagedRoute(method, route) {
|
|
245
|
+
if (route.endsWith("/history")) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
if (method === "POST") {
|
|
249
|
+
return route.endsWith("/query");
|
|
250
|
+
}
|
|
251
|
+
const segments = route.split("/").filter(Boolean);
|
|
252
|
+
return segments.length === 1;
|
|
253
|
+
}
|
|
254
|
+
function historyEntryFromWire(item) {
|
|
255
|
+
const { record_id: recordId, action_id: actionId, principal_id: principalId, ...entry } = item;
|
|
256
|
+
return { ...entry, recordId, actionId, principalId };
|
|
257
|
+
}
|
|
258
|
+
export function responseFromWire(input) {
|
|
259
|
+
const { method, path, payload } = input;
|
|
260
|
+
if (!isRecord(payload)) {
|
|
261
|
+
return payload;
|
|
262
|
+
}
|
|
263
|
+
const route = path.split("?")[0] ?? "";
|
|
264
|
+
if (!isPagedRoute(method, route) || !Object.hasOwn(payload, "next_cursor")) {
|
|
265
|
+
return payload;
|
|
266
|
+
}
|
|
267
|
+
const { next_cursor: nextCursor, ...page } = payload;
|
|
268
|
+
if (!route.endsWith("/history") || !Array.isArray(page.items)) {
|
|
269
|
+
return { ...page, nextCursor };
|
|
270
|
+
}
|
|
271
|
+
const items = page.items.map(historyEntryFromWire);
|
|
272
|
+
return { ...page, nextCursor, items };
|
|
273
|
+
}
|
|
274
|
+
/** Decode current edits using the current catalog; stored historical changes retain their names. */
|
|
275
|
+
export function editsFromWire(edits, catalog) {
|
|
276
|
+
return edits.map((edit) => {
|
|
277
|
+
const before = recordFromWire(Object.fromEntries(Object.entries(edit.changes).map(([field, change]) => [field, change.before])), edit.entity, catalog);
|
|
278
|
+
const after = recordFromWire(Object.fromEntries(Object.entries(edit.changes).map(([field, change]) => [field, change.after])), edit.entity, catalog);
|
|
279
|
+
return {
|
|
280
|
+
...edit,
|
|
281
|
+
entity: toCamelCase(edit.entity),
|
|
282
|
+
changes: Object.fromEntries(Object.keys(before).map((field) => [
|
|
283
|
+
field,
|
|
284
|
+
{ before: before[field], after: after[field] },
|
|
285
|
+
])),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
}
|
package/dist/ontology.d.ts
CHANGED
|
@@ -222,7 +222,7 @@ export interface OntologyTransport {
|
|
|
222
222
|
}
|
|
223
223
|
/**
|
|
224
224
|
* Entities and actions exist only in the app's types, so the client resolves them by name when
|
|
225
|
-
* they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/
|
|
225
|
+
* they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/change_status`.
|
|
226
226
|
*/
|
|
227
227
|
export declare function createOntologyClient<O>(transport: OntologyTransport, aggregateTransport?: OntologyTransport): OntologyClient<O>;
|
|
228
228
|
/** `find` sends the query as URL parameters: `where` as JSON, `orderBy` as `field:direction`. */
|
package/dist/ontology.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { SegmentStreamApiError, apiErrorFrom, readJson } from "./errors.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { actionInputToWire, encodeWhere, editsFromWire, queryToWire, recordFromWire, responseFromWire, } from "./ontology-wire.js";
|
|
3
|
+
import { toCamelCase, toSnakeCase } from "./ontology-names.js";
|
|
4
|
+
const ENTITY_NAME = /^[a-z][a-zA-Z0-9]*(?:_[a-z0-9]+)*$/;
|
|
5
|
+
const ACTION_NAME = ENTITY_NAME;
|
|
4
6
|
/** Names a caller may probe on any object; never an entity or an action. */
|
|
5
7
|
const RESERVED = new Set([
|
|
6
8
|
"then",
|
|
@@ -17,10 +19,11 @@ const RESERVED = new Set([
|
|
|
17
19
|
]);
|
|
18
20
|
/**
|
|
19
21
|
* 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/
|
|
22
|
+
* they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/change_status`.
|
|
21
23
|
*/
|
|
22
24
|
export function createOntologyClient(transport, aggregateTransport) {
|
|
23
25
|
const entities = new Map();
|
|
26
|
+
const catalogReader = createCatalogReader(transport);
|
|
24
27
|
return new Proxy(Object.create(null), {
|
|
25
28
|
get(_target, property) {
|
|
26
29
|
if (typeof property !== "string" || RESERVED.has(property) || !ENTITY_NAME.test(property)) {
|
|
@@ -29,11 +32,16 @@ export function createOntologyClient(transport, aggregateTransport) {
|
|
|
29
32
|
let client = entities.get(property);
|
|
30
33
|
if (client === undefined) {
|
|
31
34
|
client = {
|
|
32
|
-
...entityClient(property, transport),
|
|
35
|
+
...entityClient(toSnakeCase(property), catalogReader.transport, catalogReader.getCatalog),
|
|
33
36
|
aggregate: (request, options = {}) => {
|
|
34
|
-
if (!aggregateTransport)
|
|
37
|
+
if (!aggregateTransport) {
|
|
35
38
|
throw new TypeError("An aggregate transport is required.");
|
|
36
|
-
|
|
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
|
+
}));
|
|
37
45
|
},
|
|
38
46
|
};
|
|
39
47
|
entities.set(property, client);
|
|
@@ -42,15 +50,67 @@ export function createOntologyClient(transport, aggregateTransport) {
|
|
|
42
50
|
},
|
|
43
51
|
});
|
|
44
52
|
}
|
|
45
|
-
function
|
|
53
|
+
function createCatalogReader(transport) {
|
|
54
|
+
let catalogPromise;
|
|
55
|
+
let catalogHash;
|
|
56
|
+
const observedTransport = {
|
|
57
|
+
async fetch(path, init) {
|
|
58
|
+
const response = await transport.fetch(path, init);
|
|
59
|
+
const hash = response.headers.get("x-ontology-hash");
|
|
60
|
+
if (catalogHash !== undefined && hash !== null && hash !== catalogHash) {
|
|
61
|
+
catalogPromise = undefined;
|
|
62
|
+
catalogHash = undefined;
|
|
63
|
+
}
|
|
64
|
+
return response;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
async function readCatalog() {
|
|
68
|
+
try {
|
|
69
|
+
const catalog = await send(observedTransport, "GET", "", undefined, undefined);
|
|
70
|
+
catalogHash = catalog.hash;
|
|
71
|
+
return catalog;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
catalogPromise = undefined;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function getCatalog() {
|
|
79
|
+
catalogPromise ??= readCatalog();
|
|
80
|
+
return catalogPromise;
|
|
81
|
+
}
|
|
82
|
+
return { transport: observedTransport, getCatalog };
|
|
83
|
+
}
|
|
84
|
+
function entityClient(entity, transport, getCatalog) {
|
|
46
85
|
const base = `/${entity}`;
|
|
47
86
|
const actionCalls = new Map();
|
|
48
|
-
const runAction = (name, input, options = {}) =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
:
|
|
53
|
-
|
|
87
|
+
const runAction = async (name, input, options = {}) => {
|
|
88
|
+
const action = toSnakeCase(name);
|
|
89
|
+
const catalog = await getCatalog();
|
|
90
|
+
const wireResponse = await send(transport, "POST", `${base}/actions/${encodeURIComponent(action)}`, {
|
|
91
|
+
input: actionInputToWire(input, entity, action, catalog),
|
|
92
|
+
...(options.validateOnly === undefined
|
|
93
|
+
? {}
|
|
94
|
+
: { options: { validate_only: options.validateOnly } }),
|
|
95
|
+
}, options.signal);
|
|
96
|
+
const response = { ...wireResponse, edits: editsFromWire(wireResponse.edits, catalog) };
|
|
97
|
+
if (action === "create" || action === "update") {
|
|
98
|
+
return { ...response, result: recordFromWire(response.result, entity, catalog) };
|
|
99
|
+
}
|
|
100
|
+
if (action.startsWith("upsert_by_") &&
|
|
101
|
+
response.result !== null &&
|
|
102
|
+
typeof response.result === "object" &&
|
|
103
|
+
!Array.isArray(response.result)) {
|
|
104
|
+
return {
|
|
105
|
+
...response,
|
|
106
|
+
result: {
|
|
107
|
+
...response.result,
|
|
108
|
+
record: recordFromWire(response.result.record, entity, catalog),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return response;
|
|
113
|
+
};
|
|
54
114
|
const actions = new Proxy(Object.create(null), {
|
|
55
115
|
get(_target, property) {
|
|
56
116
|
if (typeof property !== "string" || RESERVED.has(property) || !ACTION_NAME.test(property)) {
|
|
@@ -65,12 +125,29 @@ function entityClient(entity, transport) {
|
|
|
65
125
|
},
|
|
66
126
|
});
|
|
67
127
|
return {
|
|
68
|
-
find: (query = {}, options = {}) => {
|
|
69
|
-
const
|
|
70
|
-
|
|
128
|
+
find: async (query = {}, options = {}) => {
|
|
129
|
+
const catalog = await getCatalog();
|
|
130
|
+
const where = await encodeWhere({ where: query.where, entity, getCatalog });
|
|
131
|
+
const search = findParameters({ ...query, where }).toString();
|
|
132
|
+
const page = await send(transport, "GET", search === "" ? base : `${base}?${search}`, undefined, options.signal);
|
|
133
|
+
return {
|
|
134
|
+
...page,
|
|
135
|
+
items: page.items.map((record) => recordFromWire(record, entity, catalog)),
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
query: async (query = {}, options = {}) => {
|
|
139
|
+
const catalog = await getCatalog();
|
|
140
|
+
const where = await encodeWhere({ where: query.where, entity, getCatalog });
|
|
141
|
+
const page = await send(transport, "POST", `${base}/query`, queryToWire({ ...query, where }), options.signal);
|
|
142
|
+
return {
|
|
143
|
+
...page,
|
|
144
|
+
items: page.items.map((record) => recordFromWire(record, entity, catalog)),
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
get: (id, options = {}) => {
|
|
148
|
+
const encodedId = recordId(id);
|
|
149
|
+
return getCatalog().then(async (catalog) => recordFromWire(await send(transport, "GET", `${base}/${encodedId}`, undefined, options.signal), entity, catalog));
|
|
71
150
|
},
|
|
72
|
-
query: (query = {}, options = {}) => send(transport, "POST", `${base}/query`, query, options.signal),
|
|
73
|
-
get: (id, options = {}) => send(transport, "GET", `${base}/${recordId(id)}`, undefined, options.signal),
|
|
74
151
|
history: (id, options = {}) => {
|
|
75
152
|
const search = new URLSearchParams();
|
|
76
153
|
if (options.after !== undefined) {
|
|
@@ -105,12 +182,14 @@ export function findParameters(query) {
|
|
|
105
182
|
search.set("where", JSON.stringify(query.where));
|
|
106
183
|
}
|
|
107
184
|
if (query.orderBy !== undefined && query.orderBy.length > 0) {
|
|
108
|
-
search.set("
|
|
185
|
+
search.set("order_by", query.orderBy
|
|
186
|
+
.map(({ field, direction }) => `${toSnakeCase(field)}:${direction ?? "asc"}`)
|
|
187
|
+
.join(","));
|
|
109
188
|
}
|
|
110
189
|
if (query.include !== undefined) {
|
|
111
190
|
const relationships = Object.keys(query.include).filter((name) => query.include?.[name] === true);
|
|
112
191
|
if (relationships.length > 0) {
|
|
113
|
-
search.set("include", relationships.join(","));
|
|
192
|
+
search.set("include", relationships.map(toSnakeCase).join(","));
|
|
114
193
|
}
|
|
115
194
|
}
|
|
116
195
|
if (query.after !== undefined) {
|
|
@@ -121,6 +200,34 @@ export function findParameters(query) {
|
|
|
121
200
|
}
|
|
122
201
|
return search;
|
|
123
202
|
}
|
|
203
|
+
/** The platform aggregate API keeps its existing protocol keys; ontology identifiers are canonical. */
|
|
204
|
+
function aggregateToWire(request) {
|
|
205
|
+
return {
|
|
206
|
+
...request,
|
|
207
|
+
dimensions: request.dimensions.map(toSnakeCase),
|
|
208
|
+
measures: request.measures.map(toSnakeCase),
|
|
209
|
+
...(request.granularityOf === undefined
|
|
210
|
+
? {}
|
|
211
|
+
: { granularityOf: toSnakeCase(request.granularityOf) }),
|
|
212
|
+
...(request.filters === undefined
|
|
213
|
+
? {}
|
|
214
|
+
: {
|
|
215
|
+
filters: request.filters.map((filter) => ({
|
|
216
|
+
...filter,
|
|
217
|
+
dimension: toSnakeCase(filter.dimension),
|
|
218
|
+
})),
|
|
219
|
+
}),
|
|
220
|
+
...(request.orderBy === undefined
|
|
221
|
+
? {}
|
|
222
|
+
: {
|
|
223
|
+
orderBy: request.orderBy.map((order) => ({
|
|
224
|
+
...order,
|
|
225
|
+
...(order.dimension === undefined ? {} : { dimension: toSnakeCase(order.dimension) }),
|
|
226
|
+
...(order.measure === undefined ? {} : { measure: toSnakeCase(order.measure) }),
|
|
227
|
+
})),
|
|
228
|
+
}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
124
231
|
async function send(transport, method, path, body, signal) {
|
|
125
232
|
const headers = new Headers({ accept: "application/json" });
|
|
126
233
|
if (body !== undefined) {
|
|
@@ -144,5 +251,5 @@ async function send(transport, method, path, body, signal) {
|
|
|
144
251
|
retryable: true,
|
|
145
252
|
});
|
|
146
253
|
}
|
|
147
|
-
return payload;
|
|
254
|
+
return responseFromWire({ method, path, payload });
|
|
148
255
|
}
|
package/dist/permissions.js
CHANGED
|
@@ -7,15 +7,18 @@
|
|
|
7
7
|
* (`src/permissions.ts`) so that the app SDK and its browser components do not depend on the
|
|
8
8
|
* ontology runtime. Keep the two in step.
|
|
9
9
|
*/
|
|
10
|
+
import { toSnakeCase } from "./ontology-names.js";
|
|
10
11
|
const WILDCARD = "*";
|
|
11
|
-
/** `<entity>:<verb>` with lowercase snake_case entities and
|
|
12
|
-
const PERMISSION = /^(\*|[a-z][a-
|
|
12
|
+
/** `<entity>:<verb>` with lowercase snake_case entities and lowercase snake_case verbs, or `*` for either. */
|
|
13
|
+
const PERMISSION = /^(\*|[a-z][a-zA-Z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-zA-Z0-9]*(?:_[a-z0-9]+)*)$/;
|
|
13
14
|
function parsePermission(value) {
|
|
14
15
|
if (typeof value !== "string") {
|
|
15
16
|
return undefined;
|
|
16
17
|
}
|
|
17
18
|
const match = PERMISSION.exec(value);
|
|
18
|
-
return match === null
|
|
19
|
+
return match === null
|
|
20
|
+
? undefined
|
|
21
|
+
: { entity: toSnakeCase(match[1]), verb: toSnakeCase(match[2]) };
|
|
19
22
|
}
|
|
20
23
|
/** Whether a granted permission, which may hold wildcards, covers the required one. */
|
|
21
24
|
export function grants(granted, required) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|