@segmentstream-cloud/app 0.5.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 +25 -16
- package/dist/errors.js +5 -1
- package/dist/ontology-wire.d.ts +33 -0
- package/dist/ontology-wire.js +161 -0
- package/dist/ontology.d.ts +1 -1
- package/dist/ontology.js +49 -11
- package/dist/permissions.js +2 -2
- package/package.json +1 -1
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:
|
|
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
|
|
130
|
-
|
|
|
131
|
-
| `<SignIn />`
|
|
132
|
-
| `useSignIn()`
|
|
133
|
-
| `<SignedIn>`, `<SignedOut>`
|
|
134
|
-
| `<Can permission="lead:
|
|
135
|
-
| `<UserMenu />`
|
|
136
|
-
| `useUser()`
|
|
137
|
-
| `usePermissions()`
|
|
138
|
-
| `useSession()`, `signOut()`
|
|
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:
|
|
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: "
|
|
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.
|
|
267
|
-
|
|
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 `
|
|
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
|
});
|
|
@@ -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
|
+
}
|
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.
|
|
225
|
+
* they are used: `ontology.lead.actions.change_status(…)` 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,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-
|
|
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.
|
|
21
|
+
* they are used: `ontology.lead.actions.change_status(…)` posts to `lead/actions/change_status`.
|
|
21
22
|
*/
|
|
22
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)) {
|
|
@@ -29,10 +31,11 @@ export function createOntologyClient(transport, aggregateTransport) {
|
|
|
29
31
|
let client = entities.get(property);
|
|
30
32
|
if (client === undefined) {
|
|
31
33
|
client = {
|
|
32
|
-
...entityClient(property, transport),
|
|
34
|
+
...entityClient(property, catalogReader.transport, catalogReader.getCatalog),
|
|
33
35
|
aggregate: (request, options = {}) => {
|
|
34
|
-
if (!aggregateTransport)
|
|
36
|
+
if (!aggregateTransport) {
|
|
35
37
|
throw new TypeError("An aggregate transport is required.");
|
|
38
|
+
}
|
|
36
39
|
return send(aggregateTransport, "POST", `/${property}/aggregate`, request, options.signal);
|
|
37
40
|
},
|
|
38
41
|
};
|
|
@@ -42,14 +45,45 @@ export function createOntologyClient(transport, aggregateTransport) {
|
|
|
42
45
|
},
|
|
43
46
|
});
|
|
44
47
|
}
|
|
45
|
-
function
|
|
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) {
|
|
46
80
|
const base = `/${entity}`;
|
|
47
81
|
const actionCalls = new Map();
|
|
48
82
|
const runAction = (name, input, options = {}) => send(transport, "POST", `${base}/actions/${encodeURIComponent(name)}`, {
|
|
49
83
|
input,
|
|
50
84
|
...(options.validateOnly === undefined
|
|
51
85
|
? {}
|
|
52
|
-
: { options: {
|
|
86
|
+
: { options: { validate_only: options.validateOnly } }),
|
|
53
87
|
}, options.signal);
|
|
54
88
|
const actions = new Proxy(Object.create(null), {
|
|
55
89
|
get(_target, property) {
|
|
@@ -65,11 +99,15 @@ function entityClient(entity, transport) {
|
|
|
65
99
|
},
|
|
66
100
|
});
|
|
67
101
|
return {
|
|
68
|
-
find: (query = {}, options = {}) => {
|
|
69
|
-
const
|
|
102
|
+
find: async (query = {}, options = {}) => {
|
|
103
|
+
const where = await encodeWhere({ where: query.where, entity, getCatalog });
|
|
104
|
+
const search = findParameters({ ...query, where }).toString();
|
|
70
105
|
return send(transport, "GET", search === "" ? base : `${base}?${search}`, undefined, options.signal);
|
|
71
106
|
},
|
|
72
|
-
query: (query = {}, options = {}) =>
|
|
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
|
+
},
|
|
73
111
|
get: (id, options = {}) => send(transport, "GET", `${base}/${recordId(id)}`, undefined, options.signal),
|
|
74
112
|
history: (id, options = {}) => {
|
|
75
113
|
const search = new URLSearchParams();
|
|
@@ -105,7 +143,7 @@ export function findParameters(query) {
|
|
|
105
143
|
search.set("where", JSON.stringify(query.where));
|
|
106
144
|
}
|
|
107
145
|
if (query.orderBy !== undefined && query.orderBy.length > 0) {
|
|
108
|
-
search.set("
|
|
146
|
+
search.set("order_by", query.orderBy.map(({ field, direction }) => `${field}:${direction ?? "asc"}`).join(","));
|
|
109
147
|
}
|
|
110
148
|
if (query.include !== undefined) {
|
|
111
149
|
const relationships = Object.keys(query.include).filter((name) => query.include?.[name] === true);
|
|
@@ -144,5 +182,5 @@ async function send(transport, method, path, body, signal) {
|
|
|
144
182
|
retryable: true,
|
|
145
183
|
});
|
|
146
184
|
}
|
|
147
|
-
return payload;
|
|
185
|
+
return responseFromWire({ method, path, payload });
|
|
148
186
|
}
|
package/dist/permissions.js
CHANGED
|
@@ -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
|
|
12
|
-
const PERMISSION = /^(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-
|
|
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.
|
|
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",
|